From 0db1fa5f0b2b9b357c1f96178c0e5df7858a99ab Mon Sep 17 00:00:00 2001 From: Puyodead1 Date: Fri, 23 Dec 2022 18:34:36 -0500 Subject: Refreshable connections, refactoring, access-token endpoint - Aded /users/@me/connections/:connection_name/:connection_id/access-token - Replaced `access_token` property on ConnectedAccount with `token_data` object for refreshing tokens - Made a common interface for connection things like ComonOAuthTokenResponse - Added `RefreshableConnection` class - Added token refresh to Spotify connection (disabled) --- src/connections/BattleNet/index.ts | 31 +++++++-------- src/connections/Discord/index.ts | 19 ++++----- src/connections/EpicGames/index.ts | 22 +++++------ src/connections/Facebook/index.ts | 31 +++++++-------- src/connections/GitHub/index.ts | 19 ++++----- src/connections/Reddit/index.ts | 20 ++++------ src/connections/Spotify/index.ts | 79 ++++++++++++++++++++++++++++++-------- 7 files changed, 124 insertions(+), 97 deletions(-) (limited to 'src/connections') diff --git a/src/connections/BattleNet/index.ts b/src/connections/BattleNet/index.ts index f4b317cc..ecba0fa9 100644 --- a/src/connections/BattleNet/index.ts +++ b/src/connections/BattleNet/index.ts @@ -1,6 +1,7 @@ import { Config, ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, ConnectionCallbackSchema, ConnectionLoader, DiscordApiErrors, @@ -9,14 +10,6 @@ import fetch from "node-fetch"; import Connection from "../../util/connections/Connection"; import { BattleNetSettings } from "./BattleNetSettings"; -interface OAuthTokenResponse { - access_token: string; - token_type: string; - scope: string; - refresh_token?: string; - expires_in?: number; -} - interface BattleNetConnectionUser { sub: string; id: number; @@ -65,7 +58,10 @@ export default class BattleNetConnection extends Connection { return this.tokenUrl; } - async exchangeCode(state: string, code: string): Promise { + async exchangeCode( + state: string, + code: string, + ): Promise { this.validateState(state); const url = this.getTokenUrl(); @@ -86,10 +82,15 @@ export default class BattleNetConnection extends Connection { }), }) .then((res) => res.json()) - .then((res: OAuthTokenResponse & BattleNetErrorResponse) => { - if (res.error) throw new Error(res.error_description); - return res.access_token; - }) + .then( + ( + res: ConnectedAccountCommonOAuthTokenResponse & + BattleNetErrorResponse, + ) => { + if (res.error) throw new Error(res.error_description); + return res; + }, + ) .catch((e) => { console.error( `Error exchanging token for ${this.id} connection: ${e}`, @@ -117,8 +118,8 @@ export default class BattleNetConnection extends Connection { params: ConnectionCallbackSchema, ): Promise { const userId = this.getUserId(params.state); - const token = await this.exchangeCode(params.state, params.code!); - const userInfo = await this.getUser(token); + 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()); diff --git a/src/connections/Discord/index.ts b/src/connections/Discord/index.ts index 05074c26..61efcfc5 100644 --- a/src/connections/Discord/index.ts +++ b/src/connections/Discord/index.ts @@ -1,6 +1,7 @@ import { Config, ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, ConnectionCallbackSchema, ConnectionLoader, DiscordApiErrors, @@ -9,14 +10,6 @@ import fetch from "node-fetch"; import Connection from "../../util/connections/Connection"; import { DiscordSettings } from "./DiscordSettings"; -interface OAuthTokenResponse { - access_token: string; - token_type: string; - scope: string; - refresh_token?: string; - expires_in?: number; -} - interface UserResponse { id: string; username: string; @@ -65,7 +58,10 @@ export default class DiscordConnection extends Connection { return this.tokenUrl; } - async exchangeCode(state: string, code: string): Promise { + async exchangeCode( + state: string, + code: string, + ): Promise { this.validateState(state); const url = this.getTokenUrl(); @@ -86,7 +82,6 @@ export default class DiscordConnection extends Connection { }), }) .then((res) => res.json()) - .then((res: OAuthTokenResponse) => res.access_token) .catch((e) => { console.error( `Error exchanging token for ${this.id} connection: ${e}`, @@ -109,8 +104,8 @@ export default class DiscordConnection extends Connection { params: ConnectionCallbackSchema, ): Promise { const userId = this.getUserId(params.state); - const token = await this.exchangeCode(params.state, params.code!); - const userInfo = await this.getUser(token); + 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); diff --git a/src/connections/EpicGames/index.ts b/src/connections/EpicGames/index.ts index cffafc60..f1f3f24c 100644 --- a/src/connections/EpicGames/index.ts +++ b/src/connections/EpicGames/index.ts @@ -1,6 +1,7 @@ import { Config, ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, ConnectionCallbackSchema, ConnectionLoader, DiscordApiErrors, @@ -9,21 +10,14 @@ import fetch from "node-fetch"; import Connection from "../../util/connections/Connection"; import { EpicGamesSettings } from "./EpicGamesSettings"; -interface OAuthTokenResponse { - access_token: string; - token_type: string; - scope: string; - refresh_token?: string; - expires_in?: number; -} - export interface UserResponse { accountId: string; displayName: string; preferredLanguage: string; } -export interface EpicTokenResponse extends OAuthTokenResponse { +export interface EpicTokenResponse + extends ConnectedAccountCommonOAuthTokenResponse { expires_at: string; refresh_expires_in: number; refresh_expires_at: string; @@ -70,7 +64,10 @@ export default class EpicGamesConnection extends Connection { return this.tokenUrl; } - async exchangeCode(state: string, code: string): Promise { + async exchangeCode( + state: string, + code: string, + ): Promise { this.validateState(state); const url = this.getTokenUrl(); @@ -90,7 +87,6 @@ export default class EpicGamesConnection extends Connection { }), }) .then((res) => res.json()) - .then((res: EpicTokenResponse) => res.access_token) .catch((e) => { console.error( `Error exchanging token for ${this.id} connection: ${e}`, @@ -117,8 +113,8 @@ export default class EpicGamesConnection extends Connection { params: ConnectionCallbackSchema, ): Promise { const userId = this.getUserId(params.state); - const token = await this.exchangeCode(params.state, params.code!); - const userInfo = await this.getUser(token); + 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); diff --git a/src/connections/Facebook/index.ts b/src/connections/Facebook/index.ts index 80950a8e..2d490c63 100644 --- a/src/connections/Facebook/index.ts +++ b/src/connections/Facebook/index.ts @@ -1,6 +1,7 @@ import { Config, ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, ConnectionCallbackSchema, ConnectionLoader, DiscordApiErrors, @@ -9,14 +10,6 @@ import fetch from "node-fetch"; import Connection from "../../util/connections/Connection"; import { FacebookSettings } from "./FacebookSettings"; -interface OAuthTokenResponse { - access_token: string; - token_type: string; - scope: string; - refresh_token?: string; - expires_in?: number; -} - export interface FacebookErrorResponse { error: { message: string; @@ -81,7 +74,10 @@ export default class FacebookConnection extends Connection { return url.toString(); } - async exchangeCode(state: string, code: string): Promise { + async exchangeCode( + state: string, + code: string, + ): Promise { this.validateState(state); const url = this.getTokenUrl(code); @@ -93,10 +89,15 @@ export default class FacebookConnection extends Connection { }, }) .then((res) => res.json()) - .then((res: OAuthTokenResponse & FacebookErrorResponse) => { - if (res.error) throw new Error(res.error.message); - return res.access_token; - }) + .then( + ( + res: ConnectedAccountCommonOAuthTokenResponse & + FacebookErrorResponse, + ) => { + if (res.error) throw new Error(res.error.message); + return res; + }, + ) .catch((e) => { console.error( `Error exchanging token for ${this.id} connection: ${e}`, @@ -124,8 +125,8 @@ export default class FacebookConnection extends Connection { params: ConnectionCallbackSchema, ): Promise { const userId = this.getUserId(params.state); - const token = await this.exchangeCode(params.state, params.code!); - const userInfo = await this.getUser(token); + 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); diff --git a/src/connections/GitHub/index.ts b/src/connections/GitHub/index.ts index d79fd51e..ab3f8e65 100644 --- a/src/connections/GitHub/index.ts +++ b/src/connections/GitHub/index.ts @@ -1,6 +1,7 @@ import { Config, ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, ConnectionCallbackSchema, ConnectionLoader, DiscordApiErrors, @@ -9,14 +10,6 @@ import fetch from "node-fetch"; import Connection from "../../util/connections/Connection"; import { GitHubSettings } from "./GitHubSettings"; -interface OAuthTokenResponse { - access_token: string; - token_type: string; - scope: string; - refresh_token?: string; - expires_in?: number; -} - interface UserResponse { login: string; id: number; @@ -63,7 +56,10 @@ export default class GitHubConnection extends Connection { return url.toString(); } - async exchangeCode(state: string, code: string): Promise { + async exchangeCode( + state: string, + code: string, + ): Promise { this.validateState(state); const url = this.getTokenUrl(code); @@ -75,7 +71,6 @@ export default class GitHubConnection extends Connection { }, }) .then((res) => res.json()) - .then((res: OAuthTokenResponse) => res.access_token) .catch((e) => { console.error( `Error exchanging token for ${this.id} connection: ${e}`, @@ -98,8 +93,8 @@ export default class GitHubConnection extends Connection { params: ConnectionCallbackSchema, ): Promise { const userId = this.getUserId(params.state); - const token = await this.exchangeCode(params.state, params.code!); - const userInfo = await this.getUser(token); + 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()); diff --git a/src/connections/Reddit/index.ts b/src/connections/Reddit/index.ts index c529cfa3..182cd5a5 100644 --- a/src/connections/Reddit/index.ts +++ b/src/connections/Reddit/index.ts @@ -1,6 +1,7 @@ import { Config, ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, ConnectionCallbackSchema, ConnectionLoader, DiscordApiErrors, @@ -9,14 +10,6 @@ import fetch from "node-fetch"; import Connection from "../../util/connections/Connection"; import { RedditSettings } from "./RedditSettings"; -interface OAuthTokenResponse { - access_token: string; - token_type: string; - scope: string; - refresh_token?: string; - expires_in?: number; -} - export interface UserResponse { verified: boolean; coins: number; @@ -72,7 +65,10 @@ export default class RedditConnection extends Connection { return this.tokenUrl; } - async exchangeCode(state: string, code: string): Promise { + async exchangeCode( + state: string, + code: string, + ): Promise { this.validateState(state); const url = this.getTokenUrl(); @@ -95,7 +91,6 @@ export default class RedditConnection extends Connection { }), }) .then((res) => res.json()) - .then((res: OAuthTokenResponse) => res.access_token) .catch((e) => { console.error( `Error exchanging token for ${this.id} connection: ${e}`, @@ -118,8 +113,8 @@ export default class RedditConnection extends Connection { params: ConnectionCallbackSchema, ): Promise { const userId = this.getUserId(params.state); - const token = await this.exchangeCode(params.state, params.code!); - const userInfo = await this.getUser(token); + 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()); @@ -128,7 +123,6 @@ export default class RedditConnection extends Connection { // TODO: connection metadata return await this.createConnection({ - access_token: token, user_id: userId, external_id: userInfo.id.toString(), friend_sync: params.friend_sync, diff --git a/src/connections/Spotify/index.ts b/src/connections/Spotify/index.ts index eb662141..b40d6189 100644 --- a/src/connections/Spotify/index.ts +++ b/src/connections/Spotify/index.ts @@ -1,22 +1,15 @@ import { Config, ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, ConnectionCallbackSchema, ConnectionLoader, DiscordApiErrors, } from "@fosscord/util"; import fetch from "node-fetch"; -import Connection from "../../util/connections/Connection"; +import RefreshableConnection from "../../util/connections/RefreshableConnection"; import { SpotifySettings } from "./SpotifySettings"; -interface OAuthTokenResponse { - access_token: string; - token_type: string; - scope: string; - refresh_token?: string; - expires_in?: number; -} - export interface UserResponse { display_name: string; id: string; @@ -34,7 +27,7 @@ export interface ErrorResponse { }; } -export default class SpotifyConnection extends Connection { +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"; @@ -48,6 +41,11 @@ export default class SpotifyConnection extends Connection { 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, @@ -76,7 +74,10 @@ export default class SpotifyConnection extends Connection { return this.tokenUrl; } - async exchangeCode(state: string, code: string): Promise { + async exchangeCode( + state: string, + code: string, + ): Promise { this.validateState(state); const url = this.getTokenUrl(); @@ -99,10 +100,15 @@ export default class SpotifyConnection extends Connection { }), }) .then((res) => res.json()) - .then((res: OAuthTokenResponse & TokenErrorResponse) => { - if (res.error) throw new Error(res.error_description); - return res.access_token; - }) + .then( + ( + res: ConnectedAccountCommonOAuthTokenResponse & + TokenErrorResponse, + ) => { + if (res.error) throw new Error(res.error_description); + return res; + }, + ) .catch((e) => { console.error( `Error exchanging token for ${this.id} connection: ${e}`, @@ -111,6 +117,44 @@ export default class SpotifyConnection extends Connection { }); } + async refreshToken(connectedAccount: ConnectedAccount) { + 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 fetch(url.toString(), { + method: "POST", + 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, + }), + }) + .then((res) => res.json()) + .then( + ( + res: ConnectedAccountCommonOAuthTokenResponse & + TokenErrorResponse, + ) => { + if (res.error) throw new Error(res.error_description); + return res; + }, + ) + .catch((e) => { + console.error( + `Error refreshing token for ${this.id} connection: ${e}`, + ); + throw DiscordApiErrors.INVALID_OAUTH_TOKEN; + }); + } + async getUser(token: string): Promise { const url = new URL(this.userInfoUrl); return fetch(url.toString(), { @@ -130,14 +174,15 @@ export default class SpotifyConnection extends Connection { params: ConnectionCallbackSchema, ): Promise { const userId = this.getUserId(params.state); - const token = await this.exchangeCode(params.state, params.code!); - const userInfo = await this.getUser(token); + 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, user_id: userId, external_id: userInfo.id, friend_sync: params.friend_sync, -- cgit 1.4.1