From d1ab52a4e692d5e5e4713cdff69a8ef19ad97257 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Tue, 1 Feb 2022 12:59:58 -0500 Subject: [PATCH 01/18] moving common auth items to identity.ts and fixing UserRecord definitions --- spec/common/providers/identity.spec.ts | 88 ++++++++++++++++++++ spec/v1/providers/auth.spec.ts | 77 ++--------------- src/common/providers/identity.ts | 111 +++++++++++++++++++++++++ src/providers/auth.ts | 89 ++------------------ 4 files changed, 210 insertions(+), 155 deletions(-) create mode 100644 spec/common/providers/identity.spec.ts create mode 100644 src/common/providers/identity.ts diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts new file mode 100644 index 000000000..ab0ecf1bf --- /dev/null +++ b/spec/common/providers/identity.spec.ts @@ -0,0 +1,88 @@ +// The MIT License (MIT) +// +// Copyright (c) 2017 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { expect } from 'chai'; +import * as identity from '../../../src/common/providers/identity'; + +describe('identity', () => { + describe('userRecordConstructor', () => { + it('will provide falsey values for fields that are not in raw wire data', () => { + const record = identity.userRecordConstructor({ uid: '123' }); + expect(record.toJSON()).to.deep.equal({ + uid: '123', + email: null, + emailVerified: false, + displayName: null, + photoURL: null, + phoneNumber: null, + disabled: false, + providerData: [], + customClaims: {}, + passwordSalt: null, + passwordHash: null, + tokensValidAfterTime: null, + metadata: { + creationTime: null, + lastSignInTime: null, + }, + }); + }); + + it('will not interfere with fields that are in raw wire data', () => { + const raw: any = { + uid: '123', + email: 'email@gmail.com', + emailVerified: true, + displayName: 'User', + photoURL: 'url', + phoneNumber: '1233332222', + disabled: true, + providerData: [], + customClaims: {}, + passwordSalt: 'abc', + passwordHash: 'def', + tokensValidAfterTime: '2027-02-02T23:01:19.797Z', + metadata: { + creationTime: '2017-02-02T23:06:26.124Z', + lastSignInTime: '2017-02-02T23:01:19.797Z', + }, + }; + const record = identity.userRecordConstructor(raw); + expect(record.toJSON()).to.deep.equal(raw); + }); + + it('will convert raw wire fields createdAt and lastSignedInAt to creationTime and lastSignInTime', () => { + const raw: any = { + uid: '123', + metadata: { + createdAt: '2017-02-02T23:06:26.124Z', + lastSignedInAt: '2017-02-02T23:01:19.797Z', + }, + }; + const record = identity.userRecordConstructor(raw); + expect(record.metadata).to.deep.equal({ + creationTime: '2017-02-02T23:06:26.124Z', + lastSignInTime: '2017-02-02T23:01:19.797Z', + }); + }); + }); +}); diff --git a/spec/v1/providers/auth.spec.ts b/spec/v1/providers/auth.spec.ts index 6168cf767..8659a1967 100644 --- a/spec/v1/providers/auth.spec.ts +++ b/spec/v1/providers/auth.spec.ts @@ -21,13 +21,12 @@ // SOFTWARE. import { expect } from 'chai'; -import * as firebase from 'firebase-admin'; - import { CloudFunction, Event, EventContext, } from '../../../src/cloud-functions'; +import { UserRecord } from '../../../src/common/providers/identity'; import * as functions from '../../../src/index'; import * as auth from '../../../src/providers/auth'; @@ -75,7 +74,7 @@ describe('Auth Functions', () => { }; } - const handler = (user: firebase.auth.UserRecord) => { + const handler = (user: UserRecord) => { return Promise.resolve(); }; @@ -137,14 +136,12 @@ describe('Auth Functions', () => { }); describe('#_dataConstructor', () => { - let cloudFunctionDelete: CloudFunction; + let cloudFunctionDelete: CloudFunction; before(() => { cloudFunctionDelete = auth .user() - .onDelete( - (data: firebase.auth.UserRecord, context: EventContext) => data - ); + .onDelete((data: UserRecord, context: EventContext) => data); }); it('should handle wire format as of v5.0.0 of firebase-admin', () => { @@ -162,68 +159,6 @@ describe('Auth Functions', () => { }); }); - describe('userRecordConstructor', () => { - it('will provide falsey values for fields that are not in raw wire data', () => { - const record = auth.userRecordConstructor({ uid: '123' }); - expect(record.toJSON()).to.deep.equal({ - uid: '123', - email: null, - emailVerified: false, - displayName: null, - photoURL: null, - phoneNumber: null, - disabled: false, - providerData: [], - customClaims: {}, - passwordSalt: null, - passwordHash: null, - tokensValidAfterTime: null, - metadata: { - creationTime: null, - lastSignInTime: null, - }, - }); - }); - - it('will not interfere with fields that are in raw wire data', () => { - const raw: any = { - uid: '123', - email: 'email@gmail.com', - emailVerified: true, - displayName: 'User', - photoURL: 'url', - phoneNumber: '1233332222', - disabled: true, - providerData: [], - customClaims: {}, - passwordSalt: 'abc', - passwordHash: 'def', - tokensValidAfterTime: '2027-02-02T23:01:19.797Z', - metadata: { - creationTime: '2017-02-02T23:06:26.124Z', - lastSignInTime: '2017-02-02T23:01:19.797Z', - }, - }; - const record = auth.userRecordConstructor(raw); - expect(record.toJSON()).to.deep.equal(raw); - }); - - it('will convert raw wire fields createdAt and lastSignedInAt to creationTime and lastSignInTime', () => { - const raw: any = { - uid: '123', - metadata: { - createdAt: '2017-02-02T23:06:26.124Z', - lastSignedInAt: '2017-02-02T23:01:19.797Z', - }, - }; - const record = auth.userRecordConstructor(raw); - expect(record.metadata).to.deep.equal({ - creationTime: '2017-02-02T23:06:26.124Z', - lastSignInTime: '2017-02-02T23:01:19.797Z', - }); - }); - }); - describe('handler namespace', () => { describe('#onCreate', () => { it('should return an empty trigger', () => { @@ -238,8 +173,8 @@ describe('Auth Functions', () => { }); describe('#onDelete', () => { - const cloudFunctionDelete: CloudFunction = functions.handler.auth.user.onDelete( - (data: firebase.auth.UserRecord) => data + const cloudFunctionDelete: CloudFunction = functions.handler.auth.user.onDelete( + (data: UserRecord) => data ); it('should return an empty trigger', () => { diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts new file mode 100644 index 000000000..793b93b34 --- /dev/null +++ b/src/common/providers/identity.ts @@ -0,0 +1,111 @@ +// The MIT License (MIT) +// +// Copyright (c) 2017 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import * as firebase from 'firebase-admin'; +import * as _ from 'lodash'; + +/** + * The UserRecord passed to Cloud Functions is the same UserRecord that is returned by the Firebase Admin + * SDK. + */ +export type UserRecord = firebase.auth.UserRecord; + +/** + * UserInfo that is part of the UserRecord + */ +export type UserInfo = firebase.auth.UserInfo; + +/** + * Helper class to create the user metadata in a UserRecord object + */ +export class UserRecordMetadata implements firebase.auth.UserMetadata { + constructor(public creationTime: string, public lastSignInTime: string) {} + + /** Returns a plain JavaScript object with the properties of UserRecordMetadata. */ + toJSON() { + return { + creationTime: this.creationTime, + lastSignInTime: this.lastSignInTime, + }; + } +} + +/** + * Helper function that creates a UserRecord Class from data sent over the wire. + * @param wireData data sent over the wire + * @returns an instance of UserRecord with correct toJSON functions + */ +export function userRecordConstructor(wireData: Object): UserRecord { + // Falsey values from the wire format proto get lost when converted to JSON, this adds them back. + const falseyValues: any = { + email: null, + emailVerified: false, + displayName: null, + photoURL: null, + phoneNumber: null, + disabled: false, + providerData: [], + customClaims: {}, + passwordSalt: null, + passwordHash: null, + tokensValidAfterTime: null, + }; + const record = _.assign({}, falseyValues, wireData); + + const meta = _.get(record, 'metadata'); + if (meta) { + _.set( + record, + 'metadata', + new UserRecordMetadata( + meta.createdAt || meta.creationTime, + meta.lastSignedInAt || meta.lastSignInTime + ) + ); + } else { + _.set(record, 'metadata', new UserRecordMetadata(null, null)); + } + _.forEach(record.providerData, (entry) => { + _.set(entry, 'toJSON', () => { + return entry; + }); + }); + _.set(record, 'toJSON', () => { + const json: any = _.pick(record, [ + 'uid', + 'email', + 'emailVerified', + 'displayName', + 'photoURL', + 'phoneNumber', + 'disabled', + 'passwordHash', + 'passwordSalt', + 'tokensValidAfterTime', + ]); + json.metadata = _.get(record, 'metadata').toJSON(); + json.customClaims = _.cloneDeep(record.customClaims); + json.providerData = _.map(record.providerData, (entry) => entry.toJSON()); + return json; + }); + return record as UserRecord; +} diff --git a/src/providers/auth.ts b/src/providers/auth.ts index 7274399b2..8e6de97ea 100644 --- a/src/providers/auth.ts +++ b/src/providers/auth.ts @@ -20,8 +20,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as firebase from 'firebase-admin'; -import * as _ from 'lodash'; +import { + UserRecord, + userRecordConstructor, +} from '../common/providers/identity'; import { CloudFunction, Event, @@ -52,21 +54,9 @@ export function _userWithOptions(options: DeploymentOptions) { }, options); } -export class UserRecordMetadata implements firebase.auth.UserMetadata { - constructor(public creationTime: string, public lastSignInTime: string) {} - - /** Returns a plain JavaScript object with the properties of UserRecordMetadata. */ - toJSON() { - return { - creationTime: this.creationTime, - lastSignInTime: this.lastSignInTime, - }; - } -} - /** Builder used to create Cloud Functions for Firebase Auth user lifecycle events. */ export class UserBuilder { - private static dataConstructor(raw: Event): firebase.auth.UserRecord { + private static dataConstructor(raw: Event): UserRecord { return userRecordConstructor(raw.data); } @@ -109,72 +99,3 @@ export class UserBuilder { }); } } - -/** - * The UserRecord passed to Cloud Functions is the same UserRecord that is returned by the Firebase Admin - * SDK. - */ -export type UserRecord = firebase.auth.UserRecord; - -/** - * UserInfo that is part of the UserRecord - */ -export type UserInfo = firebase.auth.UserInfo; - -export function userRecordConstructor( - wireData: Object -): firebase.auth.UserRecord { - // Falsey values from the wire format proto get lost when converted to JSON, this adds them back. - const falseyValues: any = { - email: null, - emailVerified: false, - displayName: null, - photoURL: null, - phoneNumber: null, - disabled: false, - providerData: [], - customClaims: {}, - passwordSalt: null, - passwordHash: null, - tokensValidAfterTime: null, - }; - const record = _.assign({}, falseyValues, wireData); - - const meta = _.get(record, 'metadata'); - if (meta) { - _.set( - record, - 'metadata', - new UserRecordMetadata( - meta.createdAt || meta.creationTime, - meta.lastSignedInAt || meta.lastSignInTime - ) - ); - } else { - _.set(record, 'metadata', new UserRecordMetadata(null, null)); - } - _.forEach(record.providerData, (entry) => { - _.set(entry, 'toJSON', () => { - return entry; - }); - }); - _.set(record, 'toJSON', () => { - const json: any = _.pick(record, [ - 'uid', - 'email', - 'emailVerified', - 'displayName', - 'photoURL', - 'phoneNumber', - 'disabled', - 'passwordHash', - 'passwordSalt', - 'tokensValidAfterTime', - ]); - json.metadata = _.get(record, 'metadata').toJSON(); - json.customClaims = _.cloneDeep(record.customClaims); - json.providerData = _.map(record.providerData, (entry) => entry.toJSON()); - return json; - }); - return record as firebase.auth.UserRecord; -} From 235c49e2e250bd0c40f560c69069dfc8d000a787 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Tue, 1 Feb 2022 16:58:42 -0500 Subject: [PATCH 02/18] fixing copyright year and exporting old artifacts --- spec/common/providers/identity.spec.ts | 2 +- src/common/providers/identity.ts | 2 +- src/providers/auth.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index ab0ecf1bf..fcb686102 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2017 Firebase +// Copyright (c) 2022 Firebase // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 793b93b34..24a4e2c34 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2017 Firebase +// Copyright (c) 2022 Firebase // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/src/providers/auth.ts b/src/providers/auth.ts index 8e6de97ea..160de06c8 100644 --- a/src/providers/auth.ts +++ b/src/providers/auth.ts @@ -22,6 +22,8 @@ import { UserRecord, + UserInfo, + UserRecordMetadata, userRecordConstructor, } from '../common/providers/identity'; import { @@ -32,6 +34,9 @@ import { } from '../cloud-functions'; import { DeploymentOptions } from '../function-configuration'; +// TODO: yank in next breaking change release +export { UserRecord, UserInfo, UserRecordMetadata, userRecordConstructor }; + /** @hidden */ export const provider = 'google.firebase.auth'; /** @hidden */ From 9db587311557012ca03fe2cccf2926344fa226e3 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Wed, 2 Feb 2022 12:46:19 -0500 Subject: [PATCH 03/18] adding in common files only --- package.json | 4 +- spec/common/providers/identity.spec.ts | 344 +++++++++++- src/common/providers/identity.ts | 739 +++++++++++++++++++++++++ 3 files changed, 1085 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 25f8d8d7f..6a3183008 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,8 @@ "@types/express": "4.17.3", "cors": "^2.8.5", "express": "^4.17.1", - "lodash": "^4.17.14" + "lodash": "^4.17.14", + "node-fetch": "^2.6.7" }, "devDependencies": { "@types/chai": "^4.1.7", @@ -167,6 +168,7 @@ "@types/mock-require": "^2.0.0", "@types/nock": "^10.0.3", "@types/node": "^8.10.50", + "@types/node-fetch": "^3.0.3", "@types/sinon": "^7.0.13", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index fcb686102..0376bb88c 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -20,10 +20,92 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { expect } from 'chai'; +import * as express from 'express'; +// import * as firebase from 'firebase-admin'; +// import * as sinon from 'sinon'; + import * as identity from '../../../src/common/providers/identity'; +import { expect } from 'chai'; +// import { MockRequest } from '../../fixtures/mockrequest'; + +const PROJECT = 'my-project'; +const VALID_URL = `https://us-central1-${PROJECT}.cloudfunctions.net/function-1`; + +const now = new Date(); +const DECODED_USER_RECORD = { + uid: 'abcdefghijklmnopqrstuvwxyz', + email: 'user@gmail.com', + email_verified: true, + display_name: 'John Doe', + phone_number: '+11234567890', + provider_data: [ + { + provider_id: 'google.com', + display_name: 'John Doe', + photo_url: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + email: 'user@gmail.com', + uid: '1234567890', + }, + { + provider_id: 'facebook.com', + display_name: 'John Smith', + photo_url: 'https://facebook.com/0987654321/photo.jpg', + email: 'user@facebook.com', + uid: '0987654321', + }, + { + provider_id: 'phone', + uid: '+11234567890', + phone_number: '+11234567890', + }, + { + provider_id: 'password', + email: 'user@gmail.com', + uid: 'user@gmail.com', + display_name: 'John Doe', + }, + ], + password_hash: 'passwordHash', + password_salt: 'passwordSalt', + photo_url: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + tokens_valid_after_time: 1476136676, + metadata: { + last_sign_in_time: 1476235905, + creation_time: 1476136676, + }, + custom_claims: { + admin: true, + group_id: 'group123', + }, + tenant_id: 'TENANT_ID', + multi_factor: { + enrolled_factors: [ + { + uid: 'enrollmentId1', + display_name: 'displayName1', + enrollment_time: now.toISOString(), + phone_number: '+16505551234', + factor_id: 'phone', + }, + { + uid: 'enrollmentId2', + enrollment_time: now.toISOString(), + phone_number: '+16505556789', + factor_id: 'phone', + }, + ], + }, +}; describe('identity', () => { + before(() => { + process.env.GCLOUD_PROJECT = PROJECT; + }); + + after(() => { + delete process.env.GCLOUD_PROJECT; + }); + describe('userRecordConstructor', () => { it('will provide falsey values for fields that are not in raw wire data', () => { const record = identity.userRecordConstructor({ uid: '123' }); @@ -85,4 +167,264 @@ describe('identity', () => { }); }); }); + + describe('validRequest', () => { + it('should error on non-post', () => { + const req = ({ + method: 'GET', + header: { + 'Content-Type': 'application/json', + }, + body: { + data: { + jwt: '1.2.3', + }, + }, + } as unknown) as express.Request; + + expect(() => identity.validRequest(req)).to.throw( + 'Request has invalid method "GET".' + ); + }); + + it('should error on bad Content-Type', () => { + const req = ({ + method: 'POST', + header(val: string) { + return 'text/css'; + }, + body: { + data: { + jwt: '1.2.3', + }, + }, + } as unknown) as express.Request; + + expect(() => identity.validRequest(req)).to.throw( + 'Request has invalid header Content-Type.' + ); + }); + + it('should error without req body', () => { + const req = ({ + method: 'POST', + header(val: string) { + return 'application/json'; + }, + } as unknown) as express.Request; + + expect(() => identity.validRequest(req)).to.throw( + 'Request has an invalid body.' + ); + }); + + it('should error without req body data', () => { + const req = ({ + method: 'POST', + header(val: string) { + return 'application/json'; + }, + body: {}, + } as unknown) as express.Request; + + expect(() => identity.validRequest(req)).to.throw( + 'Request has an invalid body.' + ); + }); + + it('should error without req body', () => { + const req = ({ + method: 'POST', + header(val: string) { + return 'application/json'; + }, + body: { + data: {}, + }, + } as unknown) as express.Request; + + expect(() => identity.validRequest(req)).to.throw( + 'Request has an invalid body.' + ); + }); + + it('should not error on valid request', () => { + const req = ({ + method: 'POST', + header(val: string) { + return 'application/json'; + }, + body: { + data: { + jwt: '1.2.3', + }, + }, + } as unknown) as express.Request; + + expect(() => identity.validRequest(req)).to.not.throw(); + }); + }); + + describe('getPublicKey', () => { + it('should throw if header.alg is not expected', () => { + expect(() => identity.getPublicKey({ alg: 'RS128' }, {})).to.throw( + `Provided JWT has incorrect algorithm. Expected ${identity.JWT_ALG} but got RS128.` + ); + }); + + it('should throw if header.kid is undefined', () => { + expect(() => + identity.getPublicKey({ alg: identity.JWT_ALG }, {}) + ).to.throw('JWT has no "kid" claim.'); + }); + + it('should throw if the public keys do not have a property that matches header.kid', () => { + expect(() => + identity.getPublicKey( + { + alg: identity.JWT_ALG, + kid: '123456', + }, + {} + ) + ).to.throw( + 'Provided JWT has "kid" claim which does not correspond to a known public key. Most likely the JWT is expired.' + ); + }); + + it('should return the correct public key', () => { + expect( + identity.getPublicKey( + { + alg: identity.JWT_ALG, + kid: '123456', + }, + { + '123456': '7890', + '2468': '1357', + } + ) + ).to.eq('7890'); + }); + }); + + describe('isAuthorizedCloudFunctionURL', () => { + it('should return false on a bad gcf direction', () => { + expect( + identity.isAuthorizedCloudFunctionURL( + `https://us-central1-europe-${PROJECT}.cloudfunctions.net/function-1`, + PROJECT + ) + ).to.be.false; + }); + + it('should return false on a bad project', () => { + expect( + identity.isAuthorizedCloudFunctionURL( + `https://us-central1-${PROJECT}-old.cloudfunctions.net/function-1`, + PROJECT + ) + ).to.be.false; + }); + + it('should return true on a good url', () => { + expect(identity.isAuthorizedCloudFunctionURL(VALID_URL, PROJECT)).to.be + .true; + }); + }); + + describe('checkDecodedToken', () => { + it('should throw on unauthorized function url', () => { + expect(() => + identity.checkDecodedToken( + { + aud: `fake-region-${PROJECT}.cloudfunctions.net/fn1`, + } as identity.DecodedJwt, + PROJECT + ) + ).to.throw('Provided JWT has incorrect "aud" (audience) claim.'); + }); + + it('should throw on a bad iss property', () => { + expect(() => + identity.checkDecodedToken( + { + aud: VALID_URL, + iss: `https://someissuer.com/a-project`, + } as identity.DecodedJwt, + PROJECT + ) + ).to.throw( + `Provided JWT has incorrect "iss" (issuer) claim. Expected "${identity.JWT_ISSUER}${PROJECT}" but got "https://someissuer.com/a-project".` + ); + }); + + it('should throw if sub is not a string', () => { + expect(() => + identity.checkDecodedToken( + ({ + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + sub: { + key: 'val', + }, + } as unknown) as identity.DecodedJwt, + PROJECT + ) + ).to.throw('Provided JWT has no "sub" (subject) claim.'); + }); + + it('should throw if sub is empty', () => { + expect(() => + identity.checkDecodedToken( + { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + sub: '', + } as identity.DecodedJwt, + PROJECT + ) + ).to.throw('Provided JWT has no "sub" (subject) claim.'); + }); + + it('should throw if sub length is larger than 128 chars', () => { + const str = []; + for (let i = 0; i < 129; i++) { + str.push(i); + } + expect(() => + identity.checkDecodedToken( + { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + sub: str.toString(), + } as identity.DecodedJwt, + PROJECT + ) + ).to.throw( + 'Provided JWT has "sub" (subject) claim longer than 128 characters.' + ); + }); + + it('should not throw an error on correct decoded token', () => { + expect(() => + identity.checkDecodedToken( + { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + sub: '123456', + } as identity.DecodedJwt, + PROJECT + ) + ).to.not.throw(); + }); + }); + + describe('parseUserRecord', () => { + it('should parse user record', () => { + expect(() => + identity.parseUserRecord(DECODED_USER_RECORD) + ).to.not.throw(); + }); + }); }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 24a4e2c34..5d1e1c305 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -20,8 +20,33 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import * as express from 'express'; import * as firebase from 'firebase-admin'; import * as _ from 'lodash'; +import * as jwt from 'jsonwebtoken'; +import fetch from 'node-fetch'; +import { HttpsError } from './https'; +import { EventContext } from '../../cloud-functions'; +import { logger } from '../..'; + +export { HttpsError }; + +/* API Constants */ +/** @internal */ +export const JWT_CLIENT_CERT_URL = 'https://www.googleapis.com'; +/** @internal */ +export const JWT_CLIENT_CERT_PATH = + 'robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; +/** @internal */ +export const JWT_ALG = 'RS256'; +/** @internal */ +export const JWT_ISSUER = 'https://securetoken.google.com/'; + +/** @internal */ +const EVENT_MAPPING: Record = { + beforeCreate: 'providers/cloud.auth/eventTypes/user.beforeCreate', + beforeSignIn: 'providers/cloud.auth/eventTypes/user.beforeSignIn', +}; /** * The UserRecord passed to Cloud Functions is the same UserRecord that is returned by the Firebase Admin @@ -34,6 +59,136 @@ export type UserRecord = firebase.auth.UserRecord; */ export type UserInfo = firebase.auth.UserInfo; +/** Defines the Auth event context. */ +export interface AuthEventContext extends EventContext { + locale?: string; + ipAddress: string; + userAgent: string; + additionalUserInfo?: { + providerId: string; + profile?: any; + username?: string; + isNewUser: boolean; + }; + credential?: { + claims?: { [key: string]: any }; + idToken?: string; + accessToken?: string; + refreshToken?: string; + expirationTime?: string; + secret?: string; + providerId: string; + signInMethod: string; + }; +} + +export interface BeforeCreateResponse { + displayName?: string; + disabled?: boolean; + emailVerified?: boolean; + photoURL?: string; + customClaims?: object; +} + +export interface BeforeSignInResponse extends BeforeCreateResponse { + sessionClaims?: object; +} + +/** @internal */ +interface DecodedJwtMetadata { + creation_time?: number; + last_sign_in_time?: number; +} + +/** @internal */ +interface DecodedJwtProviderUserInfo { + uid: string; + display_name?: string; + email?: string; + photo_url?: string; + phone_number?: string; + provider_id: string; +} + +/** @internal */ +interface DecodedJwtMfaInfo { + uid: string; + display_name?: string; + phone_number?: string; + enrollment_time?: string; + factor_id?: string; +} + +/** @internal */ +interface DecodedJwtEnrolledFactors { + enrolled_factors?: DecodedJwtMfaInfo[]; +} + +/** @internal */ +interface DecodedJwtUserRecord { + uid: string; + email?: string; + email_verified?: boolean; + phone_number?: string; + display_name?: string; + photo_url?: string; + disabled?: boolean; + metadata?: DecodedJwtMetadata; + password_hash?: string; + password_salt?: string; + provider_data?: DecodedJwtProviderUserInfo[]; + multi_factor?: DecodedJwtEnrolledFactors; + custom_claims?: any; + tokens_valid_after_time?: number; + tenant_id?: string; + [key: string]: any; +} + +/** Defines HTTP event JWT. */ +/** @internal */ +export interface DecodedJwt { + aud: string; + exp: number; + iat: number; + iss: string; + sub: string; + event_id: string; + event_type: string; + ip_address: string; + user_agent?: string; + locale?: string; + sign_in_method?: string; + user_record?: DecodedJwtUserRecord; + tenant_id?: string; + raw_user_info?: string; + sign_in_attributes?: { + [key: string]: any; + }; + oauth_id_token?: string; + oauth_access_token?: string; + oauth_refresh_token?: string; + oauth_token_secret?: string; + oauth_expires_in?: number; + [key: string]: any; +} + +/** @internal */ +export async function fetchPublicKeys( + publicKeys: Record +): Promise> { + const url = `${JWT_CLIENT_CERT_URL}/${JWT_CLIENT_CERT_PATH}`; + try { + const response = await fetch(url); + const data = await response.json(); + return data as Record; + } catch (err) { + logger.error( + `Failed to obtain public keys for JWT verification: ${err.message}` + ); + throw new HttpsError('internal', 'Failed to obtain public keys'); + } +} + /** * Helper class to create the user metadata in a UserRecord object */ @@ -68,6 +223,7 @@ export function userRecordConstructor(wireData: Object): UserRecord { passwordSalt: null, passwordHash: null, tokensValidAfterTime: null, + tenantId: null, }; const record = _.assign({}, falseyValues, wireData); @@ -109,3 +265,586 @@ export function userRecordConstructor(wireData: Object): UserRecord { }); return record as UserRecord; } + +/** @internal */ +export function validRequest(req: express.Request): void { + if (req.method !== 'POST') { + throw new HttpsError( + 'invalid-argument', + `Request has invalid method "${req.method}".` + ); + } + + const contentType: string = (req.header('Content-Type') || '').toLowerCase(); + if (!contentType.includes('application/json')) { + throw new HttpsError( + 'invalid-argument', + 'Request has invalid header Content-Type.' + ); + } + + if (!req.body || !req.body.data || !req.body.data.jwt) { + throw new HttpsError('invalid-argument', 'Request has an invalid body.'); + } +} + +/** @internal */ +export function getPublicKey( + header: Record, + publicKeys: Record +): string { + if (header.alg !== JWT_ALG) { + throw new HttpsError( + 'invalid-argument', + `Provided JWT has incorrect algorithm. Expected ${JWT_ALG} but got ${header.alg}.` + ); + } + if (!header.kid) { + throw new HttpsError('invalid-argument', 'JWT has no "kid" claim.'); + } + if (!publicKeys.hasOwnProperty(header.kid)) { + throw new HttpsError( + 'invalid-argument', + 'Provided JWT has "kid" claim which does not correspond to a known public key. Most likely the JWT is expired.' + ); + } + + return publicKeys[header.kid]; +} + +/** @internal */ +export function isAuthorizedCloudFunctionURL( + cloudFunctionUrl: string, + projectId: string +): boolean { + // Region can be: + // us-central1, us-east1, asia-northeast1, europe-west1, asia-east1. + // Sample: https://europe-west1-fb-sa-upgraded.cloudfunctions.net/function-1 + const gcf_directions = [ + 'central', + 'east', + 'west', + 'south', + 'southeast', + 'northeast', + // Other possible directions that could be added. + 'north', + 'southwest', + 'northwest', + ]; + const re = new RegExp( + `^https://[^-]+-(${gcf_directions.join( + '|' + )})[0-9]+-${projectId}\.cloudfunctions\.net/` + ); + const res = re.exec(cloudFunctionUrl) || []; + return res.length > 0; +} + +/** @internal */ +export function checkDecodedToken( + decodedJWT: DecodedJwt, + projectId: string +): void { + if (!isAuthorizedCloudFunctionURL(decodedJWT.aud, projectId)) { + throw new HttpsError( + 'invalid-argument', + 'Provided JWT has incorrect "aud" (audience) claim.' + ); + } + if (decodedJWT.iss !== `${JWT_ISSUER}${projectId}`) { + throw new HttpsError( + 'invalid-argument', + `Provided JWT has incorrect "iss" (issuer) claim. Expected ` + + `"${JWT_ISSUER}${projectId}" but got "${decodedJWT.iss}".` + ); + } + if (typeof decodedJWT.sub !== 'string' || decodedJWT.sub.length === 0) { + throw new HttpsError( + 'invalid-argument', + 'Provided JWT has no "sub" (subject) claim.' + ); + } + if (decodedJWT.sub.length > 128) { + throw new HttpsError( + 'invalid-argument', + 'Provided JWT has "sub" (subject) claim longer than 128 characters.' + ); + } +} + +/** @internal */ +function verifyAndDecodeJWT( + token: string, + eventType: string, + publicKeys: Record +) { + // jwt decode & verify - https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback + const header = + (jwt.decode(token, { complete: true }) as Record).header || {}; + const publicKey = getPublicKey(header, publicKeys); + const decoded = jwt.verify(token, publicKey, { + algorithms: [this.algorithm], + }) as DecodedJwt; + decoded.uid = decoded.sub; + checkDecodedToken(decoded, process.env.GCLOUD_PROJECT); + + if (decoded.event_type !== eventType) { + throw new HttpsError( + 'invalid-argument', + `Expected "${eventType}" but received "${decoded.event_type}".` + ); + } + return decoded; +} + +/** @internal */ +export function parseUserRecord( + decodedJWTUserRecord: DecodedJwtUserRecord +): UserRecord { + const parseMetadata = function(metadata: DecodedJwtMetadata) { + const creationTime = metadata?.creation_time + ? ( + (decodedJWTUserRecord.metadata.creation_time as number) * 1000 + ).toString() + : null; + const lastSignInTime = metadata?.last_sign_in_time + ? ( + (decodedJWTUserRecord.metadata.last_sign_in_time as number) * 1000 + ).toString() + : null; + return { + creationTime: creationTime, + lastSignInTime: lastSignInTime, + toJSON(): () => object { + const json: any = { + creationTime: creationTime, + lastSignInTime: lastSignInTime, + }; + return json; + }, + }; + }; + + const parseProviderData = function( + providerData: DecodedJwtProviderUserInfo[] + ): UserInfo[] { + const providers: UserInfo[] = []; + for (const provider of providerData) { + const info: UserInfo = { + uid: provider.uid, + displayName: provider.display_name, + email: provider.email, + photoURL: provider.photo_url, + providerId: provider.provider_id, + phoneNumber: provider.phone_number, + toJSON(): () => object { + const json: any = { + uid: provider.uid, + displayName: provider.display_name, + email: provider.email, + photoURL: provider.photo_url, + providerId: provider.provider_id, + phoneNumber: provider.phone_number, + }; + return json; + }, + }; + providers.push(info); + } + return providers; + }; + + const parseDate = function(tokensValidAfterTime: number) { + if (!tokensValidAfterTime) { + return null; + } + tokensValidAfterTime = tokensValidAfterTime * 1000; + try { + const date = new Date(parseInt(tokensValidAfterTime.toString(), 10)); + if (!isNaN(date.getTime())) { + return date.toUTCString(); + } + } catch { + return null; + } + return null; + }; + + const parseMultifactor = function(multiFactor: DecodedJwtEnrolledFactors) { + if (!multiFactor) { + return null; + } + const parsedEnrolledFactors = []; + for (const factor of multiFactor.enrolled_factors || []) { + if (factor.factor_id && factor.uid) { + const enrollmentTime = factor.enrollment_time + ? new Date(factor.enrollment_time).toUTCString() + : null; + const multiFactorInfo = { + uid: factor.uid, + factorId: factor.factor_id, + displayName: factor.display_name, + enrollmentTime: enrollmentTime, + phoneNumber: factor.phone_number, + toJSON: function(): object { + const json: any = { + uid: factor.uid, + factorId: factor.factor_id, + displayName: factor.display_name, + enrollmentTime: enrollmentTime, + phoneNumber: factor.phone_number, + }; + return json; + }, + }; + parsedEnrolledFactors.push(multiFactorInfo); + } else { + throw new HttpsError( + 'internal', + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response' + ); + } + } + + if (parsedEnrolledFactors.length > 0) { + const multiFactor = { + enrolledFactors: parsedEnrolledFactors, + toJSON: function(): object { + const json: any = { + enrolledFactors: parsedEnrolledFactors, + }; + return json; + }, + }; + return multiFactor; + } + return null; + }; + + if (!decodedJWTUserRecord.uid) { + throw new HttpsError( + 'internal', + 'INTERNAL ASSERT FAILED: Invalid user response' + ); + } + + const disabled = decodedJWTUserRecord.disabled || false; + + const userRecord: UserRecord = { + uid: decodedJWTUserRecord.uid, + email: decodedJWTUserRecord.email, + emailVerified: decodedJWTUserRecord.email_verified, + displayName: decodedJWTUserRecord.display_name, + photoURL: decodedJWTUserRecord.photo_url, + phoneNumber: decodedJWTUserRecord.phone_number, + disabled: disabled, + metadata: parseMetadata(decodedJWTUserRecord.metadata), + providerData: parseProviderData(decodedJWTUserRecord.provider_data), + passwordHash: decodedJWTUserRecord.password_hash, + passwordSalt: decodedJWTUserRecord.password_salt, + customClaims: decodedJWTUserRecord.custom_claims, + tenantId: decodedJWTUserRecord.tenant_id, + tokensValidAfterTime: parseDate( + decodedJWTUserRecord.tokens_valid_after_time + ), + multiFactor: parseMultifactor(decodedJWTUserRecord.multi_factor), + toJSON: function(): object { + const json: any = { + uid: decodedJWTUserRecord.uid, + email: decodedJWTUserRecord.email, + emailVerified: decodedJWTUserRecord.email_verified, + displayName: decodedJWTUserRecord.display_name, + photoURL: decodedJWTUserRecord.photo_url, + phoneNumber: decodedJWTUserRecord.phone_number, + disabled: disabled, + metadata: parseMetadata(decodedJWTUserRecord.metadata), + providerData: parseProviderData(decodedJWTUserRecord.provider_data), + passwordHash: decodedJWTUserRecord.password_hash, + passwordSalt: decodedJWTUserRecord.password_salt, + customClaims: decodedJWTUserRecord.custom_claims, + tenantId: decodedJWTUserRecord.tenant_id, + tokensValidAfterTime: parseDate( + decodedJWTUserRecord.tokens_valid_after_time + ), + multiFactor: parseMultifactor(decodedJWTUserRecord.multi_factor), + }; + return json; + }, + }; + + return userRecordConstructor(userRecord); +} + +/** @internal */ +export function parseAuthEventContext( + decodedJWT: DecodedJwt +): AuthEventContext { + if ( + !decodedJWT.sign_in_attributes && + !decodedJWT.oauth_id_token && + !decodedJWT.oauth_access_token && + !decodedJWT.oauth_refresh_token + ) { + throw new HttpsError( + 'internal', + `Not enough info in JWT for parsing auth credential.` + ); + } + const eventType = + EVENT_MAPPING[decodedJWT.event_type] || decodedJWT.event_type; + let profile, username; + if (decodedJWT.raw_user_info) + try { + profile = JSON.parse(decodedJWT.raw_user_info); + } catch (err) { + logger.debug(`Parse Error: ${err.message}`); + } + if (profile) { + if (decodedJWT.sign_in_method === 'github.com') { + username = profile.login; + } + if (decodedJWT.sign_in_method === 'twitter.com') { + username = profile.screen_name; + } + } + + const authEventContext: AuthEventContext = { + locale: decodedJWT.locale, + ipAddress: decodedJWT.ip_address, + userAgent: decodedJWT.user_agent, + eventId: decodedJWT.event_id, + eventType: + eventType + + (decodedJWT.sign_in_method ? `:${decodedJWT.sign_in_method}` : ''), + authType: !!decodedJWT.user_record ? 'USER' : 'UNAUTHENTICATED', + resource: { + service: '', // TODO(colerogers): figure out the service + name: !!decodedJWT.tenant_id + ? `projects/${this.projectId}/tenants/${decodedJWT.tenant_id}` + : `projects/${this.projectId}`, + }, + timestamp: new Date(decodedJWT.iat * 1000).toUTCString(), + additionalUserInfo: { + providerId: + decodedJWT.sign_in_method === 'emailLink' + ? 'password' + : decodedJWT.sign_in_method, + profile, + username, + isNewUser: decodedJWT.event_type === 'beforeCreate' ? true : false, + }, + credential: { + claims: decodedJWT.sign_in_attributes, + idToken: decodedJWT.oauth_id_token, + accessToken: decodedJWT.oauth_access_token, + refreshToken: decodedJWT.oauth_refresh_token, + expirationTime: decodedJWT.oauth_expires_in + ? new Date( + new Date().getTime() + decodedJWT.oauth_expires_in * 1000 + ).toUTCString() + : undefined, + secret: decodedJWT.oauth_token_secret, + providerId: + decodedJWT.sign_in_method === 'emailLink' + ? 'password' + : decodedJWT.sign_in_method, + signInMethod: decodedJWT.sign_in_method, + }, + params: {}, + }; + return authEventContext; +} + +/** @internal */ +export function validateAuthRequest(eventType: string, authRequest?: any) { + const nonAllowListedClaims = [ + 'acr', + 'amr', + 'at_hash', + 'aud', + 'auth_time', + 'azp', + 'cnf', + 'c_hash', + 'exp', + 'iat', + 'iss', + 'jti', + 'nbf', + 'nonce', + 'firebase', + ]; + const claimsMaxPayloadSize = 1000; + + if (!authRequest) { + authRequest = {}; + } + if (authRequest.customClaims) { + const invalidClaims = nonAllowListedClaims.filter((claim) => + authRequest.customClaims.hasOwnProperty(claim) + ); + if (invalidClaims.length > 0) { + throw new HttpsError( + 'invalid-argument', + `customClaims claims "${invalidClaims.join( + ',' + )}" are reserved and cannot be specified.` + ); + } + } + if (authRequest.sessionClaims) { + const invalidClaims = nonAllowListedClaims.filter((claim) => + authRequest.sessionClaims.hasOwnProperty(claim) + ); + if (invalidClaims.length > 0) { + throw new HttpsError( + 'invalid-argument', + `customClaims claims "${invalidClaims.join( + ',' + )}" are reserved and cannot be specified.` + ); + } + } + const combinedClaims = { + ...authRequest.customClaims, + ...authRequest.sessionClaims, + }; + if (JSON.stringify(combinedClaims).length > claimsMaxPayloadSize) { + throw new HttpsError( + 'invalid-argument', + `The claims payload should not exceed ${claimsMaxPayloadSize} characters.` + ); + } +} + +/** @internal */ +export function createHandler( + handler: (user: UserRecord, context: AuthEventContext) => any, + eventType: string +): (req: express.Request, resp: express.Response) => Promise { + const wrappedHandler = wrapHandler(handler, eventType); + return (req: express.Request, res: express.Response) => { + return new Promise((resolve) => { + res.on('finish', resolve); + resolve(wrappedHandler(req, res)); + }); + }; +} + +/** @internal */ +function wrapHandler( + handler: ( + user: UserRecord, + context: AuthEventContext + ) => + | BeforeCreateResponse + | Promise + | BeforeSignInResponse + | Promise + | void + | Promise, + eventType: string + // publicKeys: Record = {} +) { + return async (req: express.Request, res: express.Response): Promise => { + try { + const publicKeys = await fetchPublicKeys({}); + validRequest(req); + const decodedJWT = verifyAndDecodeJWT( + req.body.data.jwt, + eventType, + publicKeys + ); + const userRecord = parseUserRecord(decodedJWT.user_record); + const authEventContext = parseAuthEventContext(decodedJWT); + const authRequest = + (await handler(userRecord, authEventContext)) || undefined; + validateAuthRequest(eventType, authRequest); + const updateMask = generateUpdateMask(authRequest, { + customClaims: true, + sessionClaims: true, + }); + const result = { + userRecord: { + ...authRequest, + updateMask: updateMask.join(','), + }, + }; + // const result = encode(finalizedRequest); + + res.status(200); + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(result)); + } catch (err) { + if (!(err instanceof HttpsError)) { + // This doesn't count as an 'explicit' error. + logger.error('Unhandled error', err); + err = new HttpsError('internal', 'An unexpected error occurred.'); + } + + res.status(err.code); + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(err.toJSON())); + } + }; +} + +/** + * Generates the update mask for the provided object. + * Note this will ignore the last key with value undefined. + * + * @param obj The object to generate the update mask for. + * @param maxPaths The optional map of keys for maximum paths to traverse. + * Nested objects beyond that path will be ignored. + * @param currentPath The path so far. + * @return The computed update mask list. + */ +export function generateUpdateMask( + obj: any, + maxPaths: { [key: string]: boolean } = {}, + currentPath: string = '' +): string[] { + const updateMask: string[] = []; + if (!obj) { + return updateMask; + } + for (const key in obj) { + if (obj.hasOwnProperty(key) && typeof obj[key] !== 'undefined') { + const nextPath = currentPath ? currentPath + '.' + key : key; + // We hit maximum path. + if (maxPaths[nextPath]) { + // Add key and stop traversing this branch. + updateMask.push(key); + } else { + let maskList: string[] = []; + maskList = generateUpdateMask(obj[key], maxPaths, nextPath); + if (maskList.length > 0) { + maskList.forEach((mask) => { + updateMask.push(`${key}.${mask}`); + }); + } else { + updateMask.push(key); + } + } + } + } + return updateMask; +} + +/** + * Returns a copy of the object with all key/value pairs having undefined values removed. + * @param obj The input object. + * @return obj The processed object with all key/value pairs having undefined values removed. + */ +export function removeUndefinedProperties(obj: { + [key: string]: any; +}): { [key: string]: any } { + const filteredObj = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key) && typeof obj[key] !== 'undefined') { + filteredObj[key] = obj[key]; + } + } + return filteredObj; +} From 46fa3cad6a6647ed0cc6927f34712c9a72f923da Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 3 Feb 2022 10:37:59 -0500 Subject: [PATCH 04/18] breaking out parsing functions and adding a bunch of tests --- spec/common/providers/identity.spec.ts | 596 ++++++++++++++++++++++--- src/common/providers/identity.ts | 568 +++++++++++++---------- 2 files changed, 866 insertions(+), 298 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 0376bb88c..f8edb14a9 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -26,85 +26,20 @@ import * as express from 'express'; import * as identity from '../../../src/common/providers/identity'; import { expect } from 'chai'; -// import { MockRequest } from '../../fixtures/mockrequest'; const PROJECT = 'my-project'; const VALID_URL = `https://us-central1-${PROJECT}.cloudfunctions.net/function-1`; const now = new Date(); -const DECODED_USER_RECORD = { - uid: 'abcdefghijklmnopqrstuvwxyz', - email: 'user@gmail.com', - email_verified: true, - display_name: 'John Doe', - phone_number: '+11234567890', - provider_data: [ - { - provider_id: 'google.com', - display_name: 'John Doe', - photo_url: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', - email: 'user@gmail.com', - uid: '1234567890', - }, - { - provider_id: 'facebook.com', - display_name: 'John Smith', - photo_url: 'https://facebook.com/0987654321/photo.jpg', - email: 'user@facebook.com', - uid: '0987654321', - }, - { - provider_id: 'phone', - uid: '+11234567890', - phone_number: '+11234567890', - }, - { - provider_id: 'password', - email: 'user@gmail.com', - uid: 'user@gmail.com', - display_name: 'John Doe', - }, - ], - password_hash: 'passwordHash', - password_salt: 'passwordSalt', - photo_url: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', - tokens_valid_after_time: 1476136676, - metadata: { - last_sign_in_time: 1476235905, - creation_time: 1476136676, - }, - custom_claims: { - admin: true, - group_id: 'group123', - }, - tenant_id: 'TENANT_ID', - multi_factor: { - enrolled_factors: [ - { - uid: 'enrollmentId1', - display_name: 'displayName1', - enrollment_time: now.toISOString(), - phone_number: '+16505551234', - factor_id: 'phone', - }, - { - uid: 'enrollmentId2', - enrollment_time: now.toISOString(), - phone_number: '+16505556789', - factor_id: 'phone', - }, - ], - }, -}; describe('identity', () => { - before(() => { - process.env.GCLOUD_PROJECT = PROJECT; - }); + // before(() => { + // process.env.GCLOUD_PROJECT = PROJECT; + // }); - after(() => { - delete process.env.GCLOUD_PROJECT; - }); + // after(() => { + // delete process.env.GCLOUD_PROJECT; + // }); describe('userRecordConstructor', () => { it('will provide falsey values for fields that are not in raw wire data', () => { @@ -420,11 +355,524 @@ describe('identity', () => { }); }); + describe('parseMetadata', () => { + const decodedMetadata = { + last_sign_in_time: 1476235905, + creation_time: 1476136676, + }; + const metadata = { + lastSignInTime: new Date(1476235905000).toUTCString(), + creationTime: new Date(1476136676000).toUTCString(), + }; + + it('should parse an undefined object', () => { + expect(identity.parseMetadata({})).to.deep.equal({ + creationTime: null, + lastSignInTime: null, + }); + }); + + it('should parse a decoded metadata object', () => { + const md = identity.parseMetadata(decodedMetadata); + + expect(md.toJSON()).to.deep.equal(metadata); + }); + }); + + describe('parseProviderData', () => { + const decodedUserInfo = { + provider_id: 'google.com', + display_name: 'John Doe', + photo_url: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + uid: '1234567890', + email: 'user@gmail.com', + }; + const userInfo = { + providerId: 'google.com', + displayName: 'John Doe', + photoURL: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + uid: '1234567890', + email: 'user@gmail.com', + phoneNumber: undefined, + }; + const decodedUserInfoPhone = { + provider_id: 'phone', + phone_number: '+11234567890', + uid: '+11234567890', + }; + const userInfoPhone = { + providerId: 'phone', + displayName: undefined, + photoURL: undefined, + uid: '+11234567890', + email: undefined, + phoneNumber: '+11234567890', + }; + + it('should parse the user info', () => { + expect(identity.parseProviderData([decodedUserInfo])).to.deep.equal([userInfo]); + }); + + it('should parse the user info with phone', () => { + expect(identity.parseProviderData([decodedUserInfoPhone])).to.deep.equal([userInfoPhone]); + }); + }); + + describe('parseDate', () => { + it('should return null if tokens undefined', () => { + expect(identity.parseDate()).to.be.null; + }); + + it('should parse the date', () => { + expect(identity.parseDate(1476136676)).to.equal(new Date(1476136676000).toUTCString()) + }); + }); + + describe('parseMultiFactor', () => { + const decodedMultiFactors = { + enrolled_factors: [ + { + uid: 'enrollmentId1', + display_name: 'displayName1', + enrollment_time: now.toISOString(), + phone_number: '+16505551234', + }, + { + uid: 'enrollmentId2', + enrollment_time: now.toISOString(), + }, + ] + }; + const multiFactors = { + enrolledFactors: [ + { + uid: 'enrollmentId1', + displayName: 'displayName1', + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505551234', + factorId: 'phone', + }, + { + uid: 'enrollmentId2', + displayName: undefined, + enrollmentTime: now.toUTCString(), + factorId: undefined, + }, + ], + }; + + it('should return null on undefined factor', () => { + expect(identity.parseMultiFactor()).to.be.null; + }); + + it('should return null without enrolled factors', () => { + expect(identity.parseMultiFactor({})).to.be.null; + }); + + it('should error on an invalid factor', () => { + const factors = { + enrolled_factors: [{} as identity.DecodedJwtMfaInfo] + } + + expect(() => identity.parseMultiFactor(factors)).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + + it('should correctly parse factors', () => { + expect(identity.parseMultiFactor(decodedMultiFactors)).to.deep.equal(multiFactors); + }) + }); + describe('parseUserRecord', () => { + const decodedUserRecord = { + uid: 'abcdefghijklmnopqrstuvwxyz', + email: 'user@gmail.com', + email_verified: true, + display_name: 'John Doe', + phone_number: '+11234567890', + provider_data: [ + { + provider_id: 'google.com', + display_name: 'John Doe', + photo_url: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + email: 'user@gmail.com', + uid: '1234567890', + }, + { + provider_id: 'facebook.com', + display_name: 'John Smith', + photo_url: 'https://facebook.com/0987654321/photo.jpg', + email: 'user@facebook.com', + uid: '0987654321', + }, + { + provider_id: 'phone', + uid: '+11234567890', + phone_number: '+11234567890', + }, + { + provider_id: 'password', + email: 'user@gmail.com', + uid: 'user@gmail.com', + display_name: 'John Doe', + }, + ], + password_hash: 'passwordHash', + password_salt: 'passwordSalt', + photo_url: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + tokens_valid_after_time: 1476136676, + metadata: { + last_sign_in_time: 1476235905, + creation_time: 1476136676, + }, + custom_claims: { + admin: true, + group_id: 'group123', + }, + tenant_id: 'TENANT_ID', + multi_factor: { + enrolled_factors: [ + { + uid: 'enrollmentId1', + display_name: 'displayName1', + enrollment_time: now.toISOString(), + phone_number: '+16505551234', + factor_id: 'phone', + }, + { + uid: 'enrollmentId2', + enrollment_time: now.toISOString(), + phone_number: '+16505556789', + factor_id: 'phone', + }, + ], + }, + }; + + const userRecord = { + uid: 'abcdefghijklmnopqrstuvwxyz', + email: 'user@gmail.com', + phoneNumber: '+11234567890', + emailVerified: true, + disabled: false, + displayName: 'John Doe', + providerData: [ + { + providerId: 'google.com', + displayName: 'John Doe', + photoURL: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + email: 'user@gmail.com', + uid: '1234567890', + phoneNumber: undefined, + }, + { + providerId: 'facebook.com', + displayName: 'John Smith', + photoURL: 'https://facebook.com/0987654321/photo.jpg', + email: 'user@facebook.com', + uid: '0987654321', + phoneNumber: undefined, + }, + { + providerId: 'phone', + displayName: undefined, + photoURL: undefined, + email: undefined, + uid: '+11234567890', + phoneNumber: '+11234567890', + }, + { + providerId: 'password', + displayName: 'John Doe', + photoURL: undefined, + email: 'user@gmail.com', + uid: 'user@gmail.com', + phoneNumber: undefined, + }, + ], + passwordHash: 'passwordHash', + passwordSalt: 'passwordSalt', + photoURL: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + metadata: { + lastSignInTime: new Date(1476235905000).toUTCString(), + creationTime: new Date(1476136676000).toUTCString(), + }, + customClaims: { + admin: true, + group_id: 'group123', + }, + tokensValidAfterTime: new Date(1476136676000).toUTCString(), + tenantId: 'TENANT_ID', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrollmentId1', + displayName: 'displayName1', + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505551234', + factorId: 'phone', + }, + { + uid: 'enrollmentId2', + displayName: null, + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505556789', + factorId: 'phone', + }, + ], + }, + }; + + it('should error if decoded does not have uid', () => { + expect(() => identity.parseUserRecord({} as identity.DecodedJwtUserRecord)).to.throw('INTERNAL ASSERT FAILED: Invalid user response'); + }); + it('should parse user record', () => { - expect(() => - identity.parseUserRecord(DECODED_USER_RECORD) - ).to.not.throw(); + const ur = identity.parseUserRecord(decodedUserRecord); + + expect(ur.toJSON()).to.deep.equal(userRecord); + }); + }); + + describe('parseAuthEventContext', () => { + const rawUserInfo = { + name: 'John Doe', + granted_scopes: 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile', + id: '123456789', + verified_email: true, + given_name: 'John', + locale: 'en', + family_name: 'Doe', + email: 'johndoe@gmail.com', + picture: 'https://lh3.googleusercontent.com/1233456789/mo/photo.jpg', + }; + + it('should parse an unknown event', () => { + const decodedJwt = { + aud: 'https://us-east1-project_id.cloudfunctions.net/function-1', + exp: 60 * 60 + 1, + iat: 1, + iss: 'https://securetoken.google.com/project_id', + sub: 'someUid', + uid: 'someUid', + event_id: 'EVENT_ID', + event_type: 'EVENT_TYPE', + ip_address: '1.2.3.4', + user_agent: 'USER_AGENT', + locale: 'en', + raw_user_info: JSON.stringify(rawUserInfo), + }; + const context = { + locale: 'en', + ipAddress: '1.2.3.4', + userAgent: 'USER_AGENT', + eventId: 'EVENT_ID', + eventType: 'EVENT_TYPE', + authType: 'UNAUTHENTICATED', + resource: { + service: 'identitytoolkit.googleapis.com', + name: 'projects/project-id', + }, + timestamp: new Date(1000).toUTCString(), + additionalUserInfo: { + providerId: undefined, + profile: rawUserInfo, + username: undefined, + isNewUser: false, + }, + credential: null, + params: {}, + }; + + expect(identity.parseAuthEventContext(decodedJwt, "project-id")).to.deep.equal(context); + }); + + it('should parse a beforeSignIn event', () => { + const time = now.getTime(); + const decodedJwt = { + aud: 'https://us-east1-project_id.cloudfunctions.net/function-1', + exp: 60 * 60 + 1, + iat: 1, + iss: 'https://securetoken.google.com/project_id', + sub: 'someUid', + uid: 'someUid', + event_id: 'EVENT_ID', + event_type: 'beforeSignIn', + ip_address: '1.2.3.4', + user_agent: 'USER_AGENT', + locale: 'en', + sign_in_method: 'password', + raw_user_info: JSON.stringify(rawUserInfo), + oauth_id_token: 'ID_TOKEN', + oauth_access_token: 'ACCESS_TOKEN', + oauth_refresh_token: 'REFRESH_TOKEN', + oauth_token_secret: 'OAUTH_TOKEN_SECRET', + oauth_expires_in: 3600, + }; + const context = { + locale: 'en', + ipAddress: '1.2.3.4', + userAgent: 'USER_AGENT', + eventId: 'EVENT_ID', + eventType: 'providers/cloud.auth/eventTypes/user.beforeSignIn:password', + authType: 'UNAUTHENTICATED', + resource: { + service: 'identitytoolkit.googleapis.com', + name: 'projects/project-id', + }, + timestamp: new Date(1000).toUTCString(), + additionalUserInfo: { + providerId: 'password', + profile: rawUserInfo, + username: undefined, + isNewUser: false, + }, + credential: { + claims: undefined, + idToken: 'ID_TOKEN', + accessToken: 'ACCESS_TOKEN', + refreshToken: 'REFRESH_TOKEN', + expirationTime: (new Date(time + 3600 * 1000)).toUTCString(), + secret: 'OAUTH_TOKEN_SECRET', + providerId: 'password', + signInMethod: 'password', + }, + params: {}, + }; + + expect(identity.parseAuthEventContext(decodedJwt, "project-id", time)).to.deep.equal(context); + }); + + it('should parse a beforeCreate event', () => { + const time = now.getTime(); + // beforeCreate + const decodedJwt = { + aud: 'https://us-east1-project_id.cloudfunctions.net/beforeCreate', + exp: 60 * 60 + 1, + iat: 1, + iss: 'https://securetoken.google.com/project_id', + sub: 'abcdefghijklmnopqrstuvwxyz', + uid: 'abcdefghijklmnopqrstuvwxyz', + event_id: 'EVENT_ID', + event_type: 'beforeCreate', + ip_address: '1.2.3.4', + user_agent: 'USER_AGENT', + locale: 'en', + sign_in_method: 'oidc.provider', + tenant_id: 'TENANT_ID', + user_record: { + uid: 'abcdefghijklmnopqrstuvwxyz', + email: 'user@gmail.com', + email_verified: true, + display_name: 'John Doe', + phone_number: '+11234567890', + provider_data: [ + { + provider_id: 'oidc.provider', + email: 'user@gmail.com', + uid: 'user@gmail.com', + display_name: 'John Doe', + }, + ], + photo_url: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', + tokens_valid_after_time: 1476136676, + metadata: { + last_sign_in_time: 1476235905, + creation_time: 1476136676, + }, + custom_claims: { + admin: true, + group_id: 'group123', + }, + tenant_id: 'TENANT_ID', + }, + oauth_id_token: 'ID_TOKEN', + oauth_access_token: 'ACCESS_TOKEN', + oauth_refresh_token: 'REFRESH_TOKEN', + oauth_token_secret: 'OAUTH_TOKEN_SECRET', + oauth_expires_in: 3600, + raw_user_info: JSON.stringify(rawUserInfo), + }; + const context = { + locale: 'en', + ipAddress: '1.2.3.4', + userAgent: 'USER_AGENT', + eventId: 'EVENT_ID', + eventType: 'providers/cloud.auth/eventTypes/user.beforeCreate:oidc.provider', + authType: 'USER', + resource: { + service: 'identitytoolkit.googleapis.com', + name: 'projects/project-id/tenants/TENANT_ID', + }, + timestamp: new Date(1000).toUTCString(), + additionalUserInfo: { + username: undefined, + providerId: 'oidc.provider', + profile: rawUserInfo, + isNewUser: true, + }, + credential: { + claims: undefined, + accessToken: 'ACCESS_TOKEN', + expirationTime: (new Date(time + 3600 * 1000)).toUTCString(), + idToken: 'ID_TOKEN', + providerId: 'oidc.provider', + refreshToken: 'REFRESH_TOKEN', + secret: 'OAUTH_TOKEN_SECRET', + signInMethod: 'oidc.provider', + }, + params: {}, + }; + + expect(identity.parseAuthEventContext(decodedJwt, "project-id", time)).to.deep.equal(context); }); }); + + describe('validateAuthRequest', () => { + it('should not throw on undefined request', () => { + expect(() => identity.validateAuthRequest("event", undefined)).to.not.throw; + }); + + it('should throw an error if customClaims have a blocked claim', () => { + expect(() => identity.validateAuthRequest("beforeCreate", { customClaims: { acr: 'something' }})).to.throw('The customClaims claims "acr" are reserved and cannot be specified.'); + }); + + it ('should throw an error if customClaims size is too big', () => { + let str = ''; + for (let i = 0; i < 1000; i++ ) { + str += 'x' + } + + expect(() => identity.validateAuthRequest("beforeCreate", { customClaims: { idk: str }})).to.throw('The customClaims payload should not exceed 1000 characters.'); + }); + + it('should throw an error if sessionClaims have a blocked claim', () => { + expect(() => identity.validateAuthRequest("beforeSignIn", { sessionClaims: { acr: 'something' }})).to.throw('The sessionClaims claims "acr" are reserved and cannot be specified.'); + }); + + it ('should throw an error if sessionClaims size is too big', () => { + let str = ''; + for (let i = 0; i < 1000; i++ ) { + str += 'x' + } + + expect(() => identity.validateAuthRequest("beforeSignIn", { sessionClaims: { idk: str }})).to.throw('The sessionClaims payload should not exceed 1000 characters.'); + }); + + it ('should throw an error if the combined customClaims & sessionClaims size is too big', () => { + let str = ''; + for (let i = 0; i < 501; i++ ) { + str += 'x' + } + + expect(() => identity.validateAuthRequest( + "beforeSignIn", + { + customClaims: { cc: str }, + sessionClaims: { sc: str } + } + )).to.throw('The customClaims and sessionClaims payloads should not exceed 1000 characters combined.'); + }); + + }); + }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 5d1e1c305..cde626688 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -59,27 +59,53 @@ export type UserRecord = firebase.auth.UserRecord; */ export type UserInfo = firebase.auth.UserInfo; +/** + * Additional metadata about the user. + */ +export type UserMetadata = firebase.auth.UserMetadata; + +/** + * The multi-factor related properties for the current user, if available. + */ +export type MultiFactorSettings = firebase.auth.MultiFactorSettings; + +/** + * Interface representing the common properties of a user-enrolled second factor. + */ +export type MultiFactorInfo = firebase.auth.MultiFactorInfo; + +/** + * Interface representing a phone specific user-enrolled second factor. + */ +export type PhoneMultiFactorInfo = firebase.auth.PhoneMultiFactorInfo; + +/** @internal */ +interface AdditionalUserInfo { + providerId: string; + profile?: any; + username?: string; + isNewUser: boolean; +} + +/** @internal */ +interface Credential { + claims?: { [key: string]: any }; + idToken?: string; + accessToken?: string; + refreshToken?: string; + expirationTime?: string; + secret?: string; + providerId: string; + signInMethod: string; +} + /** Defines the Auth event context. */ export interface AuthEventContext extends EventContext { locale?: string; ipAddress: string; userAgent: string; - additionalUserInfo?: { - providerId: string; - profile?: any; - username?: string; - isNewUser: boolean; - }; - credential?: { - claims?: { [key: string]: any }; - idToken?: string; - accessToken?: string; - refreshToken?: string; - expirationTime?: string; - secret?: string; - providerId: string; - signInMethod: string; - }; + additionalUserInfo?: AdditionalUserInfo; + credential?: Credential; } export interface BeforeCreateResponse { @@ -90,7 +116,12 @@ export interface BeforeCreateResponse { customClaims?: object; } -export interface BeforeSignInResponse extends BeforeCreateResponse { +export interface BeforeSignInResponse { + displayName?: string; + disabled?: boolean; + emailVerified?: boolean; + photoURL?: string; + customClaims?: object; sessionClaims?: object; } @@ -101,7 +132,7 @@ interface DecodedJwtMetadata { } /** @internal */ -interface DecodedJwtProviderUserInfo { +interface DecodedJwtUserInfo { uid: string; display_name?: string; email?: string; @@ -111,7 +142,7 @@ interface DecodedJwtProviderUserInfo { } /** @internal */ -interface DecodedJwtMfaInfo { +export interface DecodedJwtMfaInfo { uid: string; display_name?: string; phone_number?: string; @@ -125,7 +156,7 @@ interface DecodedJwtEnrolledFactors { } /** @internal */ -interface DecodedJwtUserRecord { +export interface DecodedJwtUserRecord { uid: string; email?: string; email_verified?: boolean; @@ -136,7 +167,7 @@ interface DecodedJwtUserRecord { metadata?: DecodedJwtMetadata; password_hash?: string; password_salt?: string; - provider_data?: DecodedJwtProviderUserInfo[]; + provider_data?: DecodedJwtUserInfo[]; multi_factor?: DecodedJwtEnrolledFactors; custom_claims?: any; tokens_valid_after_time?: number; @@ -192,11 +223,11 @@ export async function fetchPublicKeys( /** * Helper class to create the user metadata in a UserRecord object */ -export class UserRecordMetadata implements firebase.auth.UserMetadata { +export class UserRecordMetadata implements UserMetadata { constructor(public creationTime: string, public lastSignInTime: string) {} /** Returns a plain JavaScript object with the properties of UserRecordMetadata. */ - toJSON() { + toJSON(): object { return { creationTime: this.creationTime, lastSignInTime: this.lastSignInTime, @@ -204,6 +235,90 @@ export class UserRecordMetadata implements firebase.auth.UserMetadata { } } +/** + * Helper class to create the user info in a UserRecord object + */ +export class UserRecordInfo implements UserInfo { + constructor( + public uid: string, + public displayName: string, + public email: string, + public photoURL: string, + public providerId: string, + public phoneNumber: string + ) {} + + toJSON(): object { + return { + uid: this.uid, + displayName: this.displayName, + email: this.email, + photoURL: this.photoURL, + providerId: this.providerId, + phoneNumber: this.phoneNumber, + }; + } +} + +/** + * Helper class to create the user MultiFactorInfo in a UserRecord object + */ +export class UserRecordMultiFactorInfo + implements Pick { + constructor( + public uid: string, + public factorId: string, + public displayName?: string, + public enrollmentTime?: string + ) {} + + toJSON(): object { + return { + uid: this.uid, + factorId: this.factorId, + displayName: this.displayName || null, + enrollmentTime: this.enrollmentTime || null, + }; + } +} + +/** + * Helper class to create the user PhoneMultiFactorInfo in a UserRecord object + */ +export class UserRecordPhoneMultiFactorInfo + implements Pick { + constructor( + public uid: string, + public factorId: string, + public phoneNumber: string, + public displayName?: string, + public enrollmentTime?: string + ) {} + + toJSON(): object { + return { + uid: this.uid, + factorId: this.factorId, + phoneNumber: this.phoneNumber, + displayName: this.displayName || null, + enrollmentTime: this.enrollmentTime || null, + }; + } +} + +/** + * Helper class to create the user MultiFactorSettings in a UserRecord object + */ +export class UserRecordMultiFactorSettings implements MultiFactorSettings { + constructor(public enrolledFactors: MultiFactorInfo[]) {} + + toJSON(): object { + return { + enrolledFactors: this.enrolledFactors.map((ef) => ef.toJSON()), + }; + } +} + /** * Helper function that creates a UserRecord Class from data sent over the wire. * @param wireData data sent over the wire @@ -223,7 +338,6 @@ export function userRecordConstructor(wireData: Object): UserRecord { passwordSalt: null, passwordHash: null, tokensValidAfterTime: null, - tenantId: null, }; const record = _.assign({}, falseyValues, wireData); @@ -266,7 +380,10 @@ export function userRecordConstructor(wireData: Object): UserRecord { return record as UserRecord; } -/** @internal */ +/** + * @internal + * + */ export function validRequest(req: express.Request): void { if (req.method !== 'POST') { throw new HttpsError( @@ -379,7 +496,8 @@ function verifyAndDecodeJWT( eventType: string, publicKeys: Record ) { - // jwt decode & verify - https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback + // jwt decode & verify + // https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback const header = (jwt.decode(token, { complete: true }) as Record).header || {}; const publicKey = getPublicKey(header, publicKeys); @@ -398,130 +516,110 @@ function verifyAndDecodeJWT( return decoded; } -/** @internal */ -export function parseUserRecord( - decodedJWTUserRecord: DecodedJwtUserRecord -): UserRecord { - const parseMetadata = function(metadata: DecodedJwtMetadata) { - const creationTime = metadata?.creation_time - ? ( - (decodedJWTUserRecord.metadata.creation_time as number) * 1000 - ).toString() - : null; - const lastSignInTime = metadata?.last_sign_in_time - ? ( - (decodedJWTUserRecord.metadata.last_sign_in_time as number) * 1000 - ).toString() - : null; - return { - creationTime: creationTime, - lastSignInTime: lastSignInTime, - toJSON(): () => object { - const json: any = { - creationTime: creationTime, - lastSignInTime: lastSignInTime, - }; - return json; - }, - }; - }; +/** + * @internal + * Helper function to parse the decoded metadata object into a UserMetaData object + */ +export function parseMetadata(metadata: DecodedJwtMetadata): UserMetadata { + const creationTime = metadata?.creation_time + ? new Date((metadata.creation_time as number) * 1000).toUTCString() + : null; + const lastSignInTime = metadata?.last_sign_in_time + ? new Date((metadata.last_sign_in_time as number) * 1000).toUTCString() + : null; + return new UserRecordMetadata(creationTime, lastSignInTime); +} - const parseProviderData = function( - providerData: DecodedJwtProviderUserInfo[] - ): UserInfo[] { - const providers: UserInfo[] = []; - for (const provider of providerData) { - const info: UserInfo = { - uid: provider.uid, - displayName: provider.display_name, - email: provider.email, - photoURL: provider.photo_url, - providerId: provider.provider_id, - phoneNumber: provider.phone_number, - toJSON(): () => object { - const json: any = { - uid: provider.uid, - displayName: provider.display_name, - email: provider.email, - photoURL: provider.photo_url, - providerId: provider.provider_id, - phoneNumber: provider.phone_number, - }; - return json; - }, - }; - providers.push(info); - } - return providers; - }; +/** + * @internal + * Helper function to parse the decoded user info array into a UserInfo array + */ +export function parseProviderData( + providerData: DecodedJwtUserInfo[] +): UserInfo[] { + const providers: UserInfo[] = []; + for (const provider of providerData) { + providers.push( + new UserRecordInfo( + provider.uid, + provider.display_name, + provider.email, + provider.photo_url, + provider.provider_id, + provider.phone_number + ) + ); + } + return providers; +} - const parseDate = function(tokensValidAfterTime: number) { - if (!tokensValidAfterTime) { - return null; - } - tokensValidAfterTime = tokensValidAfterTime * 1000; - try { - const date = new Date(parseInt(tokensValidAfterTime.toString(), 10)); - if (!isNaN(date.getTime())) { - return date.toUTCString(); - } - } catch { - return null; +/** @internal */ +export function parseDate(tokensValidAfterTime?: number): string | null { + if (!tokensValidAfterTime) { + return null; + } + tokensValidAfterTime = tokensValidAfterTime * 1000; + try { + const date = new Date(tokensValidAfterTime); + if (!isNaN(date.getTime())) { + return date.toUTCString(); } + } catch { return null; - }; + } + return null; +} - const parseMultifactor = function(multiFactor: DecodedJwtEnrolledFactors) { - if (!multiFactor) { - return null; +/** @internal */ +export function parseMultiFactor( + multiFactor?: DecodedJwtEnrolledFactors +): MultiFactorSettings { + if (!multiFactor) { + return null; + } + const parsedEnrolledFactors: MultiFactorInfo[] = []; + for (const factor of multiFactor.enrolled_factors || []) { + if (!factor.uid) { + throw new HttpsError( + 'internal', + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response' + ); } - const parsedEnrolledFactors = []; - for (const factor of multiFactor.enrolled_factors || []) { - if (factor.factor_id && factor.uid) { - const enrollmentTime = factor.enrollment_time - ? new Date(factor.enrollment_time).toUTCString() - : null; - const multiFactorInfo = { - uid: factor.uid, - factorId: factor.factor_id, - displayName: factor.display_name, - enrollmentTime: enrollmentTime, - phoneNumber: factor.phone_number, - toJSON: function(): object { - const json: any = { - uid: factor.uid, - factorId: factor.factor_id, - displayName: factor.display_name, - enrollmentTime: enrollmentTime, - phoneNumber: factor.phone_number, - }; - return json; - }, - }; - parsedEnrolledFactors.push(multiFactorInfo); - } else { - throw new HttpsError( - 'internal', - 'INTERNAL ASSERT FAILED: Invalid multi-factor info response' - ); - } + const enrollmentTime = factor.enrollment_time + ? new Date(factor.enrollment_time).toUTCString() + : null; + if (factor.phone_number) { + parsedEnrolledFactors.push( + new UserRecordPhoneMultiFactorInfo( + factor.uid, + factor.factor_id || "phone", + factor.phone_number, + factor.display_name, + enrollmentTime + ) as PhoneMultiFactorInfo + ); + } else { + parsedEnrolledFactors.push( + new UserRecordMultiFactorInfo( + factor.uid, + factor.factor_id, + factor.display_name, + enrollmentTime + ) as MultiFactorInfo + ); } + } - if (parsedEnrolledFactors.length > 0) { - const multiFactor = { - enrolledFactors: parsedEnrolledFactors, - toJSON: function(): object { - const json: any = { - enrolledFactors: parsedEnrolledFactors, - }; - return json; - }, - }; - return multiFactor; - } - return null; - }; + if (parsedEnrolledFactors.length > 0) { + return new UserRecordMultiFactorSettings(parsedEnrolledFactors); + } + return null; +} +/** @internal */ +export function parseUserRecord( + decodedJWTUserRecord: DecodedJwtUserRecord +): UserRecord { if (!decodedJWTUserRecord.uid) { throw new HttpsError( 'internal', @@ -530,25 +628,29 @@ export function parseUserRecord( } const disabled = decodedJWTUserRecord.disabled || false; + const metadata = parseMetadata(decodedJWTUserRecord.metadata); + const providerData = parseProviderData(decodedJWTUserRecord.provider_data); + const tokensValidAfterTime = parseDate( + decodedJWTUserRecord.tokens_valid_after_time + ); + const multiFactor = parseMultiFactor(decodedJWTUserRecord.multi_factor); - const userRecord: UserRecord = { + return { uid: decodedJWTUserRecord.uid, email: decodedJWTUserRecord.email, emailVerified: decodedJWTUserRecord.email_verified, displayName: decodedJWTUserRecord.display_name, photoURL: decodedJWTUserRecord.photo_url, phoneNumber: decodedJWTUserRecord.phone_number, - disabled: disabled, - metadata: parseMetadata(decodedJWTUserRecord.metadata), - providerData: parseProviderData(decodedJWTUserRecord.provider_data), + disabled, + metadata, + providerData, passwordHash: decodedJWTUserRecord.password_hash, passwordSalt: decodedJWTUserRecord.password_salt, customClaims: decodedJWTUserRecord.custom_claims, tenantId: decodedJWTUserRecord.tenant_id, - tokensValidAfterTime: parseDate( - decodedJWTUserRecord.tokens_valid_after_time - ), - multiFactor: parseMultifactor(decodedJWTUserRecord.multi_factor), + tokensValidAfterTime, + multiFactor, toJSON: function(): object { const json: any = { uid: decodedJWTUserRecord.uid, @@ -558,41 +660,22 @@ export function parseUserRecord( photoURL: decodedJWTUserRecord.photo_url, phoneNumber: decodedJWTUserRecord.phone_number, disabled: disabled, - metadata: parseMetadata(decodedJWTUserRecord.metadata), - providerData: parseProviderData(decodedJWTUserRecord.provider_data), + metadata: metadata.toJSON(), + providerData: providerData.map((pd) => pd.toJSON()), passwordHash: decodedJWTUserRecord.password_hash, passwordSalt: decodedJWTUserRecord.password_salt, customClaims: decodedJWTUserRecord.custom_claims, tenantId: decodedJWTUserRecord.tenant_id, - tokensValidAfterTime: parseDate( - decodedJWTUserRecord.tokens_valid_after_time - ), - multiFactor: parseMultifactor(decodedJWTUserRecord.multi_factor), + tokensValidAfterTime, + multiFactor: multiFactor.toJSON(), }; return json; }, }; - - return userRecordConstructor(userRecord); } /** @internal */ -export function parseAuthEventContext( - decodedJWT: DecodedJwt -): AuthEventContext { - if ( - !decodedJWT.sign_in_attributes && - !decodedJWT.oauth_id_token && - !decodedJWT.oauth_access_token && - !decodedJWT.oauth_refresh_token - ) { - throw new HttpsError( - 'internal', - `Not enough info in JWT for parsing auth credential.` - ); - } - const eventType = - EVENT_MAPPING[decodedJWT.event_type] || decodedJWT.event_type; +function parseAdditionalUserInfo(decodedJWT: DecodedJwt): AdditionalUserInfo { let profile, username; if (decodedJWT.raw_user_info) try { @@ -609,55 +692,78 @@ export function parseAuthEventContext( } } - const authEventContext: AuthEventContext = { + return { + providerId: + decodedJWT.sign_in_method === 'emailLink' + ? 'password' + : decodedJWT.sign_in_method, + profile, + username, + isNewUser: decodedJWT.event_type === 'beforeCreate' ? true : false, + }; +} + +/** @internal */ +function parseAuthCredential(decodedJWT: DecodedJwt, time: number): Credential { + if ( + !decodedJWT.sign_in_attributes && + !decodedJWT.oauth_id_token && + !decodedJWT.oauth_access_token && + !decodedJWT.oauth_refresh_token + ) { + return null; + } + return { + claims: decodedJWT.sign_in_attributes, + idToken: decodedJWT.oauth_id_token, + accessToken: decodedJWT.oauth_access_token, + refreshToken: decodedJWT.oauth_refresh_token, + expirationTime: decodedJWT.oauth_expires_in + ? (new Date( + time + decodedJWT.oauth_expires_in * 1000 + )).toUTCString() + : undefined, + secret: decodedJWT.oauth_token_secret, + providerId: + decodedJWT.sign_in_method === 'emailLink' + ? 'password' + : decodedJWT.sign_in_method, + signInMethod: decodedJWT.sign_in_method, + }; +} + +/** @internal */ +export function parseAuthEventContext( + decodedJWT: DecodedJwt, + projectId: string, + time: number = new Date().getTime(), +): AuthEventContext { + const eventType = (EVENT_MAPPING[decodedJWT.event_type] || decodedJWT.event_type) + + (decodedJWT.sign_in_method ? `:${decodedJWT.sign_in_method}` : ''); + + return { locale: decodedJWT.locale, ipAddress: decodedJWT.ip_address, userAgent: decodedJWT.user_agent, eventId: decodedJWT.event_id, - eventType: - eventType + - (decodedJWT.sign_in_method ? `:${decodedJWT.sign_in_method}` : ''), + eventType, authType: !!decodedJWT.user_record ? 'USER' : 'UNAUTHENTICATED', resource: { - service: '', // TODO(colerogers): figure out the service + // TODO(colerogers): figure out the correct service + service: 'identitytoolkit.googleapis.com', name: !!decodedJWT.tenant_id - ? `projects/${this.projectId}/tenants/${decodedJWT.tenant_id}` - : `projects/${this.projectId}`, - }, - timestamp: new Date(decodedJWT.iat * 1000).toUTCString(), - additionalUserInfo: { - providerId: - decodedJWT.sign_in_method === 'emailLink' - ? 'password' - : decodedJWT.sign_in_method, - profile, - username, - isNewUser: decodedJWT.event_type === 'beforeCreate' ? true : false, - }, - credential: { - claims: decodedJWT.sign_in_attributes, - idToken: decodedJWT.oauth_id_token, - accessToken: decodedJWT.oauth_access_token, - refreshToken: decodedJWT.oauth_refresh_token, - expirationTime: decodedJWT.oauth_expires_in - ? new Date( - new Date().getTime() + decodedJWT.oauth_expires_in * 1000 - ).toUTCString() - : undefined, - secret: decodedJWT.oauth_token_secret, - providerId: - decodedJWT.sign_in_method === 'emailLink' - ? 'password' - : decodedJWT.sign_in_method, - signInMethod: decodedJWT.sign_in_method, + ? `projects/${projectId}/tenants/${decodedJWT.tenant_id}` + : `projects/${projectId}`, }, + timestamp: (new Date(decodedJWT.iat * 1000)).toUTCString(), + additionalUserInfo: parseAdditionalUserInfo(decodedJWT), + credential: parseAuthCredential(decodedJWT, time), params: {}, }; - return authEventContext; } /** @internal */ -export function validateAuthRequest(eventType: string, authRequest?: any) { +export function validateAuthRequest(eventType: string, authRequest?: BeforeCreateResponse | BeforeSignInResponse) { const nonAllowListedClaims = [ 'acr', 'amr', @@ -687,34 +793,46 @@ export function validateAuthRequest(eventType: string, authRequest?: any) { if (invalidClaims.length > 0) { throw new HttpsError( 'invalid-argument', - `customClaims claims "${invalidClaims.join( + `The customClaims claims "${invalidClaims.join( ',' )}" are reserved and cannot be specified.` ); } + if (JSON.stringify(authRequest.customClaims).length > claimsMaxPayloadSize) { + throw new HttpsError( + 'invalid-argument', + `The customClaims payload should not exceed ${claimsMaxPayloadSize} characters.` + ); + } } - if (authRequest.sessionClaims) { + if (eventType === "beforeSignIn" && (authRequest as BeforeSignInResponse).sessionClaims) { const invalidClaims = nonAllowListedClaims.filter((claim) => - authRequest.sessionClaims.hasOwnProperty(claim) + (authRequest as BeforeSignInResponse).sessionClaims.hasOwnProperty(claim) ); if (invalidClaims.length > 0) { throw new HttpsError( 'invalid-argument', - `customClaims claims "${invalidClaims.join( + `The sessionClaims claims "${invalidClaims.join( ',' )}" are reserved and cannot be specified.` ); } - } - const combinedClaims = { - ...authRequest.customClaims, - ...authRequest.sessionClaims, - }; - if (JSON.stringify(combinedClaims).length > claimsMaxPayloadSize) { - throw new HttpsError( - 'invalid-argument', - `The claims payload should not exceed ${claimsMaxPayloadSize} characters.` - ); + if (JSON.stringify((authRequest as BeforeSignInResponse).sessionClaims).length > claimsMaxPayloadSize) { + throw new HttpsError( + 'invalid-argument', + `The sessionClaims payload should not exceed ${claimsMaxPayloadSize} characters.` + ); + } + const combinedClaims = { + ...authRequest.customClaims, + ...(authRequest as BeforeSignInResponse).sessionClaims, + }; + if (JSON.stringify(combinedClaims).length > claimsMaxPayloadSize) { + throw new HttpsError( + 'invalid-argument', + `The customClaims and sessionClaims payloads should not exceed ${claimsMaxPayloadSize} characters combined.` + ); + } } } @@ -757,7 +875,7 @@ function wrapHandler( publicKeys ); const userRecord = parseUserRecord(decodedJWT.user_record); - const authEventContext = parseAuthEventContext(decodedJWT); + const authEventContext = parseAuthEventContext(decodedJWT, process.env.GCLOUD_PROJECT); const authRequest = (await handler(userRecord, authEventContext)) || undefined; validateAuthRequest(eventType, authRequest); @@ -791,6 +909,7 @@ function wrapHandler( } /** + * @internal * Generates the update mask for the provided object. * Note this will ignore the last key with value undefined. * @@ -833,6 +952,7 @@ export function generateUpdateMask( } /** + * @internal * Returns a copy of the object with all key/value pairs having undefined values removed. * @param obj The input object. * @return obj The processed object with all key/value pairs having undefined values removed. From bb0d05109c2a079f4c60988e2b344197054de355 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 3 Feb 2022 10:44:29 -0500 Subject: [PATCH 05/18] clean up & formatter --- spec/common/providers/identity.spec.ts | 130 +++++++++++++++---------- src/common/providers/identity.ts | 71 +++++++------- 2 files changed, 114 insertions(+), 87 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index f8edb14a9..1cfa7bcef 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -21,8 +21,6 @@ // SOFTWARE. import * as express from 'express'; -// import * as firebase from 'firebase-admin'; -// import * as sinon from 'sinon'; import * as identity from '../../../src/common/providers/identity'; import { expect } from 'chai'; @@ -33,14 +31,6 @@ const VALID_URL = `https://us-central1-${PROJECT}.cloudfunctions.net/function-1` const now = new Date(); describe('identity', () => { - // before(() => { - // process.env.GCLOUD_PROJECT = PROJECT; - // }); - - // after(() => { - // delete process.env.GCLOUD_PROJECT; - // }); - describe('userRecordConstructor', () => { it('will provide falsey values for fields that are not in raw wire data', () => { const record = identity.userRecordConstructor({ uid: '123' }); @@ -410,11 +400,15 @@ describe('identity', () => { }; it('should parse the user info', () => { - expect(identity.parseProviderData([decodedUserInfo])).to.deep.equal([userInfo]); + expect(identity.parseProviderData([decodedUserInfo])).to.deep.equal([ + userInfo, + ]); }); it('should parse the user info with phone', () => { - expect(identity.parseProviderData([decodedUserInfoPhone])).to.deep.equal([userInfoPhone]); + expect(identity.parseProviderData([decodedUserInfoPhone])).to.deep.equal([ + userInfoPhone, + ]); }); }); @@ -424,7 +418,9 @@ describe('identity', () => { }); it('should parse the date', () => { - expect(identity.parseDate(1476136676)).to.equal(new Date(1476136676000).toUTCString()) + expect(identity.parseDate(1476136676)).to.equal( + new Date(1476136676000).toUTCString() + ); }); }); @@ -441,7 +437,7 @@ describe('identity', () => { uid: 'enrollmentId2', enrollment_time: now.toISOString(), }, - ] + ], }; const multiFactors = { enrolledFactors: [ @@ -471,15 +467,19 @@ describe('identity', () => { it('should error on an invalid factor', () => { const factors = { - enrolled_factors: [{} as identity.DecodedJwtMfaInfo] - } + enrolled_factors: [{} as identity.DecodedJwtMfaInfo], + }; - expect(() => identity.parseMultiFactor(factors)).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + expect(() => identity.parseMultiFactor(factors)).to.throw( + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response' + ); }); it('should correctly parse factors', () => { - expect(identity.parseMultiFactor(decodedMultiFactors)).to.deep.equal(multiFactors); - }) + expect(identity.parseMultiFactor(decodedMultiFactors)).to.deep.equal( + multiFactors + ); + }); }); describe('parseUserRecord', () => { @@ -623,7 +623,9 @@ describe('identity', () => { }; it('should error if decoded does not have uid', () => { - expect(() => identity.parseUserRecord({} as identity.DecodedJwtUserRecord)).to.throw('INTERNAL ASSERT FAILED: Invalid user response'); + expect(() => + identity.parseUserRecord({} as identity.DecodedJwtUserRecord) + ).to.throw('INTERNAL ASSERT FAILED: Invalid user response'); }); it('should parse user record', () => { @@ -636,7 +638,8 @@ describe('identity', () => { describe('parseAuthEventContext', () => { const rawUserInfo = { name: 'John Doe', - granted_scopes: 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile', + granted_scopes: + 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile', id: '123456789', verified_email: true, given_name: 'John', @@ -683,7 +686,9 @@ describe('identity', () => { params: {}, }; - expect(identity.parseAuthEventContext(decodedJwt, "project-id")).to.deep.equal(context); + expect( + identity.parseAuthEventContext(decodedJwt, 'project-id') + ).to.deep.equal(context); }); it('should parse a beforeSignIn event', () => { @@ -731,7 +736,7 @@ describe('identity', () => { idToken: 'ID_TOKEN', accessToken: 'ACCESS_TOKEN', refreshToken: 'REFRESH_TOKEN', - expirationTime: (new Date(time + 3600 * 1000)).toUTCString(), + expirationTime: new Date(time + 3600 * 1000).toUTCString(), secret: 'OAUTH_TOKEN_SECRET', providerId: 'password', signInMethod: 'password', @@ -739,7 +744,9 @@ describe('identity', () => { params: {}, }; - expect(identity.parseAuthEventContext(decodedJwt, "project-id", time)).to.deep.equal(context); + expect( + identity.parseAuthEventContext(decodedJwt, 'project-id', time) + ).to.deep.equal(context); }); it('should parse a beforeCreate event', () => { @@ -797,7 +804,8 @@ describe('identity', () => { ipAddress: '1.2.3.4', userAgent: 'USER_AGENT', eventId: 'EVENT_ID', - eventType: 'providers/cloud.auth/eventTypes/user.beforeCreate:oidc.provider', + eventType: + 'providers/cloud.auth/eventTypes/user.beforeCreate:oidc.provider', authType: 'USER', resource: { service: 'identitytoolkit.googleapis.com', @@ -813,7 +821,7 @@ describe('identity', () => { credential: { claims: undefined, accessToken: 'ACCESS_TOKEN', - expirationTime: (new Date(time + 3600 * 1000)).toUTCString(), + expirationTime: new Date(time + 3600 * 1000).toUTCString(), idToken: 'ID_TOKEN', providerId: 'oidc.provider', refreshToken: 'REFRESH_TOKEN', @@ -823,56 +831,80 @@ describe('identity', () => { params: {}, }; - expect(identity.parseAuthEventContext(decodedJwt, "project-id", time)).to.deep.equal(context); + expect( + identity.parseAuthEventContext(decodedJwt, 'project-id', time) + ).to.deep.equal(context); }); }); describe('validateAuthRequest', () => { it('should not throw on undefined request', () => { - expect(() => identity.validateAuthRequest("event", undefined)).to.not.throw; + expect(() => identity.validateAuthRequest('event', undefined)).to.not + .throw; }); it('should throw an error if customClaims have a blocked claim', () => { - expect(() => identity.validateAuthRequest("beforeCreate", { customClaims: { acr: 'something' }})).to.throw('The customClaims claims "acr" are reserved and cannot be specified.'); + expect(() => + identity.validateAuthRequest('beforeCreate', { + customClaims: { acr: 'something' }, + }) + ).to.throw( + 'The customClaims claims "acr" are reserved and cannot be specified.' + ); }); - it ('should throw an error if customClaims size is too big', () => { + it('should throw an error if customClaims size is too big', () => { let str = ''; - for (let i = 0; i < 1000; i++ ) { - str += 'x' + for (let i = 0; i < 1000; i++) { + str += 'x'; } - expect(() => identity.validateAuthRequest("beforeCreate", { customClaims: { idk: str }})).to.throw('The customClaims payload should not exceed 1000 characters.'); + expect(() => + identity.validateAuthRequest('beforeCreate', { + customClaims: { idk: str }, + }) + ).to.throw('The customClaims payload should not exceed 1000 characters.'); }); it('should throw an error if sessionClaims have a blocked claim', () => { - expect(() => identity.validateAuthRequest("beforeSignIn", { sessionClaims: { acr: 'something' }})).to.throw('The sessionClaims claims "acr" are reserved and cannot be specified.'); + expect(() => + identity.validateAuthRequest('beforeSignIn', { + sessionClaims: { acr: 'something' }, + }) + ).to.throw( + 'The sessionClaims claims "acr" are reserved and cannot be specified.' + ); }); - it ('should throw an error if sessionClaims size is too big', () => { + it('should throw an error if sessionClaims size is too big', () => { let str = ''; - for (let i = 0; i < 1000; i++ ) { - str += 'x' + for (let i = 0; i < 1000; i++) { + str += 'x'; } - expect(() => identity.validateAuthRequest("beforeSignIn", { sessionClaims: { idk: str }})).to.throw('The sessionClaims payload should not exceed 1000 characters.'); + expect(() => + identity.validateAuthRequest('beforeSignIn', { + sessionClaims: { idk: str }, + }) + ).to.throw( + 'The sessionClaims payload should not exceed 1000 characters.' + ); }); - it ('should throw an error if the combined customClaims & sessionClaims size is too big', () => { + it('should throw an error if the combined customClaims & sessionClaims size is too big', () => { let str = ''; - for (let i = 0; i < 501; i++ ) { - str += 'x' + for (let i = 0; i < 501; i++) { + str += 'x'; } - expect(() => identity.validateAuthRequest( - "beforeSignIn", - { + expect(() => + identity.validateAuthRequest('beforeSignIn', { customClaims: { cc: str }, - sessionClaims: { sc: str } - } - )).to.throw('The customClaims and sessionClaims payloads should not exceed 1000 characters combined.'); + sessionClaims: { sc: str }, + }) + ).to.throw( + 'The customClaims and sessionClaims payloads should not exceed 1000 characters combined.' + ); }); - }); - }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index cde626688..f8223888b 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -380,9 +380,9 @@ export function userRecordConstructor(wireData: Object): UserRecord { return record as UserRecord; } -/** +/** * @internal - * + * */ export function validRequest(req: express.Request): void { if (req.method !== 'POST') { @@ -516,8 +516,8 @@ function verifyAndDecodeJWT( return decoded; } -/** - * @internal +/** + * @internal * Helper function to parse the decoded metadata object into a UserMetaData object */ export function parseMetadata(metadata: DecodedJwtMetadata): UserMetadata { @@ -530,8 +530,8 @@ export function parseMetadata(metadata: DecodedJwtMetadata): UserMetadata { return new UserRecordMetadata(creationTime, lastSignInTime); } -/** - * @internal +/** + * @internal * Helper function to parse the decoded user info array into a UserInfo array */ export function parseProviderData( @@ -592,7 +592,7 @@ export function parseMultiFactor( parsedEnrolledFactors.push( new UserRecordPhoneMultiFactorInfo( factor.uid, - factor.factor_id || "phone", + factor.factor_id || 'phone', factor.phone_number, factor.display_name, enrollmentTime @@ -719,9 +719,7 @@ function parseAuthCredential(decodedJWT: DecodedJwt, time: number): Credential { accessToken: decodedJWT.oauth_access_token, refreshToken: decodedJWT.oauth_refresh_token, expirationTime: decodedJWT.oauth_expires_in - ? (new Date( - time + decodedJWT.oauth_expires_in * 1000 - )).toUTCString() + ? new Date(time + decodedJWT.oauth_expires_in * 1000).toUTCString() : undefined, secret: decodedJWT.oauth_token_secret, providerId: @@ -736,10 +734,11 @@ function parseAuthCredential(decodedJWT: DecodedJwt, time: number): Credential { export function parseAuthEventContext( decodedJWT: DecodedJwt, projectId: string, - time: number = new Date().getTime(), + time: number = new Date().getTime() ): AuthEventContext { - const eventType = (EVENT_MAPPING[decodedJWT.event_type] || decodedJWT.event_type) + - (decodedJWT.sign_in_method ? `:${decodedJWT.sign_in_method}` : ''); + const eventType = + (EVENT_MAPPING[decodedJWT.event_type] || decodedJWT.event_type) + + (decodedJWT.sign_in_method ? `:${decodedJWT.sign_in_method}` : ''); return { locale: decodedJWT.locale, @@ -755,7 +754,7 @@ export function parseAuthEventContext( ? `projects/${projectId}/tenants/${decodedJWT.tenant_id}` : `projects/${projectId}`, }, - timestamp: (new Date(decodedJWT.iat * 1000)).toUTCString(), + timestamp: new Date(decodedJWT.iat * 1000).toUTCString(), additionalUserInfo: parseAdditionalUserInfo(decodedJWT), credential: parseAuthCredential(decodedJWT, time), params: {}, @@ -763,7 +762,10 @@ export function parseAuthEventContext( } /** @internal */ -export function validateAuthRequest(eventType: string, authRequest?: BeforeCreateResponse | BeforeSignInResponse) { +export function validateAuthRequest( + eventType: string, + authRequest?: BeforeCreateResponse | BeforeSignInResponse +) { const nonAllowListedClaims = [ 'acr', 'amr', @@ -798,16 +800,21 @@ export function validateAuthRequest(eventType: string, authRequest?: BeforeCreat )}" are reserved and cannot be specified.` ); } - if (JSON.stringify(authRequest.customClaims).length > claimsMaxPayloadSize) { + if ( + JSON.stringify(authRequest.customClaims).length > claimsMaxPayloadSize + ) { throw new HttpsError( 'invalid-argument', `The customClaims payload should not exceed ${claimsMaxPayloadSize} characters.` ); } } - if (eventType === "beforeSignIn" && (authRequest as BeforeSignInResponse).sessionClaims) { + if ( + eventType === 'beforeSignIn' && + (authRequest as BeforeSignInResponse).sessionClaims + ) { const invalidClaims = nonAllowListedClaims.filter((claim) => - (authRequest as BeforeSignInResponse).sessionClaims.hasOwnProperty(claim) + (authRequest as BeforeSignInResponse).sessionClaims.hasOwnProperty(claim) ); if (invalidClaims.length > 0) { throw new HttpsError( @@ -817,7 +824,10 @@ export function validateAuthRequest(eventType: string, authRequest?: BeforeCreat )}" are reserved and cannot be specified.` ); } - if (JSON.stringify((authRequest as BeforeSignInResponse).sessionClaims).length > claimsMaxPayloadSize) { + if ( + JSON.stringify((authRequest as BeforeSignInResponse).sessionClaims) + .length > claimsMaxPayloadSize + ) { throw new HttpsError( 'invalid-argument', `The sessionClaims payload should not exceed ${claimsMaxPayloadSize} characters.` @@ -875,7 +885,10 @@ function wrapHandler( publicKeys ); const userRecord = parseUserRecord(decodedJWT.user_record); - const authEventContext = parseAuthEventContext(decodedJWT, process.env.GCLOUD_PROJECT); + const authEventContext = parseAuthEventContext( + decodedJWT, + process.env.GCLOUD_PROJECT + ); const authRequest = (await handler(userRecord, authEventContext)) || undefined; validateAuthRequest(eventType, authRequest); @@ -950,21 +963,3 @@ export function generateUpdateMask( } return updateMask; } - -/** - * @internal - * Returns a copy of the object with all key/value pairs having undefined values removed. - * @param obj The input object. - * @return obj The processed object with all key/value pairs having undefined values removed. - */ -export function removeUndefinedProperties(obj: { - [key: string]: any; -}): { [key: string]: any } { - const filteredObj = {}; - for (const key in obj) { - if (obj.hasOwnProperty(key) && typeof obj[key] !== 'undefined') { - filteredObj[key] = obj[key]; - } - } - return filteredObj; -} From c4dd912b430e0ed9f9036f06fac0a579b11fa8a8 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 3 Feb 2022 11:12:26 -0500 Subject: [PATCH 06/18] adding jsdoc comments --- spec/common/providers/identity.spec.ts | 14 ++-- src/common/providers/identity.ts | 107 +++++++++++++++---------- 2 files changed, 71 insertions(+), 50 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 1cfa7bcef..b6abccf78 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -837,15 +837,15 @@ describe('identity', () => { }); }); - describe('validateAuthRequest', () => { + describe('validateAuthResponse', () => { it('should not throw on undefined request', () => { - expect(() => identity.validateAuthRequest('event', undefined)).to.not + expect(() => identity.validateAuthResponse('event', undefined)).to.not .throw; }); it('should throw an error if customClaims have a blocked claim', () => { expect(() => - identity.validateAuthRequest('beforeCreate', { + identity.validateAuthResponse('beforeCreate', { customClaims: { acr: 'something' }, }) ).to.throw( @@ -860,7 +860,7 @@ describe('identity', () => { } expect(() => - identity.validateAuthRequest('beforeCreate', { + identity.validateAuthResponse('beforeCreate', { customClaims: { idk: str }, }) ).to.throw('The customClaims payload should not exceed 1000 characters.'); @@ -868,7 +868,7 @@ describe('identity', () => { it('should throw an error if sessionClaims have a blocked claim', () => { expect(() => - identity.validateAuthRequest('beforeSignIn', { + identity.validateAuthResponse('beforeSignIn', { sessionClaims: { acr: 'something' }, }) ).to.throw( @@ -883,7 +883,7 @@ describe('identity', () => { } expect(() => - identity.validateAuthRequest('beforeSignIn', { + identity.validateAuthResponse('beforeSignIn', { sessionClaims: { idk: str }, }) ).to.throw( @@ -898,7 +898,7 @@ describe('identity', () => { } expect(() => - identity.validateAuthRequest('beforeSignIn', { + identity.validateAuthResponse('beforeSignIn', { customClaims: { cc: str }, sessionClaims: { sc: str }, }) diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index f8223888b..9436c2d58 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -31,7 +31,6 @@ import { logger } from '../..'; export { HttpsError }; -/* API Constants */ /** @internal */ export const JWT_CLIENT_CERT_URL = 'https://www.googleapis.com'; /** @internal */ @@ -79,7 +78,7 @@ export type MultiFactorInfo = firebase.auth.MultiFactorInfo; */ export type PhoneMultiFactorInfo = firebase.auth.PhoneMultiFactorInfo; -/** @internal */ +/** The additional user info component of the auth event context */ interface AdditionalUserInfo { providerId: string; profile?: any; @@ -87,7 +86,7 @@ interface AdditionalUserInfo { isNewUser: boolean; } -/** @internal */ +/** The credential component of the auth event context */ interface Credential { claims?: { [key: string]: any }; idToken?: string; @@ -99,7 +98,7 @@ interface Credential { signInMethod: string; } -/** Defines the Auth event context. */ +/** Defines the auth event context for blocking events */ export interface AuthEventContext extends EventContext { locale?: string; ipAddress: string; @@ -108,6 +107,7 @@ export interface AuthEventContext extends EventContext { credential?: Credential; } +/** The handler response type for beforeCreate blocking events */ export interface BeforeCreateResponse { displayName?: string; disabled?: boolean; @@ -116,12 +116,8 @@ export interface BeforeCreateResponse { customClaims?: object; } -export interface BeforeSignInResponse { - displayName?: string; - disabled?: boolean; - emailVerified?: boolean; - photoURL?: string; - customClaims?: object; +/** The handler response type for beforeSignIn blocking events */ +export interface BeforeSignInResponse extends BeforeCreateResponse { sessionClaims?: object; } @@ -175,7 +171,6 @@ export interface DecodedJwtUserRecord { [key: string]: any; } -/** Defines HTTP event JWT. */ /** @internal */ export interface DecodedJwt { aud: string; @@ -203,23 +198,6 @@ export interface DecodedJwt { [key: string]: any; } -/** @internal */ -export async function fetchPublicKeys( - publicKeys: Record -): Promise> { - const url = `${JWT_CLIENT_CERT_URL}/${JWT_CLIENT_CERT_PATH}`; - try { - const response = await fetch(url); - const data = await response.json(); - return data as Record; - } catch (err) { - logger.error( - `Failed to obtain public keys for JWT verification: ${err.message}` - ); - throw new HttpsError('internal', 'Failed to obtain public keys'); - } -} - /** * Helper class to create the user metadata in a UserRecord object */ @@ -319,6 +297,24 @@ export class UserRecordMultiFactorSettings implements MultiFactorSettings { } } +/** + * @internal + * Obtain public keys for use in decoding and verifying the jwt sent from identity platform + */ +export async function fetchPublicKeys(): Promise> { + const url = `${JWT_CLIENT_CERT_URL}/${JWT_CLIENT_CERT_PATH}`; + try { + const response = await fetch(url); + const data = await response.json(); + return data as Record; + } catch (err) { + logger.error( + `Failed to obtain public keys for JWT verification: ${err.message}` + ); + throw new HttpsError('internal', 'Failed to obtain public keys'); + } +} + /** * Helper function that creates a UserRecord Class from data sent over the wire. * @param wireData data sent over the wire @@ -382,7 +378,7 @@ export function userRecordConstructor(wireData: Object): UserRecord { /** * @internal - * + * Checks for a valid identity platform web request, otherwise throws an HttpsError */ export function validRequest(req: express.Request): void { if (req.method !== 'POST') { @@ -429,7 +425,10 @@ export function getPublicKey( return publicKeys[header.kid]; } -/** @internal */ +/** + * @internal + * Checks for a well forms cloud functions url + */ export function isAuthorizedCloudFunctionURL( cloudFunctionUrl: string, projectId: string @@ -458,7 +457,10 @@ export function isAuthorizedCloudFunctionURL( return res.length > 0; } -/** @internal */ +/** + * @internal + * Checks for errors in a decoded jwt + */ export function checkDecodedToken( decodedJWT: DecodedJwt, projectId: string @@ -490,7 +492,11 @@ export function checkDecodedToken( } } -/** @internal */ +/** + * @internal + * Verifies the jwt using the 'jwt' library and decodes the token with the public keys + * Throws an error if the event types do not match + */ function verifyAndDecodeJWT( token: string, eventType: string, @@ -553,7 +559,10 @@ export function parseProviderData( return providers; } -/** @internal */ +/** + * @internal + * Helper function to parse the date into a UTC string + */ export function parseDate(tokensValidAfterTime?: number): string | null { if (!tokensValidAfterTime) { return null; @@ -570,7 +579,10 @@ export function parseDate(tokensValidAfterTime?: number): string | null { return null; } -/** @internal */ +/** + * @internal + * Helper function to parse the decoded enrolled factors into a valid MultiFactorSettings + */ export function parseMultiFactor( multiFactor?: DecodedJwtEnrolledFactors ): MultiFactorSettings { @@ -616,7 +628,10 @@ export function parseMultiFactor( return null; } -/** @internal */ +/** + * @internal + * Parses the decoded user record into a valid UserRecord for use in the handler + */ export function parseUserRecord( decodedJWTUserRecord: DecodedJwtUserRecord ): UserRecord { @@ -730,7 +745,10 @@ function parseAuthCredential(decodedJWT: DecodedJwt, time: number): Credential { }; } -/** @internal */ +/** + * @internal + * Parses the decoded jwt into a valid AuthEventContext for use in the handler + */ export function parseAuthEventContext( decodedJWT: DecodedJwt, projectId: string, @@ -761,8 +779,11 @@ export function parseAuthEventContext( }; } -/** @internal */ -export function validateAuthRequest( +/** + * @internal + * Checks the handler response for invalid customClaims & sessionClaims objects + */ +export function validateAuthResponse( eventType: string, authRequest?: BeforeCreateResponse | BeforeSignInResponse ) { @@ -877,7 +898,7 @@ function wrapHandler( ) { return async (req: express.Request, res: express.Response): Promise => { try { - const publicKeys = await fetchPublicKeys({}); + const publicKeys = await fetchPublicKeys(); validRequest(req); const decodedJWT = verifyAndDecodeJWT( req.body.data.jwt, @@ -889,16 +910,16 @@ function wrapHandler( decodedJWT, process.env.GCLOUD_PROJECT ); - const authRequest = + const authResponse = (await handler(userRecord, authEventContext)) || undefined; - validateAuthRequest(eventType, authRequest); - const updateMask = generateUpdateMask(authRequest, { + validateAuthResponse(eventType, authResponse); + const updateMask = generateUpdateMask(authResponse, { customClaims: true, sessionClaims: true, }); const result = { userRecord: { - ...authRequest, + ...authResponse, updateMask: updateMask.join(','), }, }; From 233f0bcf7a6aa33527dff4bfc1adb1320fceb851 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 3 Feb 2022 11:33:43 -0500 Subject: [PATCH 07/18] cleaning up update mask and adding more tests --- spec/common/providers/identity.spec.ts | 38 +++++++++++ src/common/providers/identity.ts | 88 +++++++++++--------------- 2 files changed, 75 insertions(+), 51 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index b6abccf78..9f2611591 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -907,4 +907,42 @@ describe('identity', () => { ); }); }); + + describe('getUpdateMask', () => { + it('should return empty string on undefined response', () => { + expect(identity.getUpdateMask()).to.eq(''); + }); + + it('should return empty on only customClaims and sessionClaims', () => { + const response = { + customClaims: { + claim1: 'abc', + }, + sessionClaims: { + claim2: 'def', + }, + }; + + expect(identity.getUpdateMask(response)).to.eq(''); + }); + + it('should return the right claims on a response', () => { + const response = { + displayName: 'john', + disabled: false, + emailVerified: true, + photoURL: 'google.com', + customClaims: { + claim1: 'abc', + }, + sessionClaims: { + claim2: 'def', + }, + }; + + expect(identity.getUpdateMask(response)).to.eq( + 'displayName,disabled,emailVerified,photoURL' + ); + }); + }); }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 9436c2d58..03de2f5f0 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -867,9 +867,43 @@ export function validateAuthResponse( } } +/** + * @internal + * Helper function to generate the update mask for the identity platform changed values + */ +export function getUpdateMask( + authResponse?: BeforeCreateResponse | BeforeSignInResponse +): string { + if (!authResponse) { + return ''; + } + const updateMask: string[] = []; + for (const key in authResponse) { + if (key === 'customClaims' || key === 'sessionClaims') { + continue; + } + if ( + authResponse.hasOwnProperty(key) && + typeof authResponse[key] !== 'undefined' + ) { + updateMask.push(key); + } + } + return updateMask.join(','); +} + /** @internal */ export function createHandler( - handler: (user: UserRecord, context: AuthEventContext) => any, + handler: ( + user: UserRecord, + context: AuthEventContext + ) => + | BeforeCreateResponse + | Promise + | BeforeSignInResponse + | Promise + | void + | Promise, eventType: string ): (req: express.Request, resp: express.Response) => Promise { const wrappedHandler = wrapHandler(handler, eventType); @@ -894,7 +928,6 @@ function wrapHandler( | void | Promise, eventType: string - // publicKeys: Record = {} ) { return async (req: express.Request, res: express.Response): Promise => { try { @@ -913,17 +946,13 @@ function wrapHandler( const authResponse = (await handler(userRecord, authEventContext)) || undefined; validateAuthResponse(eventType, authResponse); - const updateMask = generateUpdateMask(authResponse, { - customClaims: true, - sessionClaims: true, - }); + const updateMask = getUpdateMask(authResponse); const result = { userRecord: { ...authResponse, - updateMask: updateMask.join(','), + updateMask, }, }; - // const result = encode(finalizedRequest); res.status(200); res.setHeader('Content-Type', 'application/json'); @@ -941,46 +970,3 @@ function wrapHandler( } }; } - -/** - * @internal - * Generates the update mask for the provided object. - * Note this will ignore the last key with value undefined. - * - * @param obj The object to generate the update mask for. - * @param maxPaths The optional map of keys for maximum paths to traverse. - * Nested objects beyond that path will be ignored. - * @param currentPath The path so far. - * @return The computed update mask list. - */ -export function generateUpdateMask( - obj: any, - maxPaths: { [key: string]: boolean } = {}, - currentPath: string = '' -): string[] { - const updateMask: string[] = []; - if (!obj) { - return updateMask; - } - for (const key in obj) { - if (obj.hasOwnProperty(key) && typeof obj[key] !== 'undefined') { - const nextPath = currentPath ? currentPath + '.' + key : key; - // We hit maximum path. - if (maxPaths[nextPath]) { - // Add key and stop traversing this branch. - updateMask.push(key); - } else { - let maskList: string[] = []; - maskList = generateUpdateMask(obj[key], maxPaths, nextPath); - if (maskList.length > 0) { - maskList.forEach((mask) => { - updateMask.push(`${key}.${mask}`); - }); - } else { - updateMask.push(key); - } - } - } - } - return updateMask; -} From 8593a047b219090f56bc7ec77ac28049264a1530 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 3 Feb 2022 11:40:55 -0500 Subject: [PATCH 08/18] fixing projectId references --- spec/common/providers/identity.spec.ts | 2 -- src/common/providers/identity.ts | 14 +++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 9f2611591..9c6111e17 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -21,13 +21,11 @@ // SOFTWARE. import * as express from 'express'; - import * as identity from '../../../src/common/providers/identity'; import { expect } from 'chai'; const PROJECT = 'my-project'; const VALID_URL = `https://us-central1-${PROJECT}.cloudfunctions.net/function-1`; - const now = new Date(); describe('identity', () => { diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 03de2f5f0..cb6fc436c 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -500,7 +500,8 @@ export function checkDecodedToken( function verifyAndDecodeJWT( token: string, eventType: string, - publicKeys: Record + publicKeys: Record, + projectId: string ) { // jwt decode & verify // https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback @@ -511,7 +512,7 @@ function verifyAndDecodeJWT( algorithms: [this.algorithm], }) as DecodedJwt; decoded.uid = decoded.sub; - checkDecodedToken(decoded, process.env.GCLOUD_PROJECT); + checkDecodedToken(decoded, projectId); if (decoded.event_type !== eventType) { throw new HttpsError( @@ -931,18 +932,17 @@ function wrapHandler( ) { return async (req: express.Request, res: express.Response): Promise => { try { + const projectId = process.env.GCLOUD_PROJECT; const publicKeys = await fetchPublicKeys(); validRequest(req); const decodedJWT = verifyAndDecodeJWT( req.body.data.jwt, eventType, - publicKeys + publicKeys, + projectId ); const userRecord = parseUserRecord(decodedJWT.user_record); - const authEventContext = parseAuthEventContext( - decodedJWT, - process.env.GCLOUD_PROJECT - ); + const authEventContext = parseAuthEventContext(decodedJWT, projectId); const authResponse = (await handler(userRecord, authEventContext)) || undefined; validateAuthResponse(eventType, authResponse); From 2fec796b55c6da95245dc6a51e21d8d1c3881bfa Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Wed, 9 Feb 2022 17:31:34 -0500 Subject: [PATCH 09/18] changing classes to interfaces, adding in a key cache, and more tests --- spec/common/providers/identity.spec.ts | 298 +++++++++-- src/common/providers/identity.ts | 683 ++++++++++++++----------- 2 files changed, 629 insertions(+), 352 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 9c6111e17..9ef7f580c 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -21,14 +21,132 @@ // SOFTWARE. import * as express from 'express'; +import * as jwt from 'jsonwebtoken'; +import * as sinon from 'sinon'; import * as identity from '../../../src/common/providers/identity'; +// import * as fetch from 'node-fetch'; import { expect } from 'chai'; const PROJECT = 'my-project'; +const EVENT = 'EVENT_TYPE'; const VALID_URL = `https://us-central1-${PROJECT}.cloudfunctions.net/function-1`; const now = new Date(); describe('identity', () => { + describe('invalidPublicKeys', () => { + it('should return true if publicKeysExpireAt does not exist', () => { + expect(identity.invalidPublicKeys({ + publicKeys: {}, + })).to.be.true; + }); + + it('should return true if publicKeysExpireAt equals Date.now()', () => { + const time = Date.now(); + expect(identity.invalidPublicKeys({ + publicKeys: {}, + publicKeysExpireAt: time, + }, time)).to.be.true; + }); + + it('should return true if publicKeysExpireAt are less than Date.now()', () => { + const time = Date.now(); + expect(identity.invalidPublicKeys({ + publicKeys: {}, + publicKeysExpireAt: time - 1, + }, time)).to.be.true; + }); + + it('should return false if publicKeysExpireAt are greater than Date.now()', () => { + const time = Date.now(); + expect(identity.invalidPublicKeys({ + publicKeys: {}, + publicKeysExpireAt: time + 100, + }, time)).to.be.false; + }); + }); + + /* + describe('fetchPublicKeys', () => { + // let fetchStub: sinon.SinonStub; + + beforeEach(() => { + // fetchStub = sinon.stub(fetch).rejects("Unexpected call to fetch"); + // fetchStub.rejects("Unexpected call to fetch"); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + it('should set the public keys without cache-control', async () => { + const PublicKeysCache = { + publicKeys: {}, + publicKeysExpireAt: undefined, + } + const publicKeys = { + '123456': '7890', + '2468': '1357', + }; + // fetchStub.returns({ + // json: async () => Promise.resolve(publicKeys), + // }); + sinon.stub(fetch).returns(Promise.resolve({ + json: async () => Promise.resolve(publicKeys), + })); + + await identity.fetchPublicKeys(PublicKeysCache); + + expect(PublicKeysCache.publicKeys).to.deep.equal(publicKeys); + expect(PublicKeysCache.publicKeysExpireAt).to.be.undefined; + }); + + it('should set the public keys with cache-control but without max-age', async () => { + const PublicKeysCache = { + publicKeys: {}, + publicKeysExpireAt: undefined, + } + const publicKeys = { + '123456': '7890', + '2468': '1357', + }; + // fetchStub.resolves({ + // headers: { + // 'cache-control': 'item=val, item2=val2, item3=val3', + // }, + // json: async () => Promise.resolve(publicKeys), + // }); + + await identity.fetchPublicKeys(PublicKeysCache); + + expect(PublicKeysCache.publicKeys).to.deep.equal(publicKeys); + expect(PublicKeysCache.publicKeysExpireAt).to.be.undefined; + }); + + it('should set the public keys with cache-control but without max-age', async () => { + const time = Date.now(); + const PublicKeysCache = { + publicKeys: {}, + publicKeysExpireAt: undefined, + } + const publicKeys = { + '123456': '7890', + '2468': '1357', + }; + // fetchStub.resolves({ + // headers: { + // 'cache-control': 'item=val, max-age=50, item2=val2, item3=val3', + // }, + // json: async () => Promise.resolve(publicKeys), + // }); + + await identity.fetchPublicKeys(PublicKeysCache, time); + + expect(PublicKeysCache.publicKeys).to.deep.equal(publicKeys); + expect(PublicKeysCache.publicKeysExpireAt).to.equal(time + (50 * 1000)); + }); + }); + */ + describe('userRecordConstructor', () => { it('will provide falsey values for fields that are not in raw wire data', () => { const record = identity.userRecordConstructor({ uid: '123' }); @@ -188,22 +306,22 @@ describe('identity', () => { }); }); - describe('getPublicKey', () => { + describe('getPublicKeyFromHeader', () => { it('should throw if header.alg is not expected', () => { - expect(() => identity.getPublicKey({ alg: 'RS128' }, {})).to.throw( + expect(() => identity.getPublicKeyFromHeader({ alg: 'RS128' }, {})).to.throw( `Provided JWT has incorrect algorithm. Expected ${identity.JWT_ALG} but got RS128.` ); }); it('should throw if header.kid is undefined', () => { expect(() => - identity.getPublicKey({ alg: identity.JWT_ALG }, {}) - ).to.throw('JWT has no "kid" claim.'); + identity.getPublicKeyFromHeader({ alg: identity.JWT_ALG }, {}) + ).to.throw('JWT header missing "kid" claim.'); }); it('should throw if the public keys do not have a property that matches header.kid', () => { expect(() => - identity.getPublicKey( + identity.getPublicKeyFromHeader( { alg: identity.JWT_ALG, kid: '123456', @@ -217,7 +335,7 @@ describe('identity', () => { it('should return the correct public key', () => { expect( - identity.getPublicKey( + identity.getPublicKeyFromHeader( { alg: identity.JWT_ALG, kid: '123456', @@ -232,7 +350,7 @@ describe('identity', () => { }); describe('isAuthorizedCloudFunctionURL', () => { - it('should return false on a bad gcf direction', () => { + it('should return false on a bad gcf location', () => { expect( identity.isAuthorizedCloudFunctionURL( `https://us-central1-europe-${PROJECT}.cloudfunctions.net/function-1`, @@ -257,12 +375,21 @@ describe('identity', () => { }); describe('checkDecodedToken', () => { + it('should throw on mismatching event types', () => { + expect(() => identity.checkDecodedToken({ + event_type: EVENT, + } as identity.DecodedJwt, + "newEvent", + PROJECT)).to.throw(`Expected "newEvent" but received "${EVENT}".`); + }); it('should throw on unauthorized function url', () => { expect(() => identity.checkDecodedToken( { aud: `fake-region-${PROJECT}.cloudfunctions.net/fn1`, + event_type: EVENT, } as identity.DecodedJwt, + EVENT, PROJECT ) ).to.throw('Provided JWT has incorrect "aud" (audience) claim.'); @@ -274,7 +401,9 @@ describe('identity', () => { { aud: VALID_URL, iss: `https://someissuer.com/a-project`, + event_type: EVENT, } as identity.DecodedJwt, + EVENT, PROJECT ) ).to.throw( @@ -291,7 +420,9 @@ describe('identity', () => { sub: { key: 'val', }, + event_type: EVENT, } as unknown) as identity.DecodedJwt, + EVENT, PROJECT ) ).to.throw('Provided JWT has no "sub" (subject) claim.'); @@ -304,24 +435,25 @@ describe('identity', () => { aud: VALID_URL, iss: `${identity.JWT_ISSUER}${PROJECT}`, sub: '', + event_type: EVENT, } as identity.DecodedJwt, + EVENT, PROJECT ) ).to.throw('Provided JWT has no "sub" (subject) claim.'); }); it('should throw if sub length is larger than 128 chars', () => { - const str = []; - for (let i = 0; i < 129; i++) { - str.push(i); - } + const str = "a".repeat(129); expect(() => identity.checkDecodedToken( { aud: VALID_URL, iss: `${identity.JWT_ISSUER}${PROJECT}`, sub: str.toString(), + event_type: EVENT, } as identity.DecodedJwt, + EVENT, PROJECT ) ).to.throw( @@ -329,17 +461,109 @@ describe('identity', () => { ); }); - it('should not throw an error on correct decoded token', () => { - expect(() => - identity.checkDecodedToken( - { - aud: VALID_URL, - iss: `${identity.JWT_ISSUER}${PROJECT}`, - sub: '123456', - } as identity.DecodedJwt, - PROJECT - ) - ).to.not.throw(); + it('should not throw an error and set uid to sub', () => { + expect(() => { + const sub = '123456'; + const decoded = { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + sub: sub, + event_type: EVENT, + } as identity.DecodedJwt; + + identity.checkDecodedToken(decoded, EVENT, PROJECT); + + expect(decoded.uid).to.equal(sub); + }).to.not.throw(); + }); + }); + + describe('decodeJWT', () => { + let jwtDecodeStub: sinon.SinonStub; + + beforeEach(() => { + jwtDecodeStub = sinon.stub(jwt, "decode").throws("Unexpected call to jwt.decode"); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + it('should return empty if jwt decoded is undefined', () => { + jwtDecodeStub.returns(undefined); + + expect(identity.decodeJWT("123456")).to.deep.equal({}); + }); + + it('should return empty if payload is undefined', () => { + jwtDecodeStub.returns({ header: { key: 'val' }, }); + + expect(identity.decodeJWT("123456")).to.deep.equal({}); + }); + + it('should return the payload', () => { + const decoded = { + header: { key: 'val' }, + payload: { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + event_type: EVENT, + }, + }; + jwtDecodeStub.returns(decoded); + + expect(identity.decodeJWT("123456")).to.deep.equal(decoded.payload); + }); + }); + + describe('decodeAndVerifyJWT', () => { + const time = Date.now() + let jwtDecodeStub: sinon.SinonStub; + let jwtVerifyStub: sinon.SinonStub; + const keysCache = { + publicKeys: { + '123456': '7890', + '2468': '1357', + }, + publicKeysExpireAt: time + 1, + }; + + beforeEach(() => { + jwtDecodeStub = sinon.stub(jwt, "decode").throws("Unexpected call to jwt.decode"); + jwtVerifyStub = sinon.stub(jwt, "verify").throws("Unexpected call to jwt.verify"); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + it('should error if jwt decode returns undefined', () => { + jwtDecodeStub.returns(undefined); + + expect(() => identity.decodeAndVerifyJWT("123456", keysCache, time)).to.throw("Provided JWT has incorrect algorithm. Expected RS256 but got undefined."); + }); + + it('should error if header does not exist', () => { + jwtDecodeStub.returns({ key: 'val' }); + + expect(() => identity.decodeAndVerifyJWT("123456", keysCache, time)).to.throw("Provided JWT has incorrect algorithm. Expected RS256 but got undefined."); + }); + + it('should return the decoded jwt', () => { + const decoded = { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + event_type: EVENT, + }; + jwtDecodeStub.returns({ + header: { + alg: identity.JWT_ALG, + kid: '123456', + }, + }); + jwtVerifyStub.returns(decoded); + + expect(identity.decodeAndVerifyJWT("123456", keysCache, time)).to.deep.equal(decoded); }); }); @@ -363,7 +587,7 @@ describe('identity', () => { it('should parse a decoded metadata object', () => { const md = identity.parseMetadata(decodedMetadata); - expect(md.toJSON()).to.deep.equal(metadata); + expect(md).to.deep.equal(metadata); }); }); @@ -451,6 +675,7 @@ describe('identity', () => { displayName: undefined, enrollmentTime: now.toUTCString(), factorId: undefined, + phoneNumber: undefined, }, ], }; @@ -611,7 +836,7 @@ describe('identity', () => { }, { uid: 'enrollmentId2', - displayName: null, + displayName: undefined, enrollmentTime: now.toUTCString(), phoneNumber: '+16505556789', factorId: 'phone', @@ -622,14 +847,14 @@ describe('identity', () => { it('should error if decoded does not have uid', () => { expect(() => - identity.parseUserRecord({} as identity.DecodedJwtUserRecord) + identity.parseAuthUserRecord({} as identity.DecodedJwtUserRecord) ).to.throw('INTERNAL ASSERT FAILED: Invalid user response'); }); it('should parse user record', () => { - const ur = identity.parseUserRecord(decodedUserRecord); + const ur = identity.parseAuthUserRecord(decodedUserRecord); - expect(ur.toJSON()).to.deep.equal(userRecord); + expect(ur).to.deep.equal(userRecord); }); }); @@ -656,7 +881,7 @@ describe('identity', () => { sub: 'someUid', uid: 'someUid', event_id: 'EVENT_ID', - event_type: 'EVENT_TYPE', + event_type: EVENT, ip_address: '1.2.3.4', user_agent: 'USER_AGENT', locale: 'en', @@ -667,7 +892,7 @@ describe('identity', () => { ipAddress: '1.2.3.4', userAgent: 'USER_AGENT', eventId: 'EVENT_ID', - eventType: 'EVENT_TYPE', + eventType: EVENT, authType: 'UNAUTHENTICATED', resource: { service: 'identitytoolkit.googleapis.com', @@ -852,10 +1077,7 @@ describe('identity', () => { }); it('should throw an error if customClaims size is too big', () => { - let str = ''; - for (let i = 0; i < 1000; i++) { - str += 'x'; - } + const str = "x".repeat(1000); expect(() => identity.validateAuthResponse('beforeCreate', { @@ -875,10 +1097,7 @@ describe('identity', () => { }); it('should throw an error if sessionClaims size is too big', () => { - let str = ''; - for (let i = 0; i < 1000; i++) { - str += 'x'; - } + const str = "x".repeat(1000); expect(() => identity.validateAuthResponse('beforeSignIn', { @@ -890,10 +1109,7 @@ describe('identity', () => { }); it('should throw an error if the combined customClaims & sessionClaims size is too big', () => { - let str = ''; - for (let i = 0; i < 501; i++) { - str += 'x'; - } + const str = "x".repeat(501); expect(() => identity.validateAuthResponse('beforeSignIn', { diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index cb6fc436c..4bad71963 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -27,6 +27,7 @@ import * as jwt from 'jsonwebtoken'; import fetch from 'node-fetch'; import { HttpsError } from './https'; import { EventContext } from '../../cloud-functions'; +import { SUPPORTED_REGIONS } from '../../function-configuration'; import { logger } from '../..'; export { HttpsError }; @@ -42,6 +43,31 @@ export const JWT_ALG = 'RS256'; export const JWT_ISSUER = 'https://securetoken.google.com/'; /** @internal */ +export interface PublicKeysCache { + publicKeys: Record; + publicKeysExpireAt?: number; +} + +const CLAIMS_NON_ALLOW_LISTED = [ + 'acr', + 'amr', + 'at_hash', + 'aud', + 'auth_time', + 'azp', + 'cnf', + 'c_hash', + 'exp', + 'iat', + 'iss', + 'jti', + 'nbf', + 'nonce', + 'firebase', +]; + +const CLAIMS_MAX_PAYLOAD_SIZE = 1000; + const EVENT_MAPPING: Record = { beforeCreate: 'providers/cloud.auth/eventTypes/user.beforeCreate', beforeSignIn: 'providers/cloud.auth/eventTypes/user.beforeSignIn', @@ -59,24 +85,228 @@ export type UserRecord = firebase.auth.UserRecord; export type UserInfo = firebase.auth.UserInfo; /** - * Additional metadata about the user. + * Helper class to create the user metadata in a UserRecord object */ -export type UserMetadata = firebase.auth.UserMetadata; + export class UserRecordMetadata implements firebase.auth.UserMetadata { + constructor(public creationTime: string, public lastSignInTime: string) {} + + /** Returns a plain JavaScript object with the properties of UserRecordMetadata. */ + toJSON(): AuthUserMetadata { + return { + creationTime: this.creationTime, + lastSignInTime: this.lastSignInTime, + }; + } +} /** - * The multi-factor related properties for the current user, if available. + * Helper function that creates a UserRecord Class from data sent over the wire. + * @param wireData data sent over the wire + * @returns an instance of UserRecord with correct toJSON functions + */ +export function userRecordConstructor(wireData: Object): UserRecord { + // Falsey values from the wire format proto get lost when converted to JSON, this adds them back. + const falseyValues: any = { + email: null, + emailVerified: false, + displayName: null, + photoURL: null, + phoneNumber: null, + disabled: false, + providerData: [], + customClaims: {}, + passwordSalt: null, + passwordHash: null, + tokensValidAfterTime: null, + }; + const record = _.assign({}, falseyValues, wireData); + + const meta = _.get(record, 'metadata'); + if (meta) { + _.set( + record, + 'metadata', + new UserRecordMetadata( + meta.createdAt || meta.creationTime, + meta.lastSignedInAt || meta.lastSignInTime + ) + ); + } else { + _.set(record, 'metadata', new UserRecordMetadata(null, null)); + } + _.forEach(record.providerData, (entry) => { + _.set(entry, 'toJSON', () => { + return entry; + }); + }); + _.set(record, 'toJSON', () => { + const json: any = _.pick(record, [ + 'uid', + 'email', + 'emailVerified', + 'displayName', + 'photoURL', + 'phoneNumber', + 'disabled', + 'passwordHash', + 'passwordSalt', + 'tokensValidAfterTime', + ]); + json.metadata = _.get(record, 'metadata').toJSON(); + json.customClaims = _.cloneDeep(record.customClaims); + json.providerData = _.map(record.providerData, (entry) => entry.toJSON()); + return json; + }); + return record as UserRecord; +} + +/** + * User info that is part of the AuthUserRecord + */ +export interface AuthUserInfo { + /** + * The user identifier for the linked provider. + */ + uid: string; + /** + * The display name for the linked provider. + */ + displayName: string; + /** + * The email for the linked provider. + */ + email: string; + /** + * The photo URL for the linked provider. + */ + photoURL: string; + /** + * The linked provider ID (for example, "google.com" for the Google provider). + */ + providerId: string; + /** + * The phone number for the linked provider. + */ + phoneNumber: string; +} + +/** + * Additional metadata about the user. */ -export type MultiFactorSettings = firebase.auth.MultiFactorSettings; +export interface AuthUserMetadata { + /** + * The date the user was created, formatted as a UTC string. + */ + creationTime: string; + /** + * The date the user last signed in, formatted as a UTC string. + */ + lastSignInTime: string; +} /** * Interface representing the common properties of a user-enrolled second factor. */ -export type MultiFactorInfo = firebase.auth.MultiFactorInfo; +export interface AuthMultiFactorInfo { + /** + * The ID of the enrolled second factor. This ID is unique to the user. + */ + uid: string; + /** + * The optional display name of the enrolled second factor. + */ + displayName?: string; + /** + * The type identifier of the second factor. For SMS second factors, this is `phone`. + */ + factorId: string; + /** + * The optional date the second factor was enrolled, formatted as a UTC string. + */ + enrollmentTime?: string; + /** + * The phone number associated with a phone second factor. + */ + phoneNumber?: string; +} + +/** + * The multi-factor related properties for the current user, if available. + */ +export interface AuthMultiFactorSettings { + /** + * List of second factors enrolled with the current user. + */ + enrolledFactors: AuthMultiFactorInfo[]; +} /** - * Interface representing a phone specific user-enrolled second factor. + * The UserRecord passed to auth blocking Cloud Functions from the identity platform. */ -export type PhoneMultiFactorInfo = firebase.auth.PhoneMultiFactorInfo; +export interface AuthUserRecord { + /** + * The user's `uid`. + */ + uid: string; + /** + * The user's primary email, if set. + */ + email?: string; + /** + * Whether or not the user's primary email is verified. + */ + emailVerified: boolean; + /** + * The user's display name. + */ + displayName?: string; + /** + * The user's photo URL. + */ + photoURL?: string; + /** + * The user's primary phone number, if set. + */ + phoneNumber?: string; + /** + * Whether or not the user is disabled: `true` for disabled; `false` for + * enabled. + */ + disabled: boolean; + /** + * Additional metadata about the user. + */ + metadata: AuthUserMetadata; + /** + * An array of providers (for example, Google, Facebook) linked to the user. + */ + providerData: AuthUserInfo[]; + /** + * The user's hashed password (base64-encoded). + */ + passwordHash?: string; + /** + * The user's password salt (base64-encoded). + */ + passwordSalt?: string; + /** + * The user's custom claims object if available, typically used to define + * user roles and propagated to an authenticated user's ID token. + */ + customClaims?: Record; + /** + * The ID of the tenant the user belongs to, if available. + */ + tenantId?: string | null; + /** + * The date the user's tokens are valid after, formatted as a UTC string. + */ + tokensValidAfterTime?: string; + /** + * The multi-factor related properties for the current user, if available. + */ + multiFactor?: AuthMultiFactorSettings; +} /** The additional user info component of the auth event context */ interface AdditionalUserInfo { @@ -121,13 +351,11 @@ export interface BeforeSignInResponse extends BeforeCreateResponse { sessionClaims?: object; } -/** @internal */ interface DecodedJwtMetadata { creation_time?: number; last_sign_in_time?: number; } -/** @internal */ interface DecodedJwtUserInfo { uid: string; display_name?: string; @@ -146,7 +374,6 @@ export interface DecodedJwtMfaInfo { factor_id?: string; } -/** @internal */ interface DecodedJwtEnrolledFactors { enrolled_factors?: DecodedJwtMfaInfo[]; } @@ -199,114 +426,35 @@ export interface DecodedJwt { } /** - * Helper class to create the user metadata in a UserRecord object - */ -export class UserRecordMetadata implements UserMetadata { - constructor(public creationTime: string, public lastSignInTime: string) {} - - /** Returns a plain JavaScript object with the properties of UserRecordMetadata. */ - toJSON(): object { - return { - creationTime: this.creationTime, - lastSignInTime: this.lastSignInTime, - }; - } -} - -/** - * Helper class to create the user info in a UserRecord object - */ -export class UserRecordInfo implements UserInfo { - constructor( - public uid: string, - public displayName: string, - public email: string, - public photoURL: string, - public providerId: string, - public phoneNumber: string - ) {} - - toJSON(): object { - return { - uid: this.uid, - displayName: this.displayName, - email: this.email, - photoURL: this.photoURL, - providerId: this.providerId, - phoneNumber: this.phoneNumber, - }; - } -} - -/** - * Helper class to create the user MultiFactorInfo in a UserRecord object - */ -export class UserRecordMultiFactorInfo - implements Pick { - constructor( - public uid: string, - public factorId: string, - public displayName?: string, - public enrollmentTime?: string - ) {} - - toJSON(): object { - return { - uid: this.uid, - factorId: this.factorId, - displayName: this.displayName || null, - enrollmentTime: this.enrollmentTime || null, - }; - } -} - -/** - * Helper class to create the user PhoneMultiFactorInfo in a UserRecord object - */ -export class UserRecordPhoneMultiFactorInfo - implements Pick { - constructor( - public uid: string, - public factorId: string, - public phoneNumber: string, - public displayName?: string, - public enrollmentTime?: string - ) {} - - toJSON(): object { - return { - uid: this.uid, - factorId: this.factorId, - phoneNumber: this.phoneNumber, - displayName: this.displayName || null, - enrollmentTime: this.enrollmentTime || null, - }; - } -} - -/** - * Helper class to create the user MultiFactorSettings in a UserRecord object + * @internal + * Helper to determine if we refresh the public keys */ -export class UserRecordMultiFactorSettings implements MultiFactorSettings { - constructor(public enrolledFactors: MultiFactorInfo[]) {} - - toJSON(): object { - return { - enrolledFactors: this.enrolledFactors.map((ef) => ef.toJSON()), - }; +export function invalidPublicKeys(keys: PublicKeysCache, time: number = Date.now()): boolean { + if (!keys.publicKeysExpireAt) { + return true; } + return (time >= keys.publicKeysExpireAt); } /** * @internal - * Obtain public keys for use in decoding and verifying the jwt sent from identity platform + * Obtain public keys for use in decoding and verifying the jwt sent from identity platform. + * Will set the expiration time if available */ -export async function fetchPublicKeys(): Promise> { +export async function fetchPublicKeys(keys: PublicKeysCache, time: number = Date.now()): Promise { const url = `${JWT_CLIENT_CERT_URL}/${JWT_CLIENT_CERT_PATH}`; try { const response = await fetch(url); + if (response.headers.has('cache-control')) { + const ccHeader = response.headers.get('cache-control'); + const maxAgeEntry = ccHeader.split(', ').find((item) => item.includes('max-age')); + if (maxAgeEntry) { + const maxAge = +(maxAgeEntry.trim().split('=')[1]); + keys.publicKeysExpireAt = time + (maxAge * 1000); + } + } const data = await response.json(); - return data as Record; + keys.publicKeys = data as Record; } catch (err) { logger.error( `Failed to obtain public keys for JWT verification: ${err.message}` @@ -315,67 +463,6 @@ export async function fetchPublicKeys(): Promise> { } } -/** - * Helper function that creates a UserRecord Class from data sent over the wire. - * @param wireData data sent over the wire - * @returns an instance of UserRecord with correct toJSON functions - */ -export function userRecordConstructor(wireData: Object): UserRecord { - // Falsey values from the wire format proto get lost when converted to JSON, this adds them back. - const falseyValues: any = { - email: null, - emailVerified: false, - displayName: null, - photoURL: null, - phoneNumber: null, - disabled: false, - providerData: [], - customClaims: {}, - passwordSalt: null, - passwordHash: null, - tokensValidAfterTime: null, - }; - const record = _.assign({}, falseyValues, wireData); - - const meta = _.get(record, 'metadata'); - if (meta) { - _.set( - record, - 'metadata', - new UserRecordMetadata( - meta.createdAt || meta.creationTime, - meta.lastSignedInAt || meta.lastSignInTime - ) - ); - } else { - _.set(record, 'metadata', new UserRecordMetadata(null, null)); - } - _.forEach(record.providerData, (entry) => { - _.set(entry, 'toJSON', () => { - return entry; - }); - }); - _.set(record, 'toJSON', () => { - const json: any = _.pick(record, [ - 'uid', - 'email', - 'emailVerified', - 'displayName', - 'photoURL', - 'phoneNumber', - 'disabled', - 'passwordHash', - 'passwordSalt', - 'tokensValidAfterTime', - ]); - json.metadata = _.get(record, 'metadata').toJSON(); - json.customClaims = _.cloneDeep(record.customClaims); - json.providerData = _.map(record.providerData, (entry) => entry.toJSON()); - return json; - }); - return record as UserRecord; -} - /** * @internal * Checks for a valid identity platform web request, otherwise throws an HttpsError @@ -396,13 +483,13 @@ export function validRequest(req: express.Request): void { ); } - if (!req.body || !req.body.data || !req.body.data.jwt) { + if (!req.body?.data?.jwt) { throw new HttpsError('invalid-argument', 'Request has an invalid body.'); } } /** @internal */ -export function getPublicKey( +export function getPublicKeyFromHeader( header: Record, publicKeys: Record ): string { @@ -413,7 +500,7 @@ export function getPublicKey( ); } if (!header.kid) { - throw new HttpsError('invalid-argument', 'JWT has no "kid" claim.'); + throw new HttpsError('invalid-argument', 'JWT header missing "kid" claim.'); } if (!publicKeys.hasOwnProperty(header.kid)) { throw new HttpsError( @@ -436,23 +523,28 @@ export function isAuthorizedCloudFunctionURL( // Region can be: // us-central1, us-east1, asia-northeast1, europe-west1, asia-east1. // Sample: https://europe-west1-fb-sa-upgraded.cloudfunctions.net/function-1 - const gcf_directions = [ - 'central', - 'east', - 'west', - 'south', - 'southeast', - 'northeast', - // Other possible directions that could be added. - 'north', - 'southwest', - 'northwest', - ]; + + // const gcf_directions = [ + // 'central', + // 'east', + // 'west', + // 'south', + // 'southeast', + // 'northeast', + // // Other possible directions that could be added. + // 'north', + // 'southwest', + // 'northwest', + // ]; + const re = new RegExp( - `^https://[^-]+-(${gcf_directions.join( - '|' - )})[0-9]+-${projectId}\.cloudfunctions\.net/` + `^https://(${SUPPORTED_REGIONS.join('|')})+-${projectId}\.cloudfunctions\.net/` ); + // const re = new RegExp( + // `^https://[^-]+-(${gcf_directions.join( + // '|' + // )})[0-9]+-${projectId}\.cloudfunctions\.net/` + // ); const res = re.exec(cloudFunctionUrl) || []; return res.length > 0; } @@ -463,8 +555,15 @@ export function isAuthorizedCloudFunctionURL( */ export function checkDecodedToken( decodedJWT: DecodedJwt, + eventType: string, projectId: string ): void { + if (decodedJWT.event_type !== eventType) { + throw new HttpsError( + 'invalid-argument', + `Expected "${eventType}" but received "${decodedJWT.event_type}".` + ); + } if (!isAuthorizedCloudFunctionURL(decodedJWT.aud, projectId)) { throw new HttpsError( 'invalid-argument', @@ -490,6 +589,28 @@ export function checkDecodedToken( 'Provided JWT has "sub" (subject) claim longer than 128 characters.' ); } + // set uid to sub + decodedJWT.uid = decodedJWT.sub; +} + +/** + * Helper function to determine if we need to do full verification of the jwt +*/ +function shouldVerifyJWT() { + // TODO(colerogers): add emulator support to skip verification + return true; +} + +/** + * @internal + * Helper function to decode the jwt and return it's payload + */ +export function decodeJWT( + token: string, +): DecodedJwt { + const decoded = (jwt.decode(token, { complete: true }) as Record || {}); + + return (decoded.payload || {}) as DecodedJwt; } /** @@ -497,29 +618,21 @@ export function checkDecodedToken( * Verifies the jwt using the 'jwt' library and decodes the token with the public keys * Throws an error if the event types do not match */ -function verifyAndDecodeJWT( +export function decodeAndVerifyJWT( token: string, - eventType: string, - publicKeys: Record, - projectId: string + keysCache: PublicKeysCache, + time: number = Date.now(), ) { - // jwt decode & verify - // https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback + if (invalidPublicKeys(keysCache, time)) { + fetchPublicKeys(keysCache); + } const header = - (jwt.decode(token, { complete: true }) as Record).header || {}; - const publicKey = getPublicKey(header, publicKeys); + ((jwt.decode(token, { complete: true }) as Record || {}).header || {}); + const publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); const decoded = jwt.verify(token, publicKey, { - algorithms: [this.algorithm], + algorithms: [JWT_ALG], }) as DecodedJwt; - decoded.uid = decoded.sub; - checkDecodedToken(decoded, projectId); - - if (decoded.event_type !== eventType) { - throw new HttpsError( - 'invalid-argument', - `Expected "${eventType}" but received "${decoded.event_type}".` - ); - } + return decoded; } @@ -527,35 +640,36 @@ function verifyAndDecodeJWT( * @internal * Helper function to parse the decoded metadata object into a UserMetaData object */ -export function parseMetadata(metadata: DecodedJwtMetadata): UserMetadata { +export function parseMetadata(metadata: DecodedJwtMetadata): AuthUserMetadata { const creationTime = metadata?.creation_time ? new Date((metadata.creation_time as number) * 1000).toUTCString() : null; const lastSignInTime = metadata?.last_sign_in_time ? new Date((metadata.last_sign_in_time as number) * 1000).toUTCString() : null; - return new UserRecordMetadata(creationTime, lastSignInTime); + return { + creationTime, + lastSignInTime, + }; } /** * @internal - * Helper function to parse the decoded user info array into a UserInfo array + * Helper function to parse the decoded user info array into an AuthUserInfo array */ export function parseProviderData( providerData: DecodedJwtUserInfo[] -): UserInfo[] { - const providers: UserInfo[] = []; +): AuthUserInfo[] { + const providers: AuthUserInfo[] = []; for (const provider of providerData) { - providers.push( - new UserRecordInfo( - provider.uid, - provider.display_name, - provider.email, - provider.photo_url, - provider.provider_id, - provider.phone_number - ) - ); + providers.push({ + uid: provider.uid, + displayName: provider.display_name, + email: provider.email, + photoURL: provider.photo_url, + providerId: provider.provider_id, + phoneNumber: provider.phone_number + }); } return providers; } @@ -574,9 +688,7 @@ export function parseDate(tokensValidAfterTime?: number): string | null { if (!isNaN(date.getTime())) { return date.toUTCString(); } - } catch { - return null; - } + } catch {} return null; } @@ -586,11 +698,11 @@ export function parseDate(tokensValidAfterTime?: number): string | null { */ export function parseMultiFactor( multiFactor?: DecodedJwtEnrolledFactors -): MultiFactorSettings { +): AuthMultiFactorSettings { if (!multiFactor) { return null; } - const parsedEnrolledFactors: MultiFactorInfo[] = []; + const parsedEnrolledFactors: AuthMultiFactorInfo[] = []; for (const factor of multiFactor.enrolled_factors || []) { if (!factor.uid) { throw new HttpsError( @@ -601,30 +713,19 @@ export function parseMultiFactor( const enrollmentTime = factor.enrollment_time ? new Date(factor.enrollment_time).toUTCString() : null; - if (factor.phone_number) { - parsedEnrolledFactors.push( - new UserRecordPhoneMultiFactorInfo( - factor.uid, - factor.factor_id || 'phone', - factor.phone_number, - factor.display_name, - enrollmentTime - ) as PhoneMultiFactorInfo - ); - } else { - parsedEnrolledFactors.push( - new UserRecordMultiFactorInfo( - factor.uid, - factor.factor_id, - factor.display_name, - enrollmentTime - ) as MultiFactorInfo - ); - } + parsedEnrolledFactors.push({ + uid: factor.uid, + factorId: factor.phone_number ? factor.factor_id || 'phone' : factor.factor_id, + displayName: factor.display_name, + enrollmentTime, + phoneNumber: factor.phone_number, + }); } if (parsedEnrolledFactors.length > 0) { - return new UserRecordMultiFactorSettings(parsedEnrolledFactors); + return { + enrolledFactors: parsedEnrolledFactors, + }; } return null; } @@ -633,9 +734,9 @@ export function parseMultiFactor( * @internal * Parses the decoded user record into a valid UserRecord for use in the handler */ -export function parseUserRecord( +export function parseAuthUserRecord( decodedJWTUserRecord: DecodedJwtUserRecord -): UserRecord { +): AuthUserRecord { if (!decodedJWTUserRecord.uid) { throw new HttpsError( 'internal', @@ -667,30 +768,10 @@ export function parseUserRecord( tenantId: decodedJWTUserRecord.tenant_id, tokensValidAfterTime, multiFactor, - toJSON: function(): object { - const json: any = { - uid: decodedJWTUserRecord.uid, - email: decodedJWTUserRecord.email, - emailVerified: decodedJWTUserRecord.email_verified, - displayName: decodedJWTUserRecord.display_name, - photoURL: decodedJWTUserRecord.photo_url, - phoneNumber: decodedJWTUserRecord.phone_number, - disabled: disabled, - metadata: metadata.toJSON(), - providerData: providerData.map((pd) => pd.toJSON()), - passwordHash: decodedJWTUserRecord.password_hash, - passwordSalt: decodedJWTUserRecord.password_salt, - customClaims: decodedJWTUserRecord.custom_claims, - tenantId: decodedJWTUserRecord.tenant_id, - tokensValidAfterTime, - multiFactor: multiFactor.toJSON(), - }; - return json; - }, }; } -/** @internal */ +/** Helper to get the AdditionalUserInfo from the decoded jwt */ function parseAdditionalUserInfo(decodedJWT: DecodedJwt): AdditionalUserInfo { let profile, username; if (decodedJWT.raw_user_info) @@ -719,7 +800,7 @@ function parseAdditionalUserInfo(decodedJWT: DecodedJwt): AdditionalUserInfo { }; } -/** @internal */ +/** Helper to get the Credential from the decoded jwt */ function parseAuthCredential(decodedJWT: DecodedJwt, time: number): Credential { if ( !decodedJWT.sign_in_attributes && @@ -788,30 +869,11 @@ export function validateAuthResponse( eventType: string, authRequest?: BeforeCreateResponse | BeforeSignInResponse ) { - const nonAllowListedClaims = [ - 'acr', - 'amr', - 'at_hash', - 'aud', - 'auth_time', - 'azp', - 'cnf', - 'c_hash', - 'exp', - 'iat', - 'iss', - 'jti', - 'nbf', - 'nonce', - 'firebase', - ]; - const claimsMaxPayloadSize = 1000; - if (!authRequest) { authRequest = {}; } if (authRequest.customClaims) { - const invalidClaims = nonAllowListedClaims.filter((claim) => + const invalidClaims = CLAIMS_NON_ALLOW_LISTED.filter((claim) => authRequest.customClaims.hasOwnProperty(claim) ); if (invalidClaims.length > 0) { @@ -823,11 +885,11 @@ export function validateAuthResponse( ); } if ( - JSON.stringify(authRequest.customClaims).length > claimsMaxPayloadSize + JSON.stringify(authRequest.customClaims).length > CLAIMS_MAX_PAYLOAD_SIZE ) { throw new HttpsError( 'invalid-argument', - `The customClaims payload should not exceed ${claimsMaxPayloadSize} characters.` + `The customClaims payload should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters.` ); } } @@ -835,7 +897,7 @@ export function validateAuthResponse( eventType === 'beforeSignIn' && (authRequest as BeforeSignInResponse).sessionClaims ) { - const invalidClaims = nonAllowListedClaims.filter((claim) => + const invalidClaims = CLAIMS_NON_ALLOW_LISTED.filter((claim) => (authRequest as BeforeSignInResponse).sessionClaims.hasOwnProperty(claim) ); if (invalidClaims.length > 0) { @@ -848,21 +910,21 @@ export function validateAuthResponse( } if ( JSON.stringify((authRequest as BeforeSignInResponse).sessionClaims) - .length > claimsMaxPayloadSize + .length > CLAIMS_MAX_PAYLOAD_SIZE ) { throw new HttpsError( 'invalid-argument', - `The sessionClaims payload should not exceed ${claimsMaxPayloadSize} characters.` + `The sessionClaims payload should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters.` ); } const combinedClaims = { ...authRequest.customClaims, ...(authRequest as BeforeSignInResponse).sessionClaims, }; - if (JSON.stringify(combinedClaims).length > claimsMaxPayloadSize) { + if (JSON.stringify(combinedClaims).length > CLAIMS_MAX_PAYLOAD_SIZE) { throw new HttpsError( 'invalid-argument', - `The customClaims and sessionClaims payloads should not exceed ${claimsMaxPayloadSize} characters combined.` + `The customClaims and sessionClaims payloads should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters combined.` ); } } @@ -896,7 +958,7 @@ export function getUpdateMask( /** @internal */ export function createHandler( handler: ( - user: UserRecord, + user: AuthUserRecord, context: AuthEventContext ) => | BeforeCreateResponse @@ -905,9 +967,10 @@ export function createHandler( | Promise | void | Promise, - eventType: string + eventType: string, + keysCache: PublicKeysCache ): (req: express.Request, resp: express.Response) => Promise { - const wrappedHandler = wrapHandler(handler, eventType); + const wrappedHandler = wrapHandler(handler, eventType, keysCache); return (req: express.Request, res: express.Response) => { return new Promise((resolve) => { res.on('finish', resolve); @@ -916,10 +979,9 @@ export function createHandler( }; } -/** @internal */ function wrapHandler( handler: ( - user: UserRecord, + user: AuthUserRecord, context: AuthEventContext ) => | BeforeCreateResponse @@ -928,23 +990,22 @@ function wrapHandler( | Promise | void | Promise, - eventType: string + eventType: string, + keysCache: PublicKeysCache ) { return async (req: express.Request, res: express.Response): Promise => { try { const projectId = process.env.GCLOUD_PROJECT; - const publicKeys = await fetchPublicKeys(); validRequest(req); - const decodedJWT = verifyAndDecodeJWT( - req.body.data.jwt, - eventType, - publicKeys, - projectId - ); - const userRecord = parseUserRecord(decodedJWT.user_record); + const decodedJWT = + shouldVerifyJWT() ? + decodeAndVerifyJWT(req.body.data.jwt, keysCache) : + decodeJWT(req.body.data.jwt); + checkDecodedToken(decodedJWT, eventType, projectId); + const authUserRecord = parseAuthUserRecord(decodedJWT.user_record); const authEventContext = parseAuthEventContext(decodedJWT, projectId); const authResponse = - (await handler(userRecord, authEventContext)) || undefined; + (await handler(authUserRecord, authEventContext)) || undefined; validateAuthResponse(eventType, authResponse); const updateMask = getUpdateMask(authResponse); const result = { From 9e0900049744f1dc491d75e540c99f46ddd3742e Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Wed, 9 Feb 2022 17:33:06 -0500 Subject: [PATCH 10/18] formatter --- spec/common/providers/identity.spec.ts | 113 +++++++++----- src/common/providers/identity.ts | 197 +++++++++++++------------ 2 files changed, 180 insertions(+), 130 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 9ef7f580c..1e6e61e0a 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -35,33 +35,50 @@ const now = new Date(); describe('identity', () => { describe('invalidPublicKeys', () => { it('should return true if publicKeysExpireAt does not exist', () => { - expect(identity.invalidPublicKeys({ - publicKeys: {}, - })).to.be.true; + expect( + identity.invalidPublicKeys({ + publicKeys: {}, + }) + ).to.be.true; }); it('should return true if publicKeysExpireAt equals Date.now()', () => { const time = Date.now(); - expect(identity.invalidPublicKeys({ - publicKeys: {}, - publicKeysExpireAt: time, - }, time)).to.be.true; + expect( + identity.invalidPublicKeys( + { + publicKeys: {}, + publicKeysExpireAt: time, + }, + time + ) + ).to.be.true; }); it('should return true if publicKeysExpireAt are less than Date.now()', () => { const time = Date.now(); - expect(identity.invalidPublicKeys({ - publicKeys: {}, - publicKeysExpireAt: time - 1, - }, time)).to.be.true; + expect( + identity.invalidPublicKeys( + { + publicKeys: {}, + publicKeysExpireAt: time - 1, + }, + time + ) + ).to.be.true; }); it('should return false if publicKeysExpireAt are greater than Date.now()', () => { const time = Date.now(); - expect(identity.invalidPublicKeys({ - publicKeys: {}, - publicKeysExpireAt: time + 100, - }, time)).to.be.false; + expect( + identity.invalidPublicKeys( + { + publicKeys: {}, + publicKeysExpireAt: time + 100, + }, + time + ) + ).to.be.false; }); }); @@ -308,7 +325,9 @@ describe('identity', () => { describe('getPublicKeyFromHeader', () => { it('should throw if header.alg is not expected', () => { - expect(() => identity.getPublicKeyFromHeader({ alg: 'RS128' }, {})).to.throw( + expect(() => + identity.getPublicKeyFromHeader({ alg: 'RS128' }, {}) + ).to.throw( `Provided JWT has incorrect algorithm. Expected ${identity.JWT_ALG} but got RS128.` ); }); @@ -376,11 +395,15 @@ describe('identity', () => { describe('checkDecodedToken', () => { it('should throw on mismatching event types', () => { - expect(() => identity.checkDecodedToken({ - event_type: EVENT, - } as identity.DecodedJwt, - "newEvent", - PROJECT)).to.throw(`Expected "newEvent" but received "${EVENT}".`); + expect(() => + identity.checkDecodedToken( + { + event_type: EVENT, + } as identity.DecodedJwt, + 'newEvent', + PROJECT + ) + ).to.throw(`Expected "newEvent" but received "${EVENT}".`); }); it('should throw on unauthorized function url', () => { expect(() => @@ -444,7 +467,7 @@ describe('identity', () => { }); it('should throw if sub length is larger than 128 chars', () => { - const str = "a".repeat(129); + const str = 'a'.repeat(129); expect(() => identity.checkDecodedToken( { @@ -470,7 +493,7 @@ describe('identity', () => { sub: sub, event_type: EVENT, } as identity.DecodedJwt; - + identity.checkDecodedToken(decoded, EVENT, PROJECT); expect(decoded.uid).to.equal(sub); @@ -482,7 +505,9 @@ describe('identity', () => { let jwtDecodeStub: sinon.SinonStub; beforeEach(() => { - jwtDecodeStub = sinon.stub(jwt, "decode").throws("Unexpected call to jwt.decode"); + jwtDecodeStub = sinon + .stub(jwt, 'decode') + .throws('Unexpected call to jwt.decode'); }); afterEach(() => { @@ -492,13 +517,13 @@ describe('identity', () => { it('should return empty if jwt decoded is undefined', () => { jwtDecodeStub.returns(undefined); - expect(identity.decodeJWT("123456")).to.deep.equal({}); + expect(identity.decodeJWT('123456')).to.deep.equal({}); }); it('should return empty if payload is undefined', () => { - jwtDecodeStub.returns({ header: { key: 'val' }, }); + jwtDecodeStub.returns({ header: { key: 'val' } }); - expect(identity.decodeJWT("123456")).to.deep.equal({}); + expect(identity.decodeJWT('123456')).to.deep.equal({}); }); it('should return the payload', () => { @@ -512,12 +537,12 @@ describe('identity', () => { }; jwtDecodeStub.returns(decoded); - expect(identity.decodeJWT("123456")).to.deep.equal(decoded.payload); + expect(identity.decodeJWT('123456')).to.deep.equal(decoded.payload); }); }); describe('decodeAndVerifyJWT', () => { - const time = Date.now() + const time = Date.now(); let jwtDecodeStub: sinon.SinonStub; let jwtVerifyStub: sinon.SinonStub; const keysCache = { @@ -529,8 +554,12 @@ describe('identity', () => { }; beforeEach(() => { - jwtDecodeStub = sinon.stub(jwt, "decode").throws("Unexpected call to jwt.decode"); - jwtVerifyStub = sinon.stub(jwt, "verify").throws("Unexpected call to jwt.verify"); + jwtDecodeStub = sinon + .stub(jwt, 'decode') + .throws('Unexpected call to jwt.decode'); + jwtVerifyStub = sinon + .stub(jwt, 'verify') + .throws('Unexpected call to jwt.verify'); }); afterEach(() => { @@ -540,13 +569,21 @@ describe('identity', () => { it('should error if jwt decode returns undefined', () => { jwtDecodeStub.returns(undefined); - expect(() => identity.decodeAndVerifyJWT("123456", keysCache, time)).to.throw("Provided JWT has incorrect algorithm. Expected RS256 but got undefined."); + expect(() => + identity.decodeAndVerifyJWT('123456', keysCache, time) + ).to.throw( + 'Provided JWT has incorrect algorithm. Expected RS256 but got undefined.' + ); }); it('should error if header does not exist', () => { jwtDecodeStub.returns({ key: 'val' }); - expect(() => identity.decodeAndVerifyJWT("123456", keysCache, time)).to.throw("Provided JWT has incorrect algorithm. Expected RS256 but got undefined."); + expect(() => + identity.decodeAndVerifyJWT('123456', keysCache, time) + ).to.throw( + 'Provided JWT has incorrect algorithm. Expected RS256 but got undefined.' + ); }); it('should return the decoded jwt', () => { @@ -563,7 +600,9 @@ describe('identity', () => { }); jwtVerifyStub.returns(decoded); - expect(identity.decodeAndVerifyJWT("123456", keysCache, time)).to.deep.equal(decoded); + expect( + identity.decodeAndVerifyJWT('123456', keysCache, time) + ).to.deep.equal(decoded); }); }); @@ -1077,7 +1116,7 @@ describe('identity', () => { }); it('should throw an error if customClaims size is too big', () => { - const str = "x".repeat(1000); + const str = 'x'.repeat(1000); expect(() => identity.validateAuthResponse('beforeCreate', { @@ -1097,7 +1136,7 @@ describe('identity', () => { }); it('should throw an error if sessionClaims size is too big', () => { - const str = "x".repeat(1000); + const str = 'x'.repeat(1000); expect(() => identity.validateAuthResponse('beforeSignIn', { @@ -1109,7 +1148,7 @@ describe('identity', () => { }); it('should throw an error if the combined customClaims & sessionClaims size is too big', () => { - const str = "x".repeat(501); + const str = 'x'.repeat(501); expect(() => identity.validateAuthResponse('beforeSignIn', { diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 4bad71963..01b55a99a 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -87,7 +87,7 @@ export type UserInfo = firebase.auth.UserInfo; /** * Helper class to create the user metadata in a UserRecord object */ - export class UserRecordMetadata implements firebase.auth.UserMetadata { +export class UserRecordMetadata implements firebase.auth.UserMetadata { constructor(public creationTime: string, public lastSignInTime: string) {} /** Returns a plain JavaScript object with the properties of UserRecordMetadata. */ @@ -167,28 +167,28 @@ export interface AuthUserInfo { /** * The user identifier for the linked provider. */ - uid: string; + uid: string; /** - * The display name for the linked provider. - */ - displayName: string; + * The display name for the linked provider. + */ + displayName: string; /** - * The email for the linked provider. - */ - email: string; + * The email for the linked provider. + */ + email: string; /** - * The photo URL for the linked provider. - */ - photoURL: string; + * The photo URL for the linked provider. + */ + photoURL: string; /** - * The linked provider ID (for example, "google.com" for the Google provider). - */ - providerId: string; + * The linked provider ID (for example, "google.com" for the Google provider). + */ + providerId: string; /** - * The phone number for the linked provider. - */ - phoneNumber: string; -} + * The phone number for the linked provider. + */ + phoneNumber: string; +} /** * Additional metadata about the user. @@ -199,8 +199,8 @@ export interface AuthUserMetadata { */ creationTime: string; /** - * The date the user last signed in, formatted as a UTC string. - */ + * The date the user last signed in, formatted as a UTC string. + */ lastSignInTime: string; } @@ -213,16 +213,16 @@ export interface AuthMultiFactorInfo { */ uid: string; /** - * The optional display name of the enrolled second factor. - */ + * The optional display name of the enrolled second factor. + */ displayName?: string; /** - * The type identifier of the second factor. For SMS second factors, this is `phone`. - */ + * The type identifier of the second factor. For SMS second factors, this is `phone`. + */ factorId: string; /** - * The optional date the second factor was enrolled, formatted as a UTC string. - */ + * The optional date the second factor was enrolled, formatted as a UTC string. + */ enrollmentTime?: string; /** * The phone number associated with a phone second factor. @@ -247,64 +247,64 @@ export interface AuthUserRecord { /** * The user's `uid`. */ - uid: string; + uid: string; /** - * The user's primary email, if set. - */ - email?: string; + * The user's primary email, if set. + */ + email?: string; /** - * Whether or not the user's primary email is verified. - */ - emailVerified: boolean; + * Whether or not the user's primary email is verified. + */ + emailVerified: boolean; /** - * The user's display name. - */ - displayName?: string; + * The user's display name. + */ + displayName?: string; /** - * The user's photo URL. - */ - photoURL?: string; + * The user's photo URL. + */ + photoURL?: string; /** - * The user's primary phone number, if set. - */ - phoneNumber?: string; + * The user's primary phone number, if set. + */ + phoneNumber?: string; /** - * Whether or not the user is disabled: `true` for disabled; `false` for - * enabled. - */ - disabled: boolean; + * Whether or not the user is disabled: `true` for disabled; `false` for + * enabled. + */ + disabled: boolean; /** - * Additional metadata about the user. - */ - metadata: AuthUserMetadata; + * Additional metadata about the user. + */ + metadata: AuthUserMetadata; /** - * An array of providers (for example, Google, Facebook) linked to the user. - */ - providerData: AuthUserInfo[]; + * An array of providers (for example, Google, Facebook) linked to the user. + */ + providerData: AuthUserInfo[]; /** - * The user's hashed password (base64-encoded). - */ - passwordHash?: string; + * The user's hashed password (base64-encoded). + */ + passwordHash?: string; /** - * The user's password salt (base64-encoded). - */ - passwordSalt?: string; + * The user's password salt (base64-encoded). + */ + passwordSalt?: string; /** - * The user's custom claims object if available, typically used to define - * user roles and propagated to an authenticated user's ID token. - */ - customClaims?: Record; + * The user's custom claims object if available, typically used to define + * user roles and propagated to an authenticated user's ID token. + */ + customClaims?: Record; /** - * The ID of the tenant the user belongs to, if available. - */ - tenantId?: string | null; + * The ID of the tenant the user belongs to, if available. + */ + tenantId?: string | null; /** - * The date the user's tokens are valid after, formatted as a UTC string. - */ + * The date the user's tokens are valid after, formatted as a UTC string. + */ tokensValidAfterTime?: string; /** - * The multi-factor related properties for the current user, if available. - */ + * The multi-factor related properties for the current user, if available. + */ multiFactor?: AuthMultiFactorSettings; } @@ -429,11 +429,14 @@ export interface DecodedJwt { * @internal * Helper to determine if we refresh the public keys */ -export function invalidPublicKeys(keys: PublicKeysCache, time: number = Date.now()): boolean { +export function invalidPublicKeys( + keys: PublicKeysCache, + time: number = Date.now() +): boolean { if (!keys.publicKeysExpireAt) { return true; } - return (time >= keys.publicKeysExpireAt); + return time >= keys.publicKeysExpireAt; } /** @@ -441,16 +444,21 @@ export function invalidPublicKeys(keys: PublicKeysCache, time: number = Date.now * Obtain public keys for use in decoding and verifying the jwt sent from identity platform. * Will set the expiration time if available */ -export async function fetchPublicKeys(keys: PublicKeysCache, time: number = Date.now()): Promise { +export async function fetchPublicKeys( + keys: PublicKeysCache, + time: number = Date.now() +): Promise { const url = `${JWT_CLIENT_CERT_URL}/${JWT_CLIENT_CERT_PATH}`; try { const response = await fetch(url); if (response.headers.has('cache-control')) { const ccHeader = response.headers.get('cache-control'); - const maxAgeEntry = ccHeader.split(', ').find((item) => item.includes('max-age')); + const maxAgeEntry = ccHeader + .split(', ') + .find((item) => item.includes('max-age')); if (maxAgeEntry) { - const maxAge = +(maxAgeEntry.trim().split('=')[1]); - keys.publicKeysExpireAt = time + (maxAge * 1000); + const maxAge = +maxAgeEntry.trim().split('=')[1]; + keys.publicKeysExpireAt = time + maxAge * 1000; } } const data = await response.json(); @@ -536,9 +544,11 @@ export function isAuthorizedCloudFunctionURL( // 'southwest', // 'northwest', // ]; - + const re = new RegExp( - `^https://(${SUPPORTED_REGIONS.join('|')})+-${projectId}\.cloudfunctions\.net/` + `^https://(${SUPPORTED_REGIONS.join( + '|' + )})+-${projectId}\.cloudfunctions\.net/` ); // const re = new RegExp( // `^https://[^-]+-(${gcf_directions.join( @@ -593,9 +603,9 @@ export function checkDecodedToken( decodedJWT.uid = decodedJWT.sub; } -/** +/** * Helper function to determine if we need to do full verification of the jwt -*/ + */ function shouldVerifyJWT() { // TODO(colerogers): add emulator support to skip verification return true; @@ -605,11 +615,10 @@ function shouldVerifyJWT() { * @internal * Helper function to decode the jwt and return it's payload */ -export function decodeJWT( - token: string, -): DecodedJwt { - const decoded = (jwt.decode(token, { complete: true }) as Record || {}); - +export function decodeJWT(token: string): DecodedJwt { + const decoded = + (jwt.decode(token, { complete: true }) as Record) || {}; + return (decoded.payload || {}) as DecodedJwt; } @@ -621,18 +630,19 @@ export function decodeJWT( export function decodeAndVerifyJWT( token: string, keysCache: PublicKeysCache, - time: number = Date.now(), + time: number = Date.now() ) { if (invalidPublicKeys(keysCache, time)) { fetchPublicKeys(keysCache); } const header = - ((jwt.decode(token, { complete: true }) as Record || {}).header || {}); + ((jwt.decode(token, { complete: true }) as Record) || {}) + .header || {}; const publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); const decoded = jwt.verify(token, publicKey, { algorithms: [JWT_ALG], }) as DecodedJwt; - + return decoded; } @@ -668,7 +678,7 @@ export function parseProviderData( email: provider.email, photoURL: provider.photo_url, providerId: provider.provider_id, - phoneNumber: provider.phone_number + phoneNumber: provider.phone_number, }); } return providers; @@ -715,7 +725,9 @@ export function parseMultiFactor( : null; parsedEnrolledFactors.push({ uid: factor.uid, - factorId: factor.phone_number ? factor.factor_id || 'phone' : factor.factor_id, + factorId: factor.phone_number + ? factor.factor_id || 'phone' + : factor.factor_id, displayName: factor.display_name, enrollmentTime, phoneNumber: factor.phone_number, @@ -997,10 +1009,9 @@ function wrapHandler( try { const projectId = process.env.GCLOUD_PROJECT; validRequest(req); - const decodedJWT = - shouldVerifyJWT() ? - decodeAndVerifyJWT(req.body.data.jwt, keysCache) : - decodeJWT(req.body.data.jwt); + const decodedJWT = shouldVerifyJWT() + ? decodeAndVerifyJWT(req.body.data.jwt, keysCache) + : decodeJWT(req.body.data.jwt); checkDecodedToken(decodedJWT, eventType, projectId); const authUserRecord = parseAuthUserRecord(decodedJWT.user_record); const authEventContext = parseAuthEventContext(decodedJWT, projectId); From ad44211f18003a82a4d9bee3ade92b6206f4ca4c Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 10 Feb 2022 10:35:21 -0500 Subject: [PATCH 11/18] decoupling decode & verify --- spec/common/providers/identity.spec.ts | 74 +++++++++++++++----------- src/common/providers/identity.ts | 62 ++++++++++++--------- 2 files changed, 81 insertions(+), 55 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 1e6e61e0a..6fe9dc9f0 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -514,19 +514,27 @@ describe('identity', () => { sinon.verifyAndRestore(); }); - it('should return empty if jwt decoded is undefined', () => { + it('should throw HttpsError if jwt.decode errors', () => { + jwtDecodeStub.throws('An internal decode error occurred.'); + + expect(() => identity.decodeJWT("123456")).to.throw('Failed to decode the JWT.'); + }) + + it('should error if jwt decoded returns undefined', () => { jwtDecodeStub.returns(undefined); - expect(identity.decodeJWT('123456')).to.deep.equal({}); + expect(() => identity.decodeJWT('123456')).to.throw('The decoded JWT is not structured correctly.'); }); - it('should return empty if payload is undefined', () => { - jwtDecodeStub.returns({ header: { key: 'val' } }); + it('should error if decoded jwt does not have a payload field', () => { + jwtDecodeStub.returns({ + header: { key: 'val' }, + }); - expect(identity.decodeJWT('123456')).to.deep.equal({}); + expect(() => identity.decodeJWT("123456")).to.throw('The decoded JWT is not structured correctly.'); }); - it('should return the payload', () => { + it('should return the raw decoded jwt', () => { const decoded = { header: { key: 'val' }, payload: { @@ -537,13 +545,19 @@ describe('identity', () => { }; jwtDecodeStub.returns(decoded); - expect(identity.decodeJWT('123456')).to.deep.equal(decoded.payload); + expect(identity.decodeJWT('123456')).to.deep.equal(decoded); }); }); - describe('decodeAndVerifyJWT', () => { + describe('shouldVerifyJWT', () => { + /** Stub test that will fail when we eventually change the function */ + it('should return true', () => { + expect(identity.shouldVerifyJWT()).to.be.true; + }); + }) + + describe('verifyJWT', () => { const time = Date.now(); - let jwtDecodeStub: sinon.SinonStub; let jwtVerifyStub: sinon.SinonStub; const keysCache = { publicKeys: { @@ -552,11 +566,19 @@ describe('identity', () => { }, publicKeysExpireAt: time + 1, }; + const rawDecodedJWT = { + header: { + alg: identity.JWT_ALG, + kid: '2468', + }, + payload: { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + event_type: EVENT, + }, + }; beforeEach(() => { - jwtDecodeStub = sinon - .stub(jwt, 'decode') - .throws('Unexpected call to jwt.decode'); jwtVerifyStub = sinon .stub(jwt, 'verify') .throws('Unexpected call to jwt.verify'); @@ -566,25 +588,21 @@ describe('identity', () => { sinon.verifyAndRestore(); }); - it('should error if jwt decode returns undefined', () => { - jwtDecodeStub.returns(undefined); + it('should error if header does not exist', () => { + const rawDecodedJwt = { payload: 'val' }; expect(() => - identity.decodeAndVerifyJWT('123456', keysCache, time) + identity.verifyJWT('123456', rawDecodedJwt, keysCache, time) ).to.throw( - 'Provided JWT has incorrect algorithm. Expected RS256 but got undefined.' + 'Unable to verify JWT payload, the decoded JWT does not have a header property.' ); }); - it('should error if header does not exist', () => { - jwtDecodeStub.returns({ key: 'val' }); + it('should error if jwt verify errors', () => { + jwtVerifyStub.throws('Internal failure of jwt verify.'); - expect(() => - identity.decodeAndVerifyJWT('123456', keysCache, time) - ).to.throw( - 'Provided JWT has incorrect algorithm. Expected RS256 but got undefined.' - ); - }); + expect(() => identity.verifyJWT('123456', rawDecodedJWT, keysCache, time)).to.throw('Failed to verify the JWT.'); + }) it('should return the decoded jwt', () => { const decoded = { @@ -592,16 +610,10 @@ describe('identity', () => { iss: `${identity.JWT_ISSUER}${PROJECT}`, event_type: EVENT, }; - jwtDecodeStub.returns({ - header: { - alg: identity.JWT_ALG, - kid: '123456', - }, - }); jwtVerifyStub.returns(decoded); expect( - identity.decodeAndVerifyJWT('123456', keysCache, time) + identity.verifyJWT('123456', rawDecodedJWT, keysCache, time) ).to.deep.equal(decoded); }); }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 01b55a99a..95c439aa8 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -309,7 +309,7 @@ export interface AuthUserRecord { } /** The additional user info component of the auth event context */ -interface AdditionalUserInfo { +export interface AdditionalUserInfo { providerId: string; profile?: any; username?: string; @@ -317,7 +317,7 @@ interface AdditionalUserInfo { } /** The credential component of the auth event context */ -interface Credential { +export interface Credential { claims?: { [key: string]: any }; idToken?: string; accessToken?: string; @@ -604,22 +604,30 @@ export function checkDecodedToken( } /** - * Helper function to determine if we need to do full verification of the jwt + * @internal + * Helper function to decode the jwt, internally uses the 'jsonwebtoken' package. */ -function shouldVerifyJWT() { - // TODO(colerogers): add emulator support to skip verification - return true; +export function decodeJWT(token: string): Record { + let decoded: Record; + try { + decoded = jwt.decode(token, { complete: true }) as Record; + } catch (err) { + logger.error('Decoding the JWT failed', err); + throw new HttpsError('internal', 'Failed to decode the JWT.'); + } + if (!decoded?.payload) { + throw new HttpsError('internal', 'The decoded JWT is not structured correctly.'); + } + return decoded; } /** * @internal - * Helper function to decode the jwt and return it's payload + * Helper function to determine if we need to do full verification of the jwt */ -export function decodeJWT(token: string): DecodedJwt { - const decoded = - (jwt.decode(token, { complete: true }) as Record) || {}; - - return (decoded.payload || {}) as DecodedJwt; +export function shouldVerifyJWT(): boolean { + // TODO(colerogers): add emulator support to skip verification + return true; } /** @@ -627,23 +635,28 @@ export function decodeJWT(token: string): DecodedJwt { * Verifies the jwt using the 'jwt' library and decodes the token with the public keys * Throws an error if the event types do not match */ -export function decodeAndVerifyJWT( +export function verifyJWT( token: string, + rawDecodedJWT: Record, keysCache: PublicKeysCache, time: number = Date.now() -) { +): DecodedJwt { + if (!rawDecodedJWT.header) { + throw new HttpsError('internal', 'Unable to verify JWT payload, the decoded JWT does not have a header property.'); + } + const header = rawDecodedJWT.header; if (invalidPublicKeys(keysCache, time)) { fetchPublicKeys(keysCache); } - const header = - ((jwt.decode(token, { complete: true }) as Record) || {}) - .header || {}; const publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); - const decoded = jwt.verify(token, publicKey, { - algorithms: [JWT_ALG], - }) as DecodedJwt; - - return decoded; + try { + return jwt.verify(token, publicKey, { + algorithms: [JWT_ALG], + }) as DecodedJwt; + } catch (err) { + logger.error('Verifying the JWT failed', err); + throw new HttpsError('internal', 'Failed to verify the JWT.'); + } } /** @@ -1009,9 +1022,10 @@ function wrapHandler( try { const projectId = process.env.GCLOUD_PROJECT; validRequest(req); + const rawDecodedJWT = decodeJWT(req.body.data.jwt); const decodedJWT = shouldVerifyJWT() - ? decodeAndVerifyJWT(req.body.data.jwt, keysCache) - : decodeJWT(req.body.data.jwt); + ? verifyJWT(req.body.data.jwt, rawDecodedJWT, keysCache) + : rawDecodedJWT.payload as DecodedJwt; checkDecodedToken(decodedJWT, eventType, projectId); const authUserRecord = parseAuthUserRecord(decodedJWT.user_record); const authEventContext = parseAuthEventContext(decodedJWT, projectId); From df2c0a2584ea5c403196a740711d39a923c4bef1 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 10 Feb 2022 11:49:06 -0500 Subject: [PATCH 12/18] added tests for setting the expiration time --- spec/common/providers/identity.spec.ts | 113 ++++++++++--------------- src/common/providers/identity.ts | 60 ++++++++----- 2 files changed, 86 insertions(+), 87 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 6fe9dc9f0..3b8ecef75 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -24,7 +24,6 @@ import * as express from 'express'; import * as jwt from 'jsonwebtoken'; import * as sinon from 'sinon'; import * as identity from '../../../src/common/providers/identity'; -// import * as fetch from 'node-fetch'; import { expect } from 'chai'; const PROJECT = 'my-project'; @@ -82,87 +81,59 @@ describe('identity', () => { }); }); - /* - describe('fetchPublicKeys', () => { - // let fetchStub: sinon.SinonStub; - - beforeEach(() => { - // fetchStub = sinon.stub(fetch).rejects("Unexpected call to fetch"); - // fetchStub.rejects("Unexpected call to fetch"); - }); - - afterEach(() => { - sinon.verifyAndRestore(); - }); + describe('setKeyExpirationTime', () => { + const time = Date.now(); - it('should set the public keys without cache-control', async () => { - const PublicKeysCache = { + it('should do nothing without cache-control', async () => { + const publicKeysCache = { publicKeys: {}, publicKeysExpireAt: undefined, - } - const publicKeys = { - '123456': '7890', - '2468': '1357', }; - // fetchStub.returns({ - // json: async () => Promise.resolve(publicKeys), - // }); - sinon.stub(fetch).returns(Promise.resolve({ - json: async () => Promise.resolve(publicKeys), - })); + const response = { + headers: { + has: (str: string) => false, + }, + }; - await identity.fetchPublicKeys(PublicKeysCache); + await identity.setKeyExpirationTime(response, publicKeysCache, time); - expect(PublicKeysCache.publicKeys).to.deep.equal(publicKeys); - expect(PublicKeysCache.publicKeysExpireAt).to.be.undefined; + expect(publicKeysCache.publicKeysExpireAt).to.be.undefined; }); - it('should set the public keys with cache-control but without max-age', async () => { - const PublicKeysCache = { + it('should do nothing with cache-control but without max-age', async () => { + const publicKeysCache = { publicKeys: {}, publicKeysExpireAt: undefined, - } - const publicKeys = { - '123456': '7890', - '2468': '1357', }; - // fetchStub.resolves({ - // headers: { - // 'cache-control': 'item=val, item2=val2, item3=val3', - // }, - // json: async () => Promise.resolve(publicKeys), - // }); + const response = { + headers: { + has: (str: string) => true, + get: (str: string) => 'item=val, item2=val2, item3=val3', + }, + }; - await identity.fetchPublicKeys(PublicKeysCache); + await identity.setKeyExpirationTime(response, publicKeysCache, time); - expect(PublicKeysCache.publicKeys).to.deep.equal(publicKeys); - expect(PublicKeysCache.publicKeysExpireAt).to.be.undefined; + expect(publicKeysCache.publicKeysExpireAt).to.be.undefined; }); - it('should set the public keys with cache-control but without max-age', async () => { - const time = Date.now(); - const PublicKeysCache = { + it('should set the correctly set the expiration time', async () => { + const publicKeysCache = { publicKeys: {}, publicKeysExpireAt: undefined, - } - const publicKeys = { - '123456': '7890', - '2468': '1357', }; - // fetchStub.resolves({ - // headers: { - // 'cache-control': 'item=val, max-age=50, item2=val2, item3=val3', - // }, - // json: async () => Promise.resolve(publicKeys), - // }); + const response = { + headers: { + has: (str: string) => true, + get: (str: string) => 'item=val, max-age=50, item2=val2, item3=val3', + }, + }; - await identity.fetchPublicKeys(PublicKeysCache, time); + await identity.setKeyExpirationTime(response, publicKeysCache, time); - expect(PublicKeysCache.publicKeys).to.deep.equal(publicKeys); - expect(PublicKeysCache.publicKeysExpireAt).to.equal(time + (50 * 1000)); + expect(publicKeysCache.publicKeysExpireAt).to.equal(time + 50 * 1000); }); }); - */ describe('userRecordConstructor', () => { it('will provide falsey values for fields that are not in raw wire data', () => { @@ -517,13 +488,17 @@ describe('identity', () => { it('should throw HttpsError if jwt.decode errors', () => { jwtDecodeStub.throws('An internal decode error occurred.'); - expect(() => identity.decodeJWT("123456")).to.throw('Failed to decode the JWT.'); - }) + expect(() => identity.decodeJWT('123456')).to.throw( + 'Failed to decode the JWT.' + ); + }); it('should error if jwt decoded returns undefined', () => { jwtDecodeStub.returns(undefined); - expect(() => identity.decodeJWT('123456')).to.throw('The decoded JWT is not structured correctly.'); + expect(() => identity.decodeJWT('123456')).to.throw( + 'The decoded JWT is not structured correctly.' + ); }); it('should error if decoded jwt does not have a payload field', () => { @@ -531,7 +506,9 @@ describe('identity', () => { header: { key: 'val' }, }); - expect(() => identity.decodeJWT("123456")).to.throw('The decoded JWT is not structured correctly.'); + expect(() => identity.decodeJWT('123456')).to.throw( + 'The decoded JWT is not structured correctly.' + ); }); it('should return the raw decoded jwt', () => { @@ -554,7 +531,7 @@ describe('identity', () => { it('should return true', () => { expect(identity.shouldVerifyJWT()).to.be.true; }); - }) + }); describe('verifyJWT', () => { const time = Date.now(); @@ -601,8 +578,10 @@ describe('identity', () => { it('should error if jwt verify errors', () => { jwtVerifyStub.throws('Internal failure of jwt verify.'); - expect(() => identity.verifyJWT('123456', rawDecodedJWT, keysCache, time)).to.throw('Failed to verify the JWT.'); - }) + expect(() => + identity.verifyJWT('123456', rawDecodedJWT, keysCache, time) + ).to.throw('Failed to verify the JWT.'); + }); it('should return the decoded jwt', () => { const decoded = { diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 95c439aa8..8d49ec112 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -441,33 +441,47 @@ export function invalidPublicKeys( /** * @internal - * Obtain public keys for use in decoding and verifying the jwt sent from identity platform. - * Will set the expiration time if available + * Helper to parse the response headers to obtain the expiration time. */ -export async function fetchPublicKeys( - keys: PublicKeysCache, +export function setKeyExpirationTime( + response: any, + keysCache: PublicKeysCache, + time: number +): void { + if (response.headers.has('cache-control')) { + const ccHeader = response.headers.get('cache-control'); + const maxAgeEntry = ccHeader + .split(', ') + .find((item) => item.includes('max-age')); + if (maxAgeEntry) { + const maxAge = +maxAgeEntry.trim().split('=')[1]; + keysCache.publicKeysExpireAt = time + maxAge * 1000; + } + } +} + +/** + * @internal + * Fetch the public keys for use in decoding and verifying the jwt sent from identity platform. + */ +async function getPublicKeys( + keysCache: PublicKeysCache, time: number = Date.now() ): Promise { const url = `${JWT_CLIENT_CERT_URL}/${JWT_CLIENT_CERT_PATH}`; try { const response = await fetch(url); - if (response.headers.has('cache-control')) { - const ccHeader = response.headers.get('cache-control'); - const maxAgeEntry = ccHeader - .split(', ') - .find((item) => item.includes('max-age')); - if (maxAgeEntry) { - const maxAge = +maxAgeEntry.trim().split('=')[1]; - keys.publicKeysExpireAt = time + maxAge * 1000; - } - } + setKeyExpirationTime(response, keysCache, time); const data = await response.json(); - keys.publicKeys = data as Record; + keysCache.publicKeys = data as Record; } catch (err) { logger.error( `Failed to obtain public keys for JWT verification: ${err.message}` ); - throw new HttpsError('internal', 'Failed to obtain public keys'); + throw new HttpsError( + 'internal', + 'Failed to obtain the public keys for JWT verification.' + ); } } @@ -616,7 +630,10 @@ export function decodeJWT(token: string): Record { throw new HttpsError('internal', 'Failed to decode the JWT.'); } if (!decoded?.payload) { - throw new HttpsError('internal', 'The decoded JWT is not structured correctly.'); + throw new HttpsError( + 'internal', + 'The decoded JWT is not structured correctly.' + ); } return decoded; } @@ -642,11 +659,14 @@ export function verifyJWT( time: number = Date.now() ): DecodedJwt { if (!rawDecodedJWT.header) { - throw new HttpsError('internal', 'Unable to verify JWT payload, the decoded JWT does not have a header property.'); + throw new HttpsError( + 'internal', + 'Unable to verify JWT payload, the decoded JWT does not have a header property.' + ); } const header = rawDecodedJWT.header; if (invalidPublicKeys(keysCache, time)) { - fetchPublicKeys(keysCache); + getPublicKeys(keysCache); } const publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); try { @@ -1025,7 +1045,7 @@ function wrapHandler( const rawDecodedJWT = decodeJWT(req.body.data.jwt); const decodedJWT = shouldVerifyJWT() ? verifyJWT(req.body.data.jwt, rawDecodedJWT, keysCache) - : rawDecodedJWT.payload as DecodedJwt; + : (rawDecodedJWT.payload as DecodedJwt); checkDecodedToken(decodedJWT, eventType, projectId); const authUserRecord = parseAuthUserRecord(decodedJWT.user_record); const authEventContext = parseAuthEventContext(decodedJWT, projectId); From d63d8990a9b35eddfcede8bb477ac5ebfdb5497f Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 10 Feb 2022 11:55:04 -0500 Subject: [PATCH 13/18] cleaning up var names --- spec/common/providers/identity.spec.ts | 14 ++++----- src/common/providers/identity.ts | 39 ++++++-------------------- 2 files changed, 15 insertions(+), 38 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 3b8ecef75..837a4a33e 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -370,7 +370,7 @@ describe('identity', () => { identity.checkDecodedToken( { event_type: EVENT, - } as identity.DecodedJwt, + } as identity.DecodedJWT, 'newEvent', PROJECT ) @@ -382,7 +382,7 @@ describe('identity', () => { { aud: `fake-region-${PROJECT}.cloudfunctions.net/fn1`, event_type: EVENT, - } as identity.DecodedJwt, + } as identity.DecodedJWT, EVENT, PROJECT ) @@ -396,7 +396,7 @@ describe('identity', () => { aud: VALID_URL, iss: `https://someissuer.com/a-project`, event_type: EVENT, - } as identity.DecodedJwt, + } as identity.DecodedJWT, EVENT, PROJECT ) @@ -415,7 +415,7 @@ describe('identity', () => { key: 'val', }, event_type: EVENT, - } as unknown) as identity.DecodedJwt, + } as unknown) as identity.DecodedJWT, EVENT, PROJECT ) @@ -430,7 +430,7 @@ describe('identity', () => { iss: `${identity.JWT_ISSUER}${PROJECT}`, sub: '', event_type: EVENT, - } as identity.DecodedJwt, + } as identity.DecodedJWT, EVENT, PROJECT ) @@ -446,7 +446,7 @@ describe('identity', () => { iss: `${identity.JWT_ISSUER}${PROJECT}`, sub: str.toString(), event_type: EVENT, - } as identity.DecodedJwt, + } as identity.DecodedJWT, EVENT, PROJECT ) @@ -463,7 +463,7 @@ describe('identity', () => { iss: `${identity.JWT_ISSUER}${PROJECT}`, sub: sub, event_type: EVENT, - } as identity.DecodedJwt; + } as identity.DecodedJWT; identity.checkDecodedToken(decoded, EVENT, PROJECT); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 8d49ec112..acd62c742 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -399,7 +399,7 @@ export interface DecodedJwtUserRecord { } /** @internal */ -export interface DecodedJwt { +export interface DecodedJWT { aud: string; exp: number; iat: number; @@ -461,7 +461,6 @@ export function setKeyExpirationTime( } /** - * @internal * Fetch the public keys for use in decoding and verifying the jwt sent from identity platform. */ async function getPublicKeys( @@ -542,33 +541,11 @@ export function isAuthorizedCloudFunctionURL( cloudFunctionUrl: string, projectId: string ): boolean { - // Region can be: - // us-central1, us-east1, asia-northeast1, europe-west1, asia-east1. - // Sample: https://europe-west1-fb-sa-upgraded.cloudfunctions.net/function-1 - - // const gcf_directions = [ - // 'central', - // 'east', - // 'west', - // 'south', - // 'southeast', - // 'northeast', - // // Other possible directions that could be added. - // 'north', - // 'southwest', - // 'northwest', - // ]; - const re = new RegExp( `^https://(${SUPPORTED_REGIONS.join( '|' )})+-${projectId}\.cloudfunctions\.net/` ); - // const re = new RegExp( - // `^https://[^-]+-(${gcf_directions.join( - // '|' - // )})[0-9]+-${projectId}\.cloudfunctions\.net/` - // ); const res = re.exec(cloudFunctionUrl) || []; return res.length > 0; } @@ -578,7 +555,7 @@ export function isAuthorizedCloudFunctionURL( * Checks for errors in a decoded jwt */ export function checkDecodedToken( - decodedJWT: DecodedJwt, + decodedJWT: DecodedJWT, eventType: string, projectId: string ): void { @@ -657,7 +634,7 @@ export function verifyJWT( rawDecodedJWT: Record, keysCache: PublicKeysCache, time: number = Date.now() -): DecodedJwt { +): DecodedJWT { if (!rawDecodedJWT.header) { throw new HttpsError( 'internal', @@ -672,7 +649,7 @@ export function verifyJWT( try { return jwt.verify(token, publicKey, { algorithms: [JWT_ALG], - }) as DecodedJwt; + }) as DecodedJWT; } catch (err) { logger.error('Verifying the JWT failed', err); throw new HttpsError('internal', 'Failed to verify the JWT.'); @@ -817,7 +794,7 @@ export function parseAuthUserRecord( } /** Helper to get the AdditionalUserInfo from the decoded jwt */ -function parseAdditionalUserInfo(decodedJWT: DecodedJwt): AdditionalUserInfo { +function parseAdditionalUserInfo(decodedJWT: DecodedJWT): AdditionalUserInfo { let profile, username; if (decodedJWT.raw_user_info) try { @@ -846,7 +823,7 @@ function parseAdditionalUserInfo(decodedJWT: DecodedJwt): AdditionalUserInfo { } /** Helper to get the Credential from the decoded jwt */ -function parseAuthCredential(decodedJWT: DecodedJwt, time: number): Credential { +function parseAuthCredential(decodedJWT: DecodedJWT, time: number): Credential { if ( !decodedJWT.sign_in_attributes && !decodedJWT.oauth_id_token && @@ -877,7 +854,7 @@ function parseAuthCredential(decodedJWT: DecodedJwt, time: number): Credential { * Parses the decoded jwt into a valid AuthEventContext for use in the handler */ export function parseAuthEventContext( - decodedJWT: DecodedJwt, + decodedJWT: DecodedJWT, projectId: string, time: number = new Date().getTime() ): AuthEventContext { @@ -1045,7 +1022,7 @@ function wrapHandler( const rawDecodedJWT = decodeJWT(req.body.data.jwt); const decodedJWT = shouldVerifyJWT() ? verifyJWT(req.body.data.jwt, rawDecodedJWT, keysCache) - : (rawDecodedJWT.payload as DecodedJwt); + : (rawDecodedJWT.payload as DecodedJWT); checkDecodedToken(decodedJWT, eventType, projectId); const authUserRecord = parseAuthUserRecord(decodedJWT.user_record); const authEventContext = parseAuthEventContext(decodedJWT, projectId); From fddd14ad80758e8c22ec3d41655a71b9e5c623ef Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 10 Feb 2022 12:25:07 -0500 Subject: [PATCH 14/18] adding in retry for key verification --- spec/common/providers/identity.spec.ts | 8 -------- src/common/providers/identity.ts | 16 +++++++++++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 837a4a33e..fa151a440 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -575,14 +575,6 @@ describe('identity', () => { ); }); - it('should error if jwt verify errors', () => { - jwtVerifyStub.throws('Internal failure of jwt verify.'); - - expect(() => - identity.verifyJWT('123456', rawDecodedJWT, keysCache, time) - ).to.throw('Failed to verify the JWT.'); - }); - it('should return the decoded jwt', () => { const decoded = { aud: VALID_URL, diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index acd62c742..2ed18ad65 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -463,7 +463,7 @@ export function setKeyExpirationTime( /** * Fetch the public keys for use in decoding and verifying the jwt sent from identity platform. */ -async function getPublicKeys( +async function refreshPublicKeys( keysCache: PublicKeysCache, time: number = Date.now() ): Promise { @@ -643,15 +643,25 @@ export function verifyJWT( } const header = rawDecodedJWT.header; if (invalidPublicKeys(keysCache, time)) { - getPublicKeys(keysCache); + refreshPublicKeys(keysCache); } - const publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); + let publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); try { return jwt.verify(token, publicKey, { algorithms: [JWT_ALG], }) as DecodedJWT; } catch (err) { logger.error('Verifying the JWT failed', err); + } + // force refresh keys and retry one more time + refreshPublicKeys(keysCache); + publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); + try { + return jwt.verify(token, publicKey, { + algorithms: [JWT_ALG], + }) as DecodedJWT; + } catch (err) { + logger.error('Verifying the JWT failed again', err); throw new HttpsError('internal', 'Failed to verify the JWT.'); } } From f749634c29c040c164691dafaeedad5a6dab15da Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Mon, 21 Feb 2022 09:41:50 -0500 Subject: [PATCH 15/18] format --- src/common/providers/identity.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 2ed18ad65..e6a88d55e 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -642,11 +642,12 @@ export function verifyJWT( ); } const header = rawDecodedJWT.header; - if (invalidPublicKeys(keysCache, time)) { - refreshPublicKeys(keysCache); - } - let publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); + let publicKey; try { + if (invalidPublicKeys(keysCache, time)) { + refreshPublicKeys(keysCache); + } + publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); return jwt.verify(token, publicKey, { algorithms: [JWT_ALG], }) as DecodedJWT; @@ -654,9 +655,9 @@ export function verifyJWT( logger.error('Verifying the JWT failed', err); } // force refresh keys and retry one more time - refreshPublicKeys(keysCache); - publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); try { + refreshPublicKeys(keysCache); + publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); return jwt.verify(token, publicKey, { algorithms: [JWT_ALG], }) as DecodedJWT; From 29e688b166f1d90cabfdb6412121e30ba20e2902 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Wed, 30 Mar 2022 15:30:51 -0700 Subject: [PATCH 16/18] address comments --- spec/common/providers/identity.spec.ts | 85 ++++------- src/common/providers/identity.ts | 192 ++++++++++++++----------- 2 files changed, 136 insertions(+), 141 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index fa151a440..caa656e3e 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -41,7 +41,7 @@ describe('identity', () => { ).to.be.true; }); - it('should return true if publicKeysExpireAt equals Date.now()', () => { + it('should return true if publicKeysExpireAt are less than equals Date.now() plus a buffer', () => { const time = Date.now(); expect( identity.invalidPublicKeys( @@ -54,26 +54,13 @@ describe('identity', () => { ).to.be.true; }); - it('should return true if publicKeysExpireAt are less than Date.now()', () => { + it('should return false if publicKeysExpireAt are greater than Date.now() plus a buffer', () => { const time = Date.now(); expect( identity.invalidPublicKeys( { publicKeys: {}, - publicKeysExpireAt: time - 1, - }, - time - ) - ).to.be.true; - }); - - it('should return false if publicKeysExpireAt are greater than Date.now()', () => { - const time = Date.now(); - expect( - identity.invalidPublicKeys( - { - publicKeys: {}, - publicKeysExpireAt: time + 100, + publicKeysExpireAt: time + identity.INVALID_TOKEN_BUFFER + 60000, }, time ) @@ -89,10 +76,9 @@ describe('identity', () => { publicKeys: {}, publicKeysExpireAt: undefined, }; + const headers = new Map(); const response = { - headers: { - has: (str: string) => false, - }, + headers, }; await identity.setKeyExpirationTime(response, publicKeysCache, time); @@ -105,11 +91,10 @@ describe('identity', () => { publicKeys: {}, publicKeysExpireAt: undefined, }; + const headers = new Map(); + headers.set('cache-control', 'item=val, item2=val2, item3=val3'); const response = { - headers: { - has: (str: string) => true, - get: (str: string) => 'item=val, item2=val2, item3=val3', - }, + headers, }; await identity.setKeyExpirationTime(response, publicKeysCache, time); @@ -122,11 +107,13 @@ describe('identity', () => { publicKeys: {}, publicKeysExpireAt: undefined, }; + const headers = new Map(); + headers.set( + 'cache-control', + 'item=val, max-age=50, item2=val2, item3=val3' + ); const response = { - headers: { - has: (str: string) => true, - get: (str: string) => 'item=val, max-age=50, item2=val2, item3=val3', - }, + headers, }; await identity.setKeyExpirationTime(response, publicKeysCache, time); @@ -197,7 +184,7 @@ describe('identity', () => { }); }); - describe('validRequest', () => { + describe('isValidRequest', () => { it('should error on non-post', () => { const req = ({ method: 'GET', @@ -211,9 +198,7 @@ describe('identity', () => { }, } as unknown) as express.Request; - expect(() => identity.validRequest(req)).to.throw( - 'Request has invalid method "GET".' - ); + expect(identity.isValidRequest(req)).to.be.false; }); it('should error on bad Content-Type', () => { @@ -229,9 +214,7 @@ describe('identity', () => { }, } as unknown) as express.Request; - expect(() => identity.validRequest(req)).to.throw( - 'Request has invalid header Content-Type.' - ); + expect(identity.isValidRequest(req)).to.be.false; }); it('should error without req body', () => { @@ -242,9 +225,7 @@ describe('identity', () => { }, } as unknown) as express.Request; - expect(() => identity.validRequest(req)).to.throw( - 'Request has an invalid body.' - ); + expect(identity.isValidRequest(req)).to.be.false; }); it('should error without req body data', () => { @@ -256,9 +237,7 @@ describe('identity', () => { body: {}, } as unknown) as express.Request; - expect(() => identity.validRequest(req)).to.throw( - 'Request has an invalid body.' - ); + expect(identity.isValidRequest(req)).to.be.false; }); it('should error without req body', () => { @@ -272,9 +251,7 @@ describe('identity', () => { }, } as unknown) as express.Request; - expect(() => identity.validRequest(req)).to.throw( - 'Request has an invalid body.' - ); + expect(identity.isValidRequest(req)).to.be.false; }); it('should not error on valid request', () => { @@ -290,7 +267,7 @@ describe('identity', () => { }, } as unknown) as express.Request; - expect(() => identity.validRequest(req)).to.not.throw(); + expect(identity.isValidRequest(req)).to.be.true; }); }); @@ -370,7 +347,7 @@ describe('identity', () => { identity.checkDecodedToken( { event_type: EVENT, - } as identity.DecodedJWT, + } as identity.DecodedPayload, 'newEvent', PROJECT ) @@ -382,7 +359,7 @@ describe('identity', () => { { aud: `fake-region-${PROJECT}.cloudfunctions.net/fn1`, event_type: EVENT, - } as identity.DecodedJWT, + } as identity.DecodedPayload, EVENT, PROJECT ) @@ -396,7 +373,7 @@ describe('identity', () => { aud: VALID_URL, iss: `https://someissuer.com/a-project`, event_type: EVENT, - } as identity.DecodedJWT, + } as identity.DecodedPayload, EVENT, PROJECT ) @@ -415,7 +392,7 @@ describe('identity', () => { key: 'val', }, event_type: EVENT, - } as unknown) as identity.DecodedJWT, + } as unknown) as identity.DecodedPayload, EVENT, PROJECT ) @@ -430,7 +407,7 @@ describe('identity', () => { iss: `${identity.JWT_ISSUER}${PROJECT}`, sub: '', event_type: EVENT, - } as identity.DecodedJWT, + } as identity.DecodedPayload, EVENT, PROJECT ) @@ -446,7 +423,7 @@ describe('identity', () => { iss: `${identity.JWT_ISSUER}${PROJECT}`, sub: str.toString(), event_type: EVENT, - } as identity.DecodedJWT, + } as identity.DecodedPayload, EVENT, PROJECT ) @@ -463,7 +440,7 @@ describe('identity', () => { iss: `${identity.JWT_ISSUER}${PROJECT}`, sub: sub, event_type: EVENT, - } as identity.DecodedJWT; + } as identity.DecodedPayload; identity.checkDecodedToken(decoded, EVENT, PROJECT); @@ -541,7 +518,7 @@ describe('identity', () => { '123456': '7890', '2468': '1357', }, - publicKeysExpireAt: time + 1, + publicKeysExpireAt: time + identity.INVALID_TOKEN_BUFFER + 10000, }; const rawDecodedJWT = { header: { @@ -712,7 +689,7 @@ describe('identity', () => { it('should error on an invalid factor', () => { const factors = { - enrolled_factors: [{} as identity.DecodedJwtMfaInfo], + enrolled_factors: [{} as identity.DecodedPayloadMfaInfo], }; expect(() => identity.parseMultiFactor(factors)).to.throw( @@ -869,7 +846,7 @@ describe('identity', () => { it('should error if decoded does not have uid', () => { expect(() => - identity.parseAuthUserRecord({} as identity.DecodedJwtUserRecord) + identity.parseAuthUserRecord({} as identity.DecodedPayloadUserRecord) ).to.throw('INTERNAL ASSERT FAILED: Invalid user response'); }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index e6a88d55e..66cd5348e 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -22,7 +22,6 @@ import * as express from 'express'; import * as firebase from 'firebase-admin'; -import * as _ from 'lodash'; import * as jwt from 'jsonwebtoken'; import fetch from 'node-fetch'; import { HttpsError } from './https'; @@ -32,6 +31,9 @@ import { logger } from '../..'; export { HttpsError }; +/** @internal */ +export const INVALID_TOKEN_BUFFER = 60000; // set to 1 minute + /** @internal */ export const JWT_CLIENT_CERT_URL = 'https://www.googleapis.com'; /** @internal */ @@ -48,7 +50,7 @@ export interface PublicKeysCache { publicKeysExpireAt?: number; } -const CLAIMS_NON_ALLOW_LISTED = [ +const DISALLOWED_CUSTOM_CLAIMS = [ 'acr', 'amr', 'at_hash', @@ -119,44 +121,52 @@ export function userRecordConstructor(wireData: Object): UserRecord { passwordHash: null, tokensValidAfterTime: null, }; - const record = _.assign({}, falseyValues, wireData); + const record = { ...falseyValues, ...wireData }; - const meta = _.get(record, 'metadata'); + const meta = record['metadata']; if (meta) { - _.set( - record, - 'metadata', - new UserRecordMetadata( - meta.createdAt || meta.creationTime, - meta.lastSignedInAt || meta.lastSignInTime - ) + record['metadata'] = new UserRecordMetadata( + meta.createdAt || meta.creationTime, + meta.lastSignedInAt || meta.lastSignInTime ); } else { - _.set(record, 'metadata', new UserRecordMetadata(null, null)); + record['metadata'] = new UserRecordMetadata(null, null); } - _.forEach(record.providerData, (entry) => { - _.set(entry, 'toJSON', () => { + for (const entry of Object.entries(record.providerData)) { + entry['toJSON'] = () => { return entry; - }); - }); - _.set(record, 'toJSON', () => { - const json: any = _.pick(record, [ - 'uid', - 'email', - 'emailVerified', - 'displayName', - 'photoURL', - 'phoneNumber', - 'disabled', - 'passwordHash', - 'passwordSalt', - 'tokensValidAfterTime', - ]); - json.metadata = _.get(record, 'metadata').toJSON(); - json.customClaims = _.cloneDeep(record.customClaims); - json.providerData = _.map(record.providerData, (entry) => entry.toJSON()); + }; + } + record['toJSON'] = () => { + const { + uid, + email, + emailVerified, + displayName, + photoURL, + phoneNumber, + disabled, + passwordHash, + passwordSalt, + tokensValidAfterTime, + } = record; + const json = { + uid, + email, + emailVerified, + displayName, + photoURL, + phoneNumber, + disabled, + passwordHash, + passwordSalt, + tokensValidAfterTime, + }; + json['metadata'] = record['metadata'].toJSON(); + json['customClaims'] = JSON.parse(JSON.stringify(record.customClaims)); + json['providerData'] = record.providerData.map((entry) => entry.toJSON()); return json; - }); + }; return record as UserRecord; } @@ -351,12 +361,12 @@ export interface BeforeSignInResponse extends BeforeCreateResponse { sessionClaims?: object; } -interface DecodedJwtMetadata { +interface DecodedPayloadUserRecordMetadata { creation_time?: number; last_sign_in_time?: number; } -interface DecodedJwtUserInfo { +interface DecodedPayloadUserRecordUserInfo { uid: string; display_name?: string; email?: string; @@ -366,7 +376,7 @@ interface DecodedJwtUserInfo { } /** @internal */ -export interface DecodedJwtMfaInfo { +export interface DecodedPayloadMfaInfo { uid: string; display_name?: string; phone_number?: string; @@ -374,12 +384,12 @@ export interface DecodedJwtMfaInfo { factor_id?: string; } -interface DecodedJwtEnrolledFactors { - enrolled_factors?: DecodedJwtMfaInfo[]; +interface DecodedPayloadUserRecordEnrolledFactors { + enrolled_factors?: DecodedPayloadMfaInfo[]; } /** @internal */ -export interface DecodedJwtUserRecord { +export interface DecodedPayloadUserRecord { uid: string; email?: string; email_verified?: boolean; @@ -387,11 +397,11 @@ export interface DecodedJwtUserRecord { display_name?: string; photo_url?: string; disabled?: boolean; - metadata?: DecodedJwtMetadata; + metadata?: DecodedPayloadUserRecordMetadata; password_hash?: string; password_salt?: string; - provider_data?: DecodedJwtUserInfo[]; - multi_factor?: DecodedJwtEnrolledFactors; + provider_data?: DecodedPayloadUserRecordUserInfo[]; + multi_factor?: DecodedPayloadUserRecordEnrolledFactors; custom_claims?: any; tokens_valid_after_time?: number; tenant_id?: string; @@ -399,7 +409,7 @@ export interface DecodedJwtUserRecord { } /** @internal */ -export interface DecodedJWT { +export interface DecodedPayload { aud: string; exp: number; iat: number; @@ -411,7 +421,7 @@ export interface DecodedJWT { user_agent?: string; locale?: string; sign_in_method?: string; - user_record?: DecodedJwtUserRecord; + user_record?: DecodedPayloadUserRecord; tenant_id?: string; raw_user_info?: string; sign_in_attributes?: { @@ -426,8 +436,8 @@ export interface DecodedJWT { } /** - * @internal * Helper to determine if we refresh the public keys + * @internal */ export function invalidPublicKeys( keys: PublicKeysCache, @@ -436,12 +446,12 @@ export function invalidPublicKeys( if (!keys.publicKeysExpireAt) { return true; } - return time >= keys.publicKeysExpireAt; + return time + INVALID_TOKEN_BUFFER >= keys.publicKeysExpireAt; } /** - * @internal * Helper to parse the response headers to obtain the expiration time. + * @internal */ export function setKeyExpirationTime( response: any, @@ -485,28 +495,26 @@ async function refreshPublicKeys( } /** - * @internal * Checks for a valid identity platform web request, otherwise throws an HttpsError + * @internal */ -export function validRequest(req: express.Request): void { +export function isValidRequest(req: express.Request): boolean { if (req.method !== 'POST') { - throw new HttpsError( - 'invalid-argument', - `Request has invalid method "${req.method}".` - ); + logger.warn(`Request has invalid method "${req.method}".`); + return false; } const contentType: string = (req.header('Content-Type') || '').toLowerCase(); if (!contentType.includes('application/json')) { - throw new HttpsError( - 'invalid-argument', - 'Request has invalid header Content-Type.' - ); + logger.warn('Request has invalid header Content-Type.'); + return false; } if (!req.body?.data?.jwt) { - throw new HttpsError('invalid-argument', 'Request has an invalid body.'); + logger.warn('Request has an invalid body.'); + return false; } + return true; } /** @internal */ @@ -534,8 +542,8 @@ export function getPublicKeyFromHeader( } /** - * @internal * Checks for a well forms cloud functions url + * @internal */ export function isAuthorizedCloudFunctionURL( cloudFunctionUrl: string, @@ -551,11 +559,11 @@ export function isAuthorizedCloudFunctionURL( } /** - * @internal * Checks for errors in a decoded jwt + * @internal */ export function checkDecodedToken( - decodedJWT: DecodedJWT, + decodedJWT: DecodedPayload, eventType: string, projectId: string ): void { @@ -595,8 +603,8 @@ export function checkDecodedToken( } /** - * @internal * Helper function to decode the jwt, internally uses the 'jsonwebtoken' package. + * @internal */ export function decodeJWT(token: string): Record { let decoded: Record; @@ -616,8 +624,8 @@ export function decodeJWT(token: string): Record { } /** - * @internal * Helper function to determine if we need to do full verification of the jwt + * @internal */ export function shouldVerifyJWT(): boolean { // TODO(colerogers): add emulator support to skip verification @@ -625,16 +633,16 @@ export function shouldVerifyJWT(): boolean { } /** - * @internal * Verifies the jwt using the 'jwt' library and decodes the token with the public keys * Throws an error if the event types do not match + * @internal */ export function verifyJWT( token: string, rawDecodedJWT: Record, keysCache: PublicKeysCache, time: number = Date.now() -): DecodedJWT { +): DecodedPayload { if (!rawDecodedJWT.header) { throw new HttpsError( 'internal', @@ -650,7 +658,7 @@ export function verifyJWT( publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); return jwt.verify(token, publicKey, { algorithms: [JWT_ALG], - }) as DecodedJWT; + }) as DecodedPayload; } catch (err) { logger.error('Verifying the JWT failed', err); } @@ -660,7 +668,7 @@ export function verifyJWT( publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); return jwt.verify(token, publicKey, { algorithms: [JWT_ALG], - }) as DecodedJWT; + }) as DecodedPayload; } catch (err) { logger.error('Verifying the JWT failed again', err); throw new HttpsError('internal', 'Failed to verify the JWT.'); @@ -668,10 +676,12 @@ export function verifyJWT( } /** - * @internal * Helper function to parse the decoded metadata object into a UserMetaData object + * @internal */ -export function parseMetadata(metadata: DecodedJwtMetadata): AuthUserMetadata { +export function parseMetadata( + metadata: DecodedPayloadUserRecordMetadata +): AuthUserMetadata { const creationTime = metadata?.creation_time ? new Date((metadata.creation_time as number) * 1000).toUTCString() : null; @@ -685,11 +695,11 @@ export function parseMetadata(metadata: DecodedJwtMetadata): AuthUserMetadata { } /** - * @internal * Helper function to parse the decoded user info array into an AuthUserInfo array + * @internal */ export function parseProviderData( - providerData: DecodedJwtUserInfo[] + providerData: DecodedPayloadUserRecordUserInfo[] ): AuthUserInfo[] { const providers: AuthUserInfo[] = []; for (const provider of providerData) { @@ -706,8 +716,8 @@ export function parseProviderData( } /** - * @internal * Helper function to parse the date into a UTC string + * @internal */ export function parseDate(tokensValidAfterTime?: number): string | null { if (!tokensValidAfterTime) { @@ -724,11 +734,11 @@ export function parseDate(tokensValidAfterTime?: number): string | null { } /** - * @internal * Helper function to parse the decoded enrolled factors into a valid MultiFactorSettings + * @internal */ export function parseMultiFactor( - multiFactor?: DecodedJwtEnrolledFactors + multiFactor?: DecodedPayloadUserRecordEnrolledFactors ): AuthMultiFactorSettings { if (!multiFactor) { return null; @@ -764,11 +774,11 @@ export function parseMultiFactor( } /** - * @internal * Parses the decoded user record into a valid UserRecord for use in the handler + * @internal */ export function parseAuthUserRecord( - decodedJWTUserRecord: DecodedJwtUserRecord + decodedJWTUserRecord: DecodedPayloadUserRecord ): AuthUserRecord { if (!decodedJWTUserRecord.uid) { throw new HttpsError( @@ -805,7 +815,9 @@ export function parseAuthUserRecord( } /** Helper to get the AdditionalUserInfo from the decoded jwt */ -function parseAdditionalUserInfo(decodedJWT: DecodedJWT): AdditionalUserInfo { +function parseAdditionalUserInfo( + decodedJWT: DecodedPayload +): AdditionalUserInfo { let profile, username; if (decodedJWT.raw_user_info) try { @@ -834,7 +846,10 @@ function parseAdditionalUserInfo(decodedJWT: DecodedJWT): AdditionalUserInfo { } /** Helper to get the Credential from the decoded jwt */ -function parseAuthCredential(decodedJWT: DecodedJWT, time: number): Credential { +function parseAuthCredential( + decodedJWT: DecodedPayload, + time: number +): Credential { if ( !decodedJWT.sign_in_attributes && !decodedJWT.oauth_id_token && @@ -861,11 +876,11 @@ function parseAuthCredential(decodedJWT: DecodedJWT, time: number): Credential { } /** - * @internal * Parses the decoded jwt into a valid AuthEventContext for use in the handler + * @internal */ export function parseAuthEventContext( - decodedJWT: DecodedJWT, + decodedJWT: DecodedPayload, projectId: string, time: number = new Date().getTime() ): AuthEventContext { @@ -895,8 +910,8 @@ export function parseAuthEventContext( } /** - * @internal * Checks the handler response for invalid customClaims & sessionClaims objects + * @internal */ export function validateAuthResponse( eventType: string, @@ -906,7 +921,7 @@ export function validateAuthResponse( authRequest = {}; } if (authRequest.customClaims) { - const invalidClaims = CLAIMS_NON_ALLOW_LISTED.filter((claim) => + const invalidClaims = DISALLOWED_CUSTOM_CLAIMS.filter((claim) => authRequest.customClaims.hasOwnProperty(claim) ); if (invalidClaims.length > 0) { @@ -930,7 +945,7 @@ export function validateAuthResponse( eventType === 'beforeSignIn' && (authRequest as BeforeSignInResponse).sessionClaims ) { - const invalidClaims = CLAIMS_NON_ALLOW_LISTED.filter((claim) => + const invalidClaims = DISALLOWED_CUSTOM_CLAIMS.filter((claim) => (authRequest as BeforeSignInResponse).sessionClaims.hasOwnProperty(claim) ); if (invalidClaims.length > 0) { @@ -964,8 +979,8 @@ export function validateAuthResponse( } /** - * @internal * Helper function to generate the update mask for the identity platform changed values + * @internal */ export function getUpdateMask( authResponse?: BeforeCreateResponse | BeforeSignInResponse @@ -1029,11 +1044,14 @@ function wrapHandler( return async (req: express.Request, res: express.Response): Promise => { try { const projectId = process.env.GCLOUD_PROJECT; - validRequest(req); + if (!isValidRequest(req)) { + logger.error('Invalid request, unable to process'); + throw new HttpsError('invalid-argument', 'Bad Request'); + } const rawDecodedJWT = decodeJWT(req.body.data.jwt); const decodedJWT = shouldVerifyJWT() ? verifyJWT(req.body.data.jwt, rawDecodedJWT, keysCache) - : (rawDecodedJWT.payload as DecodedJWT); + : (rawDecodedJWT.payload as DecodedPayload); checkDecodedToken(decodedJWT, eventType, projectId); const authUserRecord = parseAuthUserRecord(decodedJWT.user_record); const authEventContext = parseAuthEventContext(decodedJWT, projectId); @@ -1060,7 +1078,7 @@ function wrapHandler( res.status(err.code); res.setHeader('Content-Type', 'application/json'); - res.send(JSON.stringify(err.toJSON())); + res.send({ error: err.toJson() }); } }; } From 42a8237f0b6e0f9e83ead8f06e8021f4576704e8 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Wed, 30 Mar 2022 15:32:51 -0700 Subject: [PATCH 17/18] add package-lock --- package-lock.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 0201ae801..cc67d00f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "@types/express": "4.17.3", "cors": "^2.8.5", "express": "^4.17.1", - "lodash": "^4.17.14" + "lodash": "^4.17.14", + "node-fetch": "^2.6.7" }, "bin": { "firebase-functions": "lib/bin/firebase-functions.js" @@ -27,6 +28,7 @@ "@types/mock-require": "^2.0.0", "@types/nock": "^10.0.3", "@types/node": "^8.10.50", + "@types/node-fetch": "^3.0.3", "@types/sinon": "^7.0.13", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", @@ -855,6 +857,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.63.tgz", "integrity": "sha512-g+nSkeHFDd2WOQChfmy9SAXLywT47WZBrGS/NC5ym5PJ8c8RC6l4pbGaUW/X0+eZJnXw6/AVNEouXWhV4iz72Q==" }, + "node_modules/@types/node-fetch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-3.0.3.tgz", + "integrity": "sha512-HhggYPH5N+AQe/OmN6fmhKmRRt2XuNJow+R3pQwJxOOF9GuwM7O2mheyGeIrs5MOIeNjDEdgdoyHBOrFeJBR3g==", + "deprecated": "This is a stub types definition. node-fetch provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "node-fetch": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.4", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz", @@ -6768,6 +6780,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.63.tgz", "integrity": "sha512-g+nSkeHFDd2WOQChfmy9SAXLywT47WZBrGS/NC5ym5PJ8c8RC6l4pbGaUW/X0+eZJnXw6/AVNEouXWhV4iz72Q==" }, + "@types/node-fetch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-3.0.3.tgz", + "integrity": "sha512-HhggYPH5N+AQe/OmN6fmhKmRRt2XuNJow+R3pQwJxOOF9GuwM7O2mheyGeIrs5MOIeNjDEdgdoyHBOrFeJBR3g==", + "dev": true, + "requires": { + "node-fetch": "*" + } + }, "@types/qs": { "version": "6.9.4", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz", From e66dc39b96fa7cec15e0ee7071c731c95141cbc8 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Wed, 30 Mar 2022 15:37:27 -0700 Subject: [PATCH 18/18] change decoded JWT to decoded payload --- src/common/providers/identity.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 66cd5348e..f3358f02e 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -1049,12 +1049,12 @@ function wrapHandler( throw new HttpsError('invalid-argument', 'Bad Request'); } const rawDecodedJWT = decodeJWT(req.body.data.jwt); - const decodedJWT = shouldVerifyJWT() + const decodedPayload = shouldVerifyJWT() ? verifyJWT(req.body.data.jwt, rawDecodedJWT, keysCache) : (rawDecodedJWT.payload as DecodedPayload); - checkDecodedToken(decodedJWT, eventType, projectId); - const authUserRecord = parseAuthUserRecord(decodedJWT.user_record); - const authEventContext = parseAuthEventContext(decodedJWT, projectId); + checkDecodedToken(decodedPayload, eventType, projectId); + const authUserRecord = parseAuthUserRecord(decodedPayload.user_record); + const authEventContext = parseAuthEventContext(decodedPayload, projectId); const authResponse = (await handler(authUserRecord, authEventContext)) || undefined; validateAuthResponse(eventType, authResponse);