diff options
Diffstat (limited to 'src/connections')
22 files changed, 1579 insertions, 0 deletions
diff --git a/src/connections/BattleNet/BattleNetSettings.ts b/src/connections/BattleNet/BattleNetSettings.ts new file mode 100644 index 00000000..75e5c3ae --- /dev/null +++ b/src/connections/BattleNet/BattleNetSettings.ts @@ -0,0 +1,5 @@ +export class BattleNetSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/BattleNet/index.ts b/src/connections/BattleNet/index.ts new file mode 100644 index 00000000..7ea919f1 --- /dev/null +++ b/src/connections/BattleNet/index.ts @@ -0,0 +1,116 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { BattleNetSettings } from "./BattleNetSettings"; + +interface BattleNetConnectionUser { + sub: string; + id: number; + battletag: string; +} + +interface BattleNetErrorResponse { + error: string; + error_description: string; +} + +export default class BattleNetConnection extends Connection { + public readonly id = "battlenet"; + public readonly authorizeUrl = "https://oauth.battle.net/authorize"; + public readonly tokenUrl = "https://oauth.battle.net/token"; + public readonly userInfoUrl = "https://us.battle.net/oauth/userinfo"; + public readonly scopes = []; + settings: BattleNetSettings = new BattleNetSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as BattleNetSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + url.searchParams.append("response_type", "code"); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<BattleNetConnectionUser> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<BattleNetConnectionUser>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id.toString()); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id.toString(), + friend_sync: params.friend_sync, + name: userInfo.battletag, + type: this.id, + }); + } +} diff --git a/src/connections/Discord/DiscordSettings.ts b/src/connections/Discord/DiscordSettings.ts new file mode 100644 index 00000000..3751b041 --- /dev/null +++ b/src/connections/Discord/DiscordSettings.ts @@ -0,0 +1,5 @@ +export class DiscordSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Discord/index.ts b/src/connections/Discord/index.ts new file mode 100644 index 00000000..24e90860 --- /dev/null +++ b/src/connections/Discord/index.ts @@ -0,0 +1,115 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { DiscordSettings } from "./DiscordSettings"; + +interface UserResponse { + id: string; + username: string; + discriminator: string; + avatar_url: string | null; +} + +export default class DiscordConnection extends Connection { + public readonly id = "discord"; + public readonly authorizeUrl = "https://discord.com/api/oauth2/authorize"; + public readonly tokenUrl = "https://discord.com/api/oauth2/token"; + public readonly userInfoUrl = "https://discord.com/api/users/@me"; + public readonly scopes = ["identify"]; + settings: DiscordSettings = new DiscordSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as DiscordSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("state", state); + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("response_type", "code"); + // controls whether, on repeated authorizations, the consent screen is shown + url.searchParams.append("consent", "none"); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + grant_type: "authorization_code", + code: code, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id, + friend_sync: params.friend_sync, + name: `${userInfo.username}#${userInfo.discriminator}`, + type: this.id, + }); + } +} diff --git a/src/connections/EpicGames/EpicGamesSettings.ts b/src/connections/EpicGames/EpicGamesSettings.ts new file mode 100644 index 00000000..4820a88a --- /dev/null +++ b/src/connections/EpicGames/EpicGamesSettings.ts @@ -0,0 +1,5 @@ +export class EpicGamesSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/EpicGames/index.ts b/src/connections/EpicGames/index.ts new file mode 100644 index 00000000..5e758540 --- /dev/null +++ b/src/connections/EpicGames/index.ts @@ -0,0 +1,128 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { EpicGamesSettings } from "./EpicGamesSettings"; + +export interface UserResponse { + accountId: string; + displayName: string; + preferredLanguage: string; +} + +export interface EpicTokenResponse + extends ConnectedAccountCommonOAuthTokenResponse { + expires_at: string; + refresh_expires_in: number; + refresh_expires_at: string; + account_id: string; + client_id: string; + application_id: string; +} + +export default class EpicGamesConnection extends Connection { + public readonly id = "epicgames"; + public readonly authorizeUrl = "https://www.epicgames.com/id/authorize"; + public readonly tokenUrl = "https://api.epicgames.dev/epic/oauth/v1/token"; + public readonly userInfoUrl = + "https://api.epicgames.dev/epic/id/v1/accounts"; + public readonly scopes = ["basic profile"]; + settings: EpicGamesSettings = new EpicGamesSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as EpicGamesSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<EpicTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId}:${this.settings.clientSecret}`, + ).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code, + }), + ) + .post() + .json<EpicTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse[]> { + const { sub } = JSON.parse( + Buffer.from(token.split(".")[1], "base64").toString("utf8"), + ); + const url = new URL(this.userInfoUrl); + url.searchParams.append("accountId", sub); + + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse[]>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo[0].accountId); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo[0].accountId, + friend_sync: params.friend_sync, + name: userInfo[0].displayName, + type: this.id, + }); + } +} diff --git a/src/connections/Facebook/FacebookSettings.ts b/src/connections/Facebook/FacebookSettings.ts new file mode 100644 index 00000000..cc3e3402 --- /dev/null +++ b/src/connections/Facebook/FacebookSettings.ts @@ -0,0 +1,5 @@ +export class FacebookSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Facebook/index.ts b/src/connections/Facebook/index.ts new file mode 100644 index 00000000..8e0f8d27 --- /dev/null +++ b/src/connections/Facebook/index.ts @@ -0,0 +1,119 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { FacebookSettings } from "./FacebookSettings"; + +export interface FacebookErrorResponse { + error: { + message: string; + type: string; + code: number; + fbtrace_id: string; + }; +} + +interface UserResponse { + name: string; + id: string; +} + +export default class FacebookConnection extends Connection { + public readonly id = "facebook"; + public readonly authorizeUrl = + "https://www.facebook.com/v14.0/dialog/oauth"; + public readonly tokenUrl = + "https://graph.facebook.com/v14.0/oauth/access_token"; + public readonly userInfoUrl = "https://graph.facebook.com/v14.0/me"; + public readonly scopes = ["public_profile"]; + settings: FacebookSettings = new FacebookSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as FacebookSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("state", state); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("display", "popup"); + return url.toString(); + } + + getTokenUrl(code: string): string { + const url = new URL(this.tokenUrl); + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("client_secret", this.settings.clientSecret!); + url.searchParams.append("code", code); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + return url.toString(); + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(code); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + }) + .get() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id, + friend_sync: params.friend_sync, + name: userInfo.name, + type: this.id, + }); + } +} diff --git a/src/connections/GitHub/GitHubSettings.ts b/src/connections/GitHub/GitHubSettings.ts new file mode 100644 index 00000000..1b4070d2 --- /dev/null +++ b/src/connections/GitHub/GitHubSettings.ts @@ -0,0 +1,5 @@ +export class GitHubSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/GitHub/index.ts b/src/connections/GitHub/index.ts new file mode 100644 index 00000000..638a34af --- /dev/null +++ b/src/connections/GitHub/index.ts @@ -0,0 +1,106 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { GitHubSettings } from "./GitHubSettings"; + +interface UserResponse { + login: string; + id: number; + name: string; +} + +export default class GitHubConnection extends Connection { + public readonly id = "github"; + public readonly authorizeUrl = "https://github.com/login/oauth/authorize"; + public readonly tokenUrl = "https://github.com/login/oauth/access_token"; + public readonly userInfoUrl = "https://api.github.com/user"; + public readonly scopes = ["read:user"]; + settings: GitHubSettings = new GitHubSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as GitHubSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(code: string): string { + const url = new URL(this.tokenUrl); + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("client_secret", this.settings.clientSecret!); + url.searchParams.append("code", code); + return url.toString(); + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(code); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + }) + + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id.toString()); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id.toString(), + friend_sync: params.friend_sync, + name: userInfo.login, + type: this.id, + }); + } +} diff --git a/src/connections/Reddit/RedditSettings.ts b/src/connections/Reddit/RedditSettings.ts new file mode 100644 index 00000000..13208fb5 --- /dev/null +++ b/src/connections/Reddit/RedditSettings.ts @@ -0,0 +1,5 @@ +export class RedditSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Reddit/index.ts b/src/connections/Reddit/index.ts new file mode 100644 index 00000000..2f3418c0 --- /dev/null +++ b/src/connections/Reddit/index.ts @@ -0,0 +1,128 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { RedditSettings } from "./RedditSettings"; + +export interface UserResponse { + verified: boolean; + coins: number; + id: string; + is_mod: boolean; + has_verified_email: boolean; + total_karma: number; + name: string; + created: number; + gold_creddits: number; + created_utc: number; +} + +export interface ErrorResponse { + message: string; + error: number; +} + +export default class RedditConnection extends Connection { + public readonly id = "reddit"; + public readonly authorizeUrl = "https://www.reddit.com/api/v1/authorize"; + public readonly tokenUrl = "https://www.reddit.com/api/v1/access_token"; + public readonly userInfoUrl = "https://oauth.reddit.com/api/v1/me"; + public readonly scopes = ["identity"]; + settings: RedditSettings = new RedditSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as RedditSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId}:${this.settings.clientSecret}`, + ).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id.toString()); + + if (exists) return null; + + // TODO: connection metadata + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id.toString(), + friend_sync: params.friend_sync, + name: userInfo.name, + verified: userInfo.has_verified_email, + type: this.id, + }); + } +} diff --git a/src/connections/Spotify/SpotifySettings.ts b/src/connections/Spotify/SpotifySettings.ts new file mode 100644 index 00000000..e73c0304 --- /dev/null +++ b/src/connections/Spotify/SpotifySettings.ts @@ -0,0 +1,5 @@ +export class SpotifySettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Spotify/index.ts b/src/connections/Spotify/index.ts new file mode 100644 index 00000000..c9517b1a --- /dev/null +++ b/src/connections/Spotify/index.ts @@ -0,0 +1,171 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import RefreshableConnection from "../../util/connections/RefreshableConnection"; +import { SpotifySettings } from "./SpotifySettings"; + +export interface UserResponse { + display_name: string; + id: string; +} + +export interface TokenErrorResponse { + error: string; + error_description: string; +} + +export interface ErrorResponse { + error: { + status: number; + message: string; + }; +} + +export default class SpotifyConnection extends RefreshableConnection { + public readonly id = "spotify"; + public readonly authorizeUrl = "https://accounts.spotify.com/authorize"; + public readonly tokenUrl = "https://accounts.spotify.com/api/token"; + public readonly userInfoUrl = "https://api.spotify.com/v1/me"; + public readonly scopes = [ + "user-read-private", + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + ]; + settings: SpotifySettings = new SpotifySettings(); + + init(): void { + /** + * The way Discord shows the currently playing song is by using Spotifys partner API. This is obviously not possible for us. + * So to prevent spamming the spotify api we disable the ability to refresh. + */ + this.refreshEnabled = false; + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as SpotifySettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async refreshToken( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + if (!connectedAccount.token_data?.refresh_token) + throw new Error("No refresh token available."); + const refresh_token = connectedAccount.token_data.refresh_token; + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token, + }), + ) + .post() + .unauthorized(async () => { + // assume the token was revoked + await connectedAccount.revoke(); + return DiscordApiErrors.CONNECTION_REVOKED; + }) + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.id, + friend_sync: params.friend_sync, + name: userInfo.display_name, + type: this.id, + }); + } +} diff --git a/src/connections/Twitch/TwitchSettings.ts b/src/connections/Twitch/TwitchSettings.ts new file mode 100644 index 00000000..eb732c82 --- /dev/null +++ b/src/connections/Twitch/TwitchSettings.ts @@ -0,0 +1,5 @@ +export class TwitchSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Twitch/index.ts b/src/connections/Twitch/index.ts new file mode 100644 index 00000000..d53215f1 --- /dev/null +++ b/src/connections/Twitch/index.ts @@ -0,0 +1,163 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import RefreshableConnection from "../../util/connections/RefreshableConnection"; +import { TwitchSettings } from "./TwitchSettings"; + +interface TwitchConnectionUserResponse { + data: { + id: string; + login: string; + display_name: string; + type: string; + broadcaster_type: string; + description: string; + profile_image_url: string; + offline_image_url: string; + view_count: number; + created_at: string; + }[]; +} + +export default class TwitchConnection extends RefreshableConnection { + public readonly id = "twitch"; + public readonly authorizeUrl = "https://id.twitch.tv/oauth2/authorize"; + public readonly tokenUrl = "https://id.twitch.tv/oauth2/token"; + public readonly userInfoUrl = "https://api.twitch.tv/helix/users"; + public readonly scopes = [ + "channel_subscriptions", + "channel_check_subscription", + "channel:read:subscriptions", + ]; + settings: TwitchSettings = new TwitchSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as TwitchSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async refreshToken( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + if (!connectedAccount.token_data?.refresh_token) + throw new Error("No refresh token available."); + const refresh_token = connectedAccount.token_data.refresh_token; + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + refresh_token: refresh_token, + }), + ) + .post() + .unauthorized(async () => { + // assume the token was revoked + await connectedAccount.revoke(); + return DiscordApiErrors.CONNECTION_REVOKED; + }) + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<TwitchConnectionUserResponse> { + const url = new URL(this.userInfoUrl); + + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + "Client-Id": this.settings.clientId!, + }) + .get() + .json<TwitchConnectionUserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.data[0].id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.data[0].id, + friend_sync: params.friend_sync, + name: userInfo.data[0].display_name, + type: this.id, + }); + } +} diff --git a/src/connections/Twitter/TwitterSettings.ts b/src/connections/Twitter/TwitterSettings.ts new file mode 100644 index 00000000..e4aa58eb --- /dev/null +++ b/src/connections/Twitter/TwitterSettings.ts @@ -0,0 +1,5 @@ +export class TwitterSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Twitter/index.ts b/src/connections/Twitter/index.ts new file mode 100644 index 00000000..f8eac894 --- /dev/null +++ b/src/connections/Twitter/index.ts @@ -0,0 +1,165 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import RefreshableConnection from "../../util/connections/RefreshableConnection"; +import { TwitterSettings } from "./TwitterSettings"; + +interface TwitterUserResponse { + data: { + id: string; + name: string; + username: string; + created_at: string; + location: string; + url: string; + description: string; + verified: string; + }; +} + +interface TwitterErrorResponse { + error: string; + error_description: string; +} + +export default class TwitterConnection extends RefreshableConnection { + public readonly id = "twitter"; + public readonly authorizeUrl = "https://twitter.com/i/oauth2/authorize"; + public readonly tokenUrl = "https://api.twitter.com/2/oauth2/token"; + public readonly userInfoUrl = + "https://api.twitter.com/2/users/me?user.fields=created_at%2Cdescription%2Cid%2Cname%2Cusername%2Cverified%2Clocation%2Curl"; + public readonly scopes = ["users.read", "tweet.read"]; + settings: TwitterSettings = new TwitterSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as TwitterSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + url.searchParams.append("code_challenge", "challenge"); // TODO: properly use PKCE challenge + url.searchParams.append("code_challenge_method", "plain"); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + redirect_uri: this.getRedirectUri(), + code_verifier: "challenge", // TODO: properly use PKCE challenge + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async refreshToken( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + if (!connectedAccount.token_data?.refresh_token) + throw new Error("No refresh token available."); + const refresh_token = connectedAccount.token_data.refresh_token; + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token, + client_id: this.settings.clientId!, + redirect_uri: this.getRedirectUri(), + code_verifier: "challenge", // TODO: properly use PKCE challenge + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<TwitterUserResponse> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<TwitterUserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.data.id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.data.id, + friend_sync: params.friend_sync, + name: userInfo.data.name, + type: this.id, + }); + } +} diff --git a/src/connections/Xbox/XboxSettings.ts b/src/connections/Xbox/XboxSettings.ts new file mode 100644 index 00000000..c1a41056 --- /dev/null +++ b/src/connections/Xbox/XboxSettings.ts @@ -0,0 +1,5 @@ +export class XboxSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Xbox/index.ts b/src/connections/Xbox/index.ts new file mode 100644 index 00000000..011f87a8 --- /dev/null +++ b/src/connections/Xbox/index.ts @@ -0,0 +1,180 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { XboxSettings } from "./XboxSettings"; + +interface XboxUserResponse { + IssueInstant: string; + NotAfter: string; + Token: string; + DisplayClaims: { + xui: { + gtg: string; + xid: string; + uhs: string; + agg: string; + usr: string; + utr: string; + prv: string; + }[]; + }; +} + +interface XboxErrorResponse { + error: string; + error_description: string; +} + +export default class XboxConnection extends Connection { + public readonly id = "xbox"; + public readonly authorizeUrl = + "https://login.live.com/oauth20_authorize.srf"; + public readonly tokenUrl = "https://login.live.com/oauth20_token.srf"; + public readonly userInfoUrl = + "https://xsts.auth.xboxlive.com/xsts/authorize"; + public readonly userAuthUrl = + "https://user.auth.xboxlive.com/user/authenticate"; + public readonly scopes = ["Xboxlive.signin", "Xboxlive.offline_access"]; + settings: XboxSettings = new XboxSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as XboxSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + url.searchParams.append("approval_prompt", "auto"); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async getUserToken(token: string): Promise<string> { + return wretch(this.userAuthUrl) + .headers({ + "x-xbl-contract-version": "3", + "Content-Type": "application/json", + Accept: "application/json", + }) + .body( + JSON.stringify({ + RelyingParty: "http://auth.xboxlive.com", + TokenType: "JWT", + Properties: { + AuthMethod: "RPS", + SiteName: "user.auth.xboxlive.com", + RpsTicket: `d=${token}`, + }, + }), + ) + .post() + .json((res: XboxUserResponse) => res.Token) + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + redirect_uri: this.getRedirectUri(), + scope: this.scopes.join(" "), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<XboxUserResponse> { + const url = new URL(this.userInfoUrl); + + return wretch(url.toString()) + .headers({ + "x-xbl-contract-version": "3", + "Content-Type": "application/json", + Accept: "application/json", + }) + .body( + JSON.stringify({ + RelyingParty: "http://xboxlive.com", + TokenType: "JWT", + Properties: { + UserTokens: [token], + SandboxId: "RETAIL", + }, + }), + ) + .post() + .json<XboxUserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userToken = await this.getUserToken(tokenData.access_token); + const userInfo = await this.getUser(userToken); + + const exists = await this.hasConnection( + userId, + userInfo.DisplayClaims.xui[0].xid, + ); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.DisplayClaims.xui[0].xid, + friend_sync: params.friend_sync, + name: userInfo.DisplayClaims.xui[0].gtg, + type: this.id, + }); + } +} diff --git a/src/connections/Youtube/YoutubeSettings.ts b/src/connections/Youtube/YoutubeSettings.ts new file mode 100644 index 00000000..5d11fa40 --- /dev/null +++ b/src/connections/Youtube/YoutubeSettings.ts @@ -0,0 +1,5 @@ +export class YoutubeSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Youtube/index.ts b/src/connections/Youtube/index.ts new file mode 100644 index 00000000..4d90e452 --- /dev/null +++ b/src/connections/Youtube/index.ts @@ -0,0 +1,133 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { YoutubeSettings } from "./YoutubeSettings"; + +interface YouTubeConnectionChannelListResult { + items: { + snippet: { + // thumbnails: Thumbnails; + title: string; + country: string; + publishedAt: string; + // localized: Localized; + description: string; + }; + kind: string; + etag: string; + id: string; + }[]; + kind: string; + etag: string; + pageInfo: { + resultsPerPage: number; + totalResults: number; + }; +} + +export default class YoutubeConnection extends Connection { + public readonly id = "youtube"; + public readonly authorizeUrl = + "https://accounts.google.com/o/oauth2/v2/auth"; + public readonly tokenUrl = "https://oauth2.googleapis.com/token"; + public readonly userInfoUrl = + "https://www.googleapis.com/youtube/v3/channels?mine=true&part=snippet"; + public readonly scopes = [ + "https://www.googleapis.com/auth/youtube.readonly", + ]; + settings: YoutubeSettings = new YoutubeSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as YoutubeSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<YouTubeConnectionChannelListResult> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<YouTubeConnectionChannelListResult>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.items[0].id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.items[0].id, + friend_sync: params.friend_sync, + name: userInfo.items[0].snippet.title, + type: this.id, + }); + } +} |