Skip to content

chore: Move token verification logic to util #1189

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

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 6 additions & 5 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/erro
import * as utils from '../utils/index';
import * as validator from '../utils/validator';
import { auth } from './index';
import {
FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier
} from './token-verifier';
import { FirebaseTokenVerifier } from '../utils/token-verifier';
import { createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier';
import {
SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse,
} from './auth-config';
Expand Down Expand Up @@ -117,8 +116,9 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
*/
public verifyIdToken(idToken: string, checkRevoked = false): Promise<DecodedIdToken> {
const isEmulator = useEmulator();
return this.idTokenVerifier.verifyJWT(idToken, isEmulator)
return this.idTokenVerifier.verifyJWT<DecodedIdToken>(idToken, isEmulator)
.then((decodedIdToken: DecodedIdToken) => {
decodedIdToken.uid = decodedIdToken.sub;
// Whether to check if the token was revoked.
if (checkRevoked || isEmulator) {
return this.verifyDecodedJWTNotRevoked(
Expand Down Expand Up @@ -520,8 +520,9 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
public verifySessionCookie(
sessionCookie: string, checkRevoked = false): Promise<DecodedIdToken> {
const isEmulator = useEmulator();
return this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator)
return this.sessionCookieVerifier.verifyJWT<DecodedIdToken>(sessionCookie, isEmulator)
.then((decodedIdToken: DecodedIdToken) => {
decodedIdToken.uid = decodedIdToken.sub;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the code to set the uid alias here. Another way is to add helper functions, verifySessionCookie() and verifyIdToken(), to auth/token-verifier. Let me know your thoughts.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about extending the util.TokenVerifier as AuthTokenVerifier, and add the logic there?

// Whether to check if the token was revoked.
if (checkRevoked || isEmulator) {
return this.verifyDecodedJWTNotRevoked(
Expand Down
319 changes: 23 additions & 296 deletions src/auth/token-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,11 @@
* limitations under the License.
*/

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 { FirebaseApp } from '../firebase-app';
import { auth } from './index';
import { AuthClientErrorCode, ErrorCodeConfig, FirebaseAuthError } from '../utils/error';
import { FirebaseTokenInfo, FirebaseTokenVerifier } from '../utils/token-verifier';

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';

// URL containing the public keys for the Google certs (whose private keys are used to sign Firebase
// Auth ID tokens)
Expand All @@ -36,13 +27,30 @@ 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';

/** Matching Auth error code config for ID token */
export const ID_TOKEN_ERROR_CODE_CONFIG: ErrorCodeConfig = {
invalidArg: AuthClientErrorCode.INVALID_ARGUMENT,
invalidCredential: AuthClientErrorCode.INVALID_CREDENTIAL,
internalError: AuthClientErrorCode.INTERNAL_ERROR,
expiredError: AuthClientErrorCode.ID_TOKEN_EXPIRED,
}

/** Matching Auth error code config for session cookie */
export const SESSION_COOKIE_ERROR_CODE_CONFIG: ErrorCodeConfig = {
invalidArg: AuthClientErrorCode.INVALID_ARGUMENT,
invalidCredential: AuthClientErrorCode.INVALID_CREDENTIAL,
internalError: AuthClientErrorCode.INTERNAL_ERROR,
expiredError: AuthClientErrorCode.SESSION_COOKIE_EXPIRED,
}

/** 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',
verifyApiName: 'verifyIdToken()',
jwtName: 'Firebase ID token',
shortName: 'ID token',
expiredErrorCode: AuthClientErrorCode.ID_TOKEN_EXPIRED,
errorCodeConfig: ID_TOKEN_ERROR_CODE_CONFIG,
errorType: FirebaseAuthError,
};

/** User facing token information related to the Firebase session cookie. */
Expand All @@ -51,291 +59,10 @@ export const SESSION_COOKIE_INFO: FirebaseTokenInfo = {
verifyApiName: 'verifySessionCookie()',
jwtName: 'Firebase session cookie',
shortName: 'session cookie',
expiredErrorCode: AuthClientErrorCode.SESSION_COOKIE_EXPIRED,
errorCodeConfig: SESSION_COOKIE_ERROR_CODE_CONFIG,
errorType: FirebaseAuthError,
};

/** Interface that defines token related user facing information. */
export interface FirebaseTokenInfo {
/** Documentation URL. */
url: string;
/** verify API name. */
verifyApiName: string;
/** The JWT full name. */
jwtName: string;
/** The JWT short name. */
shortName: string;
/** JWT Expiration error code. */
expiredErrorCode: ErrorInfo;
}

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

constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm,
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,
'The provided JWT issuer is an invalid URL.',
);
} else if (!validator.isNonNullObject(tokenInfo)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'The provided JWT information is not an object or null.',
);
} else if (!validator.isURL(tokenInfo.url)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'The provided JWT verification documentation URL is invalid.',
);
} else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'The JWT verify API name must be a non-empty string.',
);
} else if (!validator.isNonEmptyString(tokenInfo.jwtName)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'The JWT public full name must be a non-empty string.',
);
} else if (!validator.isNonEmptyString(tokenInfo.shortName)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'The JWT public short name must be a non-empty string.',
);
} else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'The JWT expiration error code must be a non-null ErrorInfo object.',
);
}
this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a';

// 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.
*/
public verifyJWT(jwtToken: string, isEmulator = false): Promise<DecodedIdToken> {
if (!validator.isString(jwtToken)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
`First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`,
);
}

return util.findProjectId(this.app)
.then((projectId) => {
return this.verifyJWTWithProjectId(jwtToken, projectId, isEmulator);
});
}

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}.`,
);
}

const fullDecodedToken: any = jwt.decode(jwtToken, {
complete: true,
});

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

const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` +
'Firebase project as the service account used to authenticate this SDK.';
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;

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') {
const isCustomToken = (payload.aud === FIREBASE_AUDIENCE);
const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d);

if (isCustomToken) {
errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` +
`${this.tokenInfo.shortName}, but was given a custom token.`;
} else if (isLegacyCustomToken) {
errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` +
`${this.tokenInfo.shortName}, but was given a legacy custom token.`;
} else {
errorMessage = 'Firebase ID token has no "kid" claim.';
}

errorMessage += verifyJwtTokenDocsMessage;
} else if (!isEmulator && header.alg !== this.algorithm) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + '" but got ' +
'"' + header.alg + '".' + verifyJwtTokenDocsMessage;
} else if (payload.aud !== projectId) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` +
projectId + '" but got "' + payload.aud + '".' + projectIdMatchMessage +
verifyJwtTokenDocsMessage;
} else if (payload.iss !== this.issuer + projectId) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` +
`"${this.issuer}` + projectId + '" but got "' +
payload.iss + '".' + projectIdMatchMessage + verifyJwtTokenDocsMessage;
} else if (typeof payload.sub !== 'string') {
errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage;
} else if (payload.sub === '') {
errorMessage = `${this.tokenInfo.jwtName} has an empty string "sub" (subject) claim.` + verifyJwtTokenDocsMessage;
} else if (payload.sub.length > 128) {
errorMessage = `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` +
verifyJwtTokenDocsMessage;
}
if (errorMessage) {
return Promise.reject(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]);
}

});
}

/**
* 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.
*/
private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise<DecodedIdToken> {
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);
}

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;
});
}
}

/**
* Creates a new FirebaseTokenVerifier to verify Firebase ID tokens.
*
Expand Down
Loading