Skip to content

Commit 05e56e4

Browse files
committed
Add unit tests for utils/jwt
1 parent 8848ac6 commit 05e56e4

File tree

5 files changed

+640
-158
lines changed

5 files changed

+640
-158
lines changed

src/auth/token-verifier.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import * as util from '../utils/index';
1919
import * as validator from '../utils/validator';
2020
import {
2121
DecodedToken, decodeJwt, JwtError, JwtErrorCode,
22-
EmulatorSignatureVerifier, PublicKeySignatureVerifier,
22+
EmulatorSignatureVerifier, PublicKeySignatureVerifier, ALGORITHM_RS256, SignatureVerifier,
2323
} from '../utils/jwt';
2424
import { FirebaseApp } from '../firebase-app';
2525
import { auth } from './index';
@@ -29,8 +29,6 @@ import DecodedIdToken = auth.DecodedIdToken;
2929
// Audience to use for Firebase Auth Custom tokens
3030
const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit';
3131

32-
const ALGORITHM_RS256 = 'RS256' as const;
33-
3432
// URL containing the public keys for the Google certs (whose private keys are used to sign Firebase
3533
// Auth ID tokens)
3634
const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/[email protected]';
@@ -77,7 +75,7 @@ export interface FirebaseTokenInfo {
7775
*/
7876
export class FirebaseTokenVerifier {
7977
private readonly shortNameArticle: string;
80-
private readonly signatureVerifier: PublicKeySignatureVerifier;
78+
private readonly signatureVerifier: SignatureVerifier;
8179

8280
constructor(clientCertUrl: string, private issuer: string, private tokenInfo: FirebaseTokenInfo,
8381
private readonly app: FirebaseApp) {
@@ -196,6 +194,13 @@ export class FirebaseTokenVerifier {
196194
});
197195
}
198196

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+
*/
199204
private verifyContent(
200205
fullDecodedToken: DecodedToken,
201206
projectId: string | null,
@@ -258,23 +263,24 @@ export class FirebaseTokenVerifier {
258263
});
259264
}
260265

266+
/**
267+
* Maps JwtError to FirebaseAuthError
268+
*
269+
* @param error JwtError to be mapped.
270+
* @returns FirebaseAuthError or Error instance.
271+
*/
261272
private mapJwtErrorToAuthError(error: JwtError): Error {
262273
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
263274
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
264-
if (!(error instanceof JwtError)) {
265-
return (error);
266-
}
267275
if (error.code === JwtErrorCode.TOKEN_EXPIRED) {
268276
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
269277
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
270278
verifyJwtTokenDocsMessage;
271279
return new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage);
272-
}
273-
else if (error.code === JwtErrorCode.INVALID_SIGNATURE) {
280+
} else if (error.code === JwtErrorCode.INVALID_SIGNATURE) {
274281
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
275282
return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
276-
}
277-
else if (error.code === JwtErrorCode.KEY_FETCH_ERROR) {
283+
} else if (error.code === JwtErrorCode.NO_MATCHING_KID) {
278284
const errorMessage = `${this.tokenInfo.jwtName} has "kid" claim which does not ` +
279285
`correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` +
280286
'is expired, so get a fresh token from your client app and try again.';

src/utils/jwt.ts

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import * as jwt from 'jsonwebtoken';
1919
import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request';
2020
import { Agent } from 'http';
2121

22-
const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const;
22+
export const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const;
2323

2424
// `jsonwebtoken` converts errors from the `getKey` callback to its own `JsonWebTokenError` type
2525
// and prefixes the error message with the following. Use the prefix to identify errors thrown
@@ -29,7 +29,7 @@ const JWT_CALLBACK_ERROR_PREFIX = 'error in secret or public key callback: ';
2929

3030
const NO_MATCHING_KID_ERROR_MESSAGE = 'no-matching-kid-error';
3131

32-
export type Dictionary = {[key: string]: any}
32+
export type Dictionary = { [key: string]: any }
3333

3434
export type DecodedToken = {
3535
header: Dictionary;
@@ -44,14 +44,17 @@ interface KeyFetcher {
4444
fetchPublicKeys(): Promise<{ [key: string]: string }>;
4545
}
4646

47-
class UrlKeyFetcher implements KeyFetcher {
47+
/**
48+
* Class to fetch public keys from a client certificates URL.
49+
*/
50+
export class UrlKeyFetcher implements KeyFetcher {
4851
private publicKeys: { [key: string]: string };
4952
private publicKeysExpireAt = 0;
5053

5154
constructor(private clientCertUrl: string, private readonly httpAgent?: Agent) {
5255
if (!validator.isURL(clientCertUrl)) {
5356
throw new Error(
54-
'The provided public client certificate URL is an invalid URL.',
57+
'The provided public client certificate URL is not a valid URL.',
5558
);
5659
}
5760
}
@@ -68,6 +71,11 @@ class UrlKeyFetcher implements KeyFetcher {
6871
return Promise.resolve(this.publicKeys);
6972
}
7073

74+
/**
75+
* Checks if the cached public keys need to be refreshed.
76+
*
77+
* @returns Whether the keys should be fetched from the client certs url or not.
78+
*/
7179
private shouldRefresh(): boolean {
7280
return !this.publicKeys || this.publicKeysExpireAt <= Date.now();
7381
}
@@ -120,7 +128,7 @@ class UrlKeyFetcher implements KeyFetcher {
120128
}
121129

122130
/**
123-
* Verifies JWT signature with a public key.
131+
* Class for verifing JWT signature with a public key.
124132
*/
125133
export class PublicKeySignatureVerifier implements SignatureVerifier {
126134
constructor(private keyFetcher: KeyFetcher) {
@@ -134,10 +142,31 @@ export class PublicKeySignatureVerifier implements SignatureVerifier {
134142
}
135143

136144
public verify(token: string): Promise<void> {
145+
if (!validator.isString(token)) {
146+
return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT,
147+
'The provided token must be a string.'));
148+
}
149+
137150
return verifyJwtSignature(token, getKeyCallback(this.keyFetcher), { algorithms: [ALGORITHM_RS256] });
138151
}
139152
}
140153

154+
/**
155+
* Class for verifing unsigned (emulator) JWTs.
156+
*/
157+
export class EmulatorSignatureVerifier implements SignatureVerifier {
158+
public verify(token: string): Promise<void> {
159+
// Signature checks skipped for emulator; no need to fetch public keys.
160+
return verifyJwtSignature(token, '');
161+
}
162+
}
163+
164+
/**
165+
* Provides a callback to fetch public keys.
166+
*
167+
* @param fetcher KeyFetcher to fetch the keys from.
168+
* @returns A callback function that can be used to get keys in `jsonwebtoken`.
169+
*/
141170
function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret {
142171
return (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => {
143172
const kid = header.kid || '';
@@ -154,15 +183,22 @@ function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret {
154183
}
155184
}
156185

157-
export class EmulatorSignatureVerifier implements SignatureVerifier {
158-
public verify(token: string): Promise<void> {
159-
// Signature checks skipped for emulator; no need to fetch public keys.
160-
return verifyJwtSignature(token, '');
186+
/**
187+
* Verifies the signature of a JWT using the provided secret or a function to fetch
188+
* the secret or public key.
189+
*
190+
* @param token The JWT to be verfied.
191+
* @param secretOrPublicKey The secret or a function to fetch the secret or public key.
192+
* @param options JWT verification options.
193+
* @returns A Promise resolving for a token with a valid signature.
194+
*/
195+
export function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.GetPublicKeyOrSecret,
196+
options?: jwt.VerifyOptions): Promise<void> {
197+
if (!validator.isString(token)) {
198+
return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT,
199+
'The provided token must be a string.'));
161200
}
162-
}
163201

164-
function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.GetPublicKeyOrSecret,
165-
options?: jwt.VerifyOptions): Promise<void> {
166202
return new Promise((resolve, reject) => {
167203
jwt.verify(token, secretOrPublicKey, options,
168204
(error: jwt.VerifyErrors | null) => {
@@ -176,12 +212,10 @@ function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.G
176212
} else if (error.name === 'JsonWebTokenError') {
177213
if (error.message && error.message.includes(JWT_CALLBACK_ERROR_PREFIX)) {
178214
const message = error.message.split(JWT_CALLBACK_ERROR_PREFIX).pop() || 'Error fetching public keys.';
179-
const code = (message === NO_MATCHING_KID_ERROR_MESSAGE) ? JwtErrorCode.KEY_FETCH_ERROR :
180-
JwtErrorCode.INVALID_ARGUMENT;
215+
const code = (message === NO_MATCHING_KID_ERROR_MESSAGE) ? JwtErrorCode.NO_MATCHING_KID :
216+
JwtErrorCode.KEY_FETCH_ERROR;
181217
return reject(new JwtError(code, message));
182218
}
183-
return reject(new JwtError(JwtErrorCode.INVALID_SIGNATURE,
184-
'The provided token has invalid signature.'));
185219
}
186220
return reject(new JwtError(JwtErrorCode.INVALID_SIGNATURE, error.message));
187221
});
@@ -190,6 +224,9 @@ function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.G
190224

191225
/**
192226
* Decodes general purpose Firebase JWTs.
227+
*
228+
* @param jwtToken JWT token to be decoded.
229+
* @returns Decoded token containing the header and payload.
193230
*/
194231
export function decodeJwt(jwtToken: string): Promise<DecodedToken> {
195232
if (!validator.isString(jwtToken)) {
@@ -233,5 +270,6 @@ export enum JwtErrorCode {
233270
INVALID_CREDENTIAL = 'invalid-credential',
234271
TOKEN_EXPIRED = 'token-expired',
235272
INVALID_SIGNATURE = 'invalid-token',
236-
KEY_FETCH_ERROR = 'no-matching-kid-error',
273+
NO_MATCHING_KID = 'no-matching-kid-error',
274+
KEY_FETCH_ERROR = 'key-fetch-error',
237275
}

test/unit/auth/token-verifier.spec.ts

Lines changed: 0 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -77,33 +77,6 @@ function mockFetchWrongPublicKeys(): nock.Scope {
7777
});
7878
}
7979

80-
/**
81-
* Returns a mocked out error response from the URL containing the public keys for the Google certs.
82-
* The status code is 200 but the response itself will contain an 'error' key.
83-
*
84-
* @return {Object} A nock response object.
85-
*/
86-
function mockFetchPublicKeysWithErrorResponse(): nock.Scope {
87-
return nock('https://www.googleapis.com')
88-
.get('/robot/v1/metadata/x509/[email protected]')
89-
.reply(200, {
90-
error: 'message',
91-
error_description: 'description', // eslint-disable-line @typescript-eslint/camelcase
92-
});
93-
}
94-
95-
/**
96-
* Returns a mocked out failed response from the URL containing the public keys for the Google certs.
97-
* The status code is non-200 and the response itself will fail.
98-
*
99-
* @return {Object} A nock response object.
100-
*/
101-
function mockFailedFetchPublicKeys(): nock.Scope {
102-
return nock('https://www.googleapis.com')
103-
.get('/robot/v1/metadata/x509/[email protected]')
104-
.replyWithError('message');
105-
}
106-
10780
function createTokenVerifier(
10881
app: FirebaseApp
10982
): verifier.FirebaseTokenVerifier {
@@ -554,108 +527,5 @@ describe('FirebaseTokenVerifier', () => {
554527
await tokenVerifier.verifyJWT(idTokenNoHeader)
555528
.should.eventually.be.rejectedWith('Firebase ID token has no "kid" claim.');
556529
});
557-
558-
it('should use the given HTTP Agent', () => {
559-
const agent = new https.Agent();
560-
const appWithAgent = mocks.appWithOptions({
561-
credential: mocks.credential,
562-
httpAgent: agent,
563-
});
564-
tokenVerifier = new verifier.FirebaseTokenVerifier(
565-
'https://www.googleapis.com/robot/v1/metadata/x509/[email protected]',
566-
'https://securetoken.google.com/',
567-
verifier.ID_TOKEN_INFO,
568-
appWithAgent,
569-
);
570-
mockedRequests.push(mockFetchPublicKeys());
571-
572-
clock = sinon.useFakeTimers(1000);
573-
574-
const mockIdToken = mocks.generateIdToken();
575-
576-
return tokenVerifier.verifyJWT(mockIdToken)
577-
.then(() => {
578-
expect(https.request).to.have.been.calledOnce;
579-
expect(httpsSpy.args[0][0].agent).to.equal(agent);
580-
});
581-
});
582-
583-
it('should not fetch the Google cert public keys until the first time verifyJWT() is called', () => {
584-
mockedRequests.push(mockFetchPublicKeys());
585-
586-
const testTokenVerifier = new verifier.FirebaseTokenVerifier(
587-
'https://www.googleapis.com/robot/v1/metadata/x509/[email protected]',
588-
'https://securetoken.google.com/',
589-
verifier.ID_TOKEN_INFO,
590-
app,
591-
);
592-
expect(https.request).not.to.have.been.called;
593-
594-
const mockIdToken = mocks.generateIdToken();
595-
596-
return testTokenVerifier.verifyJWT(mockIdToken)
597-
.then(() => expect(https.request).to.have.been.calledOnce);
598-
});
599-
600-
it('should not re-fetch the Google cert public keys every time verifyJWT() is called', () => {
601-
mockedRequests.push(mockFetchPublicKeys());
602-
603-
const mockIdToken = mocks.generateIdToken();
604-
605-
return tokenVerifier.verifyJWT(mockIdToken).then(() => {
606-
expect(https.request).to.have.been.calledOnce;
607-
return tokenVerifier.verifyJWT(mockIdToken);
608-
}).then(() => expect(https.request).to.have.been.calledOnce);
609-
});
610-
611-
it('should refresh the Google cert public keys after the "max-age" on the request expires', () => {
612-
mockedRequests.push(mockFetchPublicKeys());
613-
mockedRequests.push(mockFetchPublicKeys());
614-
mockedRequests.push(mockFetchPublicKeys());
615-
616-
clock = sinon.useFakeTimers(1000);
617-
618-
const mockIdToken = mocks.generateIdToken();
619-
620-
return tokenVerifier.verifyJWT(mockIdToken).then(() => {
621-
expect(https.request).to.have.been.calledOnce;
622-
clock!.tick(999);
623-
return tokenVerifier.verifyJWT(mockIdToken);
624-
}).then(() => {
625-
expect(https.request).to.have.been.calledOnce;
626-
clock!.tick(1);
627-
return tokenVerifier.verifyJWT(mockIdToken);
628-
}).then(() => {
629-
// One second has passed
630-
expect(https.request).to.have.been.calledTwice;
631-
clock!.tick(999);
632-
return tokenVerifier.verifyJWT(mockIdToken);
633-
}).then(() => {
634-
expect(https.request).to.have.been.calledTwice;
635-
clock!.tick(1);
636-
return tokenVerifier.verifyJWT(mockIdToken);
637-
}).then(() => {
638-
// Two seconds have passed
639-
expect(https.request).to.have.been.calledThrice;
640-
});
641-
});
642-
643-
it('should be rejected if fetching the Google public keys fails', () => {
644-
mockedRequests.push(mockFailedFetchPublicKeys());
645-
646-
const mockIdToken = mocks.generateIdToken();
647-
648-
return tokenVerifier.verifyJWT(mockIdToken)
649-
.should.eventually.be.rejectedWith('message');
650-
});
651-
652-
it('should be rejected if fetching the Google public keys returns a response with an error message', () => {
653-
mockedRequests.push(mockFetchPublicKeysWithErrorResponse());
654-
655-
const mockIdToken = mocks.generateIdToken();
656-
657-
return tokenVerifier.verifyJWT(mockIdToken)
658-
.should.eventually.be.rejectedWith('Error fetching public keys for Google certs: message (description)');
659-
});
660530
});
661531
});

test/unit/index.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import './utils/index.spec';
2525
import './utils/error.spec';
2626
import './utils/validator.spec';
2727
import './utils/api-request.spec';
28+
import './utils/jwt.spec';
2829

2930
// Auth
3031
import './auth/auth.spec';

0 commit comments

Comments
 (0)