Skip to content

Commit 9c2142b

Browse files
authored
Allow callable functions to skip token verification in debug mode (#983)
To replace monkey-patching of the Firebase Functions SDK in the Functions Emulator ([code](https://github.com/firebase/firebase-tools/blob/c2feb0836f6f64236e117f2906ef6083840e212b/src/emulator/functionsEmulatorRuntime.ts#L401-L496)), we provide native support for bypassing token verification for `onCall` handlers. Using the new debug mode introduced in #992, Auth/App Check token included in the request will be decoded but no verified.
1 parent 87472f3 commit 9c2142b

File tree

4 files changed

+332
-105
lines changed

4 files changed

+332
-105
lines changed

spec/common/providers/https.spec.ts

Lines changed: 173 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
import { expect } from 'chai';
22
import * as express from 'express';
33
import * as firebase from 'firebase-admin';
4+
import * as sinon from 'sinon';
45

56
import { apps as appsNamespace } from '../../../src/apps';
67
import * as https from '../../../src/common/providers/https';
8+
import * as debug from '../../../src/common/debug';
79
import * as mocks from '../../fixtures/credential/key.json';
810
import {
911
expectedResponseHeaders,
1012
generateAppCheckToken,
1113
generateIdToken,
14+
generateUnsignedAppCheckToken,
15+
generateUnsignedIdToken,
1216
mockFetchAppCheckPublicJwks,
1317
mockFetchPublicKeys,
1418
mockRequest,
1519
} from '../../fixtures/mockrequest';
20+
import {
21+
CallableContext,
22+
CallableRequest,
23+
unsafeDecodeAppCheckToken,
24+
unsafeDecodeIdToken,
25+
} from '../../../src/common/providers/https';
1626

1727
/**
1828
* RunHandlerResult contains the data from an express.Response.
@@ -133,6 +143,64 @@ async function runTest(test: CallTest): Promise<any> {
133143
expect(responseV2.status).to.equal(test.expectedHttpResponse.status);
134144
}
135145

146+
function checkAuthContext(
147+
context: CallableContext,
148+
projectId: string,
149+
userId: string
150+
) {
151+
expect(context.auth).to.not.be.undefined;
152+
expect(context.auth).to.not.be.null;
153+
expect(context.auth.uid).to.equal(userId);
154+
expect(context.auth.token.uid).to.equal(userId);
155+
expect(context.auth.token.sub).to.equal(userId);
156+
expect(context.auth.token.aud).to.equal(projectId);
157+
expect(context.instanceIdToken).to.be.undefined;
158+
}
159+
160+
function checkAppCheckContext(
161+
context: CallableContext,
162+
projectId: string,
163+
appId: string
164+
) {
165+
expect(context.app).to.not.be.undefined;
166+
expect(context.app).to.not.be.null;
167+
expect(context.app.appId).to.equal(appId);
168+
expect(context.app.token.app_id).to.be.equal(appId);
169+
expect(context.app.token.sub).to.be.equal(appId);
170+
expect(context.app.token.aud).to.be.deep.equal([`projects/${projectId}`]);
171+
expect(context.auth).to.be.undefined;
172+
expect(context.instanceIdToken).to.be.undefined;
173+
}
174+
175+
function checkAuthRequest(
176+
request: CallableRequest,
177+
projectId: string,
178+
userId: string
179+
) {
180+
expect(request.auth).to.not.be.undefined;
181+
expect(request.auth).to.not.be.null;
182+
expect(request.auth.uid).to.equal(userId);
183+
expect(request.auth.token.uid).to.equal(userId);
184+
expect(request.auth.token.sub).to.equal(userId);
185+
expect(request.auth.token.aud).to.equal(projectId);
186+
expect(request.instanceIdToken).to.be.undefined;
187+
}
188+
189+
function checkAppCheckRequest(
190+
request: CallableRequest,
191+
projectId: string,
192+
appId: string
193+
) {
194+
expect(request.app).to.not.be.undefined;
195+
expect(request.app).to.not.be.null;
196+
expect(request.app.appId).to.equal(appId);
197+
expect(request.app.token.app_id).to.be.equal(appId);
198+
expect(request.app.token.sub).to.be.equal(appId);
199+
expect(request.app.token.aud).to.be.deep.equal([`projects/${projectId}`]);
200+
expect(request.auth).to.be.undefined;
201+
expect(request.instanceIdToken).to.be.undefined;
202+
}
203+
136204
describe('onCallHandler', () => {
137205
let app: firebase.app.App;
138206

@@ -354,23 +422,11 @@ describe('onCallHandler', () => {
354422
}),
355423
expectedData: null,
356424
callableFunction: (data, context) => {
357-
expect(context.auth).to.not.be.undefined;
358-
expect(context.auth).to.not.be.null;
359-
expect(context.auth.uid).to.equal(mocks.user_id);
360-
expect(context.auth.token.uid).to.equal(mocks.user_id);
361-
expect(context.auth.token.sub).to.equal(mocks.user_id);
362-
expect(context.auth.token.aud).to.equal(projectId);
363-
expect(context.instanceIdToken).to.be.undefined;
425+
checkAuthContext(context, projectId, mocks.user_id);
364426
return null;
365427
},
366428
callableFunction2: (request) => {
367-
expect(request.auth).to.not.be.undefined;
368-
expect(request.auth).to.not.be.null;
369-
expect(request.auth.uid).to.equal(mocks.user_id);
370-
expect(request.auth.token.uid).to.equal(mocks.user_id);
371-
expect(request.auth.token.sub).to.equal(mocks.user_id);
372-
expect(request.auth.token.aud).to.equal(projectId);
373-
expect(request.instanceIdToken).to.be.undefined;
429+
checkAuthRequest(request, projectId, mocks.user_id);
374430
return null;
375431
},
376432
expectedHttpResponse: {
@@ -383,9 +439,11 @@ describe('onCallHandler', () => {
383439
});
384440

385441
it('should reject bad auth', async () => {
442+
const projectId = appsNamespace().admin.options.projectId;
443+
const idToken = generateUnsignedIdToken(projectId);
386444
await runTest({
387445
httpRequest: mockRequest(null, 'application/json', {
388-
authorization: 'Bearer FAKE',
446+
authorization: 'Bearer ' + idToken,
389447
}),
390448
expectedData: null,
391449
callableFunction: (data, context) => {
@@ -410,35 +468,17 @@ describe('onCallHandler', () => {
410468
it('should handle AppCheck token', async () => {
411469
const mock = mockFetchAppCheckPublicJwks();
412470
const projectId = appsNamespace().admin.options.projectId;
413-
const appId = '1:65211879909:web:3ae38ef1cdcb2e01fe5f0c';
471+
const appId = '123:web:abc';
414472
const appCheckToken = generateAppCheckToken(projectId, appId);
415473
await runTest({
416474
httpRequest: mockRequest(null, 'application/json', { appCheckToken }),
417475
expectedData: null,
418476
callableFunction: (data, context) => {
419-
expect(context.app).to.not.be.undefined;
420-
expect(context.app).to.not.be.null;
421-
expect(context.app.appId).to.equal(appId);
422-
expect(context.app.token.app_id).to.be.equal(appId);
423-
expect(context.app.token.sub).to.be.equal(appId);
424-
expect(context.app.token.aud).to.be.deep.equal([
425-
`projects/${projectId}`,
426-
]);
427-
expect(context.auth).to.be.undefined;
428-
expect(context.instanceIdToken).to.be.undefined;
477+
checkAppCheckContext(context, projectId, appId);
429478
return null;
430479
},
431480
callableFunction2: (request) => {
432-
expect(request.app).to.not.be.undefined;
433-
expect(request.app).to.not.be.null;
434-
expect(request.app.appId).to.equal(appId);
435-
expect(request.app.token.app_id).to.be.equal(appId);
436-
expect(request.app.token.sub).to.be.equal(appId);
437-
expect(request.app.token.aud).to.be.deep.equal([
438-
`projects/${projectId}`,
439-
]);
440-
expect(request.auth).to.be.undefined;
441-
expect(request.instanceIdToken).to.be.undefined;
481+
checkAppCheckRequest(request, projectId, appId);
442482
return null;
443483
},
444484
expectedHttpResponse: {
@@ -451,10 +491,11 @@ describe('onCallHandler', () => {
451491
});
452492

453493
it('should reject bad AppCheck token', async () => {
494+
const projectId = appsNamespace().admin.options.projectId;
495+
const appId = '123:web:abc';
496+
const appCheckToken = generateUnsignedAppCheckToken(projectId, appId);
454497
await runTest({
455-
httpRequest: mockRequest(null, 'application/json', {
456-
appCheckToken: 'FAKE',
457-
}),
498+
httpRequest: mockRequest(null, 'application/json', { appCheckToken }),
458499
expectedData: null,
459500
callableFunction: (data, context) => {
460501
return;
@@ -545,6 +586,66 @@ describe('onCallHandler', () => {
545586
},
546587
});
547588
});
589+
590+
describe('skip token verification debug mode support', () => {
591+
before(() => {
592+
sinon
593+
.stub(debug, 'isDebugFeatureEnabled')
594+
.withArgs('skipTokenVerification')
595+
.returns(true);
596+
});
597+
598+
after(() => {
599+
sinon.verifyAndRestore();
600+
});
601+
602+
it('should skip auth token verification', async () => {
603+
const projectId = appsNamespace().admin.options.projectId;
604+
const idToken = generateUnsignedIdToken(projectId);
605+
await runTest({
606+
httpRequest: mockRequest(null, 'application/json', {
607+
authorization: 'Bearer ' + idToken,
608+
}),
609+
expectedData: null,
610+
callableFunction: (data, context) => {
611+
checkAuthContext(context, projectId, mocks.user_id);
612+
return null;
613+
},
614+
callableFunction2: (request) => {
615+
checkAuthRequest(request, projectId, mocks.user_id);
616+
return null;
617+
},
618+
expectedHttpResponse: {
619+
status: 200,
620+
headers: expectedResponseHeaders,
621+
body: { result: null },
622+
},
623+
});
624+
});
625+
626+
it('should skip app check token verification', async () => {
627+
const projectId = appsNamespace().admin.options.projectId;
628+
const appId = '123:web:abc';
629+
const appCheckToken = generateUnsignedAppCheckToken(projectId, appId);
630+
await runTest({
631+
httpRequest: mockRequest(null, 'application/json', { appCheckToken }),
632+
expectedData: null,
633+
callableFunction: (data, context) => {
634+
checkAppCheckContext(context, projectId, appId);
635+
return null;
636+
},
637+
callableFunction2: (request) => {
638+
checkAppCheckRequest(request, projectId, appId);
639+
return null;
640+
},
641+
expectedHttpResponse: {
642+
status: 200,
643+
headers: expectedResponseHeaders,
644+
body: { result: null },
645+
},
646+
});
647+
});
648+
});
548649
});
549650

550651
describe('encoding/decoding', () => {
@@ -670,3 +771,36 @@ describe('encoding/decoding', () => {
670771
expect(https.encode(() => 'foo')).to.deep.equal({});
671772
});
672773
});
774+
775+
describe('decode tokens', () => {
776+
const projectId = 'myproject';
777+
const appId = '123:web:abc';
778+
779+
it('decodes valid Auth ID Token', () => {
780+
const idToken = unsafeDecodeIdToken(generateIdToken(projectId));
781+
expect(idToken.uid).to.equal(mocks.user_id);
782+
expect(idToken.sub).to.equal(mocks.user_id);
783+
});
784+
785+
it('decodes invalid Auth ID Token', () => {
786+
const idToken = unsafeDecodeIdToken(generateUnsignedIdToken(projectId));
787+
expect(idToken.uid).to.equal(mocks.user_id);
788+
expect(idToken.sub).to.equal(mocks.user_id);
789+
});
790+
791+
it('decodes valid App Check Token', () => {
792+
const idToken = unsafeDecodeAppCheckToken(
793+
generateAppCheckToken(projectId, appId)
794+
);
795+
expect(idToken.app_id).to.equal(appId);
796+
expect(idToken.sub).to.equal(appId);
797+
});
798+
799+
it('decodes invalid App Check Token', () => {
800+
const idToken = unsafeDecodeAppCheckToken(
801+
generateUnsignedAppCheckToken(projectId, appId)
802+
);
803+
expect(idToken.app_id).to.equal(appId);
804+
expect(idToken.sub).to.equal(appId);
805+
});
806+
});

spec/fixtures/mockrequest.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,20 @@ export function generateIdToken(projectId: string): string {
8585
return jwt.sign(claims, mockKey.private_key, options);
8686
}
8787

88+
/**
89+
* Generates a mocked, unsigned Firebase ID token.
90+
*/
91+
export function generateUnsignedIdToken(projectId: string): string {
92+
return [
93+
{ alg: 'RS256', typ: 'JWT' },
94+
{ aud: projectId, sub: mockKey.user_id },
95+
'Invalid signature',
96+
]
97+
.map((str) => JSON.stringify(str))
98+
.map((str) => Buffer.from(str).toString('base64'))
99+
.join('.');
100+
}
101+
88102
/**
89103
* Mocks out the http request used by the firebase-admin SDK to get the jwks for
90104
* verifying an AppCheck token.
@@ -121,3 +135,20 @@ export function generateAppCheckToken(
121135
};
122136
return jwt.sign(claims, jwkToPem(mockJWK, { private: true }), options);
123137
}
138+
139+
/**
140+
* Generates a mocked, unsigned AppCheck token.
141+
*/
142+
export function generateUnsignedAppCheckToken(
143+
projectId: string,
144+
appId: string
145+
): string {
146+
return [
147+
{ alg: 'RS256', typ: 'JWT' },
148+
{ aud: [`projects/${projectId}`], sub: appId },
149+
'Invalid signature',
150+
]
151+
.map((component) => JSON.stringify(component))
152+
.map((str) => Buffer.from(str).toString('base64'))
153+
.join('.');
154+
}

src/common/debug.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
const debugMode = process.env.FIREBASE_DEBUG_MODE === 'true';
2525

2626
interface DebugFeatures {
27-
skipCallableTokenVerification?: boolean;
27+
skipTokenVerification?: boolean;
2828
}
2929

3030
function loadDebugFeatures(): DebugFeatures {

0 commit comments

Comments
 (0)