firebase-auth-cloudflare-workers
Version:
Zero-dependencies firebase auth library for Cloudflare Workers.
227 lines (226 loc) • 11.6 kB
JavaScript
import { AuthClientErrorCode, FirebaseAuthError, JwtError, JwtErrorCode } from './errors';
import { EmulatorSignatureVerifier, PublicKeySignatureVerifier } from './jws-verifier';
import { RS256Token } from './jwt-decoder';
import { isNonEmptyString, isNonNullObject, isString, isURL } from './validator';
// Audience to use for Firebase Auth Custom tokens
export const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit';
const EMULATOR_VERIFIER = new EmulatorSignatureVerifier();
const makeExpectedbutGotMsg = (want, got) => `Expected "${want}" but got "${got}".`;
/**
* Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies.
*
* @internal
*/
export class FirebaseTokenVerifier {
signatureVerifier;
projectId;
issuer;
tokenInfo;
shortNameArticle;
constructor(signatureVerifier, projectId, issuer, tokenInfo) {
this.signatureVerifier = signatureVerifier;
this.projectId = projectId;
this.issuer = issuer;
this.tokenInfo = tokenInfo;
if (!isNonEmptyString(projectId)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'Your Firebase project ID must be a non-empty string');
}
else if (!isURL(issuer)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'The provided JWT issuer is an invalid URL.');
}
else if (!isNonNullObject(tokenInfo)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'The provided JWT information is not an object or null.');
}
else if (!isURL(tokenInfo.url)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'The provided JWT verification documentation URL is invalid.');
}
else if (!isNonEmptyString(tokenInfo.verifyApiName)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'The JWT verify API name must be a non-empty string.');
}
else if (!isNonEmptyString(tokenInfo.jwtName)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'The JWT public full name must be a non-empty string.');
}
else if (!isNonEmptyString(tokenInfo.shortName)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'The JWT public short name must be a non-empty string.');
}
else if (!isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'The JWT expiration error code must be a non-null ErrorInfo object.');
}
this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a';
}
/**
* Verifies the format and signature of a Firebase Auth JWT token.
*
* @param jwtToken - The Firebase Auth JWT token to verify.
* @param isEmulator - Whether to accept Auth Emulator tokens.
* @param clockSkewSeconds - The number of seconds to tolerate when checking the token's iat. Must be between 0-60, and an integer. Defualts to 0.
* @returns A promise fulfilled with the decoded claims of the Firebase Auth ID token.
*/
verifyJWT(jwtToken, isEmulator = false, clockSkewSeconds = 5) {
if (!isString(jwtToken)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`);
}
if (clockSkewSeconds < 0 || clockSkewSeconds > 60 || !Number.isInteger(clockSkewSeconds)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'clockSkewSeconds must be an integer between 0 and 60.');
}
return this.decodeAndVerify(jwtToken, isEmulator, clockSkewSeconds).then(payload => {
payload.uid = payload.sub;
return payload;
});
}
async decodeAndVerify(token, isEmulator, clockSkewSeconds = 5) {
const currentTimestamp = Math.floor(Date.now() / 1000) + clockSkewSeconds;
try {
const rs256Token = this.safeDecode(token, isEmulator, currentTimestamp);
const { payload } = rs256Token.decodedToken;
this.verifyPayload(payload, currentTimestamp);
await this.verifySignature(rs256Token, isEmulator);
return payload;
}
catch (err) {
if (err instanceof JwtError) {
throw this.mapJwtErrorToAuthError(err);
}
throw err;
}
}
safeDecode(jwtToken, isEmulator, currentTimestamp) {
try {
return RS256Token.decode(jwtToken, currentTimestamp, isEmulator);
}
catch (err) {
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
const errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed ` +
`the entire string JWT which represents ${this.shortNameArticle} ` +
`${this.tokenInfo.shortName}.` +
verifyJwtTokenDocsMessage;
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage + ` err: ${err}`);
}
}
verifyPayload(tokenPayload, currentTimestamp) {
const payload = tokenPayload;
const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` +
'Firebase project as the service account used to authenticate this SDK.';
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
const createInvalidArgument = (errorMessage) => new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
if (payload.aud !== this.projectId && payload.aud !== FIREBASE_AUDIENCE) {
throw createInvalidArgument(`${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. ` +
makeExpectedbutGotMsg(this.projectId, payload.aud) +
projectIdMatchMessage +
verifyJwtTokenDocsMessage);
}
if (payload.iss !== this.issuer + this.projectId) {
throw createInvalidArgument(`${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. ` +
makeExpectedbutGotMsg(this.issuer, payload.iss) +
projectIdMatchMessage +
verifyJwtTokenDocsMessage);
}
if (payload.sub.length > 128) {
throw createInvalidArgument(`${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` + verifyJwtTokenDocsMessage);
}
// check auth_time claim
if (typeof payload.auth_time !== 'number') {
throw createInvalidArgument(`${this.tokenInfo.jwtName} has no "auth_time" claim. ` + verifyJwtTokenDocsMessage);
}
if (currentTimestamp < payload.auth_time) {
throw createInvalidArgument(`${this.tokenInfo.jwtName} has incorrect "auth_time" claim. ` + verifyJwtTokenDocsMessage);
}
}
async verifySignature(token, isEmulator) {
const verifier = isEmulator ? EMULATOR_VERIFIER : this.signatureVerifier;
return await verifier.verify(token);
}
/**
* Maps JwtError to FirebaseAuthError
*
* @param error - JwtError to be mapped.
* @returns FirebaseAuthError or Error instance.
*/
mapJwtErrorToAuthError(error) {
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
if (error.code === JwtErrorCode.TOKEN_EXPIRED) {
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
verifyJwtTokenDocsMessage;
return new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage);
}
else if (error.code === JwtErrorCode.INVALID_SIGNATURE) {
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
}
else if (error.code === JwtErrorCode.NO_MATCHING_KID) {
const errorMessage = `${this.tokenInfo.jwtName} has "kid" claim which does not ` +
`correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` +
'is expired, so get a fresh token from your client app and try again.';
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
}
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message);
}
}
// URL containing the public keys for the Google certs (whose private keys are used to sign Firebase
// Auth ID tokens)
const CLIENT_JWK_URL = 'https://www.googleapis.com/robot/v1/metadata/jwk/securetoken@system.gserviceaccount.com';
/**
* User facing token information related to the Firebase ID token.
*
* @internal
*/
export const ID_TOKEN_INFO = {
url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens',
verifyApiName: 'verifyIdToken()',
jwtName: 'Firebase ID token',
shortName: 'ID token',
expiredErrorCode: AuthClientErrorCode.ID_TOKEN_EXPIRED,
};
/**
* Creates a new FirebaseTokenVerifier to verify Firebase ID tokens.
*
* @internal
* @returns FirebaseTokenVerifier
*/
export function createIdTokenVerifier(projectID, keyStorer) {
const signatureVerifier = PublicKeySignatureVerifier.withCertificateUrl(CLIENT_JWK_URL, keyStorer);
return baseCreateIdTokenVerifier(signatureVerifier, projectID);
}
/**
* @internal
* @returns FirebaseTokenVerifier
*/
export function baseCreateIdTokenVerifier(signatureVerifier, projectID) {
return new FirebaseTokenVerifier(signatureVerifier, projectID, 'https://securetoken.google.com/', ID_TOKEN_INFO);
}
// URL containing the public keys for Firebase session cookies.
const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys';
/**
* User facing token information related to the Firebase session cookie.
*
* @internal
*/
export const SESSION_COOKIE_INFO = {
url: 'https://firebase.google.com/docs/auth/admin/manage-cookies',
verifyApiName: 'verifySessionCookie()',
jwtName: 'Firebase session cookie',
shortName: 'session cookie',
expiredErrorCode: AuthClientErrorCode.SESSION_COOKIE_EXPIRED,
};
/**
* Creates a new FirebaseTokenVerifier to verify Firebase session cookies.
*
* @internal
* @param app - Firebase app instance.
* @returns FirebaseTokenVerifier
*/
export function createSessionCookieVerifier(projectID, keyStorer) {
const signatureVerifier = PublicKeySignatureVerifier.withCertificateUrl(SESSION_COOKIE_CERT_URL, keyStorer);
return baseCreateSessionCookieVerifier(signatureVerifier, projectID);
}
/**
* @internal
* @returns FirebaseTokenVerifier
*/
export function baseCreateSessionCookieVerifier(signatureVerifier, projectID) {
return new FirebaseTokenVerifier(signatureVerifier, projectID, 'https://session.firebase.google.com/', SESSION_COOKIE_INFO);
}