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)
This commit is contained in:
		
							parent
							
								
									50f068400d
								
							
						
					
					
						commit
						0db1fa5f0b
					
				| @ -0,0 +1,84 @@ | ||||
| import { route } from "@fosscord/api"; | ||||
| import { | ||||
| 	ApiError, | ||||
| 	ConnectedAccount, | ||||
| 	ConnectionStore, | ||||
| 	DiscordApiErrors, | ||||
| 	FieldErrors, | ||||
| } from "@fosscord/util"; | ||||
| import { Request, Response, Router } from "express"; | ||||
| import RefreshableConnection from "../../../../../../../util/connections/RefreshableConnection"; | ||||
| const router = Router(); | ||||
| 
 | ||||
| // TODO: this route is only used for spotify, twitch, and youtube. (battlenet seems to be able to PUT, maybe others also)
 | ||||
| 
 | ||||
| // spotify is disabled here because it cant be used
 | ||||
| const ALLOWED_CONNECTIONS = ["twitch", "youtube"]; | ||||
| 
 | ||||
| router.get("/", route({}), async (req: Request, res: Response) => { | ||||
| 	// TODO: get the current access token or refresh it if it's expired
 | ||||
| 	const { connection_name, connection_id } = req.params; | ||||
| 
 | ||||
| 	const connection = ConnectionStore.connections.get(connection_id); | ||||
| 
 | ||||
| 	if (!ALLOWED_CONNECTIONS.includes(connection_name) || !connection) | ||||
| 		throw FieldErrors({ | ||||
| 			provider_id: { | ||||
| 				code: "BASE_TYPE_CHOICES", | ||||
| 				message: req.t("common:field.BASE_TYPE_CHOICES", { | ||||
| 					types: ALLOWED_CONNECTIONS.join(", "), | ||||
| 				}), | ||||
| 			}, | ||||
| 		}); | ||||
| 
 | ||||
| 	if (!connection.settings.enabled) | ||||
| 		throw FieldErrors({ | ||||
| 			provider_id: { | ||||
| 				message: "This connection has been disabled server-side.", | ||||
| 			}, | ||||
| 		}); | ||||
| 
 | ||||
| 	const connectedAccount = await ConnectedAccount.findOne({ | ||||
| 		where: { | ||||
| 			type: connection_name, | ||||
| 			id: connection_id, | ||||
| 			user_id: req.user_id, | ||||
| 		}, | ||||
| 		select: [ | ||||
| 			"external_id", | ||||
| 			"type", | ||||
| 			"name", | ||||
| 			"verified", | ||||
| 			"visibility", | ||||
| 			"show_activity", | ||||
| 			"revoked", | ||||
| 			"token_data", | ||||
| 			"friend_sync", | ||||
| 			"integrations", | ||||
| 		], | ||||
| 	}); | ||||
| 	if (!connectedAccount) throw DiscordApiErrors.UNKNOWN_CONNECTION; | ||||
| 	if (connectedAccount.revoked) | ||||
| 		throw new ApiError("Connection revoked", 0, 400); | ||||
| 	if (!connectedAccount.token_data) | ||||
| 		throw new ApiError("No token data", 0, 400); | ||||
| 
 | ||||
| 	let access_token = connectedAccount.token_data.access_token; | ||||
| 	const { expires_at, expires_in } = connectedAccount.token_data; | ||||
| 
 | ||||
| 	if (expires_at && expires_at < Date.now()) { | ||||
| 		if (!(connection instanceof RefreshableConnection)) | ||||
| 			throw new ApiError("Access token expired", 0, 400); | ||||
| 		const tokenData = await connection.refresh(connectedAccount); | ||||
| 		access_token = tokenData.access_token; | ||||
| 	} else if (expires_in && expires_in < Date.now()) { | ||||
| 		if (!(connection instanceof RefreshableConnection)) | ||||
| 			throw new ApiError("Access token expired", 0, 400); | ||||
| 		const tokenData = await connection.refresh(connectedAccount); | ||||
| 		access_token = tokenData.access_token; | ||||
| 	} | ||||
| 
 | ||||
| 	res.json({ access_token }); | ||||
| }); | ||||
| 
 | ||||
| export default router; | ||||
| @ -35,7 +35,7 @@ router.get("/", route({}), async (req: Request, res: Response) => { | ||||
| 			"visibility", | ||||
| 			"show_activity", | ||||
| 			"revoked", | ||||
| 			"access_token", | ||||
| 			"token_data", | ||||
| 			"friend_sync", | ||||
| 			"integrations", | ||||
| 		], | ||||
|  | ||||
| @ -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<string> { | ||||
| 	async exchangeCode( | ||||
| 		state: string, | ||||
| 		code: string, | ||||
| 	): Promise<ConnectedAccountCommonOAuthTokenResponse> { | ||||
| 		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<ConnectedAccount | null> { | ||||
| 		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()); | ||||
| 
 | ||||
|  | ||||
| @ -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<string> { | ||||
| 	async exchangeCode( | ||||
| 		state: string, | ||||
| 		code: string, | ||||
| 	): Promise<ConnectedAccountCommonOAuthTokenResponse> { | ||||
| 		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<ConnectedAccount | null> { | ||||
| 		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); | ||||
| 
 | ||||
|  | ||||
| @ -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<string> { | ||||
| 	async exchangeCode( | ||||
| 		state: string, | ||||
| 		code: string, | ||||
| 	): Promise<EpicTokenResponse> { | ||||
| 		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<ConnectedAccount | null> { | ||||
| 		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); | ||||
| 
 | ||||
|  | ||||
| @ -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<string> { | ||||
| 	async exchangeCode( | ||||
| 		state: string, | ||||
| 		code: string, | ||||
| 	): Promise<ConnectedAccountCommonOAuthTokenResponse> { | ||||
| 		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<ConnectedAccount | null> { | ||||
| 		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); | ||||
| 
 | ||||
|  | ||||
| @ -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<string> { | ||||
| 	async exchangeCode( | ||||
| 		state: string, | ||||
| 		code: string, | ||||
| 	): Promise<ConnectedAccountCommonOAuthTokenResponse> { | ||||
| 		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<ConnectedAccount | null> { | ||||
| 		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()); | ||||
| 
 | ||||
|  | ||||
| @ -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<string> { | ||||
| 	async exchangeCode( | ||||
| 		state: string, | ||||
| 		code: string, | ||||
| 	): Promise<ConnectedAccountCommonOAuthTokenResponse> { | ||||
| 		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<ConnectedAccount | null> { | ||||
| 		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, | ||||
|  | ||||
| @ -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<string> { | ||||
| 	async exchangeCode( | ||||
| 		state: string, | ||||
| 		code: string, | ||||
| 	): Promise<ConnectedAccountCommonOAuthTokenResponse> { | ||||
| 		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<UserResponse> { | ||||
| 		const url = new URL(this.userInfoUrl); | ||||
| 		return fetch(url.toString(), { | ||||
| @ -130,14 +174,15 @@ export default class SpotifyConnection extends Connection { | ||||
| 		params: ConnectionCallbackSchema, | ||||
| 	): Promise<ConnectedAccount | null> { | ||||
| 		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, | ||||
|  | ||||
| @ -1,9 +1,11 @@ | ||||
| import crypto from "crypto"; | ||||
| import { ConnectedAccount } from "../entities"; | ||||
| import { OrmUtils } from "../imports"; | ||||
| import { ConnectedAccountSchema, ConnectionCallbackSchema } from "../schemas"; | ||||
| import { DiscordApiErrors } from "../util"; | ||||
| 
 | ||||
| /** | ||||
|  * A connection that can be used to connect to an external service. | ||||
|  */ | ||||
| export default abstract class Connection { | ||||
| 	id: string; | ||||
| 	settings: { enabled: boolean }; | ||||
| @ -21,7 +23,9 @@ export default abstract class Connection { | ||||
| 	 * Processes the callback | ||||
| 	 * @param args Callback arguments | ||||
| 	 */ | ||||
| 	abstract handleCallback(params: ConnectionCallbackSchema): Promise<ConnectedAccount | null>; | ||||
| 	abstract handleCallback( | ||||
| 		params: ConnectionCallbackSchema, | ||||
| 	): Promise<ConnectedAccount | null>; | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Gets a user id from state | ||||
| @ -54,12 +58,25 @@ export default abstract class Connection { | ||||
| 		this.states.delete(state); | ||||
| 	} | ||||
| 
 | ||||
| 	async createConnection(data: ConnectedAccountSchema): Promise<ConnectedAccount> { | ||||
| 		const ca = OrmUtils.mergeDeep(new ConnectedAccount(), data) as ConnectedAccount; | ||||
| 	/** | ||||
| 	 * Creates a Connected Account in the database. | ||||
| 	 * @param data connected account data | ||||
| 	 * @returns the new connected account | ||||
| 	 */ | ||||
| 	async createConnection( | ||||
| 		data: ConnectedAccountSchema, | ||||
| 	): Promise<ConnectedAccount> { | ||||
| 		const ca = ConnectedAccount.create({ ...data }); | ||||
| 		await ca.save(); | ||||
| 		return ca; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Checks if a user has an exist connected account for the given extenal id. | ||||
| 	 * @param userId the user id | ||||
| 	 * @param externalId the connection id to find | ||||
| 	 * @returns | ||||
| 	 */ | ||||
| 	async hasConnection(userId: string, externalId: string): Promise<boolean> { | ||||
| 		const existing = await ConnectedAccount.findOne({ | ||||
| 			where: { | ||||
|  | ||||
| @ -1,5 +1,7 @@ | ||||
| import Connection from "./Connection"; | ||||
| import RefreshableConnection from "./RefreshableConnection"; | ||||
| 
 | ||||
| export class ConnectionStore { | ||||
| 	public static connections: Map<string, Connection> = new Map(); | ||||
| 	public static connections: Map<string, Connection | RefreshableConnection> = | ||||
| 		new Map(); | ||||
| } | ||||
|  | ||||
							
								
								
									
										30
									
								
								src/util/connections/RefreshableConnection.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/util/connections/RefreshableConnection.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | ||||
| import { ConnectedAccount } from "../entities"; | ||||
| import { ConnectedAccountCommonOAuthTokenResponse } from "../interfaces"; | ||||
| import Connection from "./Connection"; | ||||
| 
 | ||||
| /** | ||||
|  * A connection that can refresh its token. | ||||
|  */ | ||||
| export default abstract class RefreshableConnection extends Connection { | ||||
| 	refreshEnabled = true; | ||||
| 	/** | ||||
| 	 * Refreshes the token for a connected account. | ||||
| 	 * @param connectedAccount The connected account to refresh | ||||
| 	 */ | ||||
| 	abstract refreshToken( | ||||
| 		connectedAccount: ConnectedAccount, | ||||
| 	): Promise<ConnectedAccountCommonOAuthTokenResponse>; | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Refreshes the token for a connected account and saves it to the database. | ||||
| 	 * @param connectedAccount The connected account to refresh | ||||
| 	 */ | ||||
| 	async refresh( | ||||
| 		connectedAccount: ConnectedAccount, | ||||
| 	): Promise<ConnectedAccountCommonOAuthTokenResponse> { | ||||
| 		const tokenData = await this.refreshToken(connectedAccount); | ||||
| 		connectedAccount.token_data = tokenData; | ||||
| 		await connectedAccount.save(); | ||||
| 		return tokenData; | ||||
| 	} | ||||
| } | ||||
| @ -2,3 +2,4 @@ export * from "./Connection"; | ||||
| export * from "./ConnectionConfig"; | ||||
| export * from "./ConnectionLoader"; | ||||
| export * from "./ConnectionStore"; | ||||
| export * from "./RefreshableConnection"; | ||||
|  | ||||
| @ -23,8 +23,8 @@ export class ConnectedAccountDTO { | ||||
| 		this.id = connectedAccount.external_id; | ||||
| 		this.user_id = connectedAccount.user_id; | ||||
| 		this.access_token = | ||||
| 			connectedAccount.access_token && with_token | ||||
| 				? connectedAccount.access_token | ||||
| 			connectedAccount.token_data && with_token | ||||
| 				? connectedAccount.token_data.access_token | ||||
| 				: undefined; | ||||
| 		this.friend_sync = connectedAccount.friend_sync; | ||||
| 		this.name = connectedAccount.name; | ||||
|  | ||||
| @ -17,6 +17,7 @@ | ||||
| */ | ||||
| 
 | ||||
| import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; | ||||
| import { ConnectedAccountTokenData } from "../interfaces"; | ||||
| import { BaseClass } from "./BaseClass"; | ||||
| import { User } from "./User"; | ||||
| 
 | ||||
| @ -40,9 +41,6 @@ export class ConnectedAccount extends BaseClass { | ||||
| 	}) | ||||
| 	user: User; | ||||
| 
 | ||||
| 	@Column({ select: false, nullable: true }) | ||||
| 	access_token?: string; | ||||
| 
 | ||||
| 	@Column({ select: false }) | ||||
| 	friend_sync?: boolean = false; | ||||
| 
 | ||||
| @ -75,4 +73,7 @@ export class ConnectedAccount extends BaseClass { | ||||
| 
 | ||||
| 	@Column() | ||||
| 	two_way_link?: boolean = false; | ||||
| 
 | ||||
| 	@Column({ select: false, nullable: true, type: "simple-json" }) | ||||
| 	token_data?: ConnectedAccountTokenData; | ||||
| } | ||||
|  | ||||
							
								
								
									
										16
									
								
								src/util/interfaces/ConnectedAccount.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/util/interfaces/ConnectedAccount.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| export interface ConnectedAccountCommonOAuthTokenResponse { | ||||
| 	access_token: string; | ||||
| 	token_type: string; | ||||
| 	scope: string; | ||||
| 	refresh_token?: string; | ||||
| 	expires_in?: number; | ||||
| } | ||||
| 
 | ||||
| export interface ConnectedAccountTokenData { | ||||
| 	access_token: string; | ||||
| 	token_type?: string; | ||||
| 	scope?: string; | ||||
| 	refresh_token?: string; | ||||
| 	expires_in?: number; | ||||
| 	expires_at?: number; | ||||
| } | ||||
| @ -17,7 +17,8 @@ | ||||
| */ | ||||
| 
 | ||||
| export * from "./Activity"; | ||||
| export * from "./Presence"; | ||||
| export * from "./Interaction"; | ||||
| export * from "./ConnectedAccount"; | ||||
| export * from "./Event"; | ||||
| export * from "./Interaction"; | ||||
| export * from "./Presence"; | ||||
| export * from "./Status"; | ||||
|  | ||||
| @ -1,7 +1,9 @@ | ||||
| import { ConnectedAccountTokenData } from "../interfaces"; | ||||
| 
 | ||||
| export interface ConnectedAccountSchema { | ||||
| 	external_id: string; | ||||
| 	user_id: string; | ||||
| 	access_token?: string; | ||||
| 	token_data?: ConnectedAccountTokenData; | ||||
| 	friend_sync?: boolean; | ||||
| 	name: string; | ||||
| 	revoked?: boolean; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Puyodead1
						Puyodead1