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 ?? [];
- }
}