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

Improve token verification logic with Auth Emulator. #1148

Merged
merged 11 commits into from
Feb 4, 2021
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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) {

Choose a reason for hiding this comment

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

@yuchenshi can you give any context around why the verifyIdToken method always calls verifyDecodedJWTNotRevokedOrDisabled if using emulator please?

I'm in the position where I'm working with uid's that may/may not exist in Firebase.

I want to verify a token (which succeeds in prod) but the emulator checks whether there's a user and whether its been disabled regardless of the value of checkRevoked .

If this isn't intended I can create a PR.

TIA

Copy link
Member Author

Choose a reason for hiding this comment

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

@lprhodes Tokens from the Auth Emulator aren't really "verifiable" since they are not signed (compared to production), so the closest approximation we can do is to ask the Auth Emulator if they exist. It sounds like you have a specific workflow in mind that you want to emulate and please open a new issue with more context (especially why you'd like to verify tokens from non-existent users) and we'll see what we can do.

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
Original file line number Diff line number Diff line change
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 "' +
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: took me a while staring at this diff because of the mixed use of string templates and string concatenation via +. No need to do anything though, probably not in scope.

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
Loading