Skip to content

Commit e118e6a

Browse files
committed
Add signature verifier and key fetcher abstractions
1 parent 24e331f commit e118e6a

File tree

3 files changed

+327
-192
lines changed

3 files changed

+327
-192
lines changed

src/auth/token-verifier.ts

Lines changed: 68 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/erro
1818
import * as util from '../utils/index';
1919
import * as validator from '../utils/validator';
2020
import * as jwt from 'jsonwebtoken';
21-
import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request';
22-
import { DecodedToken, JwtDecoder, JwtDecoderError, JwtDecoderErrorCode } from '../utils/jwt-decoder';
21+
import {
22+
DecodedToken, decodeJwt, JwtDecoderError, JwtDecoderErrorCode
23+
} from '../utils/jwt-decoder';
24+
import {
25+
EmulatorSignatureVerifier, NO_MATCHING_KID_ERROR_MESSAGE,
26+
PublicKeySignatureVerifier, SignatureVerifierError, SignatureVerifierErrorCode
27+
} from '../utils/jwt-signature-verifier';
2328
import { FirebaseApp } from '../firebase-app';
2429
import { auth } from './index';
2530

@@ -70,15 +75,14 @@ export interface FirebaseTokenInfo {
7075
}
7176

7277
/**
73-
* Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies.
78+
* Class for verifying ID tokens and session cookies.
7479
*/
7580
export class FirebaseTokenVerifier {
76-
private publicKeys: {[key: string]: string};
77-
private publicKeysExpireAt: number;
7881
private readonly shortNameArticle: string;
79-
private readonly jwtDecoder: JwtDecoder;
82+
private readonly signatureVerifier: PublicKeySignatureVerifier;
83+
private readonly emulatorSignatureVerifier: EmulatorSignatureVerifier;
8084

81-
constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm,
85+
constructor(clientCertUrl: string, private algorithm: jwt.Algorithm,
8286
private issuer: string, private tokenInfo: FirebaseTokenInfo,
8387
private readonly app: FirebaseApp) {
8488

@@ -129,7 +133,9 @@ export class FirebaseTokenVerifier {
129133
);
130134
}
131135
this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a';
132-
this.jwtDecoder = new JwtDecoder(algorithm);
136+
137+
this.signatureVerifier = new PublicKeySignatureVerifier(clientCertUrl, algorithm, app);
138+
this.emulatorSignatureVerifier = new EmulatorSignatureVerifier();
133139

134140
// For backward compatibility, the project ID is validated in the verification call.
135141
}
@@ -152,11 +158,13 @@ export class FirebaseTokenVerifier {
152158

153159
return util.findProjectId(this.app)
154160
.then((projectId) => {
155-
const fullDecodedToken = this.safeDecode(jwtToken);
156-
this.validateJWT(fullDecodedToken, projectId, isEmulator);
161+
return Promise.all([this.safeDecode(jwtToken), projectId]);
162+
})
163+
.then(([fullDecodedToken, projectId]) => {
164+
this.validateToken(fullDecodedToken, projectId, isEmulator);
157165
return Promise.all([
158166
fullDecodedToken,
159-
this.verifySignature(jwtToken, fullDecodedToken, isEmulator)
167+
this.verifySignature(jwtToken, isEmulator)
160168
]);
161169
})
162170
.then(([fullDecodedToken]) => {
@@ -166,25 +174,27 @@ export class FirebaseTokenVerifier {
166174
});
167175
}
168176

169-
private safeDecode(jwtToken: string): DecodedToken {
170-
try {
171-
return this.jwtDecoder.decodeToken(jwtToken);
172-
} catch (err) {
173-
if (!(err instanceof JwtDecoderError)) {
174-
return err;
175-
}
176-
if (err.code == JwtDecoderErrorCode.INVALID_ARGUMENT) {
177-
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
178-
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
179-
const errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` +
180-
`which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage;
181-
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
182-
}
183-
throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message);
184-
}
177+
private safeDecode(jwtToken: string): Promise<DecodedToken> {
178+
return decodeJwt(jwtToken)
179+
.catch((err) => {
180+
if (!(err instanceof JwtDecoderError)) {
181+
return Promise.reject(err);
182+
}
183+
if (err.code == JwtDecoderErrorCode.INVALID_ARGUMENT) {
184+
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
185+
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
186+
const errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed ` +
187+
`the entire string JWT which represents ${this.shortNameArticle} ` +
188+
`${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage;
189+
return Promise.reject(
190+
new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage));
191+
}
192+
return Promise.reject(
193+
new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message));
194+
});
185195
}
186196

187-
private validateJWT(
197+
private validateToken(
188198
fullDecodedToken: DecodedToken,
189199
projectId: string | null,
190200
isEmulator: boolean): void {
@@ -244,116 +254,45 @@ export class FirebaseTokenVerifier {
244254
}
245255
}
246256

247-
private verifySignature(jwtToken: string, decodeToken: DecodedToken, isEmulator: boolean):
257+
private verifySignature(jwtToken: string, isEmulator: boolean):
248258
Promise<void> {
249259
if (isEmulator) {
250-
// Signature checks skipped for emulator; no need to fetch public keys.
251-
return this.verifyJwtSignatureWithKey(jwtToken, null);
260+
return this.emulatorSignatureVerifier.verify(jwtToken)
261+
.catch((error) => {
262+
return Promise.reject(this.mapSignatureVerifierErrorToAuthError(error));
263+
});
252264
}
253265

254-
return this.fetchPublicKeys().then((publicKeys) => {
255-
if (!Object.prototype.hasOwnProperty.call(publicKeys, decodeToken.header.kid)) {
256-
return Promise.reject(
257-
new FirebaseAuthError(
258-
AuthClientErrorCode.INVALID_ARGUMENT,
259-
`${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` +
260-
`Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` +
261-
'client app and try again.',
262-
),
263-
);
264-
} else {
265-
return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[decodeToken.header.kid]);
266-
}
267-
268-
});
266+
return this.signatureVerifier.verify(jwtToken)
267+
.catch((error) => {
268+
return Promise.reject(this.mapSignatureVerifierErrorToAuthError(error));
269+
});
269270
}
270271

271-
/**
272-
* Verifies the JWT signature using the provided public key.
273-
* @param {string} jwtToken The JWT token to verify.
274-
* @param {string} publicKey The public key certificate.
275-
* @return {Promise<void>} A promise that resolves with the decoded JWT claims on successful
276-
* verification.
277-
*/
278-
private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise<void> {
272+
private mapSignatureVerifierErrorToAuthError(error: SignatureVerifierError): Error {
279273
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
280274
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
281-
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
282-
const invalidTokenError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
283-
return new Promise((resolve, reject) => {
284-
this.jwtDecoder.isSignatureValid(jwtToken, publicKey)
285-
.then(isValid => {
286-
return isValid ? resolve() : reject(invalidTokenError);
287-
})
288-
.catch(error => {
289-
if (!(error instanceof JwtDecoderError)) {
290-
return reject(error);
291-
}
292-
if (error.code === JwtDecoderErrorCode.TOKEN_EXPIRED) {
293-
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
294-
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
295-
verifyJwtTokenDocsMessage;
296-
return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage));
297-
}
298-
return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message));
299-
});
300-
});
301-
}
302-
303-
/**
304-
* Fetches the public keys for the Google certs.
305-
*
306-
* @return {Promise<object>} A promise fulfilled with public keys for the Google certs.
307-
*/
308-
private fetchPublicKeys(): Promise<{[key: string]: string}> {
309-
const publicKeysExist = (typeof this.publicKeys !== 'undefined');
310-
const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined');
311-
const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt);
312-
if (publicKeysExist && publicKeysStillValid) {
313-
return Promise.resolve(this.publicKeys);
275+
if (!(error instanceof SignatureVerifierError)) {
276+
return (error);
314277
}
315-
316-
const client = new HttpClient();
317-
const request: HttpRequestConfig = {
318-
method: 'GET',
319-
url: this.clientCertUrl,
320-
httpAgent: this.app.options.httpAgent,
321-
};
322-
return client.send(request).then((resp) => {
323-
if (!resp.isJson() || resp.data.error) {
324-
// Treat all non-json messages and messages with an 'error' field as
325-
// error responses.
326-
throw new HttpError(resp);
327-
}
328-
if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) {
329-
const cacheControlHeader: string = resp.headers['cache-control'];
330-
const parts = cacheControlHeader.split(',');
331-
parts.forEach((part) => {
332-
const subParts = part.trim().split('=');
333-
if (subParts[0] === 'max-age') {
334-
const maxAge: number = +subParts[1];
335-
this.publicKeysExpireAt = Date.now() + (maxAge * 1000);
336-
}
337-
});
338-
}
339-
this.publicKeys = resp.data;
340-
return resp.data;
341-
}).catch((err) => {
342-
if (err instanceof HttpError) {
343-
let errorMessage = 'Error fetching public keys for Google certs: ';
344-
const resp = err.response;
345-
if (resp.isJson() && resp.data.error) {
346-
errorMessage += `${resp.data.error}`;
347-
if (resp.data.error_description) {
348-
errorMessage += ' (' + resp.data.error_description + ')';
349-
}
350-
} else {
351-
errorMessage += `${resp.text}`;
352-
}
353-
throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage);
354-
}
355-
throw err;
356-
});
278+
if (error.code === SignatureVerifierErrorCode.TOKEN_EXPIRED) {
279+
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
280+
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
281+
verifyJwtTokenDocsMessage;
282+
return new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage);
283+
}
284+
else if (error.code === SignatureVerifierErrorCode.INVALID_TOKEN) {
285+
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
286+
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
287+
}
288+
else if (error.code === SignatureVerifierErrorCode.INVALID_ARGUMENT &&
289+
error.message === NO_MATCHING_KID_ERROR_MESSAGE) {
290+
const errorMessage = `${this.tokenInfo.jwtName} has "kid" claim which does not ` +
291+
`correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` +
292+
'is expired, so get a fresh token from your client app and try again.';
293+
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
294+
}
295+
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message);
357296
}
358297
}
359298

src/utils/jwt-decoder.ts

Lines changed: 24 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -26,75 +26,36 @@ export type DecodedToken = {
2626
}
2727

2828
/**
29-
* Class for decoding and verifying general purpose Firebase JWTs.
29+
* Decodes general purpose Firebase JWTs.
3030
*/
31-
export class JwtDecoder {
32-
33-
constructor(private algorithm: jwt.Algorithm) {
34-
35-
if (!validator.isNonEmptyString(algorithm)) {
36-
throw new Error('The provided JWT algorithm is an empty string.');
37-
}
31+
export function decodeJwt(jwtToken: string): Promise<DecodedToken> {
32+
if (!validator.isString(jwtToken)) {
33+
return Promise.reject(new JwtDecoderError({
34+
code: JwtDecoderErrorCode.INVALID_ARGUMENT,
35+
message: 'The provided token must be a string.'
36+
}));
3837
}
3938

40-
public decodeToken(jwtToken: string): DecodedToken {
41-
if (!validator.isString(jwtToken)) {
42-
throw new JwtDecoderError({
43-
code: JwtDecoderErrorCode.INVALID_ARGUMENT,
44-
message: 'The provided token must be a string.'
45-
});
46-
}
47-
48-
const fullDecodedToken: any = jwt.decode(jwtToken, {
49-
complete: true,
50-
});
51-
52-
if (!fullDecodedToken) {
53-
throw new JwtDecoderError({
54-
code: JwtDecoderErrorCode.INVALID_ARGUMENT,
55-
message: 'Decoding token failed.'
56-
});
57-
}
39+
const fullDecodedToken: any = jwt.decode(jwtToken, {
40+
complete: true,
41+
});
5842

59-
const header = fullDecodedToken?.header;
60-
const payload = fullDecodedToken?.payload;
61-
62-
return { header, payload };
43+
if (!fullDecodedToken) {
44+
return Promise.reject(new JwtDecoderError({
45+
code: JwtDecoderErrorCode.INVALID_ARGUMENT,
46+
message: 'Decoding token failed.'
47+
}));
6348
}
6449

65-
public isSignatureValid(jwtToken: string, publicKey: string | null): Promise<boolean> {
66-
return new Promise((resolve, reject) => {
67-
const verifyOptions: jwt.VerifyOptions = {};
68-
if (publicKey !== null) {
69-
verifyOptions.algorithms = [this.algorithm];
70-
}
71-
jwt.verify(jwtToken, publicKey || '', verifyOptions,
72-
(error: jwt.VerifyErrors | null) => {
73-
if (!error) {
74-
return resolve(true);
75-
}
76-
if (error.name === 'TokenExpiredError') {
77-
return reject(new JwtDecoderError({
78-
code: JwtDecoderErrorCode.TOKEN_EXPIRED,
79-
message: 'The provided token has expired. Get a fresh token from your ' +
80-
'client app and try again.',
81-
}));
82-
} else if (error.name === 'JsonWebTokenError') {
83-
return resolve(false);
84-
}
85-
return reject(new JwtDecoderError({
86-
code: JwtDecoderErrorCode.INVALID_ARGUMENT,
87-
message: error.message
88-
}));
89-
});
90-
});
91-
}
50+
const header = fullDecodedToken?.header;
51+
const payload = fullDecodedToken?.payload;
52+
53+
return Promise.resolve({ header, payload });
9254
}
9355

9456
/**
9557
* JwtDecoder error code structure.
9658
*
97-
* @param {ProjectManagementErrorCode} code The error code.
9859
* @param {ErrorInfo} errorInfo The error information (code and message).
9960
* @constructor
10061
*/
@@ -116,10 +77,10 @@ export class JwtDecoderError extends Error {
11677
}
11778

11879
/**
119-
* Crypto Signer error codes and their default messages.
80+
* JWT decoder error codes.
12081
*/
121-
export class JwtDecoderErrorCode {
122-
public static INVALID_ARGUMENT = 'invalid-argument';
123-
public static INVALID_CREDENTIAL = 'invalid-credential';
124-
public static TOKEN_EXPIRED = 'token-expired';
82+
export enum JwtDecoderErrorCode {
83+
INVALID_ARGUMENT = 'invalid-argument',
84+
INVALID_CREDENTIAL = 'invalid-credential',
85+
TOKEN_EXPIRED = 'token-expired',
12586
}

0 commit comments

Comments
 (0)