Skip to content

Commit

Permalink
Improve token verification logic with Auth Emulator. (#1148)
Browse files Browse the repository at this point in the history
* Improve token verification logic with Auth Emulator.

* Clean up comments.

* Fix linting issues.

* Address review comments.

* Use mock for auth emulator unit test.

* Implement session cookies.

* Call useEmulator() only once.

* Update tests.

* Delete unused test helper.

* Add unit tests for checking revocation.

* Fix typo in test comments.
  • Loading branch information
yuchenshi committed Feb 4, 2021
1 parent 1862342 commit 6ce98e2
Show file tree
Hide file tree
Showing 9 changed files with 321 additions and 173 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -69,7 +69,7 @@
},
"devDependencies": {
"@firebase/app": "^0.6.13",
"@firebase/auth": "^0.15.2",
"@firebase/auth": "^0.16.2",
"@firebase/auth-types": "^0.10.1",
"@microsoft/api-extractor": "^7.11.2",
"@types/bcrypt": "^2.0.0",
Expand Down
4 changes: 0 additions & 4 deletions src/auth/auth-api-request.ts
Expand Up @@ -2117,10 +2117,6 @@ function emulatorHost(): string | undefined {
/**
* When true the SDK should communicate with the Auth Emulator for all API
* calls and also produce unsigned tokens.
*
* This alone does <b>NOT<b> short-circuit ID Token verification.
* For security reasons that must be explicitly disabled through
* setJwtVerificationEnabled(false);
*/
export function useEmulator(): boolean {
return !!emulatorHost();
Expand Down
50 changes: 15 additions & 35 deletions src/auth/auth.ts
Expand Up @@ -29,7 +29,7 @@ import * as utils from '../utils/index';
import * as validator from '../utils/validator';
import { auth } from './index';
import {
FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier, ALGORITHM_RS256
FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier
} from './token-verifier';
import {
SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse,
Expand Down Expand Up @@ -115,15 +115,16 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
* verification.
*/
public verifyIdToken(idToken: string, checkRevoked = false): Promise<DecodedIdToken> {
return this.idTokenVerifier.verifyJWT(idToken)
const isEmulator = useEmulator();
return this.idTokenVerifier.verifyJWT(idToken, isEmulator)
.then((decodedIdToken: DecodedIdToken) => {
// Whether to check if the token was revoked.
if (!checkRevoked) {
return decodedIdToken;
if (checkRevoked || isEmulator) {
return this.verifyDecodedJWTNotRevoked(
decodedIdToken,
AuthClientErrorCode.ID_TOKEN_REVOKED);
}
return this.verifyDecodedJWTNotRevoked(
decodedIdToken,
AuthClientErrorCode.ID_TOKEN_REVOKED);
return decodedIdToken;
});
}

Expand Down Expand Up @@ -443,15 +444,16 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
*/
public verifySessionCookie(
sessionCookie: string, checkRevoked = false): Promise<DecodedIdToken> {
return this.sessionCookieVerifier.verifyJWT(sessionCookie)
const isEmulator = useEmulator();
return this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator)
.then((decodedIdToken: DecodedIdToken) => {
// Whether to check if the token was revoked.
if (!checkRevoked) {
return decodedIdToken;
if (checkRevoked || isEmulator) {
return this.verifyDecodedJWTNotRevoked(
decodedIdToken,
AuthClientErrorCode.SESSION_COOKIE_REVOKED);
}
return this.verifyDecodedJWTNotRevoked(
decodedIdToken,
AuthClientErrorCode.SESSION_COOKIE_REVOKED);
return decodedIdToken;
});
}

Expand Down Expand Up @@ -675,28 +677,6 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
return decodedIdToken;
});
}

/**
* Enable or disable ID token verification. This is used to safely short-circuit token verification with the
* Auth emulator. When disabled ONLY unsigned tokens will pass verification, production tokens will not pass.
*
* WARNING: This is a dangerous method that will compromise your app's security and break your app in
* production. Developers should never call this method, it is for internal testing use only.
*
* @internal
*/
// @ts-expect-error: this method appears unused but is used privately.
private setJwtVerificationEnabled(enabled: boolean): void {
if (!enabled && !useEmulator()) {
// We only allow verification to be disabled in conjunction with
// the emulator environment variable.
throw new Error('This method is only available when connected to the Authentication emulator.');
}

const algorithm = enabled ? ALGORITHM_RS256 : 'none';
this.idTokenVerifier.setAlgorithm(algorithm);
this.sessionCookieVerifier.setAlgorithm(algorithm);
}
}


Expand Down
73 changes: 36 additions & 37 deletions src/auth/token-verifier.ts
Expand Up @@ -79,7 +79,7 @@ export class FirebaseTokenVerifier {
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,
Expand Down Expand Up @@ -135,10 +135,11 @@ 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<DecodedIdToken>} A promise fulfilled with the decoded claims of the Firebase Auth ID
* token.
*/
public verifyJWT(jwtToken: string): Promise<DecodedIdToken> {
public verifyJWT(jwtToken: string, isEmulator = false): Promise<DecodedIdToken> {
if (!validator.isString(jwtToken)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
Expand All @@ -148,19 +149,15 @@ export class FirebaseTokenVerifier {

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

/**
* Override the JWT signing algorithm.
* @param algorithm the new signing algorithm.
*/
public setAlgorithm(algorithm: jwt.Algorithm): void {
this.algorithm = algorithm;
}

private verifyJWTWithProjectId(jwtToken: string, projectId: string | null): Promise<DecodedIdToken> {
private verifyJWTWithProjectId(
jwtToken: string,
projectId: string | null,
isEmulator: boolean
): Promise<DecodedIdToken> {
if (!validator.isNonEmptyString(projectId)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CREDENTIAL,
Expand All @@ -185,7 +182,7 @@ export class FirebaseTokenVerifier {
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 (typeof header.kid === 'undefined' && this.algorithm !== 'none') {
} 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);

Expand All @@ -200,7 +197,7 @@ export class FirebaseTokenVerifier {
}

errorMessage += verifyJwtTokenDocsMessage;
} else if (header.alg !== this.algorithm) {
} 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) {
Expand All @@ -209,7 +206,7 @@ export class FirebaseTokenVerifier {
verifyJwtTokenDocsMessage;
} else if (payload.iss !== this.issuer + projectId) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` +
`"${this.issuer}"` + projectId + '" but got "' +
`"${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;
Expand All @@ -223,9 +220,8 @@ export class FirebaseTokenVerifier {
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage));
}

// When the algorithm is set to 'none' there will be no signature and therefore we don't check
// the public keys.
if (this.algorithm === 'none') {
if (isEmulator) {
// Signature checks skipped for emulator; no need to fetch public keys.
return this.verifyJwtSignatureWithKey(jwtToken, null);
}

Expand Down Expand Up @@ -257,26 +253,29 @@ export class FirebaseTokenVerifier {
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
return new Promise((resolve, reject) => {
jwt.verify(jwtToken, publicKey || '', {
algorithms: [this.algorithm],
}, (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));
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);
}
return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message));
} else {
const decodedIdToken = (decodedToken as DecodedIdToken);
decodedIdToken.uid = decodedIdToken.sub;
resolve(decodedIdToken);
}
});
});
});
}

Expand Down

0 comments on commit 6ce98e2

Please sign in to comment.