From a539e688411f55297a711b8f8b071b8662550df2 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Sun, 21 Mar 2021 17:36:17 -0400 Subject: [PATCH 1/7] (chore): Add JWT Decoder --- src/auth/token-verifier.ts | 101 ++++++++++++++++++------------ src/utils/jwt-decoder.ts | 125 +++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 40 deletions(-) create mode 100644 src/utils/jwt-decoder.ts diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index cbb9991f4c..5da55a898f 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -19,6 +19,7 @@ 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, JwtDecoder, JwtDecoderError, JwtDecoderErrorCode } from '../utils/jwt-decoder'; import { FirebaseApp } from '../firebase-app'; import { auth } from './index'; @@ -75,6 +76,7 @@ export class FirebaseTokenVerifier { private publicKeys: {[key: string]: string}; private publicKeysExpireAt: number; private readonly shortNameArticle: string; + private readonly jwtDecoder: JwtDecoder; constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm, private issuer: string, private tokenInfo: FirebaseTokenInfo, @@ -127,6 +129,7 @@ export class FirebaseTokenVerifier { ); } this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + this.jwtDecoder = new JwtDecoder(algorithm); // For backward compatibility, the project ID is validated in the verification call. } @@ -149,15 +152,42 @@ export class FirebaseTokenVerifier { return util.findProjectId(this.app) .then((projectId) => { - return this.verifyJWTWithProjectId(jwtToken, projectId, isEmulator); + const fullDecodedToken = this.safeDecode(jwtToken); + this.validateJWT(fullDecodedToken, projectId, isEmulator); + return Promise.all([ + fullDecodedToken, + this.verifySignature(jwtToken, fullDecodedToken, isEmulator) + ]); + }) + .then(([fullDecodedToken]) => { + const decodedIdToken = fullDecodedToken.payload as DecodedIdToken; + decodedIdToken.uid = decodedIdToken.sub; + return decodedIdToken; }); } - private verifyJWTWithProjectId( - jwtToken: string, + private safeDecode(jwtToken: string): DecodedToken { + try { + return this.jwtDecoder.decodeToken(jwtToken); + } catch (err) { + if (!(err instanceof JwtDecoderError)) { + return err; + } + if (err.code == JwtDecoderErrorCode.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); + } + } + + private validateJWT( + fullDecodedToken: DecodedToken, projectId: string | null, - isEmulator: boolean - ): Promise { + isEmulator: boolean): void { if (!validator.isNonEmptyString(projectId)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CREDENTIAL, @@ -165,11 +195,7 @@ export class FirebaseTokenVerifier { `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`, ); } - - const fullDecodedToken: any = jwt.decode(jwtToken, { - complete: true, - }); - + const header = fullDecodedToken && fullDecodedToken.header; const payload = fullDecodedToken && fullDecodedToken.payload; @@ -179,10 +205,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); @@ -217,16 +240,19 @@ export class FirebaseTokenVerifier { verifyJwtTokenDocsMessage; } if (errorMessage) { - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); } + } + private verifySignature(jwtToken: string, decodeToken: DecodedToken, isEmulator: boolean): + Promise { 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)) { + if (!Object.prototype.hasOwnProperty.call(publicKeys, decodeToken.header.kid)) { return Promise.reject( new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -236,7 +262,7 @@ export class FirebaseTokenVerifier { ), ); } else { - return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]); + return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[decodeToken.header.kid]); } }); @@ -246,35 +272,30 @@ export class FirebaseTokenVerifier { * 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 + * @return {Promise} A promise that resolves with the decoded JWT claims on successful * verification. */ - private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise { + private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise { const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; + const invalidTokenError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); 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); + this.jwtDecoder.isSignatureValid(jwtToken, publicKey) + .then(isValid => { + return isValid ? resolve() : reject(invalidTokenError); + }) + .catch(error => { + if (!(error instanceof JwtDecoderError)) { + return reject(error); + } + if (error.code === JwtDecoderErrorCode.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 reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); } + return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); }); }); } diff --git a/src/utils/jwt-decoder.ts b/src/utils/jwt-decoder.ts new file mode 100644 index 0000000000..57d9b71915 --- /dev/null +++ b/src/utils/jwt-decoder.ts @@ -0,0 +1,125 @@ +/*! + * 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 { ErrorInfo } from './error'; + +type Dictionary = {[key: string]: any} + +export type DecodedToken = { + header: Dictionary; + payload: Dictionary; +} + +/** + * Class for decoding and verifying general purpose Firebase JWTs. + */ +export class JwtDecoder { + + constructor(private algorithm: jwt.Algorithm) { + + if (!validator.isNonEmptyString(algorithm)) { + throw new Error('The provided JWT algorithm is an empty string.'); + } + } + + public decodeToken(jwtToken: string): DecodedToken { + if (!validator.isString(jwtToken)) { + throw new JwtDecoderError({ + code: JwtDecoderErrorCode.INVALID_ARGUMENT, + message: 'The provided token must be a string.' + }); + } + + const fullDecodedToken: any = jwt.decode(jwtToken, { + complete: true, + }); + + if (!fullDecodedToken) { + throw new JwtDecoderError({ + code: JwtDecoderErrorCode.INVALID_ARGUMENT, + message: 'Decoding token failed.' + }); + } + + const header = fullDecodedToken?.header; + const payload = fullDecodedToken?.payload; + + return { header, payload }; + } + + public isSignatureValid(jwtToken: string, publicKey: string | null): Promise { + 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) => { + if (!error) { + return resolve(true); + } + if (error.name === 'TokenExpiredError') { + return reject(new JwtDecoderError({ + code: JwtDecoderErrorCode.TOKEN_EXPIRED, + message: 'The provided token has expired. Get a fresh token from your ' + + 'client app and try again.', + })); + } else if (error.name === 'JsonWebTokenError') { + return resolve(false); + } + return reject(new JwtDecoderError({ + code: JwtDecoderErrorCode.INVALID_ARGUMENT, + message: error.message + })); + }); + }); + } +} + +/** + * JwtDecoder error code structure. + * + * @param {ProjectManagementErrorCode} code The error code. + * @param {ErrorInfo} errorInfo The error information (code and message). + * @constructor + */ +export class JwtDecoderError extends Error { + constructor(private errorInfo: ErrorInfo) { + super(errorInfo.message); + (this as any).__proto__ = JwtDecoderError.prototype; + } + + /** @return {string} The error code. */ + public get code(): string { + return this.errorInfo.code; + } + + /** @return {string} The error message. */ + public get message(): string { + return this.errorInfo.message; + } +} + +/** + * Crypto Signer error codes and their default messages. + */ +export class JwtDecoderErrorCode { + public static INVALID_ARGUMENT = 'invalid-argument'; + public static INVALID_CREDENTIAL = 'invalid-credential'; + public static TOKEN_EXPIRED = 'token-expired'; +} From bc5bb6e8e00b6ba5e2f4f715e54dcc3772691a5c Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 23 Mar 2021 19:06:29 -0400 Subject: [PATCH 2/7] Add signature verifier and key fetcher abstractions --- src/auth/token-verifier.ts | 197 ++++++++--------------- src/utils/jwt-decoder.ts | 87 +++------- src/utils/jwt-signature-verifier.ts | 235 ++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+), 192 deletions(-) create mode 100644 src/utils/jwt-signature-verifier.ts diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index 5da55a898f..6d352885db 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -18,8 +18,13 @@ import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/erro 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, JwtDecoder, JwtDecoderError, JwtDecoderErrorCode } from '../utils/jwt-decoder'; +import { + DecodedToken, decodeJwt, JwtDecoderError, JwtDecoderErrorCode +} from '../utils/jwt-decoder'; +import { + EmulatorSignatureVerifier, NO_MATCHING_KID_ERROR_MESSAGE, + PublicKeySignatureVerifier, SignatureVerifierError, SignatureVerifierErrorCode +} from '../utils/jwt-signature-verifier'; import { FirebaseApp } from '../firebase-app'; import { auth } from './index'; @@ -70,15 +75,14 @@ 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 jwtDecoder: JwtDecoder; + private readonly signatureVerifier: PublicKeySignatureVerifier; + private readonly emulatorSignatureVerifier: EmulatorSignatureVerifier; - constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm, + constructor(clientCertUrl: string, private algorithm: jwt.Algorithm, private issuer: string, private tokenInfo: FirebaseTokenInfo, private readonly app: FirebaseApp) { @@ -129,7 +133,9 @@ export class FirebaseTokenVerifier { ); } this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; - this.jwtDecoder = new JwtDecoder(algorithm); + + this.signatureVerifier = new PublicKeySignatureVerifier(clientCertUrl, algorithm, app); + this.emulatorSignatureVerifier = new EmulatorSignatureVerifier(); // For backward compatibility, the project ID is validated in the verification call. } @@ -152,11 +158,13 @@ export class FirebaseTokenVerifier { return util.findProjectId(this.app) .then((projectId) => { - const fullDecodedToken = this.safeDecode(jwtToken); - this.validateJWT(fullDecodedToken, projectId, isEmulator); + return Promise.all([this.safeDecode(jwtToken), projectId]); + }) + .then(([fullDecodedToken, projectId]) => { + this.validateToken(fullDecodedToken, projectId, isEmulator); return Promise.all([ fullDecodedToken, - this.verifySignature(jwtToken, fullDecodedToken, isEmulator) + this.verifySignature(jwtToken, isEmulator) ]); }) .then(([fullDecodedToken]) => { @@ -166,25 +174,27 @@ export class FirebaseTokenVerifier { }); } - private safeDecode(jwtToken: string): DecodedToken { - try { - return this.jwtDecoder.decodeToken(jwtToken); - } catch (err) { - if (!(err instanceof JwtDecoderError)) { - return err; - } - if (err.code == JwtDecoderErrorCode.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); - } + private safeDecode(jwtToken: string): Promise { + return decodeJwt(jwtToken) + .catch((err) => { + if (!(err instanceof JwtDecoderError)) { + return Promise.reject(err); + } + if (err.code == JwtDecoderErrorCode.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; + return Promise.reject( + new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + } + return Promise.reject( + new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message)); + }); } - private validateJWT( + private validateToken( fullDecodedToken: DecodedToken, projectId: string | null, isEmulator: boolean): void { @@ -244,116 +254,45 @@ export class FirebaseTokenVerifier { } } - private verifySignature(jwtToken: string, decodeToken: DecodedToken, isEmulator: boolean): + private verifySignature(jwtToken: string, isEmulator: boolean): Promise { if (isEmulator) { - // Signature checks skipped for emulator; no need to fetch public keys. - return this.verifyJwtSignatureWithKey(jwtToken, null); + return this.emulatorSignatureVerifier.verify(jwtToken) + .catch((error) => { + return Promise.reject(this.mapSignatureVerifierErrorToAuthError(error)); + }); } - return this.fetchPublicKeys().then((publicKeys) => { - if (!Object.prototype.hasOwnProperty.call(publicKeys, decodeToken.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[decodeToken.header.kid]); - } - - }); + return this.signatureVerifier.verify(jwtToken) + .catch((error) => { + return Promise.reject(this.mapSignatureVerifierErrorToAuthError(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. - */ - private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise { + private mapSignatureVerifierErrorToAuthError(error: SignatureVerifierError): Error { const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; - const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; - const invalidTokenError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); - return new Promise((resolve, reject) => { - this.jwtDecoder.isSignatureValid(jwtToken, publicKey) - .then(isValid => { - return isValid ? resolve() : reject(invalidTokenError); - }) - .catch(error => { - if (!(error instanceof JwtDecoderError)) { - return reject(error); - } - if (error.code === JwtDecoderErrorCode.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 reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); - } - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); - }); - }); - } - - /** - * 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 instanceof SignatureVerifierError)) { + return (error); } - - 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; - }); + if (error.code === SignatureVerifierErrorCode.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 === SignatureVerifierErrorCode.INVALID_TOKEN) { + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; + return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); + } + else if (error.code === SignatureVerifierErrorCode.INVALID_ARGUMENT && + error.message === NO_MATCHING_KID_ERROR_MESSAGE) { + 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); } } diff --git a/src/utils/jwt-decoder.ts b/src/utils/jwt-decoder.ts index 57d9b71915..43b625f2d5 100644 --- a/src/utils/jwt-decoder.ts +++ b/src/utils/jwt-decoder.ts @@ -26,75 +26,36 @@ export type DecodedToken = { } /** - * Class for decoding and verifying general purpose Firebase JWTs. + * Decodes general purpose Firebase JWTs. */ -export class JwtDecoder { - - constructor(private algorithm: jwt.Algorithm) { - - if (!validator.isNonEmptyString(algorithm)) { - throw new Error('The provided JWT algorithm is an empty string.'); - } +export function decodeJwt(jwtToken: string): Promise { + if (!validator.isString(jwtToken)) { + return Promise.reject(new JwtDecoderError({ + code: JwtDecoderErrorCode.INVALID_ARGUMENT, + message: 'The provided token must be a string.' + })); } - public decodeToken(jwtToken: string): DecodedToken { - if (!validator.isString(jwtToken)) { - throw new JwtDecoderError({ - code: JwtDecoderErrorCode.INVALID_ARGUMENT, - message: 'The provided token must be a string.' - }); - } - - const fullDecodedToken: any = jwt.decode(jwtToken, { - complete: true, - }); - - if (!fullDecodedToken) { - throw new JwtDecoderError({ - code: JwtDecoderErrorCode.INVALID_ARGUMENT, - message: 'Decoding token failed.' - }); - } + const fullDecodedToken: any = jwt.decode(jwtToken, { + complete: true, + }); - const header = fullDecodedToken?.header; - const payload = fullDecodedToken?.payload; - - return { header, payload }; + if (!fullDecodedToken) { + return Promise.reject(new JwtDecoderError({ + code: JwtDecoderErrorCode.INVALID_ARGUMENT, + message: 'Decoding token failed.' + })); } - public isSignatureValid(jwtToken: string, publicKey: string | null): Promise { - 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) => { - if (!error) { - return resolve(true); - } - if (error.name === 'TokenExpiredError') { - return reject(new JwtDecoderError({ - code: JwtDecoderErrorCode.TOKEN_EXPIRED, - message: 'The provided token has expired. Get a fresh token from your ' + - 'client app and try again.', - })); - } else if (error.name === 'JsonWebTokenError') { - return resolve(false); - } - return reject(new JwtDecoderError({ - code: JwtDecoderErrorCode.INVALID_ARGUMENT, - message: error.message - })); - }); - }); - } + const header = fullDecodedToken?.header; + const payload = fullDecodedToken?.payload; + + return Promise.resolve({ header, payload }); } /** * JwtDecoder error code structure. * - * @param {ProjectManagementErrorCode} code The error code. * @param {ErrorInfo} errorInfo The error information (code and message). * @constructor */ @@ -116,10 +77,10 @@ export class JwtDecoderError extends Error { } /** - * Crypto Signer error codes and their default messages. + * JWT decoder error codes. */ -export class JwtDecoderErrorCode { - public static INVALID_ARGUMENT = 'invalid-argument'; - public static INVALID_CREDENTIAL = 'invalid-credential'; - public static TOKEN_EXPIRED = 'token-expired'; +export enum JwtDecoderErrorCode { + INVALID_ARGUMENT = 'invalid-argument', + INVALID_CREDENTIAL = 'invalid-credential', + TOKEN_EXPIRED = 'token-expired', } diff --git a/src/utils/jwt-signature-verifier.ts b/src/utils/jwt-signature-verifier.ts new file mode 100644 index 0000000000..b37f196519 --- /dev/null +++ b/src/utils/jwt-signature-verifier.ts @@ -0,0 +1,235 @@ +/*! + * 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 { ErrorInfo } from './error'; +import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; +import { FirebaseApp } from '../firebase-app'; + +export interface SignatureVerifier { + verify(token: string): Promise; +} + +interface KeyFetcher { + fetchPublicKeys(): Promise; +} + +class PublicKeyFetcher implements KeyFetcher<{ [key: string]: string }> { + private publicKeys: { [key: string]: string }; + private publicKeysExpireAt: number; + + constructor(private clientCertUrl: string, private readonly app: FirebaseApp) { + if (!validator.isURL(clientCertUrl)) { + throw new Error( + 'The provided public client certificate URL is an invalid URL.', + ); + } + } + + /** + * Fetches the public keys for the Google certs. + * + * @return {Promise} A promise fulfilled with public keys for the Google certs. + */ + public 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); + } + + 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 SignatureVerifierError({ + code: SignatureVerifierErrorCode.INTERNAL_ERROR, + message: errorMessage + }); + } + throw err; + }); + } +} + +export class PublicKeySignatureVerifier implements SignatureVerifier { + private publicKeyProvider: PublicKeyFetcher; + private getKeyHandler: jwt.GetPublicKeyOrSecret; + + constructor(clientCertUrl: string, private algorithm: jwt.Algorithm, + readonly app: FirebaseApp) { + if (!validator.isURL(clientCertUrl)) { + throw new Error( + 'The provided public client certificate URL is an invalid URL.', + ); + } + else if (!validator.isNonEmptyString(algorithm)) { + throw new Error('The provided JWT algorithm is an empty string.'); + } + + this.publicKeyProvider = new PublicKeyFetcher(clientCertUrl, app); + this.getKeyHandler = this.getKey.bind(this); + } + + public verify(token: string): Promise { + const error = new SignatureVerifierError({ + code: SignatureVerifierErrorCode.INVALID_TOKEN, + message: 'The provided token has invalid signature.' + }); + + const verifyOptions: jwt.VerifyOptions = {}; + verifyOptions.algorithms = [this.algorithm]; + + return isSignatureValid(token, this.getKeyHandler, verifyOptions) + .then(isValid => { + return isValid ? Promise.resolve() : Promise.reject(error); + }); + } + + private getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback): void { + const kid = header.kid || ''; + this.publicKeyProvider.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); + }); + } +} + +export class EmulatorSignatureVerifier implements SignatureVerifier { + + public verify(token: string): Promise { + const error = new SignatureVerifierError({ + code: SignatureVerifierErrorCode.INVALID_TOKEN, + message: 'The provided token has invalid signature.' + }); + + // Signature checks skipped for emulator; no need to fetch public keys. + return isSignatureValid(token, '') + .then(isValid => { + return isValid ? Promise.resolve() : Promise.reject(error); + }); + } +} + +function isSignatureValid(token: string, secretOrPublicKey: jwt.Secret | jwt.GetPublicKeyOrSecret, + options?: jwt.VerifyOptions): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, secretOrPublicKey, options, + (error: jwt.VerifyErrors | null) => { + if (!error) { + return resolve(true); + } + if (error.name === 'TokenExpiredError') { + return reject(new SignatureVerifierError({ + code: SignatureVerifierErrorCode.TOKEN_EXPIRED, + message: 'The provided token has expired. Get a fresh token from your ' + + 'client app and try again.', + })); + } else if (error.name === 'JsonWebTokenError') { + const prefix = 'error in secret or public key callback: '; + if (error.message && error.message.includes(prefix)) { + const message = error.message.split(prefix).pop(); + return reject(new SignatureVerifierError({ + code: SignatureVerifierErrorCode.INVALID_ARGUMENT, + message: message || 'Error fetching public keys.', + })); + } + return resolve(false); + } + return reject(new SignatureVerifierError({ + code: SignatureVerifierErrorCode.INVALID_TOKEN, + message: error.message + })); + }); + }); +} + +/** + * SignatureVerifier error code structure. + * + * @param {ErrorInfo} errorInfo The error information (code and message). + * @constructor + */ +export class SignatureVerifierError extends Error { + constructor(private errorInfo: ErrorInfo) { + super(errorInfo.message); + (this as any).__proto__ = SignatureVerifierError.prototype; + } + + /** @return {string} The error code. */ + public get code(): string { + return this.errorInfo.code; + } + + /** @return {string} The error message. */ + public get message(): string { + return this.errorInfo.message; + } +} + +/** + * SignatureVerifier error codes. + */ +export enum SignatureVerifierErrorCode { + INVALID_ARGUMENT = 'invalid-argument', + INVALID_TOKEN = 'invalid-token', + INVALID_CREDENTIAL = 'invalid-credential', + TOKEN_EXPIRED = 'token-expired', + INTERNAL_ERROR = 'internal-error', +} + +export const NO_MATCHING_KID_ERROR_MESSAGE = 'no-matching-kid-error'; From 5983c192f2c02522e8ca5f3e8832d979029d3e20 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 24 Mar 2021 17:55:30 -0400 Subject: [PATCH 3/7] Move the signature verifier to utils/jwt and other PR fixes --- src/auth/token-verifier.ts | 105 ++++++------ src/utils/jwt-decoder.ts | 86 ---------- .../{jwt-signature-verifier.ts => jwt.ts} | 162 +++++++++--------- 3 files changed, 138 insertions(+), 215 deletions(-) delete mode 100644 src/utils/jwt-decoder.ts rename src/utils/{jwt-signature-verifier.ts => jwt.ts} (58%) diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index 6d352885db..b9e03b54f0 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -19,12 +19,9 @@ import * as util from '../utils/index'; import * as validator from '../utils/validator'; import * as jwt from 'jsonwebtoken'; import { - DecodedToken, decodeJwt, JwtDecoderError, JwtDecoderErrorCode -} from '../utils/jwt-decoder'; -import { - EmulatorSignatureVerifier, NO_MATCHING_KID_ERROR_MESSAGE, - PublicKeySignatureVerifier, SignatureVerifierError, SignatureVerifierErrorCode -} from '../utils/jwt-signature-verifier'; + DecodedToken, decodeJwt, JwtError, JwtErrorCode, + EmulatorSignatureVerifier, PublicKeySignatureVerifier, UrlKeyFetcher, +} from '../utils/jwt'; import { FirebaseApp } from '../firebase-app'; import { auth } from './index'; @@ -33,7 +30,7 @@ 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'; +const ALGORITHM_RS256 = 'RS256' as const; // URL containing the public keys for the Google certs (whose private keys are used to sign Firebase // Auth ID tokens) @@ -80,7 +77,6 @@ export interface FirebaseTokenInfo { export class FirebaseTokenVerifier { private readonly shortNameArticle: string; private readonly signatureVerifier: PublicKeySignatureVerifier; - private readonly emulatorSignatureVerifier: EmulatorSignatureVerifier; constructor(clientCertUrl: string, private algorithm: jwt.Algorithm, private issuer: string, private tokenInfo: FirebaseTokenInfo, @@ -134,8 +130,8 @@ export class FirebaseTokenVerifier { } this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; - this.signatureVerifier = new PublicKeySignatureVerifier(clientCertUrl, algorithm, app); - this.emulatorSignatureVerifier = new EmulatorSignatureVerifier(); + this.signatureVerifier = new PublicKeySignatureVerifier( + new UrlKeyFetcher(clientCertUrl, app.options.httpAgent)); // For backward compatibility, the project ID is validated in the verification call. } @@ -156,55 +152,69 @@ export class FirebaseTokenVerifier { ); } - return util.findProjectId(this.app) + return this.ensureProjectId() .then((projectId) => { - return Promise.all([this.safeDecode(jwtToken), projectId]); - }) - .then(([fullDecodedToken, projectId]) => { - this.validateToken(fullDecodedToken, projectId, isEmulator); - return Promise.all([ - fullDecodedToken, - this.verifySignature(jwtToken, isEmulator) - ]); + return this.decodeAndVerify(jwtToken, projectId, isEmulator); }) - .then(([fullDecodedToken]) => { - const decodedIdToken = fullDecodedToken.payload as DecodedIdToken; + .then((decoded) => { + const decodedIdToken = decoded.payload as DecodedIdToken; decodedIdToken.uid = decodedIdToken.sub; return decodedIdToken; }); } + 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); + }) + } + + private decodeAndVerify(token: string, projectId: string, isEmulator: boolean): Promise { + return this.verifyContent(token, projectId, isEmulator) + .then((decoded) => { + return this.verifySignature(token, isEmulator) + .then(() => decoded); + }); + } + + private verifyContent(token: string, projectId: string, isEmulator: boolean): Promise { + return this.safeDecode(token).then((decodedToken) => { + this.validateTokenContent(decodedToken, projectId, isEmulator); + return Promise.resolve(decodedToken); + }); + } + private safeDecode(jwtToken: string): Promise { return decodeJwt(jwtToken) .catch((err) => { - if (!(err instanceof JwtDecoderError)) { - return Promise.reject(err); + if (!(err instanceof JwtError)) { + throw err; } - if (err.code == JwtDecoderErrorCode.INVALID_ARGUMENT) { + 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; - return Promise.reject( - new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, + errorMessage); } - return Promise.reject( - new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message)); + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message); }); } - private validateToken( + private validateTokenContent( fullDecodedToken: DecodedToken, projectId: string | null, isEmulator: boolean): void { - 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}.`, - ); - } const header = fullDecodedToken && fullDecodedToken.header; const payload = fullDecodedToken && fullDecodedToken.payload; @@ -256,37 +266,30 @@ export class FirebaseTokenVerifier { private verifySignature(jwtToken: string, isEmulator: boolean): Promise { - if (isEmulator) { - return this.emulatorSignatureVerifier.verify(jwtToken) - .catch((error) => { - return Promise.reject(this.mapSignatureVerifierErrorToAuthError(error)); - }); - } - - return this.signatureVerifier.verify(jwtToken) + const verifier = isEmulator ? new EmulatorSignatureVerifier() : this.signatureVerifier; + return verifier.verify(jwtToken) .catch((error) => { - return Promise.reject(this.mapSignatureVerifierErrorToAuthError(error)); + throw this.mapJwtErrorToAuthError(error); }); } - private mapSignatureVerifierErrorToAuthError(error: SignatureVerifierError): Error { + private mapJwtErrorToAuthError(error: JwtError): Error { const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; - if (!(error instanceof SignatureVerifierError)) { + if (!(error instanceof JwtError)) { return (error); } - if (error.code === SignatureVerifierErrorCode.TOKEN_EXPIRED) { + 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 === SignatureVerifierErrorCode.INVALID_TOKEN) { + else if (error.code === JwtErrorCode.INVALID_TOKEN) { const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); } - else if (error.code === SignatureVerifierErrorCode.INVALID_ARGUMENT && - error.message === NO_MATCHING_KID_ERROR_MESSAGE) { + 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.'; diff --git a/src/utils/jwt-decoder.ts b/src/utils/jwt-decoder.ts deleted file mode 100644 index 43b625f2d5..0000000000 --- a/src/utils/jwt-decoder.ts +++ /dev/null @@ -1,86 +0,0 @@ -/*! - * 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 { ErrorInfo } from './error'; - -type Dictionary = {[key: string]: any} - -export type DecodedToken = { - header: Dictionary; - payload: Dictionary; -} - -/** - * Decodes general purpose Firebase JWTs. - */ -export function decodeJwt(jwtToken: string): Promise { - if (!validator.isString(jwtToken)) { - return Promise.reject(new JwtDecoderError({ - code: JwtDecoderErrorCode.INVALID_ARGUMENT, - message: 'The provided token must be a string.' - })); - } - - const fullDecodedToken: any = jwt.decode(jwtToken, { - complete: true, - }); - - if (!fullDecodedToken) { - return Promise.reject(new JwtDecoderError({ - code: JwtDecoderErrorCode.INVALID_ARGUMENT, - message: 'Decoding token failed.' - })); - } - - const header = fullDecodedToken?.header; - const payload = fullDecodedToken?.payload; - - return Promise.resolve({ header, payload }); -} - -/** - * JwtDecoder error code structure. - * - * @param {ErrorInfo} errorInfo The error information (code and message). - * @constructor - */ -export class JwtDecoderError extends Error { - constructor(private errorInfo: ErrorInfo) { - super(errorInfo.message); - (this as any).__proto__ = JwtDecoderError.prototype; - } - - /** @return {string} The error code. */ - public get code(): string { - return this.errorInfo.code; - } - - /** @return {string} The error message. */ - public get message(): string { - return this.errorInfo.message; - } -} - -/** - * JWT decoder error codes. - */ -export enum JwtDecoderErrorCode { - INVALID_ARGUMENT = 'invalid-argument', - INVALID_CREDENTIAL = 'invalid-credential', - TOKEN_EXPIRED = 'token-expired', -} diff --git a/src/utils/jwt-signature-verifier.ts b/src/utils/jwt.ts similarity index 58% rename from src/utils/jwt-signature-verifier.ts rename to src/utils/jwt.ts index b37f196519..3a484a4c5d 100644 --- a/src/utils/jwt-signature-verifier.ts +++ b/src/utils/jwt.ts @@ -16,23 +16,30 @@ import * as validator from './validator'; import * as jwt from 'jsonwebtoken'; -import { ErrorInfo } from './error'; import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; -import { FirebaseApp } from '../firebase-app'; + +import http = require('http'); + +export type Dictionary = {[key: string]: any} + +export type DecodedToken = { + header: Dictionary; + payload: Dictionary; +} export interface SignatureVerifier { verify(token: string): Promise; } -interface KeyFetcher { - fetchPublicKeys(): Promise; +interface KeyFetcher { + fetchPublicKeys(): Promise<{ [key: string]: string }>; } -class PublicKeyFetcher implements KeyFetcher<{ [key: string]: string }> { +export class UrlKeyFetcher implements KeyFetcher { private publicKeys: { [key: string]: string }; private publicKeysExpireAt: number; - constructor(private clientCertUrl: string, private readonly app: FirebaseApp) { + constructor(private clientCertUrl: string, private readonly httpAgent?: http.Agent) { if (!validator.isURL(clientCertUrl)) { throw new Error( 'The provided public client certificate URL is an invalid URL.', @@ -43,21 +50,28 @@ class PublicKeyFetcher implements KeyFetcher<{ [key: string]: string }> { /** * Fetches the public keys for the Google certs. * - * @return {Promise} A promise fulfilled with 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); + } + + private shouldRefresh(): boolean { 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); - } + return !(publicKeysExist && publicKeysStillValid); + } + private refresh(): Promise<{ [key: string]: string }> { const client = new HttpClient(); const request: HttpRequestConfig = { method: 'GET', url: this.clientCertUrl, - httpAgent: this.app.options.httpAgent, + httpAgent: this.httpAgent, }; return client.send(request).then((resp) => { if (!resp.isJson() || resp.data.error) { @@ -90,53 +104,39 @@ class PublicKeyFetcher implements KeyFetcher<{ [key: string]: string }> { } else { errorMessage += `${resp.text}`; } - throw new SignatureVerifierError({ - code: SignatureVerifierErrorCode.INTERNAL_ERROR, - message: errorMessage - }); + throw new JwtError(JwtErrorCode.INTERNAL_ERROR, errorMessage); } throw err; }); } } +/** + * Verifies JWT signature with a public key. + */ export class PublicKeySignatureVerifier implements SignatureVerifier { - private publicKeyProvider: PublicKeyFetcher; - private getKeyHandler: jwt.GetPublicKeyOrSecret; - - constructor(clientCertUrl: string, private algorithm: jwt.Algorithm, - readonly app: FirebaseApp) { - if (!validator.isURL(clientCertUrl)) { - throw new Error( - 'The provided public client certificate URL is an invalid URL.', - ); - } - else if (!validator.isNonEmptyString(algorithm)) { - throw new Error('The provided JWT algorithm is an empty string.'); + constructor(private keyFetcher: UrlKeyFetcher) { + if (!validator.isNonNullObject(keyFetcher)) { + throw new Error('The provided key fetcher is not an object or null.'); } - - this.publicKeyProvider = new PublicKeyFetcher(clientCertUrl, app); - this.getKeyHandler = this.getKey.bind(this); } public verify(token: string): Promise { - const error = new SignatureVerifierError({ - code: SignatureVerifierErrorCode.INVALID_TOKEN, - message: 'The provided token has invalid signature.' - }); - - const verifyOptions: jwt.VerifyOptions = {}; - verifyOptions.algorithms = [this.algorithm]; - - return isSignatureValid(token, this.getKeyHandler, verifyOptions) + const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const; + const error = new JwtError(JwtErrorCode.INVALID_TOKEN, + 'The provided token has invalid signature.'); + return isSignatureValid(token, getKeyCallback(this.keyFetcher), + { algorithms: [ALGORITHM_RS256] }) .then(isValid => { return isValid ? Promise.resolve() : Promise.reject(error); }); } +} - private getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback): void { +function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret { + return (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { const kid = header.kid || ''; - this.publicKeyProvider.fetchPublicKeys().then((publicKeys) => { + fetcher.fetchPublicKeys().then((publicKeys) => { if (!Object.prototype.hasOwnProperty.call(publicKeys, kid)) { callback(new Error(NO_MATCHING_KID_ERROR_MESSAGE)); } else { @@ -150,12 +150,9 @@ export class PublicKeySignatureVerifier implements SignatureVerifier { } export class EmulatorSignatureVerifier implements SignatureVerifier { - public verify(token: string): Promise { - const error = new SignatureVerifierError({ - code: SignatureVerifierErrorCode.INVALID_TOKEN, - message: 'The provided token has invalid signature.' - }); + const error = new JwtError(JwtErrorCode.INVALID_TOKEN, + 'The provided token has invalid signature.'); // Signature checks skipped for emulator; no need to fetch public keys. return isSignatureValid(token, '') @@ -174,62 +171,71 @@ function isSignatureValid(token: string, secretOrPublicKey: jwt.Secret | jwt.Get return resolve(true); } if (error.name === 'TokenExpiredError') { - return reject(new SignatureVerifierError({ - code: SignatureVerifierErrorCode.TOKEN_EXPIRED, - message: 'The provided token has expired. Get a fresh token from your ' + - 'client app and try again.', - })); + 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') { const prefix = 'error in secret or public key callback: '; if (error.message && error.message.includes(prefix)) { - const message = error.message.split(prefix).pop(); - return reject(new SignatureVerifierError({ - code: SignatureVerifierErrorCode.INVALID_ARGUMENT, - message: message || 'Error fetching public keys.', - })); + const message = error.message.split(prefix).pop() || 'Error fetching public keys.'; + const code = (message === NO_MATCHING_KID_ERROR_MESSAGE) ? JwtErrorCode.NO_MATCHING_KID : + JwtErrorCode.INVALID_ARGUMENT; + return reject(new JwtError(code, message)); } return resolve(false); } - return reject(new SignatureVerifierError({ - code: SignatureVerifierErrorCode.INVALID_TOKEN, - message: error.message - })); + return reject(new JwtError(JwtErrorCode.INVALID_TOKEN, error.message)); }); }); } /** - * SignatureVerifier error code structure. - * - * @param {ErrorInfo} errorInfo The error information (code and message). - * @constructor + * Decodes general purpose Firebase JWTs. */ -export class SignatureVerifierError extends Error { - constructor(private errorInfo: ErrorInfo) { - super(errorInfo.message); - (this as any).__proto__ = SignatureVerifierError.prototype; +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.')); } - /** @return {string} The error code. */ - public get code(): string { - return this.errorInfo.code; + const fullDecodedToken: any = jwt.decode(jwtToken, { + complete: true, + }); + + if (!fullDecodedToken) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'Decoding token failed.')); } - /** @return {string} The error message. */ - public get message(): string { - return this.errorInfo.message; + 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; } } /** - * SignatureVerifier error codes. + * JWT error codes. */ -export enum SignatureVerifierErrorCode { +export enum JwtErrorCode { INVALID_ARGUMENT = 'invalid-argument', - INVALID_TOKEN = 'invalid-token', INVALID_CREDENTIAL = 'invalid-credential', TOKEN_EXPIRED = 'token-expired', + INVALID_TOKEN = 'invalid-token', + NO_MATCHING_KID = 'no-matching-kid-error', INTERNAL_ERROR = 'internal-error', } -export const NO_MATCHING_KID_ERROR_MESSAGE = 'no-matching-kid-error'; +const NO_MATCHING_KID_ERROR_MESSAGE = 'no-matching-kid-error'; From 41d42df47d74531bfabb5f370f076dbd033d3932 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 24 Mar 2021 18:17:00 -0400 Subject: [PATCH 4/7] Fix http import --- src/utils/jwt.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 3a484a4c5d..485f48d07b 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -17,8 +17,7 @@ import * as validator from './validator'; import * as jwt from 'jsonwebtoken'; import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; - -import http = require('http'); +import { Agent } from 'http'; export type Dictionary = {[key: string]: any} @@ -39,7 +38,7 @@ export class UrlKeyFetcher implements KeyFetcher { private publicKeys: { [key: string]: string }; private publicKeysExpireAt: number; - constructor(private clientCertUrl: string, private readonly httpAgent?: http.Agent) { + constructor(private clientCertUrl: string, private readonly httpAgent?: Agent) { if (!validator.isURL(clientCertUrl)) { throw new Error( 'The provided public client certificate URL is an invalid URL.', From 04b2c547c8d725c02b670c7ce8e24061bb32a0db Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 24 Mar 2021 21:03:35 -0400 Subject: [PATCH 5/7] PR fixes --- src/auth/token-verifier.ts | 65 +++++++++--------------- src/utils/jwt.ts | 71 +++++++++++++-------------- test/unit/auth/token-verifier.spec.ts | 43 +--------------- 3 files changed, 59 insertions(+), 120 deletions(-) diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index b9e03b54f0..cef9c3ad7d 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -17,10 +17,9 @@ 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 { DecodedToken, decodeJwt, JwtError, JwtErrorCode, - EmulatorSignatureVerifier, PublicKeySignatureVerifier, UrlKeyFetcher, + EmulatorSignatureVerifier, PublicKeySignatureVerifier, } from '../utils/jwt'; import { FirebaseApp } from '../firebase-app'; import { auth } from './index'; @@ -39,6 +38,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', @@ -78,8 +79,7 @@ export class FirebaseTokenVerifier { private readonly shortNameArticle: string; private readonly signatureVerifier: PublicKeySignatureVerifier; - constructor(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)) { @@ -87,11 +87,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, @@ -130,8 +125,8 @@ export class FirebaseTokenVerifier { } this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; - this.signatureVerifier = new PublicKeySignatureVerifier( - new UrlKeyFetcher(clientCertUrl, app.options.httpAgent)); + this.signatureVerifier = + PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl, app.options.httpAgent); // For backward compatibility, the project ID is validated in the verification call. } @@ -139,10 +134,9 @@ export class FirebaseTokenVerifier { /** * 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)) { @@ -178,26 +172,17 @@ export class FirebaseTokenVerifier { } private decodeAndVerify(token: string, projectId: string, isEmulator: boolean): Promise { - return this.verifyContent(token, projectId, isEmulator) - .then((decoded) => { + return this.safeDecode(token) + .then((decodedToken) => { + this.verifyContent(decodedToken, projectId, isEmulator); return this.verifySignature(token, isEmulator) - .then(() => decoded); + .then(() => decodedToken); }); } - private verifyContent(token: string, projectId: string, isEmulator: boolean): Promise { - return this.safeDecode(token).then((decodedToken) => { - this.validateTokenContent(decodedToken, projectId, isEmulator); - return Promise.resolve(decodedToken); - }); - } - private safeDecode(jwtToken: string): Promise { return decodeJwt(jwtToken) - .catch((err) => { - if (!(err instanceof JwtError)) { - throw err; - } + .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}.`; @@ -211,7 +196,7 @@ export class FirebaseTokenVerifier { }); } - private validateTokenContent( + private verifyContent( fullDecodedToken: DecodedToken, projectId: string | null, isEmulator: boolean): void { @@ -240,8 +225,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 "` + @@ -266,7 +251,7 @@ export class FirebaseTokenVerifier { private verifySignature(jwtToken: string, isEmulator: boolean): Promise { - const verifier = isEmulator ? new EmulatorSignatureVerifier() : this.signatureVerifier; + const verifier = isEmulator ? EMULATOR_VERIFIER : this.signatureVerifier; return verifier.verify(jwtToken) .catch((error) => { throw this.mapJwtErrorToAuthError(error); @@ -285,11 +270,11 @@ export class FirebaseTokenVerifier { verifyJwtTokenDocsMessage; return new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage); } - else if (error.code === JwtErrorCode.INVALID_TOKEN) { + 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) { + else if (error.code === JwtErrorCode.KEY_FETCH_ERROR) { 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.'; @@ -302,13 +287,12 @@ export class FirebaseTokenVerifier { /** * 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 @@ -318,13 +302,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 index 485f48d07b..dbcba53006 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -19,6 +19,16 @@ import * as jwt from 'jsonwebtoken'; import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; import { Agent } from 'http'; +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 = { @@ -34,9 +44,9 @@ interface KeyFetcher { fetchPublicKeys(): Promise<{ [key: string]: string }>; } -export class UrlKeyFetcher implements KeyFetcher { +class UrlKeyFetcher implements KeyFetcher { private publicKeys: { [key: string]: string }; - private publicKeysExpireAt: number; + private publicKeysExpireAt = 0; constructor(private clientCertUrl: string, private readonly httpAgent?: Agent) { if (!validator.isURL(clientCertUrl)) { @@ -59,10 +69,7 @@ export class UrlKeyFetcher implements KeyFetcher { } private shouldRefresh(): boolean { - const publicKeysExist = (typeof this.publicKeys !== 'undefined'); - const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined'); - const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt); - return !(publicKeysExist && publicKeysStillValid); + return !this.publicKeys || this.publicKeysExpireAt <= Date.now(); } private refresh(): Promise<{ [key: string]: string }> { @@ -78,6 +85,8 @@ export class UrlKeyFetcher implements KeyFetcher { // 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(','); @@ -103,7 +112,7 @@ export class UrlKeyFetcher implements KeyFetcher { } else { errorMessage += `${resp.text}`; } - throw new JwtError(JwtErrorCode.INTERNAL_ERROR, errorMessage); + throw new Error(errorMessage); } throw err; }); @@ -114,21 +123,18 @@ export class UrlKeyFetcher implements KeyFetcher { * Verifies JWT signature with a public key. */ export class PublicKeySignatureVerifier implements SignatureVerifier { - constructor(private keyFetcher: UrlKeyFetcher) { + 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 { - const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const; - const error = new JwtError(JwtErrorCode.INVALID_TOKEN, - 'The provided token has invalid signature.'); - return isSignatureValid(token, getKeyCallback(this.keyFetcher), - { algorithms: [ALGORITHM_RS256] }) - .then(isValid => { - return isValid ? Promise.resolve() : Promise.reject(error); - }); + return verifyJwtSignature(token, getKeyCallback(this.keyFetcher), { algorithms: [ALGORITHM_RS256] }); } } @@ -150,40 +156,34 @@ function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret { export class EmulatorSignatureVerifier implements SignatureVerifier { public verify(token: string): Promise { - const error = new JwtError(JwtErrorCode.INVALID_TOKEN, - 'The provided token has invalid signature.'); - // Signature checks skipped for emulator; no need to fetch public keys. - return isSignatureValid(token, '') - .then(isValid => { - return isValid ? Promise.resolve() : Promise.reject(error); - }); + return verifyJwtSignature(token, ''); } } -function isSignatureValid(token: string, secretOrPublicKey: jwt.Secret | jwt.GetPublicKeyOrSecret, - options?: jwt.VerifyOptions): Promise { +function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.GetPublicKeyOrSecret, + options?: jwt.VerifyOptions): Promise { return new Promise((resolve, reject) => { jwt.verify(token, secretOrPublicKey, options, (error: jwt.VerifyErrors | null) => { if (!error) { - return resolve(true); + 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') { - const prefix = 'error in secret or public key callback: '; - if (error.message && error.message.includes(prefix)) { - const message = error.message.split(prefix).pop() || 'Error fetching public keys.'; - const code = (message === NO_MATCHING_KID_ERROR_MESSAGE) ? JwtErrorCode.NO_MATCHING_KID : + 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.KEY_FETCH_ERROR : JwtErrorCode.INVALID_ARGUMENT; return reject(new JwtError(code, message)); } - return resolve(false); + return reject(new JwtError(JwtErrorCode.INVALID_SIGNATURE, + 'The provided token has invalid signature.')); } - return reject(new JwtError(JwtErrorCode.INVALID_TOKEN, error.message)); + return reject(new JwtError(JwtErrorCode.INVALID_SIGNATURE, error.message)); }); }); } @@ -232,9 +232,6 @@ export enum JwtErrorCode { INVALID_ARGUMENT = 'invalid-argument', INVALID_CREDENTIAL = 'invalid-credential', TOKEN_EXPIRED = 'token-expired', - INVALID_TOKEN = 'invalid-token', - NO_MATCHING_KID = 'no-matching-kid-error', - INTERNAL_ERROR = 'internal-error', + INVALID_SIGNATURE = 'invalid-token', + KEY_FETCH_ERROR = 'no-matching-kid-error', } - -const NO_MATCHING_KID_ERROR_MESSAGE = 'no-matching-kid-error'; diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index d863a0e849..993d80ac58 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -34,7 +34,6 @@ 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'; chai.should(); chai.use(sinonChai); @@ -106,13 +105,10 @@ function mockFailedFetchPublicKeys(): nock.Scope { } 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 @@ -152,7 +148,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 +167,6 @@ describe('FirebaseTokenVerifier', () => { expect(() => { new verifier.FirebaseTokenVerifier( invalidCertUrl as any, - 'RS256', 'https://www.example.com/issuer/', verifier.ID_TOKEN_INFO, app, @@ -181,27 +175,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 +195,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 +215,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 +235,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 +255,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', @@ -331,7 +306,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(), @@ -438,7 +412,6 @@ describe('FirebaseTokenVerifier', () => { it('should be rejected given an expired Firebase session cookie', () => { const tokenVerifierSessionCookie = new verifier.FirebaseTokenVerifier( 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', - 'RS256', 'https://session.firebase.google.com/', verifier.SESSION_COOKIE_INFO, app, @@ -483,7 +456,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 +481,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, @@ -567,16 +538,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); @@ -602,7 +563,6 @@ describe('FirebaseTokenVerifier', () => { }); 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, @@ -625,7 +585,6 @@ describe('FirebaseTokenVerifier', () => { 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, From 6ffbeccfe410a5bd5e7014bc085b4619fb51ab7d Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 25 Mar 2021 20:51:48 -0400 Subject: [PATCH 6/7] Add unit tests for utils/jwt --- src/auth/token-verifier.ts | 28 +- src/utils/jwt.ts | 72 +++- test/unit/auth/token-verifier.spec.ts | 263 +++---------- test/unit/index.spec.ts | 1 + test/unit/utils/jwt.spec.ts | 541 ++++++++++++++++++++++++++ 5 files changed, 662 insertions(+), 243 deletions(-) create mode 100644 test/unit/utils/jwt.spec.ts diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index cef9c3ad7d..e100cc25f6 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -19,7 +19,7 @@ import * as util from '../utils/index'; import * as validator from '../utils/validator'; import { DecodedToken, decodeJwt, JwtError, JwtErrorCode, - EmulatorSignatureVerifier, PublicKeySignatureVerifier, + EmulatorSignatureVerifier, PublicKeySignatureVerifier, ALGORITHM_RS256, SignatureVerifier, } from '../utils/jwt'; import { FirebaseApp } from '../firebase-app'; import { auth } from './index'; @@ -29,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'; -const ALGORITHM_RS256 = 'RS256' as const; - // 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'; @@ -77,7 +75,7 @@ export interface FirebaseTokenInfo { */ export class FirebaseTokenVerifier { private readonly shortNameArticle: string; - private readonly signatureVerifier: PublicKeySignatureVerifier; + private readonly signatureVerifier: SignatureVerifier; constructor(clientCertUrl: string, private issuer: string, private tokenInfo: FirebaseTokenInfo, private readonly app: FirebaseApp) { @@ -196,6 +194,13 @@ export class FirebaseTokenVerifier { }); } + /** + * 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, @@ -258,23 +263,24 @@ export class FirebaseTokenVerifier { }); } + /** + * Maps JwtError to FirebaseAuthError + * + * @param error JwtError to be mapped. + * @returns FirebaseAuthError or Error instance. + */ private mapJwtErrorToAuthError(error: JwtError): Error { const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; - if (!(error instanceof JwtError)) { - return (error); - } 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) { + } 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.KEY_FETCH_ERROR) { + } 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.'; diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index dbcba53006..d048567061 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -19,7 +19,7 @@ import * as jwt from 'jsonwebtoken'; import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; import { Agent } from 'http'; -const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const; +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 @@ -29,7 +29,7 @@ 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 Dictionary = { [key: string]: any } export type DecodedToken = { header: Dictionary; @@ -44,14 +44,17 @@ interface KeyFetcher { fetchPublicKeys(): Promise<{ [key: string]: string }>; } -class UrlKeyFetcher implements KeyFetcher { +/** + * 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 an invalid URL.', + 'The provided public client certificate URL is not a valid URL.', ); } } @@ -68,6 +71,11 @@ class UrlKeyFetcher implements KeyFetcher { 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(); } @@ -120,7 +128,7 @@ class UrlKeyFetcher implements KeyFetcher { } /** - * Verifies JWT signature with a public key. + * Class for verifing JWT signature with a public key. */ export class PublicKeySignatureVerifier implements SignatureVerifier { constructor(private keyFetcher: KeyFetcher) { @@ -134,10 +142,31 @@ export class PublicKeySignatureVerifier implements SignatureVerifier { } 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 || ''; @@ -154,15 +183,22 @@ function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret { } } -export class EmulatorSignatureVerifier implements SignatureVerifier { - public verify(token: string): Promise { - // Signature checks skipped for emulator; no need to fetch public keys. - return verifyJwtSignature(token, ''); +/** + * 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.')); } -} -function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.GetPublicKeyOrSecret, - options?: jwt.VerifyOptions): Promise { return new Promise((resolve, reject) => { jwt.verify(token, secretOrPublicKey, options, (error: jwt.VerifyErrors | null) => { @@ -176,12 +212,10 @@ function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.G } 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.KEY_FETCH_ERROR : - JwtErrorCode.INVALID_ARGUMENT; + 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, - 'The provided token has invalid signature.')); } return reject(new JwtError(JwtErrorCode.INVALID_SIGNATURE, error.message)); }); @@ -190,6 +224,9 @@ function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.G /** * 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)) { @@ -233,5 +270,6 @@ export enum JwtErrorCode { INVALID_CREDENTIAL = 'invalid-credential', TOKEN_EXPIRED = 'token-expired', INVALID_SIGNATURE = 'invalid-token', - KEY_FETCH_ERROR = 'no-matching-kid-error', + 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 993d80ac58..6cbc0c71a3 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -34,6 +34,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 { JwtError, JwtErrorCode, PublicKeySignatureVerifier } from '../../../src/utils/jwt'; chai.should(); chai.use(sinonChai); @@ -42,67 +43,6 @@ 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 @@ -272,10 +212,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', () => { @@ -325,21 +269,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', @@ -366,8 +295,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'); @@ -388,56 +335,43 @@ 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', '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); - - // Cookie should still be valid - return tokenVerifierSessionCookie.verifyJWT(mockSessionCookie).then(() => { - clock!.tick(1); - - // 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'); - }); + 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'); }); - 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 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); const mockIdToken = mocks.generateIdToken(); @@ -496,7 +430,9 @@ describe('FirebaseTokenVerifier', () => { }); 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); @@ -554,108 +490,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', - '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', - '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)'); + }); + }); +}); From 00ee0ec2df875ff89301ce992cf697d946f47d8f Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 30 Mar 2021 12:03:39 -0400 Subject: [PATCH 7/7] Add tests for key fetch error and http options asserts in the verifier --- test/unit/auth/token-verifier.spec.ts | 35 ++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index 6cbc0c71a3..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'; @@ -61,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(() => { @@ -76,7 +73,6 @@ describe('FirebaseTokenVerifier', () => { clock.restore(); clock = undefined; } - httpsSpy.restore(); }); after(() => { @@ -379,6 +375,17 @@ describe('FirebaseTokenVerifier', () => { .should.eventually.be.rejectedWith('Firebase ID token has invalid signature'); }); + 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('Error fetching public keys.'); + }); + it('should be rejected given a custom token with error using article "an" before JWT short name', () => { return tokenGenerator.createCustomToken(mocks.uid) .then((customToken) => { @@ -429,6 +436,22 @@ 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', () => { const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') .resolves();