UNPKG

@badgateway/oauth2-client

Version:

OAuth2 client for browsers and Node.js. Tiny footprint, PKCE support

280 lines (221 loc) 7.59 kB
import type { OAuth2Token } from './token.ts'; import { OAuth2Client } from './client.ts'; type FetchMiddleware = (request: Request, next: (request: Request) => Promise<Response>) => Promise<Response>; type OAuth2FetchOptions = { /** * Reference to OAuth2 client. */ client: OAuth2Client; /** * You are responsible for implementing this function. * it's purpose is to supply the 'initial' oauth2 token. * * This function may be async. Return `null` to fail the process. */ getNewToken(): OAuth2Token | null | Promise<OAuth2Token | null>; /** * If set, will be called if authentication fatally failed. */ onError?: (err: Error) => void; /** * This function is called whenever the active token changes. Using this is * optional, but it may be used to (for example) put the token in off-line * storage for later usage. */ storeToken?: (token: OAuth2Token) => void; /** * Also an optional feature. Implement this if you want the wrapper to try a * stored token before attempting a full re-authentication. * * This function may be async. Return null if there was no token. */ getStoredToken?: () => OAuth2Token | null | Promise<OAuth2Token | null>; /** * Whether to automatically schedule token refresh. * * Certain execution environments, e.g. React Native, do not handle scheduled * tasks with setTimeout() in a graceful or predictable fashion. The default * behavior is to schedule refresh. Set this to false to disable scheduling. */ scheduleRefresh?: boolean; } export class OAuth2Fetch { private options: OAuth2FetchOptions; /** * Current active token (if any) */ private token: OAuth2Token | null = null; /** * If the user had a storedToken, the process to fetch it * may be async. We keep track of this process in this * promise, so it may be awaited to avoid race conditions. * * As soon as this promise resolves, this property gets nulled. */ private activeGetStoredToken: null | Promise<void> = null; constructor(options: OAuth2FetchOptions) { if (options?.scheduleRefresh === undefined) { options.scheduleRefresh = true; } this.options = options; if (options.getStoredToken) { this.activeGetStoredToken = (async () => { this.token = await options.getStoredToken!(); this.activeGetStoredToken = null; })(); } this.scheduleRefresh(); } /** * Does a fetch request and adds a Bearer / access token. * * If the access token is not known, this function attempts to fetch it * first. If the access token is almost expiring, this function might attempt * to refresh it. */ async fetch(input: RequestInfo, init?: RequestInit): Promise<Response> { // input might be a string or a Request object, we want to make sure this // is always a fully-formed Request object. const request = new Request(input, init); return this.mw()( request, req => fetch(req) ); } /** * This function allows the fetch-mw to be called as more traditional * middleware. * * This function returns a middleware function with the signature * (request, next): Response */ mw(): FetchMiddleware { return async (request, next) => { const accessToken = await this.getAccessToken(); // Make a clone. We need to clone if we need to retry the request later. let authenticatedRequest = request.clone(); authenticatedRequest.headers.set('Authorization', 'Bearer ' + accessToken); let response = await next(authenticatedRequest); if (!response.ok && response.status === 401) { const newToken = await this.refreshToken(); authenticatedRequest = request.clone(); authenticatedRequest.headers.set('Authorization', 'Bearer ' + newToken.accessToken); response = await next(authenticatedRequest); } return response; }; } /** * Returns current token information. * * There result object will have: * * accessToken * * expiresAt - when the token expires, or null. * * refreshToken - may be null * * This function will attempt to automatically refresh if stale. */ async getToken(): Promise<OAuth2Token> { if (this.token && (this.token.expiresAt === null || this.token.expiresAt > Date.now())) { // The current token is still valid return this.token; } return this.refreshToken(); } /** * Returns an access token. * * If the current access token is not known, it will attempt to fetch it. * If the access token is expiring, it will attempt to refresh it. */ async getAccessToken(): Promise<string> { // Ensure getStoredToken finished. await this.activeGetStoredToken; const token = await this.getToken(); return token.accessToken; } /** * Keeping track of an active refreshToken operation. * * This will allow us to ensure only 1 such operation happens at any * given time. */ private activeRefresh: Promise<OAuth2Token> | null = null; /** * Forces an access token refresh */ async refreshToken(): Promise<OAuth2Token> { if (this.activeRefresh) { // If we are currently already doing this operation, // make sure we don't do it twice in parallel. return this.activeRefresh; } const oldToken = this.token; this.activeRefresh = (async() => { let newToken: OAuth2Token|null = null; try { if (oldToken?.refreshToken) { // We had a refresh token, lets see if we can use it! newToken = await this.options.client.refreshToken(oldToken); } } catch (_err) { console.warn('[oauth2] refresh token not accepted, we\'ll try reauthenticating'); } if (!newToken) { newToken = await this.options.getNewToken(); } if (!newToken) { const err = new Error('Unable to obtain OAuth2 tokens, a full reauth may be needed'); this.options.onError?.(err); throw err; } return newToken; })(); try { const token = await this.activeRefresh; this.token = token; this.options.storeToken?.(token); this.scheduleRefresh(); return token; } catch (err: any) { if (this.options.onError) { this.options.onError(err); } throw err; } finally { // Make sure we clear the current refresh operation. this.activeRefresh = null; } } /** * Timer trigger for the next automated refresh */ private refreshTimer: ReturnType<typeof setTimeout> | null = null; private scheduleRefresh() { if (!this.options.scheduleRefresh) { return; } if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } if (!this.token?.expiresAt || !this.token.refreshToken) { // If we don't know when the token expires, or don't have a refresh_token, don't bother. return; } const expiresIn = this.token.expiresAt - Date.now(); // We only schedule this event if it happens more than 2 minutes in the future. if (expiresIn < 120*1000) { return; } // Schedule 1 minute before expiry this.refreshTimer = setTimeout(async () => { try { await this.refreshToken(); } catch (err) { // eslint-disable-next-line no-console console.error('[fetch-mw-oauth2] error while doing a background OAuth2 auto-refresh', err); } }, expiresIn - 60*1000); } }