diff --git a/packages/auth-electron/src/ElectronAuthProvider.ts b/packages/auth-electron/src/ElectronAuthProvider.ts index 2828a8e3b..d7822ccdf 100644 --- a/packages/auth-electron/src/ElectronAuthProvider.ts +++ b/packages/auth-electron/src/ElectronAuthProvider.ts @@ -1,7 +1,8 @@ /// import { parse, stringify } from '@d-fischer/qs'; -import type { AccessToken, AuthProvider, AuthProviderTokenType } from '@twurple/auth'; +import type { AccessToken, AuthProviderTokenType } from '@twurple/auth'; +import { BaseAuthProvider } from '@twurple/auth'; import type { BrowserWindowConstructorOptions } from 'electron'; import { BrowserWindow } from 'electron'; import type { @@ -37,7 +38,7 @@ const defaultOptions: BaseOptions & Partial closeOnLogin: true }; -export class ElectronAuthProvider implements AuthProvider { +export class ElectronAuthProvider extends BaseAuthProvider { private _accessToken?: AccessToken; private readonly _currentScopes = new Set(); private readonly _options: BaseOptions & Partial; @@ -51,6 +52,7 @@ export class ElectronAuthProvider implements AuthProvider { clientCredentials: TwitchClientCredentials, options?: ElectronAuthProviderOptions | ElectronAuthProviderOptions ) { + super(); this._clientId = clientCredentials.clientId; this._redirectUri = clientCredentials.redirectUri; this._options = { ...defaultOptions, ...options }; @@ -68,7 +70,7 @@ export class ElectronAuthProvider implements AuthProvider { return Array.from(this._currentScopes); } - async getAccessToken(scopes: string[] = []): Promise { + async _doGetAccessToken(scopes: string[] = []): Promise { return await new Promise((resolve, reject) => { if (this._accessToken && scopes.every(scope => this._currentScopes.has(scope))) { resolve(this._accessToken); @@ -79,7 +81,7 @@ export class ElectronAuthProvider implements AuthProvider { response_type: 'token', client_id: this.clientId, redirect_uri: this._redirectUri, - scope: scopes.join(' ') + scope: Array.from(new Set([...this.currentScopes, ...scopes])).join(' ') }; if (this._allowUserChange) { queryParams.force_verify = true; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 9b8eb98c8..58d731323 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -14,6 +14,7 @@ export { TokenInfo } from './TokenInfo'; export type { TokenInfoData } from './TokenInfo'; export type { AuthProvider, AuthProviderTokenType } from './providers/AuthProvider'; +export { BaseAuthProvider } from './providers/BaseAuthProvider'; export { ClientCredentialsAuthProvider } from './providers/ClientCredentialsAuthProvider'; export { RefreshingAuthProvider } from './providers/RefreshingAuthProvider'; export type { RefreshConfig } from './providers/RefreshingAuthProvider'; diff --git a/packages/auth/src/providers/BaseAuthProvider.ts b/packages/auth/src/providers/BaseAuthProvider.ts new file mode 100644 index 000000000..ef1591c63 --- /dev/null +++ b/packages/auth/src/providers/BaseAuthProvider.ts @@ -0,0 +1,72 @@ +import type { AccessToken } from '../AccessToken'; +import type { AuthProvider, AuthProviderTokenType } from './AuthProvider'; + +export abstract class BaseAuthProvider implements AuthProvider { + abstract clientId: string; + abstract tokenType: AuthProviderTokenType; + abstract currentScopes: string[]; + + private _newTokenScopes = new Set(); + private _newTokenPromise: Promise | null = null; + private _queuedScopes = new Set(); + private _queueExecutor: (() => void) | null = null; + private _queuePromise: Promise | null = null; + + async getAccessToken(scopes?: string[]): Promise { + if (this._newTokenPromise) { + if (!scopes || scopes.every(scope => this._newTokenScopes.has(scope))) { + return await this._newTokenPromise; + } + + if (this._queueExecutor) { + for (const scope of scopes) { + this._queuedScopes.add(scope); + } + } else { + this._queuedScopes = new Set(scopes); + } + + this._queuePromise ??= new Promise((resolve, reject) => { + this._queueExecutor = async () => { + if (!this._queuePromise) { + return; + } + this._newTokenScopes = this._queuedScopes; + this._queuedScopes = new Set(); + this._newTokenPromise = this._queuePromise; + this._queuePromise = null; + this._queueExecutor = null; + try { + resolve(await this._doGetAccessToken(Array.from(this._newTokenScopes))); + } catch (e) { + reject(e); + } finally { + this._newTokenPromise = null; + this._newTokenScopes = new Set(); + (this._queueExecutor as (() => void) | null)?.(); + } + }; + }); + + return await this._queuePromise; + } + + this._newTokenScopes = new Set(scopes ?? []); + this._newTokenPromise = new Promise(async (resolve, reject) => { + try { + const scopesToFetch = Array.from(this._newTokenScopes); + resolve(await this._doGetAccessToken(scopesToFetch)); + } catch (e) { + reject(e); + } finally { + this._newTokenPromise = null; + this._newTokenScopes = new Set(); + this._queueExecutor?.(); + } + }); + + return await this._newTokenPromise; + } + + protected abstract _doGetAccessToken(scopes?: string[]): Promise; +} diff --git a/packages/auth/src/providers/ClientCredentialsAuthProvider.ts b/packages/auth/src/providers/ClientCredentialsAuthProvider.ts index b3ece55d1..a9edd11fc 100644 --- a/packages/auth/src/providers/ClientCredentialsAuthProvider.ts +++ b/packages/auth/src/providers/ClientCredentialsAuthProvider.ts @@ -3,13 +3,14 @@ import { rtfm } from '@twurple/common'; import type { AccessToken } from '../AccessToken'; import { accessTokenIsExpired } from '../AccessToken'; import { getAppToken } from '../helpers'; -import type { AuthProvider, AuthProviderTokenType } from './AuthProvider'; +import type { AuthProviderTokenType } from './AuthProvider'; +import { BaseAuthProvider } from './BaseAuthProvider'; /** * An auth provider that retrieve tokens using client credentials. */ @rtfm('auth', 'ClientCredentialsAuthProvider', 'clientId') -export class ClientCredentialsAuthProvider implements AuthProvider { +export class ClientCredentialsAuthProvider extends BaseAuthProvider { private readonly _clientId: string; @Enumerable(false) private readonly _clientSecret: string; @Enumerable(false) private _token?: AccessToken; @@ -28,31 +29,11 @@ export class ClientCredentialsAuthProvider implements AuthProvider { * @param clientSecret The client secret of your application. */ constructor(clientId: string, clientSecret: string) { + super(); this._clientId = clientId; this._clientSecret = clientSecret; } - /** - * Retrieves an access token. - * - * If any scopes are provided, this throws. The client credentials flow does not support scopes. - * - * @param scopes The requested scopes. - */ - async getAccessToken(scopes?: string[]): Promise { - if (scopes && scopes.length > 0) { - throw new Error( - `Scope ${scopes.join(', ')} requested but the client credentials flow does not support scopes` - ); - } - - if (!this._token || accessTokenIsExpired(this._token)) { - return await this.refresh(); - } - - return this._token; - } - /** * Retrieves a new app access token. */ @@ -73,4 +54,25 @@ export class ClientCredentialsAuthProvider implements AuthProvider { get currentScopes(): string[] { return []; } + + /** + * Retrieves an access token. + * + * If any scopes are provided, this throws. The client credentials flow does not support scopes. + * + * @param scopes The requested scopes. + */ + protected async _doGetAccessToken(scopes?: string[]): Promise { + if (scopes && scopes.length > 0) { + throw new Error( + `Scope ${scopes.join(', ')} requested but the client credentials flow does not support scopes` + ); + } + + if (!this._token || accessTokenIsExpired(this._token)) { + return await this.refresh(); + } + + return this._token; + } } diff --git a/packages/auth/src/providers/RefreshingAuthProvider.ts b/packages/auth/src/providers/RefreshingAuthProvider.ts index 4fa772f9a..74ecb3225 100644 --- a/packages/auth/src/providers/RefreshingAuthProvider.ts +++ b/packages/auth/src/providers/RefreshingAuthProvider.ts @@ -5,7 +5,8 @@ import type { AccessToken } from '../AccessToken'; import { accessTokenIsExpired } from '../AccessToken'; import { InvalidTokenError } from '../errors/InvalidTokenError'; import { compareScopes, loadAndCompareScopes, refreshUserToken } from '../helpers'; -import type { AuthProvider, AuthProviderTokenType } from './AuthProvider'; +import type { AuthProviderTokenType } from './AuthProvider'; +import { BaseAuthProvider } from './BaseAuthProvider'; /** * Configuration for the {@RefreshingAuthProvider}. @@ -34,7 +35,7 @@ export interface RefreshConfig { * automatically refreshing the access token whenever necessary. */ @rtfm('auth', 'RefreshingAuthProvider', 'clientId') -export class RefreshingAuthProvider implements AuthProvider { +export class RefreshingAuthProvider extends BaseAuthProvider { private readonly _clientId: string; @Enumerable(false) private readonly _clientSecret: string; @Enumerable(false) private _accessToken: MakeOptional; @@ -56,12 +57,39 @@ export class RefreshingAuthProvider implements AuthProvider { * @param initialToken The initial access token. */ constructor(refreshConfig: RefreshConfig, initialToken: MakeOptional) { + super(); this._clientId = refreshConfig.clientId; this._clientSecret = refreshConfig.clientSecret; this._onRefresh = refreshConfig.onRefresh; this._accessToken = initialToken; } + /** + * Force a refresh of the access token. + */ + async refresh(): Promise { + const tokenData = await refreshUserToken(this.clientId, this._clientSecret, this._accessToken.refreshToken!); + this._accessToken = tokenData; + + this._onRefresh?.(tokenData); + + return tokenData; + } + + /** + * The client ID. + */ + get clientId(): string { + return this._clientId; + } + + /** + * The scopes that are currently available using the access token. + */ + get currentScopes(): string[] { + return this._accessToken.scope ?? []; + } + /** * Retrieves an access token. * @@ -72,7 +100,7 @@ export class RefreshingAuthProvider implements AuthProvider { * * @param scopes The requested scopes. */ - async getAccessToken(scopes?: string[]): Promise { + protected async _doGetAccessToken(scopes?: string[]): Promise { // if we don't have a current token, we just pass this and refresh right away if (this._accessToken.accessToken && !accessTokenIsExpired(this._accessToken)) { try { @@ -103,30 +131,4 @@ export class RefreshingAuthProvider implements AuthProvider { compareScopes(refreshedToken.scope, scopes); return refreshedToken; } - - /** - * Force a refresh of the access token. - */ - async refresh(): Promise { - const tokenData = await refreshUserToken(this.clientId, this._clientSecret, this._accessToken.refreshToken!); - this._accessToken = tokenData; - - this._onRefresh?.(tokenData); - - return tokenData; - } - - /** - * The client ID. - */ - get clientId(): string { - return this._clientId; - } - - /** - * The scopes that are currently available using the access token. - */ - get currentScopes(): string[] { - return this._accessToken.scope ?? []; - } }