Skip to content

Commit 60b4e29

Browse files
(chore): Add JWT Decoder and Signature Verifier (#1204)
* (chore): Add JWT Decoder * Add signature verifier and key fetcher abstractions * Add unit tests for utils/jwt
1 parent 2a5b7f6 commit 60b4e29

File tree

5 files changed

+1000
-416
lines changed

5 files changed

+1000
-416
lines changed

src/auth/token-verifier.ts

Lines changed: 106 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/error';
1818
import * as util from '../utils/index';
1919
import * as validator from '../utils/validator';
20-
import * as jwt from 'jsonwebtoken';
21-
import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request';
20+
import {
21+
DecodedToken, decodeJwt, JwtError, JwtErrorCode,
22+
EmulatorSignatureVerifier, PublicKeySignatureVerifier, ALGORITHM_RS256, SignatureVerifier,
23+
} from '../utils/jwt';
2224
import { FirebaseApp } from '../firebase-app';
2325
import { auth } from './index';
2426

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

30-
export const ALGORITHM_RS256 = 'RS256';
31-
3232
// URL containing the public keys for the Google certs (whose private keys are used to sign Firebase
3333
// Auth ID tokens)
3434
const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/[email protected]';
3535

3636
// URL containing the public keys for Firebase session cookies. This will be updated to a different URL soon.
3737
const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys';
3838

39+
const EMULATOR_VERIFIER = new EmulatorSignatureVerifier();
40+
3941
/** User facing token information related to the Firebase ID token. */
4042
export const ID_TOKEN_INFO: FirebaseTokenInfo = {
4143
url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens',
@@ -69,27 +71,20 @@ export interface FirebaseTokenInfo {
6971
}
7072

7173
/**
72-
* Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies.
74+
* Class for verifying ID tokens and session cookies.
7375
*/
7476
export class FirebaseTokenVerifier {
75-
private publicKeys: {[key: string]: string};
76-
private publicKeysExpireAt: number;
7777
private readonly shortNameArticle: string;
78+
private readonly signatureVerifier: SignatureVerifier;
7879

79-
constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm,
80-
private issuer: string, private tokenInfo: FirebaseTokenInfo,
80+
constructor(clientCertUrl: string, private issuer: string, private tokenInfo: FirebaseTokenInfo,
8181
private readonly app: FirebaseApp) {
8282

8383
if (!validator.isURL(clientCertUrl)) {
8484
throw new FirebaseAuthError(
8585
AuthClientErrorCode.INVALID_ARGUMENT,
8686
'The provided public client certificate URL is an invalid URL.',
8787
);
88-
} else if (!validator.isNonEmptyString(algorithm)) {
89-
throw new FirebaseAuthError(
90-
AuthClientErrorCode.INVALID_ARGUMENT,
91-
'The provided JWT algorithm is an empty string.',
92-
);
9388
} else if (!validator.isURL(issuer)) {
9489
throw new FirebaseAuthError(
9590
AuthClientErrorCode.INVALID_ARGUMENT,
@@ -128,16 +123,18 @@ export class FirebaseTokenVerifier {
128123
}
129124
this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a';
130125

126+
this.signatureVerifier =
127+
PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl, app.options.httpAgent);
128+
131129
// For backward compatibility, the project ID is validated in the verification call.
132130
}
133131

134132
/**
135133
* Verifies the format and signature of a Firebase Auth JWT token.
136134
*
137-
* @param {string} jwtToken The Firebase Auth JWT token to verify.
138-
* @param {boolean=} isEmulator Whether to accept Auth Emulator tokens.
139-
* @return {Promise<DecodedIdToken>} A promise fulfilled with the decoded claims of the Firebase Auth ID
140-
* token.
135+
* @param jwtToken The Firebase Auth JWT token to verify.
136+
* @param isEmulator Whether to accept Auth Emulator tokens.
137+
* @return A promise fulfilled with the decoded claims of the Firebase Auth ID token.
141138
*/
142139
public verifyJWT(jwtToken: string, isEmulator = false): Promise<DecodedIdToken> {
143140
if (!validator.isString(jwtToken)) {
@@ -147,29 +144,68 @@ export class FirebaseTokenVerifier {
147144
);
148145
}
149146

150-
return util.findProjectId(this.app)
147+
return this.ensureProjectId()
151148
.then((projectId) => {
152-
return this.verifyJWTWithProjectId(jwtToken, projectId, isEmulator);
149+
return this.decodeAndVerify(jwtToken, projectId, isEmulator);
150+
})
151+
.then((decoded) => {
152+
const decodedIdToken = decoded.payload as DecodedIdToken;
153+
decodedIdToken.uid = decodedIdToken.sub;
154+
return decodedIdToken;
153155
});
154156
}
155157

156-
private verifyJWTWithProjectId(
157-
jwtToken: string,
158-
projectId: string | null,
159-
isEmulator: boolean
160-
): Promise<DecodedIdToken> {
161-
if (!validator.isNonEmptyString(projectId)) {
162-
throw new FirebaseAuthError(
163-
AuthClientErrorCode.INVALID_CREDENTIAL,
164-
'Must initialize app with a cert credential or set your Firebase project ID as the ' +
165-
`GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`,
166-
);
167-
}
158+
private ensureProjectId(): Promise<string> {
159+
return util.findProjectId(this.app)
160+
.then((projectId) => {
161+
if (!validator.isNonEmptyString(projectId)) {
162+
throw new FirebaseAuthError(
163+
AuthClientErrorCode.INVALID_CREDENTIAL,
164+
'Must initialize app with a cert credential or set your Firebase project ID as the ' +
165+
`GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`,
166+
);
167+
}
168+
return Promise.resolve(projectId);
169+
})
170+
}
168171

169-
const fullDecodedToken: any = jwt.decode(jwtToken, {
170-
complete: true,
171-
});
172+
private decodeAndVerify(token: string, projectId: string, isEmulator: boolean): Promise<DecodedToken> {
173+
return this.safeDecode(token)
174+
.then((decodedToken) => {
175+
this.verifyContent(decodedToken, projectId, isEmulator);
176+
return this.verifySignature(token, isEmulator)
177+
.then(() => decodedToken);
178+
});
179+
}
172180

181+
private safeDecode(jwtToken: string): Promise<DecodedToken> {
182+
return decodeJwt(jwtToken)
183+
.catch((err: JwtError) => {
184+
if (err.code == JwtErrorCode.INVALID_ARGUMENT) {
185+
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
186+
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
187+
const errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed ` +
188+
`the entire string JWT which represents ${this.shortNameArticle} ` +
189+
`${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage;
190+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT,
191+
errorMessage);
192+
}
193+
throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message);
194+
});
195+
}
196+
197+
/**
198+
* Verifies the content of a Firebase Auth JWT.
199+
*
200+
* @param fullDecodedToken The decoded JWT.
201+
* @param projectId The Firebase Project Id.
202+
* @param isEmulator Whether the token is an Emulator token.
203+
*/
204+
private verifyContent(
205+
fullDecodedToken: DecodedToken,
206+
projectId: string | null,
207+
isEmulator: boolean): void {
208+
173209
const header = fullDecodedToken && fullDecodedToken.header;
174210
const payload = fullDecodedToken && fullDecodedToken.payload;
175211

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

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

@@ -197,8 +230,8 @@ export class FirebaseTokenVerifier {
197230
}
198231

199232
errorMessage += verifyJwtTokenDocsMessage;
200-
} else if (!isEmulator && header.alg !== this.algorithm) {
201-
errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + '" but got ' +
233+
} else if (!isEmulator && header.alg !== ALGORITHM_RS256) {
234+
errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + ALGORITHM_RS256 + '" but got ' +
202235
'"' + header.alg + '".' + verifyJwtTokenDocsMessage;
203236
} else if (payload.aud !== projectId) {
204237
errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` +
@@ -217,135 +250,55 @@ export class FirebaseTokenVerifier {
217250
verifyJwtTokenDocsMessage;
218251
}
219252
if (errorMessage) {
220-
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage));
253+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
221254
}
255+
}
222256

223-
if (isEmulator) {
224-
// Signature checks skipped for emulator; no need to fetch public keys.
225-
return this.verifyJwtSignatureWithKey(jwtToken, null);
226-
}
227-
228-
return this.fetchPublicKeys().then((publicKeys) => {
229-
if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) {
230-
return Promise.reject(
231-
new FirebaseAuthError(
232-
AuthClientErrorCode.INVALID_ARGUMENT,
233-
`${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` +
234-
`Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` +
235-
'client app and try again.',
236-
),
237-
);
238-
} else {
239-
return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]);
240-
}
241-
242-
});
257+
private verifySignature(jwtToken: string, isEmulator: boolean):
258+
Promise<void> {
259+
const verifier = isEmulator ? EMULATOR_VERIFIER : this.signatureVerifier;
260+
return verifier.verify(jwtToken)
261+
.catch((error) => {
262+
throw this.mapJwtErrorToAuthError(error);
263+
});
243264
}
244265

245266
/**
246-
* Verifies the JWT signature using the provided public key.
247-
* @param {string} jwtToken The JWT token to verify.
248-
* @param {string} publicKey The public key certificate.
249-
* @return {Promise<DecodedIdToken>} A promise that resolves with the decoded JWT claims on successful
250-
* verification.
267+
* Maps JwtError to FirebaseAuthError
268+
*
269+
* @param error JwtError to be mapped.
270+
* @returns FirebaseAuthError or Error instance.
251271
*/
252-
private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise<DecodedIdToken> {
272+
private mapJwtErrorToAuthError(error: JwtError): Error {
253273
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
254274
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
255-
return new Promise((resolve, reject) => {
256-
const verifyOptions: jwt.VerifyOptions = {};
257-
if (publicKey !== null) {
258-
verifyOptions.algorithms = [this.algorithm];
259-
}
260-
jwt.verify(jwtToken, publicKey || '', verifyOptions,
261-
(error: jwt.VerifyErrors | null, decodedToken: object | undefined) => {
262-
if (error) {
263-
if (error.name === 'TokenExpiredError') {
264-
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
265-
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
266-
verifyJwtTokenDocsMessage;
267-
return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage));
268-
} else if (error.name === 'JsonWebTokenError') {
269-
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
270-
return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage));
271-
}
272-
return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message));
273-
} else {
274-
const decodedIdToken = (decodedToken as DecodedIdToken);
275-
decodedIdToken.uid = decodedIdToken.sub;
276-
resolve(decodedIdToken);
277-
}
278-
});
279-
});
280-
}
281-
282-
/**
283-
* Fetches the public keys for the Google certs.
284-
*
285-
* @return {Promise<object>} A promise fulfilled with public keys for the Google certs.
286-
*/
287-
private fetchPublicKeys(): Promise<{[key: string]: string}> {
288-
const publicKeysExist = (typeof this.publicKeys !== 'undefined');
289-
const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined');
290-
const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt);
291-
if (publicKeysExist && publicKeysStillValid) {
292-
return Promise.resolve(this.publicKeys);
275+
if (error.code === JwtErrorCode.TOKEN_EXPIRED) {
276+
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
277+
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
278+
verifyJwtTokenDocsMessage;
279+
return new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage);
280+
} else if (error.code === JwtErrorCode.INVALID_SIGNATURE) {
281+
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
282+
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
283+
} else if (error.code === JwtErrorCode.NO_MATCHING_KID) {
284+
const errorMessage = `${this.tokenInfo.jwtName} has "kid" claim which does not ` +
285+
`correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` +
286+
'is expired, so get a fresh token from your client app and try again.';
287+
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
293288
}
294-
295-
const client = new HttpClient();
296-
const request: HttpRequestConfig = {
297-
method: 'GET',
298-
url: this.clientCertUrl,
299-
httpAgent: this.app.options.httpAgent,
300-
};
301-
return client.send(request).then((resp) => {
302-
if (!resp.isJson() || resp.data.error) {
303-
// Treat all non-json messages and messages with an 'error' field as
304-
// error responses.
305-
throw new HttpError(resp);
306-
}
307-
if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) {
308-
const cacheControlHeader: string = resp.headers['cache-control'];
309-
const parts = cacheControlHeader.split(',');
310-
parts.forEach((part) => {
311-
const subParts = part.trim().split('=');
312-
if (subParts[0] === 'max-age') {
313-
const maxAge: number = +subParts[1];
314-
this.publicKeysExpireAt = Date.now() + (maxAge * 1000);
315-
}
316-
});
317-
}
318-
this.publicKeys = resp.data;
319-
return resp.data;
320-
}).catch((err) => {
321-
if (err instanceof HttpError) {
322-
let errorMessage = 'Error fetching public keys for Google certs: ';
323-
const resp = err.response;
324-
if (resp.isJson() && resp.data.error) {
325-
errorMessage += `${resp.data.error}`;
326-
if (resp.data.error_description) {
327-
errorMessage += ' (' + resp.data.error_description + ')';
328-
}
329-
} else {
330-
errorMessage += `${resp.text}`;
331-
}
332-
throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage);
333-
}
334-
throw err;
335-
});
289+
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message);
336290
}
337291
}
338292

339293
/**
340294
* Creates a new FirebaseTokenVerifier to verify Firebase ID tokens.
341295
*
342-
* @param {FirebaseApp} app Firebase app instance.
343-
* @return {FirebaseTokenVerifier}
296+
* @param app Firebase app instance.
297+
* @return FirebaseTokenVerifier
344298
*/
345299
export function createIdTokenVerifier(app: FirebaseApp): FirebaseTokenVerifier {
346300
return new FirebaseTokenVerifier(
347301
CLIENT_CERT_URL,
348-
ALGORITHM_RS256,
349302
'https://securetoken.google.com/',
350303
ID_TOKEN_INFO,
351304
app
@@ -355,13 +308,12 @@ export function createIdTokenVerifier(app: FirebaseApp): FirebaseTokenVerifier {
355308
/**
356309
* Creates a new FirebaseTokenVerifier to verify Firebase session cookies.
357310
*
358-
* @param {FirebaseApp} app Firebase app instance.
359-
* @return {FirebaseTokenVerifier}
311+
* @param app Firebase app instance.
312+
* @return FirebaseTokenVerifier
360313
*/
361314
export function createSessionCookieVerifier(app: FirebaseApp): FirebaseTokenVerifier {
362315
return new FirebaseTokenVerifier(
363316
SESSION_COOKIE_CERT_URL,
364-
ALGORITHM_RS256,
365317
'https://session.firebase.google.com/',
366318
SESSION_COOKIE_INFO,
367319
app

0 commit comments

Comments
 (0)