UNPKG

scrivito

Version:

Scrivito is a professional, yet easy to use SaaS Enterprise Content Management Service, built for digital agencies and medium to large businesses. It is completely maintenance-free, cost-effective, and has unprecedented performance and security.

154 lines (128 loc) 4.41 kB
import { AuthorizationProvider, ClientError, ClientErrorRequestDetails, } from 'scrivito_sdk/client'; import { ExponentialBackoff } from 'scrivito_sdk/client/exponential_backoff'; import { isErrorResponse } from 'scrivito_sdk/client/is_error_response'; import { ERROR_CODE_AUTH_CHECK_REQUIRED } from 'scrivito_sdk/client/login_handler'; import { ScrivitoError, registerAsyncTask } from 'scrivito_sdk/common'; export type ResultOrFail<T> = | { result: T; } | { authenticationFailed: { error: string; code: string; details?: object; }; }; export class TokenAuthorizationError extends ScrivitoError { constructor( readonly message: string, readonly code: string | undefined, readonly httpStatus: number, readonly requestDetails: ClientErrorRequestDetails = {} ) { super(message); } } export class TokenAuthorizationProvider implements AuthorizationProvider { private fetchTokenPromise?: Promise<string | null>; constructor(private fetchToken: () => Promise<string | null>) {} async authorize( request: (auth?: string) => Promise<Response> ): Promise<Response> { return this.authorizeAbstract<Response>( async (auth: string | undefined) => { const result = await request(auth ? `Bearer ${auth}` : undefined); // IAM always responds with a 401 status code if a new token is needed. // Any other status code indicates that something went wrong. if (result.status !== 401) return { result }; const contentType = result.headers.get('content-type'); if (!contentType?.includes('application/json')) { return { authenticationFailed: fallbackErrorResponse() }; } const clonedResponse = result.clone(); const authenticationFailed = await registerAsyncTask(async () => { try { // Explicit cast ensures TS infers the return type correctly. // Without it, `json()` is typed as any. return clonedResponse.json() as Promise<unknown>; } catch { return Promise.resolve(); } }); if ( !isErrorResponse(authenticationFailed) || !authenticationFailed.code ) { return { authenticationFailed: fallbackErrorResponse() }; } return { authenticationFailed: { error: authenticationFailed.error, code: authenticationFailed.code, details: authenticationFailed.details, }, }; } ); } async authorizeAbstract<T>( callback: (auth?: string) => Promise<ResultOrFail<T>> ): Promise<T> { const backoff = new ExponentialBackoff(); let fetchedTokenBefore = false; // note: using a loop instead of recursion avoids stack overflow while (true) { if (!this.fetchTokenPromise) { this.fetchTokenPromise = (async () => { if (fetchedTokenBefore) await backoff.nextDelay(); fetchedTokenBefore = true; try { return await this.fetchToken(); } catch (error) { if (error instanceof ClientError && error.httpStatus === 404) { throw new TokenAuthorizationError( error.message, error.code, error.httpStatus, error.requestDetails ); } throw error; } })(); } const tokenPromise = this.fetchTokenPromise; const token = await tokenPromise; const outcome = token === null ? await callback() : await callback(token); if ('result' in outcome) return outcome.result; const { authenticationFailed: { error, code, details = {} }, } = outcome; if ( outcome.authenticationFailed.code === ERROR_CODE_AUTH_CHECK_REQUIRED ) { throw new ClientError(error, code, details); } // is token renewal already in progress? (concurrency) if (tokenPromise === this.fetchTokenPromise) { // if not: trigger renewal this.fetchTokenPromise = undefined; } } } /** for test purposes */ injectToken(token: string): void { this.fetchTokenPromise = Promise.resolve(token); } } function fallbackErrorResponse() { return { error: 'authentication invalid', code: 'auth_missing', }; }