Skip to content

Allow callable functions to skip token verification in debug mode #983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 39 commits into from
Oct 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
8781162
Add module to determine whether a debug feature is enabled.
taeold Oct 7, 2021
99d1a3b
Try to be more clever.
taeold Oct 7, 2021
1567bbd
Minor cleanups.
taeold Oct 7, 2021
77c15c3
Meaningless nits.
taeold Oct 8, 2021
c0ecb9d
Prettier.
taeold Oct 8, 2021
8ab4672
Use one env var for feature toggle.
taeold Oct 12, 2021
5cb4849
Allow reloading debug feature config in env var.
taeold Oct 14, 2021
c4746bf
Shorten comments.
taeold Oct 14, 2021
a500177
Skip auth token verification if request is from the emulator.
taeold Sep 28, 2021
393976e
Decode ID Token.
taeold Sep 29, 2021
eeae9ea
Prettier
taeold Sep 29, 2021
b31ea7e
Fix import path.
taeold Sep 29, 2021
988a1bf
Refactor, apply skip policy to app check.
taeold Sep 29, 2021
9981857
Nits.
taeold Sep 29, 2021
a37ece0
Refactor function name for clarity.
taeold Sep 29, 2021
107ed68
Add more tests.
taeold Sep 29, 2021
63e8319
Refactor to reduce code dupes.
taeold Sep 29, 2021
9754f58
Prefer uid/app_id in token payload if it exists.
taeold Sep 29, 2021
9951e9b
Uninstall jws package.
taeold Oct 2, 2021
aa4ed8f
Prettier.
taeold Oct 2, 2021
ce7b993
More fake app ids.
taeold Oct 2, 2021
fc39f8e
Internal instead of hidden.
taeold Oct 2, 2021
b841366
toString defaults to utf8
taeold Oct 2, 2021
0b52dff
Elminate need for jws as dev dep.
taeold Oct 6, 2021
361246c
Use debug feature to toggle token verification.
taeold Oct 14, 2021
4a9e4cc
Revert package-lock.json changes.
taeold Oct 14, 2021
2ef950d
Cleanup unnecessary changes.
taeold Oct 14, 2021
2cc7eb9
Make it more clear that skipping token verification is unsafe.
taeold Oct 14, 2021
9a652ff
Fix rebase gone wrong.
taeold Oct 18, 2021
1f6d44f
More refreshes.
taeold Oct 18, 2021
de196d2
Prettier.
taeold Oct 18, 2021
5e36503
Fix path.
taeold Oct 18, 2021
74a8da5
Address comments pt1.
taeold Oct 19, 2021
b339785
Rename debug feature name.
taeold Oct 19, 2021
facfe67
Fix test, pull out constant var as arg.
taeold Oct 19, 2021
d0e6af1
Add more tests.
taeold Oct 19, 2021
f12ca60
Prettier.
taeold Oct 19, 2021
25b4458
Merge branch 'master' into dl-cf3-emulator-callable
taeold Oct 19, 2021
09f1827
Merge branch 'master' into dl-cf3-emulator-callable
taeold Oct 28, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 173 additions & 39 deletions spec/common/providers/https.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import { expect } from 'chai';
import * as express from 'express';
import * as firebase from 'firebase-admin';
import * as sinon from 'sinon';

import { apps as appsNamespace } from '../../../src/apps';
import * as https from '../../../src/common/providers/https';
import * as debug from '../../../src/common/debug';
import * as mocks from '../../fixtures/credential/key.json';
import {
expectedResponseHeaders,
generateAppCheckToken,
generateIdToken,
generateUnsignedAppCheckToken,
generateUnsignedIdToken,
mockFetchAppCheckPublicJwks,
mockFetchPublicKeys,
mockRequest,
} from '../../fixtures/mockrequest';
import {
CallableContext,
CallableRequest,
unsafeDecodeAppCheckToken,
unsafeDecodeIdToken,
} from '../../../src/common/providers/https';

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

function checkAuthContext(
context: CallableContext,
projectId: string,
userId: string
) {
expect(context.auth).to.not.be.undefined;
expect(context.auth).to.not.be.null;
expect(context.auth.uid).to.equal(userId);
expect(context.auth.token.uid).to.equal(userId);
expect(context.auth.token.sub).to.equal(userId);
expect(context.auth.token.aud).to.equal(projectId);
expect(context.instanceIdToken).to.be.undefined;
}

function checkAppCheckContext(
context: CallableContext,
projectId: string,
appId: string
) {
expect(context.app).to.not.be.undefined;
expect(context.app).to.not.be.null;
expect(context.app.appId).to.equal(appId);
expect(context.app.token.app_id).to.be.equal(appId);
expect(context.app.token.sub).to.be.equal(appId);
expect(context.app.token.aud).to.be.deep.equal([`projects/${projectId}`]);
expect(context.auth).to.be.undefined;
expect(context.instanceIdToken).to.be.undefined;
}

function checkAuthRequest(
request: CallableRequest,
projectId: string,
userId: string
) {
expect(request.auth).to.not.be.undefined;
expect(request.auth).to.not.be.null;
expect(request.auth.uid).to.equal(userId);
expect(request.auth.token.uid).to.equal(userId);
expect(request.auth.token.sub).to.equal(userId);
expect(request.auth.token.aud).to.equal(projectId);
expect(request.instanceIdToken).to.be.undefined;
}

function checkAppCheckRequest(
request: CallableRequest,
projectId: string,
appId: string
) {
expect(request.app).to.not.be.undefined;
expect(request.app).to.not.be.null;
expect(request.app.appId).to.equal(appId);
expect(request.app.token.app_id).to.be.equal(appId);
expect(request.app.token.sub).to.be.equal(appId);
expect(request.app.token.aud).to.be.deep.equal([`projects/${projectId}`]);
expect(request.auth).to.be.undefined;
expect(request.instanceIdToken).to.be.undefined;
}

describe('onCallHandler', () => {
let app: firebase.app.App;

Expand Down Expand Up @@ -354,23 +422,11 @@ describe('onCallHandler', () => {
}),
expectedData: null,
callableFunction: (data, context) => {
expect(context.auth).to.not.be.undefined;
expect(context.auth).to.not.be.null;
expect(context.auth.uid).to.equal(mocks.user_id);
expect(context.auth.token.uid).to.equal(mocks.user_id);
expect(context.auth.token.sub).to.equal(mocks.user_id);
expect(context.auth.token.aud).to.equal(projectId);
expect(context.instanceIdToken).to.be.undefined;
checkAuthContext(context, projectId, mocks.user_id);
return null;
},
callableFunction2: (request) => {
expect(request.auth).to.not.be.undefined;
expect(request.auth).to.not.be.null;
expect(request.auth.uid).to.equal(mocks.user_id);
expect(request.auth.token.uid).to.equal(mocks.user_id);
expect(request.auth.token.sub).to.equal(mocks.user_id);
expect(request.auth.token.aud).to.equal(projectId);
expect(request.instanceIdToken).to.be.undefined;
checkAuthRequest(request, projectId, mocks.user_id);
return null;
},
expectedHttpResponse: {
Expand All @@ -383,9 +439,11 @@ describe('onCallHandler', () => {
});

it('should reject bad auth', async () => {
const projectId = appsNamespace().admin.options.projectId;
const idToken = generateUnsignedIdToken(projectId);
await runTest({
httpRequest: mockRequest(null, 'application/json', {
authorization: 'Bearer FAKE',
authorization: 'Bearer ' + idToken,
}),
expectedData: null,
callableFunction: (data, context) => {
Expand All @@ -410,35 +468,17 @@ describe('onCallHandler', () => {
it('should handle AppCheck token', async () => {
const mock = mockFetchAppCheckPublicJwks();
const projectId = appsNamespace().admin.options.projectId;
const appId = '1:65211879909:web:3ae38ef1cdcb2e01fe5f0c';
const appId = '123:web:abc';
const appCheckToken = generateAppCheckToken(projectId, appId);
await runTest({
httpRequest: mockRequest(null, 'application/json', { appCheckToken }),
expectedData: null,
callableFunction: (data, context) => {
expect(context.app).to.not.be.undefined;
expect(context.app).to.not.be.null;
expect(context.app.appId).to.equal(appId);
expect(context.app.token.app_id).to.be.equal(appId);
expect(context.app.token.sub).to.be.equal(appId);
expect(context.app.token.aud).to.be.deep.equal([
`projects/${projectId}`,
]);
expect(context.auth).to.be.undefined;
expect(context.instanceIdToken).to.be.undefined;
checkAppCheckContext(context, projectId, appId);
return null;
},
callableFunction2: (request) => {
expect(request.app).to.not.be.undefined;
expect(request.app).to.not.be.null;
expect(request.app.appId).to.equal(appId);
expect(request.app.token.app_id).to.be.equal(appId);
expect(request.app.token.sub).to.be.equal(appId);
expect(request.app.token.aud).to.be.deep.equal([
`projects/${projectId}`,
]);
expect(request.auth).to.be.undefined;
expect(request.instanceIdToken).to.be.undefined;
checkAppCheckRequest(request, projectId, appId);
return null;
},
expectedHttpResponse: {
Expand All @@ -451,10 +491,11 @@ describe('onCallHandler', () => {
});

it('should reject bad AppCheck token', async () => {
const projectId = appsNamespace().admin.options.projectId;
const appId = '123:web:abc';
const appCheckToken = generateUnsignedAppCheckToken(projectId, appId);
await runTest({
httpRequest: mockRequest(null, 'application/json', {
appCheckToken: 'FAKE',
}),
httpRequest: mockRequest(null, 'application/json', { appCheckToken }),
expectedData: null,
callableFunction: (data, context) => {
return;
Expand Down Expand Up @@ -545,6 +586,66 @@ describe('onCallHandler', () => {
},
});
});

describe('skip token verification debug mode support', () => {
before(() => {
sinon
.stub(debug, 'isDebugFeatureEnabled')
.withArgs('skipTokenVerification')
.returns(true);
});

after(() => {
sinon.verifyAndRestore();
});

it('should skip auth token verification', async () => {
const projectId = appsNamespace().admin.options.projectId;
const idToken = generateUnsignedIdToken(projectId);
await runTest({
httpRequest: mockRequest(null, 'application/json', {
authorization: 'Bearer ' + idToken,
}),
expectedData: null,
callableFunction: (data, context) => {
checkAuthContext(context, projectId, mocks.user_id);
return null;
},
callableFunction2: (request) => {
checkAuthRequest(request, projectId, mocks.user_id);
return null;
},
expectedHttpResponse: {
status: 200,
headers: expectedResponseHeaders,
body: { result: null },
},
});
});

it('should skip app check token verification', async () => {
const projectId = appsNamespace().admin.options.projectId;
const appId = '123:web:abc';
const appCheckToken = generateUnsignedAppCheckToken(projectId, appId);
await runTest({
httpRequest: mockRequest(null, 'application/json', { appCheckToken }),
expectedData: null,
callableFunction: (data, context) => {
checkAppCheckContext(context, projectId, appId);
return null;
},
callableFunction2: (request) => {
checkAppCheckRequest(request, projectId, appId);
return null;
},
expectedHttpResponse: {
status: 200,
headers: expectedResponseHeaders,
body: { result: null },
},
});
});
});
});

describe('encoding/decoding', () => {
Expand Down Expand Up @@ -670,3 +771,36 @@ describe('encoding/decoding', () => {
expect(https.encode(() => 'foo')).to.deep.equal({});
});
});

describe('decode tokens', () => {
const projectId = 'myproject';
const appId = '123:web:abc';

it('decodes valid Auth ID Token', () => {
const idToken = unsafeDecodeIdToken(generateIdToken(projectId));
expect(idToken.uid).to.equal(mocks.user_id);
expect(idToken.sub).to.equal(mocks.user_id);
});

it('decodes invalid Auth ID Token', () => {
const idToken = unsafeDecodeIdToken(generateUnsignedIdToken(projectId));
expect(idToken.uid).to.equal(mocks.user_id);
expect(idToken.sub).to.equal(mocks.user_id);
});

it('decodes valid App Check Token', () => {
const idToken = unsafeDecodeAppCheckToken(
generateAppCheckToken(projectId, appId)
);
expect(idToken.app_id).to.equal(appId);
expect(idToken.sub).to.equal(appId);
});

it('decodes invalid App Check Token', () => {
const idToken = unsafeDecodeAppCheckToken(
generateUnsignedAppCheckToken(projectId, appId)
);
expect(idToken.app_id).to.equal(appId);
expect(idToken.sub).to.equal(appId);
});
});
31 changes: 31 additions & 0 deletions spec/fixtures/mockrequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ export function generateIdToken(projectId: string): string {
return jwt.sign(claims, mockKey.private_key, options);
}

/**
* Generates a mocked, unsigned Firebase ID token.
*/
export function generateUnsignedIdToken(projectId: string): string {
return [
{ alg: 'RS256', typ: 'JWT' },
{ aud: projectId, sub: mockKey.user_id },
'Invalid signature',
]
.map((str) => JSON.stringify(str))
.map((str) => Buffer.from(str).toString('base64'))
.join('.');
}

/**
* Mocks out the http request used by the firebase-admin SDK to get the jwks for
* verifying an AppCheck token.
Expand Down Expand Up @@ -121,3 +135,20 @@ export function generateAppCheckToken(
};
return jwt.sign(claims, jwkToPem(mockJWK, { private: true }), options);
}

/**
* Generates a mocked, unsigned AppCheck token.
*/
export function generateUnsignedAppCheckToken(
projectId: string,
appId: string
): string {
return [
{ alg: 'RS256', typ: 'JWT' },
{ aud: [`projects/${projectId}`], sub: appId },
'Invalid signature',
]
.map((component) => JSON.stringify(component))
.map((str) => Buffer.from(str).toString('base64'))
.join('.');
}
2 changes: 1 addition & 1 deletion src/common/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
const debugMode = process.env.FIREBASE_DEBUG_MODE === 'true';

interface DebugFeatures {
skipCallableTokenVerification?: boolean;
skipTokenVerification?: boolean;
}

function loadDebugFeatures(): DebugFeatures {
Expand Down
Loading