Skip to content

Commit 24e331f

Browse files
committed
(chore): Add JWT Decoder
1 parent 738eba7 commit 24e331f

File tree

2 files changed

+186
-40
lines changed

2 files changed

+186
-40
lines changed

src/auth/token-verifier.ts

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import * as util from '../utils/index';
1919
import * as validator from '../utils/validator';
2020
import * as jwt from 'jsonwebtoken';
2121
import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request';
22+
import { DecodedToken, JwtDecoder, JwtDecoderError, JwtDecoderErrorCode } from '../utils/jwt-decoder';
2223
import { FirebaseApp } from '../firebase-app';
2324
import { auth } from './index';
2425

@@ -75,6 +76,7 @@ export class FirebaseTokenVerifier {
7576
private publicKeys: {[key: string]: string};
7677
private publicKeysExpireAt: number;
7778
private readonly shortNameArticle: string;
79+
private readonly jwtDecoder: JwtDecoder;
7880

7981
constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm,
8082
private issuer: string, private tokenInfo: FirebaseTokenInfo,
@@ -127,6 +129,7 @@ export class FirebaseTokenVerifier {
127129
);
128130
}
129131
this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a';
132+
this.jwtDecoder = new JwtDecoder(algorithm);
130133

131134
// For backward compatibility, the project ID is validated in the verification call.
132135
}
@@ -149,27 +152,50 @@ export class FirebaseTokenVerifier {
149152

150153
return util.findProjectId(this.app)
151154
.then((projectId) => {
152-
return this.verifyJWTWithProjectId(jwtToken, projectId, isEmulator);
155+
const fullDecodedToken = this.safeDecode(jwtToken);
156+
this.validateJWT(fullDecodedToken, projectId, isEmulator);
157+
return Promise.all([
158+
fullDecodedToken,
159+
this.verifySignature(jwtToken, fullDecodedToken, isEmulator)
160+
]);
161+
})
162+
.then(([fullDecodedToken]) => {
163+
const decodedIdToken = fullDecodedToken.payload as DecodedIdToken;
164+
decodedIdToken.uid = decodedIdToken.sub;
165+
return decodedIdToken;
153166
});
154167
}
155168

156-
private verifyJWTWithProjectId(
157-
jwtToken: string,
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+
}
185+
}
186+
187+
private validateJWT(
188+
fullDecodedToken: DecodedToken,
158189
projectId: string | null,
159-
isEmulator: boolean
160-
): Promise<DecodedIdToken> {
190+
isEmulator: boolean): void {
161191
if (!validator.isNonEmptyString(projectId)) {
162192
throw new FirebaseAuthError(
163193
AuthClientErrorCode.INVALID_CREDENTIAL,
164194
'Must initialize app with a cert credential or set your Firebase project ID as the ' +
165195
`GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`,
166196
);
167197
}
168-
169-
const fullDecodedToken: any = jwt.decode(jwtToken, {
170-
complete: true,
171-
});
172-
198+
173199
const header = fullDecodedToken && fullDecodedToken.header;
174200
const payload = fullDecodedToken && fullDecodedToken.payload;
175201

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

181207
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') {
208+
if (!isEmulator && typeof header.kid === 'undefined') {
186209
const isCustomToken = (payload.aud === FIREBASE_AUDIENCE);
187210
const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d);
188211

@@ -217,16 +240,19 @@ export class FirebaseTokenVerifier {
217240
verifyJwtTokenDocsMessage;
218241
}
219242
if (errorMessage) {
220-
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage));
243+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
221244
}
245+
}
222246

247+
private verifySignature(jwtToken: string, decodeToken: DecodedToken, isEmulator: boolean):
248+
Promise<void> {
223249
if (isEmulator) {
224250
// Signature checks skipped for emulator; no need to fetch public keys.
225251
return this.verifyJwtSignatureWithKey(jwtToken, null);
226252
}
227253

228254
return this.fetchPublicKeys().then((publicKeys) => {
229-
if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) {
255+
if (!Object.prototype.hasOwnProperty.call(publicKeys, decodeToken.header.kid)) {
230256
return Promise.reject(
231257
new FirebaseAuthError(
232258
AuthClientErrorCode.INVALID_ARGUMENT,
@@ -236,7 +262,7 @@ export class FirebaseTokenVerifier {
236262
),
237263
);
238264
} else {
239-
return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]);
265+
return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[decodeToken.header.kid]);
240266
}
241267

242268
});
@@ -246,35 +272,30 @@ export class FirebaseTokenVerifier {
246272
* Verifies the JWT signature using the provided public key.
247273
* @param {string} jwtToken The JWT token to verify.
248274
* @param {string} publicKey The public key certificate.
249-
* @return {Promise<DecodedIdToken>} A promise that resolves with the decoded JWT claims on successful
275+
* @return {Promise<void>} A promise that resolves with the decoded JWT claims on successful
250276
* verification.
251277
*/
252-
private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise<DecodedIdToken> {
278+
private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise<void> {
253279
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
254280
`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);
255283
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);
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));
277297
}
298+
return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message));
278299
});
279300
});
280301
}

src/utils/jwt-decoder.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*!
2+
* Copyright 2021 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as validator from './validator';
18+
import * as jwt from 'jsonwebtoken';
19+
import { ErrorInfo } from './error';
20+
21+
type Dictionary = {[key: string]: any}
22+
23+
export type DecodedToken = {
24+
header: Dictionary;
25+
payload: Dictionary;
26+
}
27+
28+
/**
29+
* Class for decoding and verifying general purpose Firebase JWTs.
30+
*/
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+
}
38+
}
39+
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+
}
58+
59+
const header = fullDecodedToken?.header;
60+
const payload = fullDecodedToken?.payload;
61+
62+
return { header, payload };
63+
}
64+
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+
}
92+
}
93+
94+
/**
95+
* JwtDecoder error code structure.
96+
*
97+
* @param {ProjectManagementErrorCode} code The error code.
98+
* @param {ErrorInfo} errorInfo The error information (code and message).
99+
* @constructor
100+
*/
101+
export class JwtDecoderError extends Error {
102+
constructor(private errorInfo: ErrorInfo) {
103+
super(errorInfo.message);
104+
(this as any).__proto__ = JwtDecoderError.prototype;
105+
}
106+
107+
/** @return {string} The error code. */
108+
public get code(): string {
109+
return this.errorInfo.code;
110+
}
111+
112+
/** @return {string} The error message. */
113+
public get message(): string {
114+
return this.errorInfo.message;
115+
}
116+
}
117+
118+
/**
119+
* Crypto Signer error codes and their default messages.
120+
*/
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';
125+
}

0 commit comments

Comments
 (0)