Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add unit tests for utils/jwt
  • Loading branch information
lahirumaramba committed Mar 29, 2021
commit 6ffbeccfe410a5bd5e7014bc085b4619fb51ab7d
28 changes: 17 additions & 11 deletions src/auth/token-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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/[email protected]';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.';
Expand Down
72 changes: 55 additions & 17 deletions src/utils/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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.',
);
}
}
Expand All @@ -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();
}
Expand Down Expand Up @@ -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) {
Expand All @@ -134,10 +142,31 @@ export class PublicKeySignatureVerifier implements SignatureVerifier {
}

public verify(token: string): Promise<void> {
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<void> {
// 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 || '';
Expand All @@ -154,15 +183,22 @@ function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret {
}
}

export class EmulatorSignatureVerifier implements SignatureVerifier {
public verify(token: string): Promise<void> {
// 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<void> {
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<void> {
return new Promise((resolve, reject) => {
jwt.verify(token, secretOrPublicKey, options,
(error: jwt.VerifyErrors | null) => {
Expand All @@ -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));
});
Expand All @@ -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<DecodedToken> {
if (!validator.isString(jwtToken)) {
Expand Down Expand Up @@ -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',
}
Loading