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", diff --git a/package.json b/package.json index a478a7a91..099e5ec3f 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,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", @@ -168,6 +169,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..caa656e3e 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -20,10 +20,108 @@ // 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 jwt from 'jsonwebtoken'; +import * as sinon from 'sinon'; import * as identity from '../../../src/common/providers/identity'; +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 are less than equals Date.now() plus a buffer', () => { + const time = Date.now(); + expect( + identity.invalidPublicKeys( + { + publicKeys: {}, + publicKeysExpireAt: time, + }, + time + ) + ).to.be.true; + }); + + it('should return false if publicKeysExpireAt are greater than Date.now() plus a buffer', () => { + const time = Date.now(); + expect( + identity.invalidPublicKeys( + { + publicKeys: {}, + publicKeysExpireAt: time + identity.INVALID_TOKEN_BUFFER + 60000, + }, + time + ) + ).to.be.false; + }); + }); + + describe('setKeyExpirationTime', () => { + const time = Date.now(); + + it('should do nothing without cache-control', async () => { + const publicKeysCache = { + publicKeys: {}, + publicKeysExpireAt: undefined, + }; + const headers = new Map(); + const response = { + headers, + }; + + await identity.setKeyExpirationTime(response, publicKeysCache, time); + + expect(publicKeysCache.publicKeysExpireAt).to.be.undefined; + }); + + it('should do nothing with cache-control but without max-age', async () => { + const publicKeysCache = { + publicKeys: {}, + publicKeysExpireAt: undefined, + }; + const headers = new Map(); + headers.set('cache-control', 'item=val, item2=val2, item3=val3'); + const response = { + headers, + }; + + await identity.setKeyExpirationTime(response, publicKeysCache, time); + + expect(publicKeysCache.publicKeysExpireAt).to.be.undefined; + }); + + it('should set the correctly set the expiration time', async () => { + const publicKeysCache = { + publicKeys: {}, + publicKeysExpireAt: undefined, + }; + const headers = new Map(); + headers.set( + 'cache-control', + 'item=val, max-age=50, item2=val2, item3=val3' + ); + const response = { + headers, + }; + + await identity.setKeyExpirationTime(response, publicKeysCache, time); + + 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' }); @@ -85,4 +183,979 @@ describe('identity', () => { }); }); }); + + describe('isValidRequest', () => { + 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.isValidRequest(req)).to.be.false; + }); + + 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.isValidRequest(req)).to.be.false; + }); + + it('should error without req body', () => { + const req = ({ + method: 'POST', + header(val: string) { + return 'application/json'; + }, + } as unknown) as express.Request; + + expect(identity.isValidRequest(req)).to.be.false; + }); + + 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.isValidRequest(req)).to.be.false; + }); + + 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.isValidRequest(req)).to.be.false; + }); + + 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.isValidRequest(req)).to.be.true; + }); + }); + + describe('getPublicKeyFromHeader', () => { + it('should throw if header.alg is not expected', () => { + 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.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.getPublicKeyFromHeader( + { + 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.getPublicKeyFromHeader( + { + alg: identity.JWT_ALG, + kid: '123456', + }, + { + '123456': '7890', + '2468': '1357', + } + ) + ).to.eq('7890'); + }); + }); + + describe('isAuthorizedCloudFunctionURL', () => { + it('should return false on a bad gcf location', () => { + 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 mismatching event types', () => { + expect(() => + identity.checkDecodedToken( + { + event_type: EVENT, + } as identity.DecodedPayload, + '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.DecodedPayload, + EVENT, + 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`, + event_type: EVENT, + } as identity.DecodedPayload, + EVENT, + 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', + }, + event_type: EVENT, + } as unknown) as identity.DecodedPayload, + EVENT, + 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: '', + event_type: EVENT, + } as identity.DecodedPayload, + EVENT, + PROJECT + ) + ).to.throw('Provided JWT has no "sub" (subject) claim.'); + }); + + it('should throw if sub length is larger than 128 chars', () => { + const str = 'a'.repeat(129); + expect(() => + identity.checkDecodedToken( + { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + sub: str.toString(), + event_type: EVENT, + } as identity.DecodedPayload, + EVENT, + PROJECT + ) + ).to.throw( + 'Provided JWT has "sub" (subject) claim longer than 128 characters.' + ); + }); + + 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.DecodedPayload; + + 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 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.throw( + 'The decoded JWT is not structured correctly.' + ); + }); + + it('should error if decoded jwt does not have a payload field', () => { + jwtDecodeStub.returns({ + header: { key: 'val' }, + }); + + expect(() => identity.decodeJWT('123456')).to.throw( + 'The decoded JWT is not structured correctly.' + ); + }); + + it('should return the raw decoded jwt', () => { + 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); + }); + }); + + 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 jwtVerifyStub: sinon.SinonStub; + const keysCache = { + publicKeys: { + '123456': '7890', + '2468': '1357', + }, + publicKeysExpireAt: time + identity.INVALID_TOKEN_BUFFER + 10000, + }; + const rawDecodedJWT = { + header: { + alg: identity.JWT_ALG, + kid: '2468', + }, + payload: { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + event_type: EVENT, + }, + }; + + beforeEach(() => { + jwtVerifyStub = sinon + .stub(jwt, 'verify') + .throws('Unexpected call to jwt.verify'); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + it('should error if header does not exist', () => { + const rawDecodedJwt = { payload: 'val' }; + + expect(() => + identity.verifyJWT('123456', rawDecodedJwt, keysCache, time) + ).to.throw( + 'Unable to verify JWT payload, the decoded JWT does not have a header property.' + ); + }); + + it('should return the decoded jwt', () => { + const decoded = { + aud: VALID_URL, + iss: `${identity.JWT_ISSUER}${PROJECT}`, + event_type: EVENT, + }; + jwtVerifyStub.returns(decoded); + + expect( + identity.verifyJWT('123456', rawDecodedJWT, keysCache, time) + ).to.deep.equal(decoded); + }); + }); + + 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).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, + phoneNumber: 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.DecodedPayloadMfaInfo], + }; + + 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: undefined, + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505556789', + factorId: 'phone', + }, + ], + }, + }; + + it('should error if decoded does not have uid', () => { + expect(() => + identity.parseAuthUserRecord({} as identity.DecodedPayloadUserRecord) + ).to.throw('INTERNAL ASSERT FAILED: Invalid user response'); + }); + + it('should parse user record', () => { + const ur = identity.parseAuthUserRecord(decodedUserRecord); + + expect(ur).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, + 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, + 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('validateAuthResponse', () => { + it('should not throw on undefined request', () => { + expect(() => identity.validateAuthResponse('event', undefined)).to.not + .throw; + }); + + it('should throw an error if customClaims have a blocked claim', () => { + expect(() => + identity.validateAuthResponse('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', () => { + const str = 'x'.repeat(1000); + + expect(() => + identity.validateAuthResponse('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.validateAuthResponse('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', () => { + const str = 'x'.repeat(1000); + + expect(() => + identity.validateAuthResponse('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', () => { + const str = 'x'.repeat(501); + + expect(() => + identity.validateAuthResponse('beforeSignIn', { + customClaims: { cc: str }, + sessionClaims: { sc: str }, + }) + ).to.throw( + 'The customClaims and sessionClaims payloads should not exceed 1000 characters combined.' + ); + }); + }); + + 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 24a4e2c34..f3358f02e 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -20,8 +20,60 @@ // 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 { SUPPORTED_REGIONS } from '../../function-configuration'; +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 */ +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 */ +export interface PublicKeysCache { + publicKeys: Record; + publicKeysExpireAt?: number; +} + +const DISALLOWED_CUSTOM_CLAIMS = [ + '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', +}; /** * The UserRecord passed to Cloud Functions is the same UserRecord that is returned by the Firebase Admin @@ -41,7 +93,7 @@ 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() { + toJSON(): AuthUserMetadata { return { creationTime: this.creationTime, lastSignInTime: this.lastSignInTime, @@ -69,43 +121,964 @@ 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; } + +/** + * 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 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 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[]; +} + +/** + * The UserRecord passed to auth blocking Cloud Functions from the identity platform. + */ +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 */ +export interface AdditionalUserInfo { + providerId: string; + profile?: any; + username?: string; + isNewUser: boolean; +} + +/** The credential component of the auth event context */ +export 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 for blocking events */ +export interface AuthEventContext extends EventContext { + locale?: string; + ipAddress: string; + userAgent: string; + additionalUserInfo?: AdditionalUserInfo; + credential?: Credential; +} + +/** The handler response type for beforeCreate blocking events */ +export interface BeforeCreateResponse { + 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; +} + +interface DecodedPayloadUserRecordMetadata { + creation_time?: number; + last_sign_in_time?: number; +} + +interface DecodedPayloadUserRecordUserInfo { + uid: string; + display_name?: string; + email?: string; + photo_url?: string; + phone_number?: string; + provider_id: string; +} + +/** @internal */ +export interface DecodedPayloadMfaInfo { + uid: string; + display_name?: string; + phone_number?: string; + enrollment_time?: string; + factor_id?: string; +} + +interface DecodedPayloadUserRecordEnrolledFactors { + enrolled_factors?: DecodedPayloadMfaInfo[]; +} + +/** @internal */ +export interface DecodedPayloadUserRecord { + uid: string; + email?: string; + email_verified?: boolean; + phone_number?: string; + display_name?: string; + photo_url?: string; + disabled?: boolean; + metadata?: DecodedPayloadUserRecordMetadata; + password_hash?: string; + password_salt?: string; + provider_data?: DecodedPayloadUserRecordUserInfo[]; + multi_factor?: DecodedPayloadUserRecordEnrolledFactors; + custom_claims?: any; + tokens_valid_after_time?: number; + tenant_id?: string; + [key: string]: any; +} + +/** @internal */ +export interface DecodedPayload { + 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?: DecodedPayloadUserRecord; + 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; +} + +/** + * Helper to determine if we refresh the public keys + * @internal + */ +export function invalidPublicKeys( + keys: PublicKeysCache, + time: number = Date.now() +): boolean { + if (!keys.publicKeysExpireAt) { + return true; + } + return time + INVALID_TOKEN_BUFFER >= keys.publicKeysExpireAt; +} + +/** + * Helper to parse the response headers to obtain the expiration time. + * @internal + */ +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; + } + } +} + +/** + * Fetch the public keys for use in decoding and verifying the jwt sent from identity platform. + */ +async function refreshPublicKeys( + keysCache: PublicKeysCache, + time: number = Date.now() +): Promise { + const url = `${JWT_CLIENT_CERT_URL}/${JWT_CLIENT_CERT_PATH}`; + try { + const response = await fetch(url); + setKeyExpirationTime(response, keysCache, time); + const data = await response.json(); + 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 the public keys for JWT verification.' + ); + } +} + +/** + * Checks for a valid identity platform web request, otherwise throws an HttpsError + * @internal + */ +export function isValidRequest(req: express.Request): boolean { + if (req.method !== 'POST') { + logger.warn(`Request has invalid method "${req.method}".`); + return false; + } + + const contentType: string = (req.header('Content-Type') || '').toLowerCase(); + if (!contentType.includes('application/json')) { + logger.warn('Request has invalid header Content-Type.'); + return false; + } + + if (!req.body?.data?.jwt) { + logger.warn('Request has an invalid body.'); + return false; + } + return true; +} + +/** @internal */ +export function getPublicKeyFromHeader( + 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 header missing "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]; +} + +/** + * Checks for a well forms cloud functions url + * @internal + */ +export function isAuthorizedCloudFunctionURL( + cloudFunctionUrl: string, + projectId: string +): boolean { + const re = new RegExp( + `^https://(${SUPPORTED_REGIONS.join( + '|' + )})+-${projectId}\.cloudfunctions\.net/` + ); + const res = re.exec(cloudFunctionUrl) || []; + return res.length > 0; +} + +/** + * Checks for errors in a decoded jwt + * @internal + */ +export function checkDecodedToken( + decodedJWT: DecodedPayload, + 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', + '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.' + ); + } + // set uid to sub + decodedJWT.uid = decodedJWT.sub; +} + +/** + * Helper function to decode the jwt, internally uses the 'jsonwebtoken' package. + * @internal + */ +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; +} + +/** + * 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 + return true; +} + +/** + * 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() +): DecodedPayload { + 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; + let publicKey; + try { + if (invalidPublicKeys(keysCache, time)) { + refreshPublicKeys(keysCache); + } + publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); + return jwt.verify(token, publicKey, { + algorithms: [JWT_ALG], + }) as DecodedPayload; + } catch (err) { + logger.error('Verifying the JWT failed', err); + } + // force refresh keys and retry one more time + try { + refreshPublicKeys(keysCache); + publicKey = getPublicKeyFromHeader(header, keysCache.publicKeys); + return jwt.verify(token, publicKey, { + algorithms: [JWT_ALG], + }) as DecodedPayload; + } catch (err) { + logger.error('Verifying the JWT failed again', err); + throw new HttpsError('internal', 'Failed to verify the JWT.'); + } +} + +/** + * Helper function to parse the decoded metadata object into a UserMetaData object + * @internal + */ +export function parseMetadata( + metadata: DecodedPayloadUserRecordMetadata +): 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 { + creationTime, + lastSignInTime, + }; +} + +/** + * Helper function to parse the decoded user info array into an AuthUserInfo array + * @internal + */ +export function parseProviderData( + providerData: DecodedPayloadUserRecordUserInfo[] +): AuthUserInfo[] { + const providers: AuthUserInfo[] = []; + for (const provider of providerData) { + 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; +} + +/** + * Helper function to parse the date into a UTC string + * @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; +} + +/** + * Helper function to parse the decoded enrolled factors into a valid MultiFactorSettings + * @internal + */ +export function parseMultiFactor( + multiFactor?: DecodedPayloadUserRecordEnrolledFactors +): AuthMultiFactorSettings { + if (!multiFactor) { + return null; + } + const parsedEnrolledFactors: AuthMultiFactorInfo[] = []; + for (const factor of multiFactor.enrolled_factors || []) { + if (!factor.uid) { + throw new HttpsError( + 'internal', + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response' + ); + } + const enrollmentTime = factor.enrollment_time + ? new Date(factor.enrollment_time).toUTCString() + : null; + 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 { + enrolledFactors: parsedEnrolledFactors, + }; + } + return null; +} + +/** + * Parses the decoded user record into a valid UserRecord for use in the handler + * @internal + */ +export function parseAuthUserRecord( + decodedJWTUserRecord: DecodedPayloadUserRecord +): AuthUserRecord { + if (!decodedJWTUserRecord.uid) { + throw new HttpsError( + 'internal', + 'INTERNAL ASSERT FAILED: Invalid user response' + ); + } + + 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); + + return { + uid: decodedJWTUserRecord.uid, + email: decodedJWTUserRecord.email, + emailVerified: decodedJWTUserRecord.email_verified, + displayName: decodedJWTUserRecord.display_name, + photoURL: decodedJWTUserRecord.photo_url, + phoneNumber: decodedJWTUserRecord.phone_number, + disabled, + metadata, + providerData, + passwordHash: decodedJWTUserRecord.password_hash, + passwordSalt: decodedJWTUserRecord.password_salt, + customClaims: decodedJWTUserRecord.custom_claims, + tenantId: decodedJWTUserRecord.tenant_id, + tokensValidAfterTime, + multiFactor, + }; +} + +/** Helper to get the AdditionalUserInfo from the decoded jwt */ +function parseAdditionalUserInfo( + decodedJWT: DecodedPayload +): AdditionalUserInfo { + 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; + } + } + + return { + providerId: + decodedJWT.sign_in_method === 'emailLink' + ? 'password' + : decodedJWT.sign_in_method, + profile, + username, + isNewUser: decodedJWT.event_type === 'beforeCreate' ? true : false, + }; +} + +/** Helper to get the Credential from the decoded jwt */ +function parseAuthCredential( + decodedJWT: DecodedPayload, + 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, + }; +} + +/** + * Parses the decoded jwt into a valid AuthEventContext for use in the handler + * @internal + */ +export function parseAuthEventContext( + decodedJWT: DecodedPayload, + 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, + authType: !!decodedJWT.user_record ? 'USER' : 'UNAUTHENTICATED', + resource: { + // TODO(colerogers): figure out the correct service + service: 'identitytoolkit.googleapis.com', + name: !!decodedJWT.tenant_id + ? `projects/${projectId}/tenants/${decodedJWT.tenant_id}` + : `projects/${projectId}`, + }, + timestamp: new Date(decodedJWT.iat * 1000).toUTCString(), + additionalUserInfo: parseAdditionalUserInfo(decodedJWT), + credential: parseAuthCredential(decodedJWT, time), + params: {}, + }; +} + +/** + * Checks the handler response for invalid customClaims & sessionClaims objects + * @internal + */ +export function validateAuthResponse( + eventType: string, + authRequest?: BeforeCreateResponse | BeforeSignInResponse +) { + if (!authRequest) { + authRequest = {}; + } + if (authRequest.customClaims) { + const invalidClaims = DISALLOWED_CUSTOM_CLAIMS.filter((claim) => + authRequest.customClaims.hasOwnProperty(claim) + ); + if (invalidClaims.length > 0) { + throw new HttpsError( + 'invalid-argument', + `The customClaims claims "${invalidClaims.join( + ',' + )}" are reserved and cannot be specified.` + ); + } + if ( + JSON.stringify(authRequest.customClaims).length > CLAIMS_MAX_PAYLOAD_SIZE + ) { + throw new HttpsError( + 'invalid-argument', + `The customClaims payload should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters.` + ); + } + } + if ( + eventType === 'beforeSignIn' && + (authRequest as BeforeSignInResponse).sessionClaims + ) { + const invalidClaims = DISALLOWED_CUSTOM_CLAIMS.filter((claim) => + (authRequest as BeforeSignInResponse).sessionClaims.hasOwnProperty(claim) + ); + if (invalidClaims.length > 0) { + throw new HttpsError( + 'invalid-argument', + `The sessionClaims claims "${invalidClaims.join( + ',' + )}" are reserved and cannot be specified.` + ); + } + if ( + JSON.stringify((authRequest as BeforeSignInResponse).sessionClaims) + .length > CLAIMS_MAX_PAYLOAD_SIZE + ) { + throw new HttpsError( + 'invalid-argument', + `The sessionClaims payload should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters.` + ); + } + const combinedClaims = { + ...authRequest.customClaims, + ...(authRequest as BeforeSignInResponse).sessionClaims, + }; + if (JSON.stringify(combinedClaims).length > CLAIMS_MAX_PAYLOAD_SIZE) { + throw new HttpsError( + 'invalid-argument', + `The customClaims and sessionClaims payloads should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters combined.` + ); + } + } +} + +/** + * Helper function to generate the update mask for the identity platform changed values + * @internal + */ +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: AuthUserRecord, + context: AuthEventContext + ) => + | BeforeCreateResponse + | Promise + | BeforeSignInResponse + | Promise + | void + | Promise, + eventType: string, + keysCache: PublicKeysCache +): (req: express.Request, resp: express.Response) => Promise { + const wrappedHandler = wrapHandler(handler, eventType, keysCache); + return (req: express.Request, res: express.Response) => { + return new Promise((resolve) => { + res.on('finish', resolve); + resolve(wrappedHandler(req, res)); + }); + }; +} + +function wrapHandler( + handler: ( + user: AuthUserRecord, + context: AuthEventContext + ) => + | BeforeCreateResponse + | Promise + | BeforeSignInResponse + | Promise + | void + | Promise, + eventType: string, + keysCache: PublicKeysCache +) { + return async (req: express.Request, res: express.Response): Promise => { + try { + const projectId = process.env.GCLOUD_PROJECT; + 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 decodedPayload = shouldVerifyJWT() + ? verifyJWT(req.body.data.jwt, rawDecodedJWT, keysCache) + : (rawDecodedJWT.payload as DecodedPayload); + 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); + const updateMask = getUpdateMask(authResponse); + const result = { + userRecord: { + ...authResponse, + updateMask, + }, + }; + + 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({ error: err.toJson() }); + } + }; +}