UNPKG

firebase-auth-cloudflare-workers

Version:

Zero-dependencies firebase auth library for Cloudflare Workers.

227 lines (226 loc) 11.6 kB
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); }