Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(chore): Add JWT Decoder and Signature Verifier #1204

Merged
merged 8 commits into from
Mar 30, 2021
260 changes: 106 additions & 154 deletions src/auth/token-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/error';
import * as util from '../utils/index';
import * as validator from '../utils/validator';
import * as jwt from 'jsonwebtoken';
import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request';
import {
DecodedToken, decodeJwt, JwtError, JwtErrorCode,
EmulatorSignatureVerifier, PublicKeySignatureVerifier, ALGORITHM_RS256, SignatureVerifier,
} from '../utils/jwt';
import { FirebaseApp } from '../firebase-app';
import { auth } from './index';

Expand All @@ -27,15 +29,15 @@ import DecodedIdToken = auth.DecodedIdToken;
// Audience to use for Firebase Auth Custom tokens
const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit';

export const ALGORITHM_RS256 = 'RS256';

// URL containing the public keys for the Google certs (whose private keys are used to sign Firebase
// Auth ID tokens)
const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';

// 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',
Expand Down Expand Up @@ -69,27 +71,20 @@ export interface FirebaseTokenInfo {
}

/**
* Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies.
* Class for verifying ID tokens and session cookies.
*/
export class FirebaseTokenVerifier {
private publicKeys: {[key: string]: string};
private publicKeysExpireAt: number;
private readonly shortNameArticle: string;
private readonly signatureVerifier: SignatureVerifier;

constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm,
private issuer: string, private tokenInfo: FirebaseTokenInfo,
constructor(clientCertUrl: string, private issuer: string, private tokenInfo: FirebaseTokenInfo,
private readonly app: FirebaseApp) {

if (!validator.isURL(clientCertUrl)) {
throw new FirebaseAuthError(
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,
Expand Down Expand Up @@ -128,16 +123,18 @@ export class FirebaseTokenVerifier {
}
this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a';

this.signatureVerifier =
PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl, app.options.httpAgent);

// For backward compatibility, the project ID is validated in the verification call.
}

/**
* Verifies the format and signature of a Firebase Auth JWT token.
*
* @param {string} jwtToken The Firebase Auth JWT token to verify.
* @param {boolean=} isEmulator Whether to accept Auth Emulator tokens.
* @return {Promise<DecodedIdToken>} 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<DecodedIdToken> {
if (!validator.isString(jwtToken)) {
Expand All @@ -147,29 +144,68 @@ export class FirebaseTokenVerifier {
);
}

return util.findProjectId(this.app)
return this.ensureProjectId()
.then((projectId) => {
return this.verifyJWTWithProjectId(jwtToken, projectId, isEmulator);
return this.decodeAndVerify(jwtToken, projectId, isEmulator);
})
.then((decoded) => {
const decodedIdToken = decoded.payload as DecodedIdToken;
decodedIdToken.uid = decodedIdToken.sub;
return decodedIdToken;
});
}

private verifyJWTWithProjectId(
jwtToken: string,
projectId: string | null,
isEmulator: boolean
): Promise<DecodedIdToken> {
if (!validator.isNonEmptyString(projectId)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CREDENTIAL,
'Must initialize app with a cert credential or set your Firebase project ID as the ' +
`GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`,
);
}
private ensureProjectId(): Promise<string> {
return util.findProjectId(this.app)
.then((projectId) => {
if (!validator.isNonEmptyString(projectId)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CREDENTIAL,
'Must initialize app with a cert credential or set your Firebase project ID as the ' +
`GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`,
);
}
return Promise.resolve(projectId);
})
}

const fullDecodedToken: any = jwt.decode(jwtToken, {
complete: true,
});
private decodeAndVerify(token: string, projectId: string, isEmulator: boolean): Promise<DecodedToken> {
hiranya911 marked this conversation as resolved.
Show resolved Hide resolved
return this.safeDecode(token)
.then((decodedToken) => {
this.verifyContent(decodedToken, projectId, isEmulator);
return this.verifySignature(token, isEmulator)
.then(() => decodedToken);
});
}

private safeDecode(jwtToken: string): Promise<DecodedToken> {
return decodeJwt(jwtToken)
.catch((err: JwtError) => {
if (err.code == JwtErrorCode.INVALID_ARGUMENT) {
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
const errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed ` +
`the entire string JWT which represents ${this.shortNameArticle} ` +
`${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage;
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT,
errorMessage);
}
throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message);
});
}

/**
* Verifies the content of a Firebase Auth JWT.
*
* @param fullDecodedToken The decoded JWT.
* @param projectId The Firebase Project Id.
* @param isEmulator Whether the token is an Emulator token.
*/
private verifyContent(
fullDecodedToken: DecodedToken,
projectId: string | null,
isEmulator: boolean): void {

const header = fullDecodedToken && fullDecodedToken.header;
const payload = fullDecodedToken && fullDecodedToken.payload;

Expand All @@ -179,10 +215,7 @@ export class FirebaseTokenVerifier {
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;

let errorMessage: string | undefined;
if (!fullDecodedToken) {
errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` +
`which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage;
} else if (!isEmulator && typeof header.kid === 'undefined') {
if (!isEmulator && typeof header.kid === 'undefined') {
const isCustomToken = (payload.aud === FIREBASE_AUDIENCE);
const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d);

Expand All @@ -197,8 +230,8 @@ export class FirebaseTokenVerifier {
}

errorMessage += verifyJwtTokenDocsMessage;
} else if (!isEmulator && header.alg !== this.algorithm) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + '" but got ' +
} else if (!isEmulator && header.alg !== ALGORITHM_RS256) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + ALGORITHM_RS256 + '" but got ' +
'"' + header.alg + '".' + verifyJwtTokenDocsMessage;
} else if (payload.aud !== projectId) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` +
Expand All @@ -217,135 +250,55 @@ export class FirebaseTokenVerifier {
verifyJwtTokenDocsMessage;
}
if (errorMessage) {
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage));
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
}
}

if (isEmulator) {
// Signature checks skipped for emulator; no need to fetch public keys.
return this.verifyJwtSignatureWithKey(jwtToken, null);
}

return this.fetchPublicKeys().then((publicKeys) => {
if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) {
return Promise.reject(
new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
`${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` +
`Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` +
'client app and try again.',
),
);
} else {
return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]);
}

});
private verifySignature(jwtToken: string, isEmulator: boolean):
Promise<void> {
const verifier = isEmulator ? EMULATOR_VERIFIER : this.signatureVerifier;
return verifier.verify(jwtToken)
.catch((error) => {
throw this.mapJwtErrorToAuthError(error);
});
}

/**
* Verifies the JWT signature using the provided public key.
* @param {string} jwtToken The JWT token to verify.
* @param {string} publicKey The public key certificate.
* @return {Promise<DecodedIdToken>} A promise that resolves with the decoded JWT claims on successful
* verification.
* Maps JwtError to FirebaseAuthError
*
* @param error JwtError to be mapped.
* @returns FirebaseAuthError or Error instance.
*/
private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise<DecodedIdToken> {
private mapJwtErrorToAuthError(error: JwtError): Error {
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
return new Promise((resolve, reject) => {
const verifyOptions: jwt.VerifyOptions = {};
if (publicKey !== null) {
verifyOptions.algorithms = [this.algorithm];
}
jwt.verify(jwtToken, publicKey || '', verifyOptions,
(error: jwt.VerifyErrors | null, decodedToken: object | undefined) => {
if (error) {
if (error.name === 'TokenExpiredError') {
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
verifyJwtTokenDocsMessage;
return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage));
} else if (error.name === 'JsonWebTokenError') {
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage));
}
return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message));
} else {
const decodedIdToken = (decodedToken as DecodedIdToken);
decodedIdToken.uid = decodedIdToken.sub;
resolve(decodedIdToken);
}
});
});
}

/**
* Fetches the public keys for the Google certs.
*
* @return {Promise<object>} A promise fulfilled with public keys for the Google certs.
*/
private fetchPublicKeys(): Promise<{[key: string]: string}> {
const publicKeysExist = (typeof this.publicKeys !== 'undefined');
const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined');
const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt);
if (publicKeysExist && publicKeysStillValid) {
return Promise.resolve(this.publicKeys);
if (error.code === JwtErrorCode.TOKEN_EXPIRED) {
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
verifyJwtTokenDocsMessage;
return new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage);
} else if (error.code === JwtErrorCode.INVALID_SIGNATURE) {
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
} else if (error.code === JwtErrorCode.NO_MATCHING_KID) {
const errorMessage = `${this.tokenInfo.jwtName} has "kid" claim which does not ` +
`correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` +
'is expired, so get a fresh token from your client app and try again.';
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
}

const client = new HttpClient();
const request: HttpRequestConfig = {
method: 'GET',
url: this.clientCertUrl,
httpAgent: this.app.options.httpAgent,
};
return client.send(request).then((resp) => {
if (!resp.isJson() || resp.data.error) {
// Treat all non-json messages and messages with an 'error' field as
// error responses.
throw new HttpError(resp);
}
if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) {
const cacheControlHeader: string = resp.headers['cache-control'];
const parts = cacheControlHeader.split(',');
parts.forEach((part) => {
const subParts = part.trim().split('=');
if (subParts[0] === 'max-age') {
const maxAge: number = +subParts[1];
this.publicKeysExpireAt = Date.now() + (maxAge * 1000);
}
});
}
this.publicKeys = resp.data;
return resp.data;
}).catch((err) => {
if (err instanceof HttpError) {
let errorMessage = 'Error fetching public keys for Google certs: ';
const resp = err.response;
if (resp.isJson() && resp.data.error) {
errorMessage += `${resp.data.error}`;
if (resp.data.error_description) {
errorMessage += ' (' + resp.data.error_description + ')';
}
} else {
errorMessage += `${resp.text}`;
}
throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage);
}
throw err;
});
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message);
}
}

/**
* Creates a new FirebaseTokenVerifier to verify Firebase ID tokens.
*
* @param {FirebaseApp} app Firebase app instance.
* @return {FirebaseTokenVerifier}
* @param app Firebase app instance.
* @return FirebaseTokenVerifier
*/
export function createIdTokenVerifier(app: FirebaseApp): FirebaseTokenVerifier {
return new FirebaseTokenVerifier(
CLIENT_CERT_URL,
ALGORITHM_RS256,
'https://securetoken.google.com/',
ID_TOKEN_INFO,
app
Expand All @@ -355,13 +308,12 @@ export function createIdTokenVerifier(app: FirebaseApp): FirebaseTokenVerifier {
/**
* Creates a new FirebaseTokenVerifier to verify Firebase session cookies.
*
* @param {FirebaseApp} app Firebase app instance.
* @return {FirebaseTokenVerifier}
* @param app Firebase app instance.
* @return FirebaseTokenVerifier
*/
export function createSessionCookieVerifier(app: FirebaseApp): FirebaseTokenVerifier {
return new FirebaseTokenVerifier(
SESSION_COOKIE_CERT_URL,
ALGORITHM_RS256,
'https://session.firebase.google.com/',
SESSION_COOKIE_INFO,
app
Expand Down
Loading