Skip to content

Commit c29a8a5

Browse files
authored
Support verification of AppCheck token in Callable Functions (#885)
Callable Functions will now verify the AppCheck token included in the X-Firebase-AppCheck request header. Similar to auth, Callable Function will return 401 Unauthorized if the AppCheck token is invalid.
1 parent 09120b6 commit c29a8a5

File tree

8 files changed

+851
-607
lines changed

8 files changed

+851
-607
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
- Functions may now be deployed with 8GB RAM
22
- Functions may now be deployed to europe-central2 (Warsaw)
3+
- Add support for validating AppCheck tokens for Callable Functions

package-lock.json

Lines changed: 618 additions & 579 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@
5858
"chai": "^4.2.0",
5959
"chai-as-promised": "^7.1.1",
6060
"child-process-promise": "^2.2.1",
61-
"firebase-admin": "^8.2.0",
61+
"firebase-admin": "^9.8.0",
6262
"js-yaml": "^3.13.1",
6363
"jsdom": "^16.2.1",
6464
"jsonwebtoken": "^8.5.1",
65+
"jwk-to-pem": "^2.0.5",
6566
"mocha": "^6.1.4",
6667
"mock-require": "^3.0.3",
6768
"mz": "^2.7.0",

spec/fixtures/credential/jwk.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"p": "9cTVRzGXbDfhIMQW9gXtWveDW0u_Hvwnbjx7TRPgSfawZ0MjgKfSbnyHTDXiqM1ifcN_Nk58KJ-PG9eZ7V7_mfTUnPv2puDaecn-kgHobnTJMoBR9hpzyyMpyNJuMvX4kqE7Qh8iFMBK_-p8ICiW15gK5WykswIKfIOkUZc52XM",
3+
"kty": "RSA",
4+
"q": "pYdUNL244sCoc4XrONKlu787AiHrjFFLHdTjoFLbvxSpszXM8iSjoiFAM_MCF-uWks2iBVDw9wlG4MB7MfNf_fD0i1wqyknSOtfMxknU7D4eU_Sp6tI99Jl8f_GAzODK__k_0MpqqXgZmJbUvYuIXMiha-5lddz8ENa4pYpbr7M",
5+
"d": "MpkXqjmjvzwfmlq3o0uZAXjeeAnBlYQSNaSllBWKepgPjg4FxFIt_BlXex1NeP0npNy_oCgaM_x7NiALaaPhwPK52lhYThc-xomCic1KDkyPecODTPXi4Iw94Q_gp442SYMWz2ZktS-2DgXc3599fGHkY80u0rHNSO8ptdk8SUDUIZ82ZQ3pBhClF_uY3c1jZLuqVgCwKksInZmNPnv3ge088wmQC26t0Ph5u1HU6lISgaqZ8ol23iNWJPf4UEi8Twy1a73nphQS-y1yK9UC3c5Knk-WI2TMmjlxqC02ZjKqnRDxElTj9kpodasPRHRV_KJI8rTaStgxd7peMFODzQ",
6+
"e": "AQAB",
7+
"use": "sig",
8+
"kid": "a12KBE",
9+
"qi": "aJCrZVWeOjxYmMBTTI7aJhxcvvfS3I5Q7wwN4Oyb1rJZ4fgGYjDohlzeZz_3fNantPAgcDbzJfa3XS327sHJGaAVqvDugZUgyHeLZGzXGs-_mlL72wzcfvTa1C9_lIndLNZJle5_mg3xJAqRKV0s7kymSdYt0wL5fDaqo5SDNqQ",
10+
"dp": "haBk2hWzoApt5HPZjCDC4g_rosr3enBdPAm0fL8O1whC95JAjmYw-xPIOH6f42nwYDLYSv23chr3I4tBTRe2382HgGdav3dIMqnKOTbCWrQy5LtyVN4jEVLoGCGZ-ylT4t25K4Vj8WZwIN8saAvJoCUx33YHwrCcZQDqadZQhNM",
11+
"alg": "RS256",
12+
"dq": "j6NdeN7hnzMbehPNyGNSmhcZd4JDymGI03w3gpokQi4GDJM1IzKUJE7CTdIkEOnIod97Jy3TzCrqrIGa5f-RXuVG79-s6hkhKxq0gaTz9YT6AFShVjnWtXizRrskz6SJw5JgxCfCYwjq_TR1q313eTxIh0Y6GQsIWPxbApuLcG0",
13+
"n": "nunJGpOcPvVsP3q-NLgf3H6OycPhnXUxywMR2_H_JJP7BUIDSsYcOGBTFe7OphHYfyb1Gs14yAER243swndpNbQkuDJhj9a9kK6dJZmPGmvCySk_E5URj6MimZg1MBbwhsVAbRp2uerESZuoRrfdTdV87E3pGyg6Irl0IXRjy5w9SsFjjIi7E-Qxpf3TcNNjfVRLj9V2bSzmS7hlsPKBhDon0tWecuNKoNNMiGI46mz_MSUa2y1lPV6Cqhf1su_TRd7N7u9eP7xWArr7wqtqHiFTZ3qp1xoA_dr_xv_Ao2kBtohZiAFLV-PQShprSN5fafztRZFkSEF0m2tUkvmoaQ"
14+
}

spec/fixtures/mockrequest.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as jwt from 'jsonwebtoken';
2+
import * as jwkToPem from 'jwk-to-pem';
23
import * as _ from 'lodash';
34
import * as nock from 'nock';
4-
import * as mocks from '../fixtures/credential/key.json';
5+
import * as mockKey from '../fixtures/credential/key.json';
6+
import * as mockJWK from '../fixtures/credential/jwk.json';
57

68
// MockRequest mocks an https.Request.
79
export class MockRequest {
@@ -26,6 +28,7 @@ export function mockRequest(
2628
context: {
2729
authorization?: string;
2830
instanceIdToken?: string;
31+
appCheckToken?: string;
2932
} = {}
3033
) {
3134
const body: any = {};
@@ -37,6 +40,7 @@ export function mockRequest(
3740
'content-type': contentType,
3841
authorization: context.authorization,
3942
'firebase-instance-id-token': context.instanceIdToken,
43+
'x-firebase-appcheck': context.appCheckToken,
4044
origin: 'example.com',
4145
};
4246

@@ -53,7 +57,7 @@ export const expectedResponseHeaders = {
5357
* verifying an id token.
5458
*/
5559
export function mockFetchPublicKeys(): nock.Scope {
56-
const mockedResponse = { [mocks.key_id]: mocks.public_key };
60+
const mockedResponse = { [mockKey.key_id]: mockKey.public_key };
5761
const headers = {
5862
'cache-control': 'public, max-age=1, must-revalidate, no-transform',
5963
};
@@ -72,11 +76,48 @@ export function generateIdToken(projectId: string): string {
7276
audience: projectId,
7377
expiresIn: 60 * 60, // 1 hour in seconds
7478
issuer: 'https://securetoken.google.com/' + projectId,
75-
subject: mocks.user_id,
79+
subject: mockKey.user_id,
7680
algorithm: 'RS256',
7781
header: {
78-
kid: mocks.key_id,
82+
kid: mockKey.key_id,
7983
},
8084
};
81-
return jwt.sign(claims, mocks.private_key, options);
85+
return jwt.sign(claims, mockKey.private_key, options);
86+
}
87+
88+
/**
89+
* Mocks out the http request used by the firebase-admin SDK to get the jwks for
90+
* verifying an AppCheck token.
91+
*/
92+
export function mockFetchAppCheckPublicJwks(): nock.Scope {
93+
const { kty, use, alg, kid, n, e } = mockJWK;
94+
const mockedResponse = {
95+
keys: [{ kty, use, alg, kid, n, e }],
96+
};
97+
98+
return nock('https://firebaseappcheck.googleapis.com:443')
99+
.get('/v1beta/jwks')
100+
.reply(200, mockedResponse);
101+
}
102+
103+
/**
104+
* Generates a mocked AppCheck token.
105+
*/
106+
export function generateAppCheckToken(
107+
projectId: string,
108+
appId: string
109+
): string {
110+
const claims = {};
111+
const options: jwt.SignOptions = {
112+
audience: [`projects/${projectId}`],
113+
expiresIn: 60 * 60, // 1 hour in seconds
114+
issuer: `https://firebaseappcheck.googleapis.com/${projectId}`,
115+
subject: appId,
116+
header: {
117+
alg: 'RS256',
118+
typ: 'JWT',
119+
kid: mockJWK.kid,
120+
},
121+
};
122+
return jwt.sign(claims, jwkToPem(mockJWK, { private: true }), options);
82123
}

spec/providers/https.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import * as https from '../../src/providers/https';
3030
import * as mocks from '../fixtures/credential/key.json';
3131
import {
3232
expectedResponseHeaders,
33+
generateAppCheckToken,
3334
generateIdToken,
35+
mockFetchAppCheckPublicJwks,
3436
mockFetchPublicKeys,
3537
MockRequest,
3638
mockRequest,
@@ -414,6 +416,58 @@ describe('callable.FunctionBuilder', () => {
414416
});
415417
});
416418

419+
it('should handle AppCheck token', async () => {
420+
const mock = mockFetchAppCheckPublicJwks();
421+
const projectId = appsNamespace().admin.options.projectId;
422+
const appId = '1:65211879909:web:3ae38ef1cdcb2e01fe5f0c';
423+
const appCheckToken = generateAppCheckToken(projectId, appId);
424+
await runTest({
425+
httpRequest: mockRequest(null, 'application/json', { appCheckToken }),
426+
expectedData: null,
427+
callableFunction: (data, context) => {
428+
expect(context.app).to.not.be.undefined;
429+
expect(context.app).to.not.be.null;
430+
expect(context.app.appId).to.equal(appId);
431+
expect(context.app.token.app_id).to.be.equal(appId);
432+
expect(context.app.token.sub).to.be.equal(appId);
433+
expect(context.app.token.aud).to.be.deep.equal([
434+
`projects/${projectId}`,
435+
]);
436+
expect(context.auth).to.be.undefined;
437+
expect(context.instanceIdToken).to.be.undefined;
438+
return null;
439+
},
440+
expectedHttpResponse: {
441+
status: 200,
442+
headers: expectedResponseHeaders,
443+
body: { result: null },
444+
},
445+
});
446+
mock.done();
447+
});
448+
449+
it('should reject bad AppCheck token', async () => {
450+
await runTest({
451+
httpRequest: mockRequest(null, 'application/json', {
452+
appCheckToken: 'FAKE',
453+
}),
454+
expectedData: null,
455+
callableFunction: (data, context) => {
456+
return;
457+
},
458+
expectedHttpResponse: {
459+
status: 401,
460+
headers: expectedResponseHeaders,
461+
body: {
462+
error: {
463+
status: 'UNAUTHENTICATED',
464+
message: 'Unauthenticated',
465+
},
466+
},
467+
},
468+
});
469+
});
470+
417471
it('should handle instance id', async () => {
418472
await runTest({
419473
httpRequest: mockRequest(null, 'application/json', {

src/providers/https.ts

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import * as _ from 'lodash';
2828
import { apps } from '../apps';
2929
import { HttpsFunction, optionsToTrigger, Runnable } from '../cloud-functions';
3030
import { DeploymentOptions } from '../function-configuration';
31-
import { warn, error } from '../logger';
31+
import { error, info, warn } from '../logger';
3232

3333
/** @hidden */
3434
export interface Request extends express.Request {
@@ -248,6 +248,14 @@ export class HttpsError extends Error {
248248
* The interface for metadata for the API as passed to the handler.
249249
*/
250250
export interface CallableContext {
251+
/**
252+
* The result of decoding and verifying a Firebase AppCheck token.
253+
*/
254+
app?: {
255+
appId: string;
256+
token: firebase.appCheck.DecodedAppCheckToken;
257+
};
258+
251259
/**
252260
* The result of decoding and verifying a Firebase Auth ID token.
253261
*/
@@ -411,6 +419,108 @@ export function decode(data: any): any {
411419
return data;
412420
}
413421

422+
/**
423+
* Be careful when changing token status values.
424+
*
425+
* Users are encouraged to setup log-based metric based on these values, and
426+
* changing their values may cause their metrics to break.
427+
*
428+
*/
429+
/** @hidden */
430+
type TokenStatus = 'MISSING' | 'VALID' | 'INVALID';
431+
432+
/** @hidden */
433+
interface CallableTokenStatus {
434+
app: TokenStatus;
435+
auth: TokenStatus;
436+
}
437+
438+
/**
439+
* Check and verify tokens included in the requests. Once verified, tokens
440+
* are injected into the callable context.
441+
*
442+
* @param {Request} req - Request sent to the Callable function.
443+
* @param {CallableContext} ctx - Context to be sent to callable function handler.
444+
* @return {CallableTokenStatus} Status of the token verifications.
445+
*/
446+
/** @hidden */
447+
async function checkTokens(
448+
req: Request,
449+
ctx: CallableContext
450+
): Promise<CallableTokenStatus> {
451+
const verifications: CallableTokenStatus = {
452+
app: 'MISSING',
453+
auth: 'MISSING',
454+
};
455+
456+
const appCheck = req.header('X-Firebase-AppCheck');
457+
if (appCheck) {
458+
verifications.app = 'INVALID';
459+
try {
460+
if (!apps().admin.appCheck) {
461+
throw new Error(
462+
'Cannot validate AppCheck token. Please uupdate Firebase Admin SDK to >= v9.8.0'
463+
);
464+
}
465+
const appCheckToken = await apps()
466+
.admin.appCheck()
467+
.verifyToken(appCheck);
468+
ctx.app = {
469+
appId: appCheckToken.appId,
470+
token: appCheckToken.token,
471+
};
472+
verifications.app = 'VALID';
473+
} catch (err) {
474+
warn('Failed to validate AppCheck token.', err);
475+
}
476+
}
477+
478+
const authorization = req.header('Authorization');
479+
if (authorization) {
480+
verifications.auth = 'INVALID';
481+
const match = authorization.match(/^Bearer (.*)$/);
482+
if (match) {
483+
const idToken = match[1];
484+
try {
485+
const authToken = await apps()
486+
.admin.auth()
487+
.verifyIdToken(idToken);
488+
489+
verifications.auth = 'VALID';
490+
ctx.auth = {
491+
uid: authToken.uid,
492+
token: authToken,
493+
};
494+
} catch (err) {
495+
warn('Failed to validate auth token.', err);
496+
}
497+
}
498+
}
499+
500+
const logPayload = {
501+
verifications,
502+
'logging.googleapis.com/labels': {
503+
'firebase-log-type': 'callable-request-verification',
504+
},
505+
};
506+
507+
const errs = [];
508+
if (verifications.app === 'INVALID') {
509+
errs.push('AppCheck token was rejected.');
510+
}
511+
if (verifications.auth === 'INVALID') {
512+
errs.push('Auth token was rejected.');
513+
}
514+
515+
if (errs.length == 0) {
516+
info('Callable request verification passed', logPayload);
517+
} else {
518+
warn(`Callable request verification failed: ${errs.join(' ')}`, logPayload);
519+
}
520+
521+
return verifications;
522+
}
523+
414524
/** @hidden */
415525
const corsHandler = cors({ origin: true, methods: 'POST' });
416526

@@ -427,25 +537,9 @@ export function _onCallWithOptions(
427537
}
428538

429539
const context: CallableContext = { rawRequest: req };
430-
431-
const authorization = req.header('Authorization');
432-
if (authorization) {
433-
const match = authorization.match(/^Bearer (.*)$/);
434-
if (!match) {
435-
throw new HttpsError('unauthenticated', 'Unauthenticated');
436-
}
437-
const idToken = match[1];
438-
try {
439-
const authToken = await apps()
440-
.admin.auth()
441-
.verifyIdToken(idToken);
442-
context.auth = {
443-
uid: authToken.uid,
444-
token: authToken,
445-
};
446-
} catch (err) {
447-
throw new HttpsError('unauthenticated', 'Unauthenticated');
448-
}
540+
const tokenStatus = await checkTokens(req, context);
541+
if (tokenStatus.app === 'INVALID' || tokenStatus.auth === 'INVALID') {
542+
throw new HttpsError('unauthenticated', 'Unauthenticated');
449543
}
450544

451545
const instanceId = req.header('Firebase-Instance-ID-Token');

tsconfig.release.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"compilerOptions": {
33
"declaration": true,
4-
"lib": ["es2017"],
4+
"lib": ["es2018"],
55
"module": "commonjs",
66
"noImplicitAny": false,
77
"noUnusedLocals": true,
88
"outDir": "lib",
99
"stripInternal": true,
10-
"target": "es2017",
10+
"target": "es2018",
1111
"typeRoots": ["./node_modules/@types"]
1212
},
1313
"files": ["./src/index.ts", "./src/logger.ts", "./src/logger/compat.ts"]

0 commit comments

Comments
 (0)