Skip to content

Commit

Permalink
implement queue for requests to bundled AuthProvider implementations (#…
Browse files Browse the repository at this point in the history
…320)

fixes #318
  • Loading branch information
d-fischer committed Jan 25, 2022
1 parent 5eab69e commit 1db82da
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 56 deletions.
10 changes: 6 additions & 4 deletions packages/auth-electron/src/ElectronAuthProvider.ts
@@ -1,7 +1,8 @@
/// <reference lib="dom" />

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 {
Expand Down Expand Up @@ -37,7 +38,7 @@ const defaultOptions: BaseOptions & Partial<WindowStyleOptions & WindowOptions>
closeOnLogin: true
};

export class ElectronAuthProvider implements AuthProvider {
export class ElectronAuthProvider extends BaseAuthProvider {
private _accessToken?: AccessToken;
private readonly _currentScopes = new Set<string>();
private readonly _options: BaseOptions & Partial<WindowOptions & WindowStyleOptions>;
Expand All @@ -51,6 +52,7 @@ export class ElectronAuthProvider implements AuthProvider {
clientCredentials: TwitchClientCredentials,
options?: ElectronAuthProviderOptions | ElectronAuthProviderOptions<WindowOptions>
) {
super();
this._clientId = clientCredentials.clientId;
this._redirectUri = clientCredentials.redirectUri;
this._options = { ...defaultOptions, ...options };
Expand All @@ -68,7 +70,7 @@ export class ElectronAuthProvider implements AuthProvider {
return Array.from(this._currentScopes);
}

async getAccessToken(scopes: string[] = []): Promise<AccessToken> {
async _doGetAccessToken(scopes: string[] = []): Promise<AccessToken> {
return await new Promise<AccessToken>((resolve, reject) => {
if (this._accessToken && scopes.every(scope => this._currentScopes.has(scope))) {
resolve(this._accessToken);
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/index.ts
Expand Up @@ -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';
Expand Down
72 changes: 72 additions & 0 deletions 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<string>();
private _newTokenPromise: Promise<AccessToken | null> | null = null;
private _queuedScopes = new Set<string>();
private _queueExecutor: (() => void) | null = null;
private _queuePromise: Promise<AccessToken | null> | null = null;

async getAccessToken(scopes?: string[]): Promise<AccessToken | null> {
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<string>(scopes);
}

this._queuePromise ??= new Promise<AccessToken | null>((resolve, reject) => {
this._queueExecutor = async () => {
if (!this._queuePromise) {
return;
}
this._newTokenScopes = this._queuedScopes;
this._queuedScopes = new Set<string>();
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<string>();
(this._queueExecutor as (() => void) | null)?.();
}
};
});

return await this._queuePromise;
}

this._newTokenScopes = new Set<string>(scopes ?? []);
this._newTokenPromise = new Promise<AccessToken | null>(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<string>();
this._queueExecutor?.();
}
});

return await this._newTokenPromise;
}

protected abstract _doGetAccessToken(scopes?: string[]): Promise<AccessToken | null>;
}
48 changes: 25 additions & 23 deletions packages/auth/src/providers/ClientCredentialsAuthProvider.ts
Expand Up @@ -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<ClientCredentialsAuthProvider>('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;
Expand All @@ -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<AccessToken> {
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.
*/
Expand All @@ -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<AccessToken> {
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;
}
}
60 changes: 31 additions & 29 deletions packages/auth/src/providers/RefreshingAuthProvider.ts
Expand Up @@ -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}.
Expand Down Expand Up @@ -34,7 +35,7 @@ export interface RefreshConfig {
* automatically refreshing the access token whenever necessary.
*/
@rtfm<RefreshingAuthProvider>('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<AccessToken, 'accessToken' | 'scope'>;
Expand All @@ -56,12 +57,39 @@ export class RefreshingAuthProvider implements AuthProvider {
* @param initialToken The initial access token.
*/
constructor(refreshConfig: RefreshConfig, initialToken: MakeOptional<AccessToken, 'accessToken' | 'scope'>) {
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<AccessToken> {
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.
*
Expand All @@ -72,7 +100,7 @@ export class RefreshingAuthProvider implements AuthProvider {
*
* @param scopes The requested scopes.
*/
async getAccessToken(scopes?: string[]): Promise<AccessToken | null> {
protected async _doGetAccessToken(scopes?: string[]): Promise<AccessToken | null> {
// if we don't have a current token, we just pass this and refresh right away
if (this._accessToken.accessToken && !accessTokenIsExpired(this._accessToken)) {
try {
Expand Down Expand Up @@ -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<AccessToken> {
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 ?? [];
}
}

0 comments on commit 1db82da

Please sign in to comment.