diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index cbb9991f4c..e100cc25f6 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -17,8 +17,10 @@ import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/error'; import * as util from '../utils/index'; import * as validator from '../utils/validator'; -import * as jwt from 'jsonwebtoken'; -import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; +import { + DecodedToken, decodeJwt, JwtError, JwtErrorCode, + EmulatorSignatureVerifier, PublicKeySignatureVerifier, ALGORITHM_RS256, SignatureVerifier, +} from '../utils/jwt'; import { FirebaseApp } from '../firebase-app'; import { auth } from './index'; @@ -27,8 +29,6 @@ import DecodedIdToken = auth.DecodedIdToken; // Audience to use for Firebase Auth Custom tokens const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; -export const ALGORITHM_RS256 = 'RS256'; - // URL containing the public keys for the Google certs (whose private keys are used to sign Firebase // Auth ID tokens) const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; @@ -36,6 +36,8 @@ const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/secur // URL containing the public keys for Firebase session cookies. This will be updated to a different URL soon. const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys'; +const EMULATOR_VERIFIER = new EmulatorSignatureVerifier(); + /** User facing token information related to the Firebase ID token. */ export const ID_TOKEN_INFO: FirebaseTokenInfo = { url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', @@ -69,15 +71,13 @@ export interface FirebaseTokenInfo { } /** - * Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies. + * Class for verifying ID tokens and session cookies. */ export class FirebaseTokenVerifier { - private publicKeys: {[key: string]: string}; - private publicKeysExpireAt: number; private readonly shortNameArticle: string; + private readonly signatureVerifier: SignatureVerifier; - constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm, - private issuer: string, private tokenInfo: FirebaseTokenInfo, + constructor(clientCertUrl: string, private issuer: string, private tokenInfo: FirebaseTokenInfo, private readonly app: FirebaseApp) { if (!validator.isURL(clientCertUrl)) { @@ -85,11 +85,6 @@ export class FirebaseTokenVerifier { AuthClientErrorCode.INVALID_ARGUMENT, 'The provided public client certificate URL is an invalid URL.', ); - } else if (!validator.isNonEmptyString(algorithm)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'The provided JWT algorithm is an empty string.', - ); } else if (!validator.isURL(issuer)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -128,16 +123,18 @@ export class FirebaseTokenVerifier { } this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + this.signatureVerifier = + PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl, app.options.httpAgent); + // For backward compatibility, the project ID is validated in the verification call. } /** * Verifies the format and signature of a Firebase Auth JWT token. * - * @param {string} jwtToken The Firebase Auth JWT token to verify. - * @param {boolean=} isEmulator Whether to accept Auth Emulator tokens. - * @return {Promise} A promise fulfilled with the decoded claims of the Firebase Auth ID - * token. + * @param jwtToken The Firebase Auth JWT token to verify. + * @param isEmulator Whether to accept Auth Emulator tokens. + * @return A promise fulfilled with the decoded claims of the Firebase Auth ID token. */ public verifyJWT(jwtToken: string, isEmulator = false): Promise { if (!validator.isString(jwtToken)) { @@ -147,29 +144,68 @@ export class FirebaseTokenVerifier { ); } - return util.findProjectId(this.app) + return this.ensureProjectId() .then((projectId) => { - return this.verifyJWTWithProjectId(jwtToken, projectId, isEmulator); + return this.decodeAndVerify(jwtToken, projectId, isEmulator); + }) + .then((decoded) => { + const decodedIdToken = decoded.payload as DecodedIdToken; + decodedIdToken.uid = decodedIdToken.sub; + return decodedIdToken; }); } - private verifyJWTWithProjectId( - jwtToken: string, - projectId: string | null, - isEmulator: boolean - ): Promise { - if (!validator.isNonEmptyString(projectId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - 'Must initialize app with a cert credential or set your Firebase project ID as the ' + - `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`, - ); - } + private ensureProjectId(): Promise { + return util.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + 'Must initialize app with a cert credential or set your Firebase project ID as the ' + + `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`, + ); + } + return Promise.resolve(projectId); + }) + } - const fullDecodedToken: any = jwt.decode(jwtToken, { - complete: true, - }); + private decodeAndVerify(token: string, projectId: string, isEmulator: boolean): Promise { + return this.safeDecode(token) + .then((decodedToken) => { + this.verifyContent(decodedToken, projectId, isEmulator); + return this.verifySignature(token, isEmulator) + .then(() => decodedToken); + }); + } + private safeDecode(jwtToken: string): Promise { + return decodeJwt(jwtToken) + .catch((err: JwtError) => { + if (err.code == JwtErrorCode.INVALID_ARGUMENT) { + 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); + } + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message); + }); + } + + /** + * Verifies the content of a Firebase Auth JWT. + * + * @param fullDecodedToken The decoded JWT. + * @param projectId The Firebase Project Id. + * @param isEmulator Whether the token is an Emulator token. + */ + private verifyContent( + fullDecodedToken: DecodedToken, + projectId: string | null, + isEmulator: boolean): void { + const header = fullDecodedToken && fullDecodedToken.header; const payload = fullDecodedToken && fullDecodedToken.payload; @@ -179,10 +215,7 @@ export class FirebaseTokenVerifier { `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; let errorMessage: string | undefined; - if (!fullDecodedToken) { - errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` + - `which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; - } else if (!isEmulator && typeof header.kid === 'undefined') { + if (!isEmulator && typeof header.kid === 'undefined') { const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); @@ -197,8 +230,8 @@ export class FirebaseTokenVerifier { } errorMessage += verifyJwtTokenDocsMessage; - } else if (!isEmulator && header.alg !== this.algorithm) { - errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + '" but got ' + + } else if (!isEmulator && header.alg !== ALGORITHM_RS256) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + ALGORITHM_RS256 + '" but got ' + '"' + header.alg + '".' + verifyJwtTokenDocsMessage; } else if (payload.aud !== projectId) { errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + @@ -217,135 +250,55 @@ export class FirebaseTokenVerifier { verifyJwtTokenDocsMessage; } if (errorMessage) { - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); } + } - if (isEmulator) { - // Signature checks skipped for emulator; no need to fetch public keys. - return this.verifyJwtSignatureWithKey(jwtToken, null); - } - - return this.fetchPublicKeys().then((publicKeys) => { - if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) { - return Promise.reject( - new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `${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.', - ), - ); - } else { - return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]); - } - - }); + private verifySignature(jwtToken: string, isEmulator: boolean): + Promise { + const verifier = isEmulator ? EMULATOR_VERIFIER : this.signatureVerifier; + return verifier.verify(jwtToken) + .catch((error) => { + throw this.mapJwtErrorToAuthError(error); + }); } /** - * Verifies the JWT signature using the provided public key. - * @param {string} jwtToken The JWT token to verify. - * @param {string} publicKey The public key certificate. - * @return {Promise} A promise that resolves with the decoded JWT claims on successful - * verification. + * Maps JwtError to FirebaseAuthError + * + * @param error JwtError to be mapped. + * @returns FirebaseAuthError or Error instance. */ - private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise { + private mapJwtErrorToAuthError(error: JwtError): Error { const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; - return new Promise((resolve, reject) => { - const verifyOptions: jwt.VerifyOptions = {}; - if (publicKey !== null) { - verifyOptions.algorithms = [this.algorithm]; - } - jwt.verify(jwtToken, publicKey || '', verifyOptions, - (error: jwt.VerifyErrors | null, decodedToken: object | undefined) => { - if (error) { - if (error.name === 'TokenExpiredError') { - 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 reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); - } else if (error.name === 'JsonWebTokenError') { - const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); - } - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); - } else { - const decodedIdToken = (decodedToken as DecodedIdToken); - decodedIdToken.uid = decodedIdToken.sub; - resolve(decodedIdToken); - } - }); - }); - } - - /** - * Fetches the public keys for the Google certs. - * - * @return {Promise} A promise fulfilled with public keys for the Google certs. - */ - private fetchPublicKeys(): Promise<{[key: string]: string}> { - const publicKeysExist = (typeof this.publicKeys !== 'undefined'); - const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined'); - const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt); - if (publicKeysExist && publicKeysStillValid) { - return Promise.resolve(this.publicKeys); + 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); } - - const client = new HttpClient(); - const request: HttpRequestConfig = { - method: 'GET', - url: this.clientCertUrl, - httpAgent: this.app.options.httpAgent, - }; - return client.send(request).then((resp) => { - if (!resp.isJson() || resp.data.error) { - // Treat all non-json messages and messages with an 'error' field as - // error responses. - throw new HttpError(resp); - } - if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) { - const cacheControlHeader: string = resp.headers['cache-control']; - const parts = cacheControlHeader.split(','); - parts.forEach((part) => { - const subParts = part.trim().split('='); - if (subParts[0] === 'max-age') { - const maxAge: number = +subParts[1]; - this.publicKeysExpireAt = Date.now() + (maxAge * 1000); - } - }); - } - this.publicKeys = resp.data; - return resp.data; - }).catch((err) => { - if (err instanceof HttpError) { - let errorMessage = 'Error fetching public keys for Google certs: '; - const resp = err.response; - if (resp.isJson() && resp.data.error) { - errorMessage += `${resp.data.error}`; - if (resp.data.error_description) { - errorMessage += ' (' + resp.data.error_description + ')'; - } - } else { - errorMessage += `${resp.text}`; - } - throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage); - } - throw err; - }); + return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message); } } /** * Creates a new FirebaseTokenVerifier to verify Firebase ID tokens. * - * @param {FirebaseApp} app Firebase app instance. - * @return {FirebaseTokenVerifier} + * @param app Firebase app instance. + * @return FirebaseTokenVerifier */ export function createIdTokenVerifier(app: FirebaseApp): FirebaseTokenVerifier { return new FirebaseTokenVerifier( CLIENT_CERT_URL, - ALGORITHM_RS256, 'https://securetoken.google.com/', ID_TOKEN_INFO, app @@ -355,13 +308,12 @@ export function createIdTokenVerifier(app: FirebaseApp): FirebaseTokenVerifier { /** * Creates a new FirebaseTokenVerifier to verify Firebase session cookies. * - * @param {FirebaseApp} app Firebase app instance. - * @return {FirebaseTokenVerifier} + * @param app Firebase app instance. + * @return FirebaseTokenVerifier */ export function createSessionCookieVerifier(app: FirebaseApp): FirebaseTokenVerifier { return new FirebaseTokenVerifier( SESSION_COOKIE_CERT_URL, - ALGORITHM_RS256, 'https://session.firebase.google.com/', SESSION_COOKIE_INFO, app diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000000..d048567061 --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,275 @@ +/*! + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as validator from './validator'; +import * as jwt from 'jsonwebtoken'; +import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; +import { Agent } from 'http'; + +export const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const; + +// `jsonwebtoken` converts errors from the `getKey` callback to its own `JsonWebTokenError` type +// and prefixes the error message with the following. Use the prefix to identify errors thrown +// from the key provider callback. +// https://github.com/auth0/node-jsonwebtoken/blob/d71e383862fc735991fd2e759181480f066bf138/verify.js#L96 +const JWT_CALLBACK_ERROR_PREFIX = 'error in secret or public key callback: '; + +const NO_MATCHING_KID_ERROR_MESSAGE = 'no-matching-kid-error'; + +export type Dictionary = { [key: string]: any } + +export type DecodedToken = { + header: Dictionary; + payload: Dictionary; +} + +export interface SignatureVerifier { + verify(token: string): Promise; +} + +interface KeyFetcher { + fetchPublicKeys(): Promise<{ [key: string]: string }>; +} + +/** + * Class to fetch public keys from a client certificates URL. + */ +export class UrlKeyFetcher implements KeyFetcher { + private publicKeys: { [key: string]: string }; + private publicKeysExpireAt = 0; + + constructor(private clientCertUrl: string, private readonly httpAgent?: Agent) { + if (!validator.isURL(clientCertUrl)) { + throw new Error( + 'The provided public client certificate URL is not a valid URL.', + ); + } + } + + /** + * Fetches the public keys for the Google certs. + * + * @return A promise fulfilled with public keys for the Google certs. + */ + public fetchPublicKeys(): Promise<{ [key: string]: string }> { + if (this.shouldRefresh()) { + return this.refresh(); + } + return Promise.resolve(this.publicKeys); + } + + /** + * Checks if the cached public keys need to be refreshed. + * + * @returns Whether the keys should be fetched from the client certs url or not. + */ + private shouldRefresh(): boolean { + return !this.publicKeys || this.publicKeysExpireAt <= Date.now(); + } + + private refresh(): Promise<{ [key: string]: string }> { + const client = new HttpClient(); + const request: HttpRequestConfig = { + method: 'GET', + url: this.clientCertUrl, + httpAgent: this.httpAgent, + }; + return client.send(request).then((resp) => { + if (!resp.isJson() || resp.data.error) { + // Treat all non-json messages and messages with an 'error' field as + // error responses. + throw new HttpError(resp); + } + // reset expire at from previous set of keys. + this.publicKeysExpireAt = 0; + if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) { + const cacheControlHeader: string = resp.headers['cache-control']; + const parts = cacheControlHeader.split(','); + parts.forEach((part) => { + const subParts = part.trim().split('='); + if (subParts[0] === 'max-age') { + const maxAge: number = +subParts[1]; + this.publicKeysExpireAt = Date.now() + (maxAge * 1000); + } + }); + } + this.publicKeys = resp.data; + return resp.data; + }).catch((err) => { + if (err instanceof HttpError) { + let errorMessage = 'Error fetching public keys for Google certs: '; + const resp = err.response; + if (resp.isJson() && resp.data.error) { + errorMessage += `${resp.data.error}`; + if (resp.data.error_description) { + errorMessage += ' (' + resp.data.error_description + ')'; + } + } else { + errorMessage += `${resp.text}`; + } + throw new Error(errorMessage); + } + throw err; + }); + } +} + +/** + * Class for verifing JWT signature with a public key. + */ +export class PublicKeySignatureVerifier implements SignatureVerifier { + constructor(private keyFetcher: KeyFetcher) { + if (!validator.isNonNullObject(keyFetcher)) { + throw new Error('The provided key fetcher is not an object or null.'); + } + } + + public static withCertificateUrl(clientCertUrl: string, httpAgent?: Agent): PublicKeySignatureVerifier { + return new PublicKeySignatureVerifier(new UrlKeyFetcher(clientCertUrl, httpAgent)); + } + + public verify(token: string): Promise { + if (!validator.isString(token)) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'The provided token must be a string.')); + } + + return verifyJwtSignature(token, getKeyCallback(this.keyFetcher), { algorithms: [ALGORITHM_RS256] }); + } +} + +/** + * Class for verifing unsigned (emulator) JWTs. + */ +export class EmulatorSignatureVerifier implements SignatureVerifier { + public verify(token: string): Promise { + // Signature checks skipped for emulator; no need to fetch public keys. + return verifyJwtSignature(token, ''); + } +} + +/** + * Provides a callback to fetch public keys. + * + * @param fetcher KeyFetcher to fetch the keys from. + * @returns A callback function that can be used to get keys in `jsonwebtoken`. + */ +function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret { + return (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { + const kid = header.kid || ''; + fetcher.fetchPublicKeys().then((publicKeys) => { + if (!Object.prototype.hasOwnProperty.call(publicKeys, kid)) { + callback(new Error(NO_MATCHING_KID_ERROR_MESSAGE)); + } else { + callback(null, publicKeys[kid]); + } + }) + .catch(error => { + callback(error); + }); + } +} + +/** + * Verifies the signature of a JWT using the provided secret or a function to fetch + * the secret or public key. + * + * @param token The JWT to be verfied. + * @param secretOrPublicKey The secret or a function to fetch the secret or public key. + * @param options JWT verification options. + * @returns A Promise resolving for a token with a valid signature. + */ +export function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.GetPublicKeyOrSecret, + options?: jwt.VerifyOptions): Promise { + if (!validator.isString(token)) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'The provided token must be a string.')); + } + + return new Promise((resolve, reject) => { + jwt.verify(token, secretOrPublicKey, options, + (error: jwt.VerifyErrors | null) => { + if (!error) { + return resolve(); + } + if (error.name === 'TokenExpiredError') { + return reject(new JwtError(JwtErrorCode.TOKEN_EXPIRED, + 'The provided token has expired. Get a fresh token from your ' + + 'client app and try again.')); + } else if (error.name === 'JsonWebTokenError') { + if (error.message && error.message.includes(JWT_CALLBACK_ERROR_PREFIX)) { + const message = error.message.split(JWT_CALLBACK_ERROR_PREFIX).pop() || 'Error fetching public keys.'; + const code = (message === NO_MATCHING_KID_ERROR_MESSAGE) ? JwtErrorCode.NO_MATCHING_KID : + JwtErrorCode.KEY_FETCH_ERROR; + return reject(new JwtError(code, message)); + } + } + return reject(new JwtError(JwtErrorCode.INVALID_SIGNATURE, error.message)); + }); + }); +} + +/** + * Decodes general purpose Firebase JWTs. + * + * @param jwtToken JWT token to be decoded. + * @returns Decoded token containing the header and payload. + */ +export function decodeJwt(jwtToken: string): Promise { + if (!validator.isString(jwtToken)) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'The provided token must be a string.')); + } + + const fullDecodedToken: any = jwt.decode(jwtToken, { + complete: true, + }); + + if (!fullDecodedToken) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'Decoding token failed.')); + } + + const header = fullDecodedToken?.header; + const payload = fullDecodedToken?.payload; + return Promise.resolve({ header, payload }); +} + +/** + * Jwt error code structure. + * + * @param code The error code. + * @param message The error message. + * @constructor + */ +export class JwtError extends Error { + constructor(readonly code: JwtErrorCode, readonly message: string) { + super(message); + (this as any).__proto__ = JwtError.prototype; + } +} + +/** + * JWT error codes. + */ +export enum JwtErrorCode { + INVALID_ARGUMENT = 'invalid-argument', + INVALID_CREDENTIAL = 'invalid-credential', + TOKEN_EXPIRED = 'token-expired', + INVALID_SIGNATURE = 'invalid-token', + NO_MATCHING_KID = 'no-matching-kid-error', + KEY_FETCH_ERROR = 'key-fetch-error', +} diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index d863a0e849..1f7e3546f8 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -16,15 +16,14 @@ 'use strict'; -// Use untyped import syntax for Node built-ins -import https = require('https'); - import * as _ from 'lodash'; import * as chai from 'chai'; import * as nock from 'nock'; import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; +import { Agent } from 'http'; + import LegacyFirebaseTokenGenerator = require('firebase-token-generator'); import * as mocks from '../../resources/mocks'; @@ -34,7 +33,7 @@ import * as verifier from '../../../src/auth/token-verifier'; import { ServiceAccountCredential } from '../../../src/credential/credential-internal'; import { AuthClientErrorCode } from '../../../src/utils/error'; import { FirebaseApp } from '../../../src/firebase-app'; -import { Algorithm } from 'jsonwebtoken'; +import { JwtError, JwtErrorCode, PublicKeySignatureVerifier } from '../../../src/utils/jwt'; chai.should(); chai.use(sinonChai); @@ -43,76 +42,12 @@ chai.use(chaiAsPromised); const expect = chai.expect; const ONE_HOUR_IN_SECONDS = 60 * 60; -const idTokenPublicCertPath = '/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; - -/** - * Returns a mocked out success response from the URL containing the public keys for the Google certs. - * - * @param {string=} path URL path to which the mock request should be made. If not specified, defaults - * to the URL path of ID token public key certificates. - * @return {Object} A nock response object. - */ -function mockFetchPublicKeys(path: string = idTokenPublicCertPath): nock.Scope { - const mockedResponse: {[key: string]: string} = {}; - mockedResponse[mocks.certificateObject.private_key_id] = mocks.keyPairs[0].public; - return nock('https://www.googleapis.com') - .get(path) - .reply(200, mockedResponse, { - 'cache-control': 'public, max-age=1, must-revalidate, no-transform', - }); -} - -/** - * Returns a mocked out success response from the URL containing the public keys for the Google certs - * which contains a public key which won't match the mocked token. - * - * @return {Object} A nock response object. - */ -function mockFetchWrongPublicKeys(): nock.Scope { - const mockedResponse: {[key: string]: string} = {}; - mockedResponse[mocks.certificateObject.private_key_id] = mocks.keyPairs[1].public; - return nock('https://www.googleapis.com') - .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') - .reply(200, mockedResponse, { - 'cache-control': 'public, max-age=1, must-revalidate, no-transform', - }); -} - -/** - * Returns a mocked out error response from the URL containing the public keys for the Google certs. - * The status code is 200 but the response itself will contain an 'error' key. - * - * @return {Object} A nock response object. - */ -function mockFetchPublicKeysWithErrorResponse(): nock.Scope { - return nock('https://www.googleapis.com') - .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') - .reply(200, { - error: 'message', - error_description: 'description', // eslint-disable-line @typescript-eslint/camelcase - }); -} - -/** - * Returns a mocked out failed response from the URL containing the public keys for the Google certs. - * The status code is non-200 and the response itself will fail. - * - * @return {Object} A nock response object. - */ -function mockFailedFetchPublicKeys(): nock.Scope { - return nock('https://www.googleapis.com') - .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') - .replyWithError('message'); -} function createTokenVerifier( - app: FirebaseApp, - options: { algorithm?: Algorithm } = {} + app: FirebaseApp ): verifier.FirebaseTokenVerifier { - const algorithm = options.algorithm || 'RS256'; return new verifier.FirebaseTokenVerifier( 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', - algorithm, 'https://securetoken.google.com/', verifier.ID_TOKEN_INFO, app @@ -125,14 +60,12 @@ describe('FirebaseTokenVerifier', () => { let tokenVerifier: verifier.FirebaseTokenVerifier; let tokenGenerator: FirebaseTokenGenerator; let clock: sinon.SinonFakeTimers | undefined; - let httpsSpy: sinon.SinonSpy; beforeEach(() => { // Needed to generate custom token for testing. app = mocks.app(); const cert = new ServiceAccountCredential(mocks.certificateObject); tokenGenerator = new FirebaseTokenGenerator(new ServiceAccountSigner(cert)); tokenVerifier = createTokenVerifier(app); - httpsSpy = sinon.spy(https, 'request'); }); afterEach(() => { @@ -140,7 +73,6 @@ describe('FirebaseTokenVerifier', () => { clock.restore(); clock = undefined; } - httpsSpy.restore(); }); after(() => { @@ -152,7 +84,6 @@ describe('FirebaseTokenVerifier', () => { expect(() => { tokenVerifier = new verifier.FirebaseTokenVerifier( 'https://www.example.com/publicKeys', - 'RS256', 'https://www.example.com/issuer/', { url: 'https://docs.example.com/verify-tokens', @@ -172,7 +103,6 @@ describe('FirebaseTokenVerifier', () => { expect(() => { new verifier.FirebaseTokenVerifier( invalidCertUrl as any, - 'RS256', 'https://www.example.com/issuer/', verifier.ID_TOKEN_INFO, app, @@ -181,27 +111,12 @@ describe('FirebaseTokenVerifier', () => { }); }); - const invalidAlgorithms = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, '']; - invalidAlgorithms.forEach((invalidAlgorithm) => { - it('should throw given an invalid algorithm: ' + JSON.stringify(invalidAlgorithm), () => { - expect(() => { - new verifier.FirebaseTokenVerifier( - 'https://www.example.com/publicKeys', - invalidAlgorithm as any, - 'https://www.example.com/issuer/', - verifier.ID_TOKEN_INFO, - app); - }).to.throw('The provided JWT algorithm is an empty string.'); - }); - }); - const invalidIssuers = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, 'file://invalid']; invalidIssuers.forEach((invalidIssuer) => { it('should throw given a non-URL issuer: ' + JSON.stringify(invalidIssuer), () => { expect(() => { new verifier.FirebaseTokenVerifier( 'https://www.example.com/publicKeys', - 'RS256', invalidIssuer as any, verifier.ID_TOKEN_INFO, app, @@ -216,7 +131,6 @@ describe('FirebaseTokenVerifier', () => { expect(() => { new verifier.FirebaseTokenVerifier( 'https://www.example.com/publicKeys', - 'RS256', 'https://www.example.com/issuer/', { url: 'https://docs.example.com/verify-tokens', @@ -237,7 +151,6 @@ describe('FirebaseTokenVerifier', () => { expect(() => { new verifier.FirebaseTokenVerifier( 'https://www.example.com/publicKeys', - 'RS256', 'https://www.example.com/issuer/', { url: 'https://docs.example.com/verify-tokens', @@ -258,7 +171,6 @@ describe('FirebaseTokenVerifier', () => { expect(() => { new verifier.FirebaseTokenVerifier( 'https://www.example.com/publicKeys', - 'RS256', 'https://www.example.com/issuer/', { url: 'https://docs.example.com/verify-tokens', @@ -279,7 +191,6 @@ describe('FirebaseTokenVerifier', () => { expect(() => { new verifier.FirebaseTokenVerifier( 'https://www.example.com/publicKeys', - 'RS256', 'https://www.example.com/issuer/', { url: 'https://docs.example.com/verify-tokens', @@ -297,10 +208,14 @@ describe('FirebaseTokenVerifier', () => { describe('verifyJWT()', () => { let mockedRequests: nock.Scope[] = []; + let stubs: sinon.SinonStub[] = []; afterEach(() => { _.forEach(mockedRequests, (mockedRequest) => mockedRequest.done()); mockedRequests = []; + + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; }); it('should throw given no Firebase JWT token', () => { @@ -331,7 +246,6 @@ describe('FirebaseTokenVerifier', () => { it('should throw if the token verifier was initialized with no "project_id"', () => { const tokenVerifierWithNoProjectId = new verifier.FirebaseTokenVerifier( 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', - 'RS256', 'https://securetoken.google.com/', verifier.ID_TOKEN_INFO, mocks.mockCredentialApp(), @@ -351,21 +265,6 @@ describe('FirebaseTokenVerifier', () => { .should.eventually.be.rejectedWith('Firebase ID token has no "kid" claim'); }); - it('should be rejected given a Firebase JWT token with a kid which does not match any of the ' + - 'actual public keys', () => { - mockedRequests.push(mockFetchPublicKeys()); - - const mockIdToken = mocks.generateIdToken({ - header: { - kid: 'wrongkid', - }, - }); - - return tokenVerifier.verifyJWT(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has "kid" claim which does not ' + - 'correspond to a known public key'); - }); - it('should be rejected given a Firebase JWT token with an incorrect algorithm', () => { const mockIdToken = mocks.generateIdToken({ algorithm: 'HS256', @@ -392,8 +291,26 @@ describe('FirebaseTokenVerifier', () => { .should.eventually.be.rejectedWith('Firebase ID token has incorrect "iss" (issuer) claim'); }); + it('should be rejected when the verifier throws no maching kid error', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.NO_MATCHING_KID, 'No matching key ID.')); + stubs.push(verifierStub); + + const mockIdToken = mocks.generateIdToken({ + header: { + kid: 'wrongkid', + }, + }); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has "kid" claim which does not ' + + 'correspond to a known public key'); + }); + it('should be rejected given a Firebase JWT token with a subject with greater than 128 characters', () => { - mockedRequests.push(mockFetchPublicKeys()); + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .resolves(); + stubs.push(verifierStub); // uid of length 128 should be fulfilled let uid = Array(129).join('a'); @@ -414,62 +331,59 @@ describe('FirebaseTokenVerifier', () => { }); }); - it('should be rejected given an expired Firebase JWT token', () => { - mockedRequests.push(mockFetchPublicKeys()); - - clock = sinon.useFakeTimers(1000); + it('should be rejected when the verifier throws for expired Firebase JWT token', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.TOKEN_EXPIRED, 'Expired token.')); + stubs.push(verifierStub); const mockIdToken = mocks.generateIdToken(); - clock.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); - - // Token should still be valid - return tokenVerifier.verifyJWT(mockIdToken).then(() => { - clock!.tick(1); - - // Token should now be invalid - return tokenVerifier.verifyJWT(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has expired. Get a fresh ID token from your client ' + - 'app and try again (auth/id-token-expired)') - .and.have.property('code', 'auth/id-token-expired'); - }); + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has expired. Get a fresh ID token from your client ' + + 'app and try again (auth/id-token-expired)') + .and.have.property('code', 'auth/id-token-expired'); }); - it('should be rejected given an expired Firebase session cookie', () => { + it('should be rejected when the verifier throws for expired Firebase session cookie', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.TOKEN_EXPIRED, 'Expired token.')); + stubs.push(verifierStub); + const tokenVerifierSessionCookie = new verifier.FirebaseTokenVerifier( 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', - 'RS256', 'https://session.firebase.google.com/', verifier.SESSION_COOKIE_INFO, app, ); - mockedRequests.push(mockFetchPublicKeys('/identitytoolkit/v3/relyingparty/publicKeys')); - - clock = sinon.useFakeTimers(1000); const mockSessionCookie = mocks.generateSessionCookie(); - clock.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); + return tokenVerifierSessionCookie.verifyJWT(mockSessionCookie) + .should.eventually.be.rejectedWith('Firebase session cookie has expired. Get a fresh session cookie from ' + + 'your client app and try again (auth/session-cookie-expired).') + .and.have.property('code', 'auth/session-cookie-expired'); + }); - // Cookie should still be valid - return tokenVerifierSessionCookie.verifyJWT(mockSessionCookie).then(() => { - clock!.tick(1); + it('should be rejected when the verifier throws invalid signature for a Firebase JWT token.', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.INVALID_SIGNATURE, 'invalid signature.')); + stubs.push(verifierStub); - // Cookie should now be invalid - return tokenVerifierSessionCookie.verifyJWT(mockSessionCookie) - .should.eventually.be.rejectedWith('Firebase session cookie has expired. Get a fresh session cookie from ' + - 'your client app and try again (auth/session-cookie-expired).') - .and.have.property('code', 'auth/session-cookie-expired'); - }); + const mockIdToken = mocks.generateIdToken(); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has invalid signature'); }); - it('should be rejected given a Firebase JWT token which was not signed with the kid it specifies', () => { - mockedRequests.push(mockFetchWrongPublicKeys()); + it('should be rejected when the verifier throws key fetch error.', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.KEY_FETCH_ERROR, 'Error fetching public keys.')); + stubs.push(verifierStub); const mockIdToken = mocks.generateIdToken(); return tokenVerifier.verifyJWT(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has invalid signature'); + .should.eventually.be.rejectedWith('Error fetching public keys.'); }); it('should be rejected given a custom token with error using article "an" before JWT short name', () => { @@ -483,7 +397,6 @@ describe('FirebaseTokenVerifier', () => { it('should be rejected given a custom token with error using article "a" before JWT short name', () => { const tokenVerifierSessionCookie = new verifier.FirebaseTokenVerifier( 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', - 'RS256', 'https://session.firebase.google.com/', verifier.SESSION_COOKIE_INFO, app, @@ -509,7 +422,6 @@ describe('FirebaseTokenVerifier', () => { it('should be rejected given a legacy custom token with error using article "a" before JWT short name', () => { const tokenVerifierSessionCookie = new verifier.FirebaseTokenVerifier( 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', - 'RS256', 'https://session.firebase.google.com/', verifier.SESSION_COOKIE_INFO, app, @@ -524,8 +436,26 @@ describe('FirebaseTokenVerifier', () => { 'verifySessionCookie() expects a session cookie, but was given a legacy custom token'); }); + it('AppOptions.httpAgent should be passed to the verifier', () => { + const mockAppWithAgent = mocks.appWithOptions({ + httpAgent: new Agent() + }); + const agentForApp = mockAppWithAgent.options.httpAgent; + const verifierSpy = sinon.spy(PublicKeySignatureVerifier, 'withCertificateUrl'); + + expect(verifierSpy.args).to.be.empty; + + createTokenVerifier(mockAppWithAgent); + + expect(verifierSpy.args[0][1]).to.equal(agentForApp); + + verifierSpy.restore(); + }); + it('should be fulfilled with decoded claims given a valid Firebase JWT token', () => { - mockedRequests.push(mockFetchPublicKeys()); + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .resolves(); + stubs.push(verifierStub); clock = sinon.useFakeTimers(1000); @@ -567,16 +497,6 @@ describe('FirebaseTokenVerifier', () => { }); }); - it('should not decode a signed token when the algorithm is set to none (emulator)', async () => { - clock = sinon.useFakeTimers(1000); - - const emulatorVerifier = createTokenVerifier(app, { algorithm: 'none' }); - const mockIdToken = mocks.generateIdToken(); - - await emulatorVerifier.verifyJWT(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has incorrect algorithm. Expected "none"'); - }); - it('should not decode an unsigned token when the algorithm is not overridden (emulator)', async () => { clock = sinon.useFakeTimers(1000); @@ -593,110 +513,5 @@ describe('FirebaseTokenVerifier', () => { await tokenVerifier.verifyJWT(idTokenNoHeader) .should.eventually.be.rejectedWith('Firebase ID token has no "kid" claim.'); }); - - it('should use the given HTTP Agent', () => { - const agent = new https.Agent(); - const appWithAgent = mocks.appWithOptions({ - credential: mocks.credential, - httpAgent: agent, - }); - tokenVerifier = new verifier.FirebaseTokenVerifier( - 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', - 'RS256', - 'https://securetoken.google.com/', - verifier.ID_TOKEN_INFO, - appWithAgent, - ); - mockedRequests.push(mockFetchPublicKeys()); - - clock = sinon.useFakeTimers(1000); - - const mockIdToken = mocks.generateIdToken(); - - return tokenVerifier.verifyJWT(mockIdToken) - .then(() => { - expect(https.request).to.have.been.calledOnce; - expect(httpsSpy.args[0][0].agent).to.equal(agent); - }); - }); - - it('should not fetch the Google cert public keys until the first time verifyJWT() is called', () => { - mockedRequests.push(mockFetchPublicKeys()); - - const testTokenVerifier = new verifier.FirebaseTokenVerifier( - 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', - 'RS256', - 'https://securetoken.google.com/', - verifier.ID_TOKEN_INFO, - app, - ); - expect(https.request).not.to.have.been.called; - - const mockIdToken = mocks.generateIdToken(); - - return testTokenVerifier.verifyJWT(mockIdToken) - .then(() => expect(https.request).to.have.been.calledOnce); - }); - - it('should not re-fetch the Google cert public keys every time verifyJWT() is called', () => { - mockedRequests.push(mockFetchPublicKeys()); - - const mockIdToken = mocks.generateIdToken(); - - return tokenVerifier.verifyJWT(mockIdToken).then(() => { - expect(https.request).to.have.been.calledOnce; - return tokenVerifier.verifyJWT(mockIdToken); - }).then(() => expect(https.request).to.have.been.calledOnce); - }); - - it('should refresh the Google cert public keys after the "max-age" on the request expires', () => { - mockedRequests.push(mockFetchPublicKeys()); - mockedRequests.push(mockFetchPublicKeys()); - mockedRequests.push(mockFetchPublicKeys()); - - clock = sinon.useFakeTimers(1000); - - const mockIdToken = mocks.generateIdToken(); - - return tokenVerifier.verifyJWT(mockIdToken).then(() => { - expect(https.request).to.have.been.calledOnce; - clock!.tick(999); - return tokenVerifier.verifyJWT(mockIdToken); - }).then(() => { - expect(https.request).to.have.been.calledOnce; - clock!.tick(1); - return tokenVerifier.verifyJWT(mockIdToken); - }).then(() => { - // One second has passed - expect(https.request).to.have.been.calledTwice; - clock!.tick(999); - return tokenVerifier.verifyJWT(mockIdToken); - }).then(() => { - expect(https.request).to.have.been.calledTwice; - clock!.tick(1); - return tokenVerifier.verifyJWT(mockIdToken); - }).then(() => { - // Two seconds have passed - expect(https.request).to.have.been.calledThrice; - }); - }); - - it('should be rejected if fetching the Google public keys fails', () => { - mockedRequests.push(mockFailedFetchPublicKeys()); - - const mockIdToken = mocks.generateIdToken(); - - return tokenVerifier.verifyJWT(mockIdToken) - .should.eventually.be.rejectedWith('message'); - }); - - it('should be rejected if fetching the Google public keys returns a response with an error message', () => { - mockedRequests.push(mockFetchPublicKeysWithErrorResponse()); - - const mockIdToken = mocks.generateIdToken(); - - return tokenVerifier.verifyJWT(mockIdToken) - .should.eventually.be.rejectedWith('Error fetching public keys for Google certs: message (description)'); - }); }); }); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index efbe059e96..e8c5a6d17d 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -25,6 +25,7 @@ import './utils/index.spec'; import './utils/error.spec'; import './utils/validator.spec'; import './utils/api-request.spec'; +import './utils/jwt.spec'; // Auth import './auth/auth.spec'; diff --git a/test/unit/utils/jwt.spec.ts b/test/unit/utils/jwt.spec.ts new file mode 100644 index 0000000000..525608feef --- /dev/null +++ b/test/unit/utils/jwt.spec.ts @@ -0,0 +1,541 @@ +/*! + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// Use untyped import syntax for Node built-ins +import https = require('https'); + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as nock from 'nock'; +import * as sinon from 'sinon'; +//import * as sinonChai from 'sinon-chai'; +//import * as chaiAsPromised from 'chai-as-promised'; + +import * as mocks from '../../resources/mocks'; +import * as jwtUtil from '../../../src/utils/jwt'; + +const expect = chai.expect; + +const ONE_HOUR_IN_SECONDS = 60 * 60; +const publicCertPath = '/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; + +/** + * Returns a mocked out success response from the URL containing the public keys for the Google certs. + * + * @param {string=} path URL path to which the mock request should be made. If not specified, defaults + * to the URL path of ID token public key certificates. + * @return {Object} A nock response object. + */ +function mockFetchPublicKeys(path: string = publicCertPath): nock.Scope { + const mockedResponse: { [key: string]: string } = {}; + mockedResponse[mocks.certificateObject.private_key_id] = mocks.keyPairs[0].public; + return nock('https://www.googleapis.com') + .get(path) + .reply(200, mockedResponse, { + 'cache-control': 'public, max-age=1, must-revalidate, no-transform', + }); +} + +/** + * Returns a mocked out error response from the URL containing the public keys for the Google certs. + * The status code is 200 but the response itself will contain an 'error' key. + * + * @return {Object} A nock response object. + */ + +function mockFetchPublicKeysWithErrorResponse(): nock.Scope { + return nock('https://www.googleapis.com') + .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') + .reply(200, { + error: 'message', + error_description: 'description', // eslint-disable-line @typescript-eslint/camelcase + }); +} + +/** + * Returns a mocked out failed response from the URL containing the public keys for the Google certs. + * The status code is non-200 and the response itself will fail. + * + * @return {Object} A nock response object. + */ + +function mockFailedFetchPublicKeys(): nock.Scope { + return nock('https://www.googleapis.com') + .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') + .replyWithError('message'); +} + + +const TOKEN_PAYLOAD = { + one: 'uno', + two: 'dos', + iat: 1, + exp: ONE_HOUR_IN_SECONDS + 1, + aud: mocks.projectId, + iss: 'https://securetoken.google.com/' + mocks.projectId, + sub: mocks.uid, +}; + +const DECODED_SIGNED_TOKEN: jwtUtil.DecodedToken = { + header: { + alg: 'RS256', + kid: 'aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd', + typ: 'JWT', + }, + payload: TOKEN_PAYLOAD +}; + +const DECODED_UNSIGNED_TOKEN: jwtUtil.DecodedToken = { + header: { + alg: 'none', + typ: 'JWT', + }, + payload: TOKEN_PAYLOAD +}; + +const VALID_PUBLIC_KEYS_RESPONSE: { [key: string]: string } = {}; +VALID_PUBLIC_KEYS_RESPONSE[mocks.certificateObject.private_key_id] = mocks.keyPairs[0].public; + +describe('decodeJwt', () => { + let clock: sinon.SinonFakeTimers | undefined; + + afterEach(() => { + if (clock) { + clock.restore(); + clock = undefined; + } + }); + + it('should reject given no token', () => { + return (jwtUtil.decodeJwt as any)() + .should.eventually.be.rejectedWith('The provided token must be a string.'); + }); + + const invalidIdTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidIdTokens.forEach((invalidIdToken) => { + it('should reject given a non-string token: ' + JSON.stringify(invalidIdToken), () => { + return jwtUtil.decodeJwt(invalidIdToken as any) + .should.eventually.be.rejectedWith('The provided token must be a string.'); + }); + }); + + it('should reject given an empty string token', () => { + return jwtUtil.decodeJwt('') + .should.eventually.be.rejectedWith('Decoding token failed.'); + }); + + it('should reject given an invalid token', () => { + return jwtUtil.decodeJwt('invalid-token') + .should.eventually.be.rejectedWith('Decoding token failed.'); + }); + + it('should be fulfilled with decoded claims given a valid signed token', () => { + clock = sinon.useFakeTimers(1000); + + const mockIdToken = mocks.generateIdToken(); + + return jwtUtil.decodeJwt(mockIdToken) + .should.eventually.be.fulfilled.and.deep.equal(DECODED_SIGNED_TOKEN); + }); + + it('should be fulfilled with decoded claims given a valid unsigned token', () => { + clock = sinon.useFakeTimers(1000); + + const mockIdToken = mocks.generateIdToken({ + algorithm: 'none', + header: {} + }); + + return jwtUtil.decodeJwt(mockIdToken) + .should.eventually.be.fulfilled.and.deep.equal(DECODED_UNSIGNED_TOKEN); + }); +}); + + +describe('verifyJwtSignature', () => { + let clock: sinon.SinonFakeTimers | undefined; + + afterEach(() => { + if (clock) { + clock.restore(); + clock = undefined; + } + }); + + it('should throw given no token', () => { + return (jwtUtil.verifyJwtSignature as any)() + .should.eventually.be.rejectedWith('The provided token must be a string.'); + }); + + const invalidIdTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidIdTokens.forEach((invalidIdToken) => { + it('should reject given a non-string token: ' + JSON.stringify(invalidIdToken), () => { + return jwtUtil.verifyJwtSignature(invalidIdToken as any, mocks.keyPairs[0].public) + .should.eventually.be.rejectedWith('The provided token must be a string.'); + }); + }); + + it('should reject given an empty string token', () => { + return jwtUtil.verifyJwtSignature('', mocks.keyPairs[0].public) + .should.eventually.be.rejectedWith('jwt must be provided'); + }); + + it('should be fulfilled given a valid signed token and public key', () => { + const mockIdToken = mocks.generateIdToken(); + + return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, + { algorithms: [jwtUtil.ALGORITHM_RS256] }) + .should.eventually.be.fulfilled; + }); + + it('should be fulfilled given a valid unsigned (emulator) token and no public key', () => { + const mockIdToken = mocks.generateIdToken({ + algorithm: 'none', + header: {} + }); + + return jwtUtil.verifyJwtSignature(mockIdToken, '') + .should.eventually.be.fulfilled; + }); + + it('should be fulfilled given a valid signed token and a function to provide public keys', () => { + const mockIdToken = mocks.generateIdToken(); + const getKeyCallback = (_: any, callback: any): void => callback(null, mocks.keyPairs[0].public); + + return jwtUtil.verifyJwtSignature(mockIdToken, getKeyCallback, + { algorithms: [jwtUtil.ALGORITHM_RS256] }) + .should.eventually.be.fulfilled; + }); + + it('should be rejected when the given algorithm does not match the token', () => { + const mockIdToken = mocks.generateIdToken(); + + return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, + { algorithms: ['RS384'] }) + .should.eventually.be.rejectedWith('invalid algorithm') + .with.property('code', jwtUtil.JwtErrorCode.INVALID_SIGNATURE); + }); + + it('should be rejected given an expired token', () => { + clock = sinon.useFakeTimers(1000); + const mockIdToken = mocks.generateIdToken(); + clock.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); + + // token should still be valid + return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, + { algorithms: [jwtUtil.ALGORITHM_RS256] }) + .then(() => { + clock!.tick(1); + + // token should now be invalid + return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, + { algorithms: [jwtUtil.ALGORITHM_RS256] }) + .should.eventually.be.rejectedWith( + 'The provided token has expired. Get a fresh token from your client app and try again.' + ) + .with.property('code', jwtUtil.JwtErrorCode.TOKEN_EXPIRED); + }); + }); + + it('should be rejected with correct public key fetch error.', () => { + const mockIdToken = mocks.generateIdToken(); + const getKeyCallback = (_: any, callback: any): void => + callback(new Error('key fetch failed.')); + + return jwtUtil.verifyJwtSignature(mockIdToken, getKeyCallback, + { algorithms: [jwtUtil.ALGORITHM_RS256] }) + .should.eventually.be.rejectedWith('key fetch failed.') + .with.property('code', jwtUtil.JwtErrorCode.KEY_FETCH_ERROR); + }); + + it('should be rejected with correct no matching key id found error.', () => { + const mockIdToken = mocks.generateIdToken(); + const getKeyCallback = (_: any, callback: any): void => + callback(new Error('no-matching-kid-error')); + + return jwtUtil.verifyJwtSignature(mockIdToken, getKeyCallback, + { algorithms: [jwtUtil.ALGORITHM_RS256] }) + .should.eventually.be.rejectedWith('no-matching-kid-error') + .with.property('code', jwtUtil.JwtErrorCode.NO_MATCHING_KID); + }); + + it('should be rejected given a public key that does not match the token.', () => { + const mockIdToken = mocks.generateIdToken(); + + return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[1].public, + { algorithms: [jwtUtil.ALGORITHM_RS256] }) + .should.eventually.be.rejectedWith('invalid signature') + .with.property('code', jwtUtil.JwtErrorCode.INVALID_SIGNATURE); + }); + + it('should be rejected given an invalid JWT.', () => { + return jwtUtil.verifyJwtSignature('invalid-token', mocks.keyPairs[0].public) + .should.eventually.be.rejectedWith('jwt malformed') + .with.property('code', jwtUtil.JwtErrorCode.INVALID_SIGNATURE); + }); +}); + +describe('PublicKeySignatureVerifier', () => { + let stubs: sinon.SinonStub[] = []; + const verifier = new jwtUtil.PublicKeySignatureVerifier( + new jwtUtil.UrlKeyFetcher('https://www.example.com/publicKeys')); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + describe('Constructor', () => { + it('should not throw when valid key fetcher is provided', () => { + expect(() => { + new jwtUtil.PublicKeySignatureVerifier( + new jwtUtil.UrlKeyFetcher('https://www.example.com/publicKeys')); + }).not.to.throw(); + }); + + const invalidKeyFetchers = [null, NaN, 0, 1, true, false, [], ['a'], _.noop, '', 'a']; + invalidKeyFetchers.forEach((invalidKeyFetcher) => { + it('should throw given an invalid key fetcher: ' + JSON.stringify(invalidKeyFetcher), () => { + expect(() => { + new jwtUtil.PublicKeySignatureVerifier(invalidKeyFetchers as any); + }).to.throw('The provided key fetcher is not an object or null.'); + }); + }); + }); + + describe('withCertificateUrl', () => { + it('should return a PublicKeySignatureVerifier instance when a valid cert url is provided', () => { + expect( + jwtUtil.PublicKeySignatureVerifier.withCertificateUrl('https://www.example.com/publicKeys') + ).to.be.an.instanceOf(jwtUtil.PublicKeySignatureVerifier); + }); + }); + + describe('verify', () => { + it('should throw given no token', () => { + return (verifier.verify as any)() + .should.eventually.be.rejectedWith('The provided token must be a string.'); + }); + + const invalidIdTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidIdTokens.forEach((invalidIdToken) => { + it('should reject given a non-string token: ' + JSON.stringify(invalidIdToken), () => { + return verifier.verify(invalidIdToken as any) + .should.eventually.be.rejectedWith('The provided token must be a string.'); + }); + }); + + it('should reject given an empty string token', () => { + return verifier.verify('') + .should.eventually.be.rejectedWith('jwt must be provided'); + }); + + it('should be fullfilled given a valid token', () => { + const keyFetcherStub = sinon.stub(jwtUtil.UrlKeyFetcher.prototype, 'fetchPublicKeys') + .resolves(VALID_PUBLIC_KEYS_RESPONSE); + stubs.push(keyFetcherStub); + const mockIdToken = mocks.generateIdToken(); + + return verifier.verify(mockIdToken).should.eventually.be.fulfilled; + }); + + it('should be rejected given a token with an incorrect algorithm', () => { + const keyFetcherStub = sinon.stub(jwtUtil.UrlKeyFetcher.prototype, 'fetchPublicKeys') + .resolves(VALID_PUBLIC_KEYS_RESPONSE); + stubs.push(keyFetcherStub); + const mockIdToken = mocks.generateIdToken({ + algorithm: 'HS256', + }); + + return verifier.verify(mockIdToken).should.eventually.be + .rejectedWith('invalid algorithm') + .with.property('code', jwtUtil.JwtErrorCode.INVALID_SIGNATURE); + }); + + // tests to cover the private getKeyCallback function. + it('should reject when no matching kid found', () => { + const keyFetcherStub = sinon.stub(jwtUtil.UrlKeyFetcher.prototype, 'fetchPublicKeys') + .resolves({ 'not-a-matching-key': 'public-key' }); + stubs.push(keyFetcherStub); + const mockIdToken = mocks.generateIdToken(); + + return verifier.verify(mockIdToken).should.eventually.be + .rejectedWith('no-matching-kid-error') + .with.property('code', jwtUtil.JwtErrorCode.NO_MATCHING_KID); + }); + + it('should reject when an error occurs while fetching the keys', () => { + const keyFetcherStub = sinon.stub(jwtUtil.UrlKeyFetcher.prototype, 'fetchPublicKeys') + .rejects(new Error('Error fetching public keys.')); + stubs.push(keyFetcherStub); + const mockIdToken = mocks.generateIdToken(); + + return verifier.verify(mockIdToken).should.eventually.be + .rejectedWith('Error fetching public keys.') + .with.property('code', jwtUtil.JwtErrorCode.KEY_FETCH_ERROR); + }); + }); +}); + +describe('EmulatorSignatureVerifier', () => { + const emulatorVerifier = new jwtUtil.EmulatorSignatureVerifier(); + + describe('verify', () => { + it('should be fullfilled given a valid unsigned (emulator) token', () => { + const mockIdToken = mocks.generateIdToken({ + algorithm: 'none', + header: {} + }); + + return emulatorVerifier.verify(mockIdToken).should.eventually.be.fulfilled; + }); + + it('should be rejected given a valid signed (non-emulator) token', () => { + const mockIdToken = mocks.generateIdToken(); + + return emulatorVerifier.verify(mockIdToken).should.eventually.be.rejected; + }); + }); +}); + +describe('UrlKeyFetcher', () => { + const agent = new https.Agent(); + let keyFetcher: jwtUtil.UrlKeyFetcher; + let clock: sinon.SinonFakeTimers | undefined; + let httpsSpy: sinon.SinonSpy; + + beforeEach(() => { + keyFetcher = new jwtUtil.UrlKeyFetcher( + 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', + agent); + httpsSpy = sinon.spy(https, 'request'); + }); + + afterEach(() => { + if (clock) { + clock.restore(); + clock = undefined; + } + httpsSpy.restore(); + }); + + after(() => { + nock.cleanAll(); + }); + + describe('Constructor', () => { + it('should not throw when valid key parameters are provided', () => { + expect(() => { + new jwtUtil.UrlKeyFetcher('https://www.example.com/publicKeys', agent); + }).not.to.throw(); + }); + + const invalidCertURLs = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, 'file://invalid']; + invalidCertURLs.forEach((invalidCertUrl) => { + it('should throw given a non-URL public cert: ' + JSON.stringify(invalidCertUrl), () => { + expect(() => { + new jwtUtil.UrlKeyFetcher(invalidCertUrl as any, agent); + }).to.throw('The provided public client certificate URL is not a valid URL.'); + }); + }); + }); + + describe('fetchPublicKeys', () => { + let mockedRequests: nock.Scope[] = []; + + afterEach(() => { + _.forEach(mockedRequests, (mockedRequest) => mockedRequest.done()); + mockedRequests = []; + }); + + it('should use the given HTTP Agent', () => { + const agent = new https.Agent(); + const urlKeyFetcher = new jwtUtil.UrlKeyFetcher('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', agent); + mockedRequests.push(mockFetchPublicKeys()); + + return urlKeyFetcher.fetchPublicKeys() + .then(() => { + expect(https.request).to.have.been.calledOnce; + expect(httpsSpy.args[0][0].agent).to.equal(agent); + }); + }); + + it('should not fetch the public keys until the first time fetchPublicKeys() is called', () => { + mockedRequests.push(mockFetchPublicKeys()); + + const urlKeyFetcher = new jwtUtil.UrlKeyFetcher('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', agent); + expect(https.request).not.to.have.been.called; + + return urlKeyFetcher.fetchPublicKeys() + .then(() => expect(https.request).to.have.been.calledOnce); + }); + + it('should not re-fetch the public keys every time fetchPublicKeys() is called', () => { + mockedRequests.push(mockFetchPublicKeys()); + + return keyFetcher.fetchPublicKeys().then(() => { + expect(https.request).to.have.been.calledOnce; + return keyFetcher.fetchPublicKeys(); + }).then(() => expect(https.request).to.have.been.calledOnce); + }); + + it('should refresh the public keys after the "max-age" on the request expires', () => { + mockedRequests.push(mockFetchPublicKeys()); + mockedRequests.push(mockFetchPublicKeys()); + mockedRequests.push(mockFetchPublicKeys()); + + clock = sinon.useFakeTimers(1000); + + return keyFetcher.fetchPublicKeys().then(() => { + expect(https.request).to.have.been.calledOnce; + clock!.tick(999); + return keyFetcher.fetchPublicKeys(); + }).then(() => { + expect(https.request).to.have.been.calledOnce; + clock!.tick(1); + return keyFetcher.fetchPublicKeys(); + }).then(() => { + // One second has passed + expect(https.request).to.have.been.calledTwice; + clock!.tick(999); + return keyFetcher.fetchPublicKeys(); + }).then(() => { + expect(https.request).to.have.been.calledTwice; + clock!.tick(1); + return keyFetcher.fetchPublicKeys(); + }).then(() => { + // Two seconds have passed + expect(https.request).to.have.been.calledThrice; + }); + }); + + it('should be rejected if fetching the public keys fails', () => { + mockedRequests.push(mockFailedFetchPublicKeys()); + + return keyFetcher.fetchPublicKeys() + .should.eventually.be.rejectedWith('message'); + }); + + it('should be rejected if fetching the public keys returns a response with an error message', () => { + mockedRequests.push(mockFetchPublicKeysWithErrorResponse()); + + return keyFetcher.fetchPublicKeys() + .should.eventually.be.rejectedWith('Error fetching public keys for Google certs: message (description)'); + }); + }); +});