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
text/typescript
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',
};
}