firebase-auth-cloudflare-workers
Version:
Zero-dependencies firebase auth library for Cloudflare Workers.
157 lines (156 loc) • 6.14 kB
JavaScript
import { useEmulator } from './emulator';
import { AppErrorCodes, FirebaseAppError, FirebaseAuthError } from './errors';
import { isNonNullObject } from './validator';
import { version } from './version';
/**
* Default retry configuration for HTTP requests. Retries up to 4 times on connection reset and timeout errors
* as well as HTTP 503 errors. Exposed as a function to ensure that every HttpClient gets its own RetryConfig
* instance.
*/
export function defaultRetryConfig() {
return {
maxRetries: 4,
statusCodes: [503],
ioErrorCodes: ['ECONNRESET', 'ETIMEDOUT'],
backOffFactor: 0.5,
maxDelayInMillis: 60 * 1000,
};
}
export function buildApiUrl(projectId, apiSettings, env) {
const defaultAuthURL = 'https://identitytoolkit.googleapis.com';
const baseUrl = env?.FIREBASE_AUTH_EMULATOR_HOST
? `http://${env.FIREBASE_AUTH_EMULATOR_HOST}/identitytoolkit.googleapis.com`
: defaultAuthURL;
const endpoint = apiSettings.getEndpoint();
return `${baseUrl}/${apiSettings.getVersion()}/projects/${projectId}${endpoint}`;
}
export class BaseClient {
projectId;
credential;
retryConfig;
constructor(projectId, credential, retryConfig = defaultRetryConfig()) {
this.projectId = projectId;
this.credential = credential;
this.retryConfig = retryConfig;
}
async getToken(env) {
if (useEmulator(env)) {
return 'owner';
}
const result = await this.credential.getAccessToken();
return result.access_token;
}
async fetch(apiSettings, requestData, env) {
const fullUrl = buildApiUrl(this.projectId, apiSettings, env);
if (requestData) {
const requestValidator = apiSettings.getRequestValidator();
requestValidator(requestData);
}
const token = await this.getToken(env);
const method = apiSettings.getHttpMethod();
const signal = AbortSignal.timeout(25000); // 25s
return await this.fetchWithRetry(fullUrl, {
method,
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': `Code-Hex/firebase-auth-cloudflare-workers/${version}`,
'X-Client-Version': `Code-Hex/firebase-auth-cloudflare-workers/${version}`,
'Content-Type': 'application/json;charset=utf-8',
},
body: requestData ? JSON.stringify(requestData) : undefined,
signal,
});
}
async fetchWithRetry(url, init, retryAttempts = 0) {
try {
const res = await fetch(url, init);
const text = await res.text();
if (!res.ok) {
throw new HttpError(res.status, text);
}
try {
return JSON.parse(text);
}
catch (err) {
throw new HttpError(res.status, text, {
cause: new FirebaseAppError(AppErrorCodes.UNABLE_TO_PARSE_RESPONSE, `Error while parsing response data: "${String(err)}". Raw server ` +
`response: "${text}". Status code: "${res.status}". Outgoing ` +
`request: "${init.method} ${url}."`),
});
}
}
catch (err) {
const canRetry = this.isRetryEligible(retryAttempts, err);
const delayMillis = this.backOffDelayMillis(retryAttempts);
if (canRetry && delayMillis <= this.retryConfig.maxDelayInMillis) {
await this.waitForRetry(delayMillis);
return await this.fetchWithRetry(url, init, retryAttempts + 1);
}
if (err instanceof HttpError) {
if (err.cause) {
throw err.cause;
}
try {
const json = JSON.parse(err.message);
const errorCode = this.getErrorCode(json);
if (errorCode) {
throw FirebaseAuthError.fromServerError(errorCode, json);
}
}
catch (err) {
if (err instanceof FirebaseAuthError) {
throw err;
}
}
throw new FirebaseAppError(AppErrorCodes.INTERNAL_ERROR, `Error while sending request or reading response: "${err}". Raw server ` +
`response: Status code: "${err.status}". Outgoing ` +
`request: "${init.method} ${url}."`);
}
throw new FirebaseAppError(AppErrorCodes.NETWORK_ERROR, `Error while making request: ${String(err)}`);
}
}
/**
* @param response - The response to check for errors.
* @returns The error code if present; null otherwise.
*/
getErrorCode(response) {
return (isNonNullObject(response) && response.error && response.error.message) || null;
}
waitForRetry(delayMillis) {
if (delayMillis > 0) {
return new Promise(resolve => {
setTimeout(resolve, delayMillis);
});
}
return Promise.resolve();
}
isRetryEligible(retryAttempts, err) {
if (retryAttempts >= this.retryConfig.maxRetries) {
return false;
}
if (err instanceof HttpError) {
const statusCodes = this.retryConfig.statusCodes || [];
return statusCodes.includes(err.status);
}
if (err instanceof Error && err.name === 'AbortError') {
return false;
}
return true;
}
backOffDelayMillis(retryAttempts) {
if (retryAttempts === 0) {
return 0;
}
const backOffFactor = this.retryConfig.backOffFactor || 0;
const delayInSeconds = 2 ** retryAttempts * backOffFactor;
return Math.min(delayInSeconds * 1000, this.retryConfig.maxDelayInMillis);
}
}
class HttpError extends Error {
status;
constructor(status, message, opts) {
super(message, opts);
this.status = status;
this.name = 'HttpError';
}
}