@twurple/auth
Version:
Authenticate with Twitch and stop caring about refreshing tokens.
251 lines (249 loc) • 9.73 kB
JavaScript
import { callTwitchApi, HttpStatusCodeError } from '@twurple/api-call';
import { InvalidTokenError } from './errors/InvalidTokenError.js';
import { InvalidTokenTypeError } from './errors/InvalidTokenTypeError.js';
import { createExchangeCodeQuery, createGetAppTokenQuery, createRefreshTokenQuery, createRevokeTokenQuery, } from './helpers.external.js';
import { TokenInfo } from './TokenInfo.js';
/** @internal */
function createAccessTokenFromData(data) {
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || null,
scope: data.scope ?? [],
expiresIn: data.expires_in ?? null,
obtainmentTimestamp: Date.now(),
};
}
/**
* Gets an access token with your client credentials and an authorization code.
*
* @param clientId The client ID of your application.
* @param clientSecret The client secret of your application.
* @param code The authorization code.
* @param redirectUri The redirect URI.
*
* This serves no real purpose here, but must still match one of the redirect URIs you configured in the Twitch Developer dashboard.
*/
export async function exchangeCode(clientId, clientSecret, code, redirectUri) {
return createAccessTokenFromData(await callTwitchApi({
type: 'auth',
url: 'token',
method: 'POST',
query: createExchangeCodeQuery(clientId, clientSecret, code, redirectUri),
}));
}
/**
* Gets an app access token with your client credentials.
*
* @param clientId The client ID of your application.
* @param clientSecret The client secret of your application.
*/
export async function getAppToken(clientId, clientSecret) {
return createAccessTokenFromData(await callTwitchApi({
type: 'auth',
url: 'token',
method: 'POST',
query: createGetAppTokenQuery(clientId, clientSecret),
}));
}
/**
* Refreshes an expired access token with your client credentials and the refresh token that was given by the initial authentication.
*
* @param clientId The client ID of your application.
* @param clientSecret The client secret of your application.
* @param refreshToken The refresh token.
*/
export async function refreshUserToken(clientId, clientSecret, refreshToken) {
return createAccessTokenFromData(await callTwitchApi({
type: 'auth',
url: 'token',
method: 'POST',
query: createRefreshTokenQuery(clientId, clientSecret, refreshToken),
}));
}
/**
* Revokes an access token.
*
* @param clientId The client ID of your application.
* @param accessToken The access token.
*/
export async function revokeToken(clientId, accessToken) {
await callTwitchApi({
type: 'auth',
url: 'revoke',
method: 'POST',
query: createRevokeTokenQuery(clientId, accessToken),
});
}
/**
* Gets information about an access token.
*
* @param accessToken The access token to get the information of.
* @param clientId The client ID of your application.
*
* You need to obtain one using one of the [Twitch OAuth flows](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/).
*/
export async function getTokenInfo(accessToken, clientId) {
try {
const data = await callTwitchApi({ type: 'auth', url: 'validate' }, clientId, accessToken);
return new TokenInfo(data);
}
catch (e) {
if (e instanceof HttpStatusCodeError && e.statusCode === 401) {
throw new InvalidTokenError({ cause: e });
}
throw e;
}
}
/** @private */
export async function getValidTokenFromProviderForUser(provider, userId, scopes, logger) {
let lastTokenError = null;
let foundUser = false;
try {
const accessToken = await provider.getAccessTokenForUser(userId, scopes);
if (accessToken) {
foundUser = true;
// check validity
const tokenInfo = await getTokenInfo(accessToken.accessToken);
return { accessToken, tokenInfo };
}
}
catch (e) {
if (e instanceof InvalidTokenError) {
lastTokenError = e;
}
else {
logger?.error(`Retrieving an access token failed: ${e.message}`);
}
}
if (foundUser) {
logger?.warn('No valid token available; trying to refresh');
if (provider.refreshAccessTokenForUser) {
try {
const newToken = await provider.refreshAccessTokenForUser(userId);
// check validity
const tokenInfo = await getTokenInfo(newToken.accessToken);
return { accessToken: newToken, tokenInfo };
}
catch (e) {
if (e instanceof InvalidTokenError) {
lastTokenError = e;
}
else {
logger?.error(`Refreshing the access token failed: ${e.message}`);
}
}
}
}
throw lastTokenError ?? new Error('Could not retrieve a valid token');
}
/** @private */
export async function getValidTokenFromProviderForIntent(provider, intent, scopes, logger) {
let lastTokenError = null;
let foundUser = false;
if (!provider.getAccessTokenForIntent) {
throw new InvalidTokenTypeError(`This call requires an AuthProvider that supports intents.
Please use an auth provider that does, such as \`RefreshingAuthProvider\`.`);
}
try {
const accessToken = await provider.getAccessTokenForIntent(intent, scopes);
if (accessToken) {
foundUser = true;
// check validity
const tokenInfo = await getTokenInfo(accessToken.accessToken);
return { accessToken, tokenInfo };
}
}
catch (e) {
if (e instanceof InvalidTokenError) {
lastTokenError = e;
}
else {
logger?.error(`Retrieving an access token failed: ${e.message}`);
}
}
if (foundUser) {
logger?.warn('No valid token available; trying to refresh');
if (provider.refreshAccessTokenForIntent) {
try {
const newToken = await provider.refreshAccessTokenForIntent(intent);
// check validity
const tokenInfo = await getTokenInfo(newToken.accessToken);
return { accessToken: newToken, tokenInfo };
}
catch (e) {
if (e instanceof InvalidTokenError) {
lastTokenError = e;
}
else {
logger?.error(`Refreshing the access token failed: ${e.message}`);
}
}
}
}
throw lastTokenError ?? new Error('Could not retrieve a valid token');
}
const scopeEquivalencies = new Map([
['channel_commercial', ['channel:edit:commercial']],
['channel_editor', ['channel:manage:broadcast']],
['channel_read', ['channel:read:stream_key']],
['channel_subscriptions', ['channel:read:subscriptions']],
['user_blocks_read', ['user:read:blocked_users']],
['user_blocks_edit', ['user:manage:blocked_users']],
['user_follows_edit', ['user:edit:follows']],
['user_read', ['user:read:email']],
['user_subscriptions', ['user:read:subscriptions']],
['user:edit:broadcast', ['channel:manage:broadcast', 'channel:manage:extensions']],
]);
/**
* Compares scopes for a non-upgradable {@link AuthProvider} instance.
*
* @param scopesToCompare The scopes to compare against.
* @param requestedScopes The scopes you requested.
*/
export function compareScopes(scopesToCompare, requestedScopes) {
if (requestedScopes?.length) {
const scopes = new Set(scopesToCompare.flatMap(scope => [scope, ...(scopeEquivalencies.get(scope) ?? [])]));
if (requestedScopes.every(scope => !scopes.has(scope))) {
const scopesStr = requestedScopes.join(', ');
throw new Error(`This token does not have any of the requested scopes (${scopesStr}) and can not be upgraded.
If you need dynamically upgrading scopes, please implement the AuthProvider interface accordingly:
\thttps://twurple.js.org/reference/auth/interfaces/AuthProvider.html`);
}
}
}
/**
* Compares scope sets for a non-upgradable {@link AuthProvider} instance.
*
* @param scopesToCompare The scopes to compare against.
* @param requestedScopeSets The scope sets you requested.
*/
export function compareScopeSets(scopesToCompare, requestedScopeSets) {
for (const requestedScopes of requestedScopeSets) {
compareScopes(scopesToCompare, requestedScopes);
}
}
/**
* Compares scopes for a non-upgradable `AuthProvider` instance, loading them from the token if necessary,
* and returns them together with the user ID.
*
* @param clientId The client ID of your application.
* @param token The access token.
* @param userId The user ID that was already loaded.
* @param loadedScopes The scopes that were already loaded.
* @param requestedScopeSets The scope sets you requested.
*/
export async function loadAndCompareTokenInfo(clientId, token, userId, loadedScopes, requestedScopeSets) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (requestedScopeSets?.length || !userId) {
const userInfo = await getTokenInfo(token, clientId);
if (!userInfo.userId) {
throw new Error('Trying to use an app access token as a user access token');
}
const scopesToCompare = loadedScopes ?? userInfo.scopes;
if (requestedScopeSets) {
compareScopeSets(scopesToCompare, requestedScopeSets.filter((val) => Boolean(val)));
}
return [scopesToCompare, userInfo.userId];
}
return [loadedScopes, userId];
}