UNPKG

@twurple/auth

Version:

Authenticate with Twitch and stop caring about refreshing tokens.

440 lines (439 loc) 17.6 kB
import { __decorate } from "tslib"; import { Enumerable } from '@d-fischer/shared-utils'; import { EventEmitter } from '@d-fischer/typed-event-emitter'; import { extractUserId, HellFreezesOverError, rtfm } from '@twurple/common'; import { accessTokenIsExpired, } from "../AccessToken.mjs"; import { CachedRefreshFailureError } from "../errors/CachedRefreshFailureError.mjs"; import { IntermediateUserRemovalError } from "../errors/IntermediateUserRemovalError.mjs"; import { InvalidTokenError } from "../errors/InvalidTokenError.mjs"; import { InvalidTokenTypeError } from "../errors/InvalidTokenTypeError.mjs"; import { UnknownIntentError } from "../errors/UnknownIntentError.mjs"; import { compareScopeSets, exchangeCode, getAppToken, getTokenInfo, loadAndCompareTokenInfo, refreshUserToken, } from "../helpers.mjs"; import { TokenFetcher } from "../TokenFetcher.mjs"; /** * An auth provider with the ability to make use of refresh tokens, * automatically refreshing the access token whenever necessary. */ let RefreshingAuthProvider = class RefreshingAuthProvider extends EventEmitter { /** * Creates a new auth provider based on the given one that can automatically * refresh access tokens. * * @param refreshConfig The information necessary to automatically refresh an access token. */ constructor(refreshConfig) { var _a; super(); /** @internal */ this._userAccessTokens = new Map(); /** @internal */ this._userTokenFetchers = new Map(); this._intentToUserId = new Map(); this._userIdToIntents = new Map(); this._cachedRefreshFailures = new Set(); /** * Fires when a user token is refreshed. * * @param userId The ID of the user whose token was successfully refreshed. * @param token The refreshed token data. */ this.onRefresh = this.registerEvent(); /** * Fires when a user token fails to refresh. * * @param userId The ID of the user whose token wasn't successfully refreshed. */ this.onRefreshFailure = this.registerEvent(); this._clientId = refreshConfig.clientId; this._clientSecret = refreshConfig.clientSecret; this._redirectUri = refreshConfig.redirectUri; this._appImpliedScopes = (_a = refreshConfig.appImpliedScopes) !== null && _a !== void 0 ? _a : []; this._appTokenFetcher = new TokenFetcher(async (scopes) => await this._fetchAppToken(scopes)); } /** * Adds the given user with their corresponding token to the provider. * * @param user The user to add. * @param initialToken The token for the user. * @param intents The intents to add to the user. * * Any intents that were already set before will be overwritten to point to this user instead. */ addUser(user, initialToken, intents) { const userId = extractUserId(user); if (!initialToken.refreshToken) { throw new Error(`Trying to add user ${userId} without refresh token`); } this._cachedRefreshFailures.delete(userId); this._userAccessTokens.set(userId, { ...initialToken, userId, }); if (!this._userTokenFetchers.has(userId)) { this._userTokenFetchers.set(userId, new TokenFetcher(async (scopes) => await this._fetchUserToken(userId, scopes))); } if (intents) { this.addIntentsToUser(user, intents); } } /** * Figures out the user associated to the given token and adds them to the provider. * * If you already know the ID of the user you're adding, * consider using {@link RefreshingAuthProvider#addUser} instead. * * @param initialToken The token for the user. * @param intents The intents to add to the user. * * Any intents that were already set before will be overwritten to point to the associated user instead. */ async addUserForToken(initialToken, intents) { let tokenWithInfo = null; if (initialToken.accessToken && !accessTokenIsExpired(initialToken)) { try { const tokenInfo = await getTokenInfo(initialToken.accessToken); tokenWithInfo = [initialToken, tokenInfo]; } catch (e) { if (!(e instanceof InvalidTokenError)) { throw e; } } } if (!tokenWithInfo) { if (!initialToken.refreshToken) { throw new InvalidTokenError(); } const refreshedToken = await refreshUserToken(this._clientId, this._clientSecret, initialToken.refreshToken); const tokenInfo = await getTokenInfo(refreshedToken.accessToken); this.emit(this.onRefresh, tokenInfo.userId, refreshedToken); tokenWithInfo = [refreshedToken, tokenInfo]; } const [tokenToAdd, tokenInfo] = tokenWithInfo; if (!tokenInfo.userId) { throw new InvalidTokenTypeError('Could not determine a user ID for your token; you might be trying to disguise an app token as a user token.'); } const token = tokenToAdd.scope ? tokenToAdd : { ...tokenToAdd, scope: tokenInfo.scopes, }; this.addUser(tokenInfo.userId, token, intents); return tokenInfo.userId; } /** * Gets an OAuth token from the given authorization code and adds the user to the provider. * * An authorization code can be obtained using the * [OAuth Authorization Code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow). * * @param code The authorization code. * @param intents The intents to add to the user. * * Any intents that were already set before will be overwritten to point to the associated user instead. */ async addUserForCode(code, intents) { if (!this._redirectUri) { throw new Error('This method requires you to pass a `redirectUri` as a configuration property'); } const token = await exchangeCode(this._clientId, this._clientSecret, code, this._redirectUri); return await this.addUserForToken(token, intents); } /** * Checks whether a user was added to the provider. * * @param user The user to check. */ hasUser(user) { return this._userTokenFetchers.has(extractUserId(user)); } /** * Removes a user from the provider. * * This also makes all intents this user was assigned to unusable. * * @param user The user to remove. */ removeUser(user) { const userId = extractUserId(user); if (this._userIdToIntents.has(userId)) { const intents = this._userIdToIntents.get(userId); for (const intent of intents) { this._intentToUserId.delete(intent); } this._userIdToIntents.delete(userId); } this._userAccessTokens.delete(userId); this._userTokenFetchers.delete(userId); this._cachedRefreshFailures.delete(userId); } /** * Adds intents to a user. * * Any intents that were already set before will be overwritten to point to this user instead. * * @param user The user to add intents to. * @param intents The intents to add to the user. */ addIntentsToUser(user, intents) { const userId = extractUserId(user); if (!this._userAccessTokens.has(userId)) { throw new Error('Trying to add intents to a user that was not added to this provider'); } for (const intent of intents) { if (this._intentToUserId.has(intent)) { this._userIdToIntents.get(this._intentToUserId.get(intent)).delete(intent); } this._intentToUserId.set(intent, userId); if (this._userIdToIntents.has(userId)) { this._userIdToIntents.get(userId).add(intent); } else { this._userIdToIntents.set(userId, new Set([intent])); } } } /** * Gets all intents assigned to the given user. * * @param user The user to get intents of. */ getIntentsForUser(user) { const userId = extractUserId(user); return this._userIdToIntents.has(userId) ? Array.from(this._userIdToIntents.get(userId)) : []; } /** * Removes all given intents from any user who they might be assigned to. * * Intents that have not been assigned are silently ignored. * * @param intents The intents to remove. */ removeIntents(intents) { var _a; for (const intent of intents) { if (this._intentToUserId.has(intent)) { const userId = this._intentToUserId.get(intent); (_a = this._userIdToIntents.get(userId)) === null || _a === void 0 ? void 0 : _a.delete(intent); this._intentToUserId.delete(intent); } } } /** * Requests that the provider fetches a new token from Twitch for the given user. * * @param user The user to refresh the token for. */ async refreshAccessTokenForUser(user) { const userId = extractUserId(user); if (this._cachedRefreshFailures.has(userId)) { throw new CachedRefreshFailureError(userId); } const previousTokenData = this._userAccessTokens.get(userId); if (!previousTokenData) { throw new Error('Trying to refresh token for user that was not added to the provider'); } const tokenData = await this._refreshUserTokenWithCallback(userId, previousTokenData.refreshToken); this._checkIntermediateUserRemoval(userId); this._userAccessTokens.set(userId, { ...tokenData, userId, }); this.emit(this.onRefresh, userId, tokenData); return { ...tokenData, userId, }; } /** * Requests that the provider fetches a new token from Twitch for the given intent. * * @param intent The intent to refresh the token for. */ async refreshAccessTokenForIntent(intent) { if (!this._intentToUserId.has(intent)) { throw new UnknownIntentError(intent); } const userId = this._intentToUserId.get(intent); return await this.refreshAccessTokenForUser(userId); } /** * The client ID. */ get clientId() { return this._clientId; } /** * Gets the scopes that are currently available using the access token. * * @param user The user to get the current scopes for. */ getCurrentScopesForUser(user) { var _a; const token = this._userAccessTokens.get(extractUserId(user)); if (!token) { throw new Error('Trying to get scopes for user that was not added to the provider'); } return (_a = token.scope) !== null && _a !== void 0 ? _a : []; } /** * Gets an access token for the given user. * * @param user The user to get an access token for. * @param scopeSets The requested scopes. */ async getAccessTokenForUser(user, ...scopeSets) { const userId = extractUserId(user); const fetcher = this._userTokenFetchers.get(userId); if (!fetcher) { return null; } if (this._cachedRefreshFailures.has(userId)) { throw new CachedRefreshFailureError(userId); } compareScopeSets(this.getCurrentScopesForUser(userId), scopeSets.filter(Boolean)); return await fetcher.fetch(...scopeSets); } /** * Fetches a token for a user identified by the given intent. * * @param intent The intent to fetch a token for. * @param scopeSets The requested scopes. */ async getAccessTokenForIntent(intent, ...scopeSets) { if (!this._intentToUserId.has(intent)) { return null; } const userId = this._intentToUserId.get(intent); const newToken = await this.getAccessTokenForUser(userId, ...scopeSets); if (!newToken) { throw new HellFreezesOverError(`Found intent ${intent} corresponding to user ID ${userId} but no token was found`); } return { ...newToken, userId, }; } /** * Fetches any token to use with a request that supports both user and app tokens, * i.e. public data relating to a user. * * @param user The user. */ async getAnyAccessToken(user) { if (user) { const userId = extractUserId(user); if (this._userAccessTokens.has(userId)) { const token = await this.getAccessTokenForUser(userId); if (!token) { throw new HellFreezesOverError(`Token for user ID ${userId} exists but nothing was returned by getAccessTokenForUser`); } return { ...token, userId, }; } } return await this.getAppAccessToken(); } /** * Fetches an app access token. * * @param forceNew Whether to always get a new token, even if the old one is still deemed valid internally. */ async getAppAccessToken(forceNew = false) { if (forceNew) { this._appAccessToken = undefined; } return await this._appTokenFetcher.fetch(...this._appImpliedScopes.map(scopes => [scopes])); } _checkIntermediateUserRemoval(userId) { if (!this._userTokenFetchers.has(userId)) { this._cachedRefreshFailures.delete(userId); throw new IntermediateUserRemovalError(userId); } } async _fetchUserToken(userId, scopeSets) { const previousToken = this._userAccessTokens.get(userId); if (!previousToken) { throw new Error('Trying to get token for user that was not added to the provider'); } // if we don't have a current token, we just pass this and refresh right away if (previousToken.accessToken && !accessTokenIsExpired(previousToken)) { try { // don't create new object on every get if (previousToken.scope) { compareScopeSets(previousToken.scope, scopeSets); return previousToken; } const [scope = []] = await loadAndCompareTokenInfo(this._clientId, previousToken.accessToken, userId, previousToken.scope, scopeSets); const newToken = { ...previousToken, scope, }; this._checkIntermediateUserRemoval(userId); this._userAccessTokens.set(userId, newToken); return newToken; } catch (e) { // if loading scopes failed, ignore InvalidTokenError and proceed with refreshing if (!(e instanceof InvalidTokenError)) { throw e; } } } this._checkIntermediateUserRemoval(userId); const refreshedToken = await this.refreshAccessTokenForUser(userId); compareScopeSets(refreshedToken.scope, scopeSets); return refreshedToken; } async _refreshUserTokenWithCallback(userId, refreshToken) { try { return await refreshUserToken(this.clientId, this._clientSecret, refreshToken); } catch (e) { this._cachedRefreshFailures.add(userId); this.emit(this.onRefreshFailure, userId, e); throw e; } } async _fetchAppToken(scopeSets) { if (scopeSets.length > 0) { for (const scopes of scopeSets) { if (this._appImpliedScopes.length) { if (scopes.every(scope => !this._appImpliedScopes.includes(scope))) { throw new Error(`One of the scopes ${scopes.join(', ')} requested but only the scope ${this._appImpliedScopes.join(', ')} is implied`); } } else { throw new Error(`One of the scopes ${scopes.join(', ')} requested but the client credentials flow does not support scopes`); } } } if (!this._appAccessToken || accessTokenIsExpired(this._appAccessToken)) { return await this._refreshAppToken(); } return this._appAccessToken; } async _refreshAppToken() { return (this._appAccessToken = await getAppToken(this._clientId, this._clientSecret)); } }; __decorate([ Enumerable(false) ], RefreshingAuthProvider.prototype, "_clientSecret", void 0); __decorate([ Enumerable(false) ], RefreshingAuthProvider.prototype, "_userAccessTokens", void 0); __decorate([ Enumerable(false) ], RefreshingAuthProvider.prototype, "_userTokenFetchers", void 0); __decorate([ Enumerable(false) ], RefreshingAuthProvider.prototype, "_appAccessToken", void 0); __decorate([ Enumerable(false) ], RefreshingAuthProvider.prototype, "_appTokenFetcher", void 0); RefreshingAuthProvider = __decorate([ rtfm('auth', 'RefreshingAuthProvider', 'clientId') ], RefreshingAuthProvider); export { RefreshingAuthProvider };