From ea509af9b19c3e90c31a8fff81bb164ef7ec2d9c Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 23 Aug 2023 14:48:02 -0700 Subject: [PATCH 1/6] supporting recaptcha verdict for auth blocking functions --- spec/common/providers/identity.spec.ts | 64 +++++++++++++++++++++----- src/common/providers/identity.ts | 50 +++++++++++++++----- 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index a5112b6c3..bc3d7ffdb 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -26,6 +26,7 @@ import * as identity from "../../../src/common/providers/identity"; const EVENT = "EVENT_TYPE"; const now = new Date(); +const TEST_NAME = "John Doe"; describe("identity", () => { describe("userRecordConstructor", () => { @@ -232,14 +233,14 @@ describe("identity", () => { describe("parseProviderData", () => { const decodedUserInfo = { provider_id: "google.com", - display_name: "John Doe", + display_name: TEST_NAME, photo_url: "https://lh3.googleusercontent.com/1234567890/photo.jpg", uid: "1234567890", email: "user@gmail.com", }; const userInfo = { providerId: "google.com", - displayName: "John Doe", + displayName: TEST_NAME, photoURL: "https://lh3.googleusercontent.com/1234567890/photo.jpg", uid: "1234567890", email: "user@gmail.com", @@ -340,12 +341,12 @@ describe("identity", () => { uid: "abcdefghijklmnopqrstuvwxyz", email: "user@gmail.com", email_verified: true, - display_name: "John Doe", + display_name: TEST_NAME, phone_number: "+11234567890", provider_data: [ { provider_id: "google.com", - display_name: "John Doe", + display_name: TEST_NAME, photo_url: "https://lh3.googleusercontent.com/1234567890/photo.jpg", email: "user@gmail.com", uid: "1234567890", @@ -366,7 +367,7 @@ describe("identity", () => { provider_id: "password", email: "user@gmail.com", uid: "user@gmail.com", - display_name: "John Doe", + display_name: TEST_NAME, }, ], password_hash: "passwordHash", @@ -407,11 +408,11 @@ describe("identity", () => { phoneNumber: "+11234567890", emailVerified: true, disabled: false, - displayName: "John Doe", + displayName: TEST_NAME, providerData: [ { providerId: "google.com", - displayName: "John Doe", + displayName: TEST_NAME, photoURL: "https://lh3.googleusercontent.com/1234567890/photo.jpg", email: "user@gmail.com", uid: "1234567890", @@ -435,7 +436,7 @@ describe("identity", () => { }, { providerId: "password", - displayName: "John Doe", + displayName: TEST_NAME, photoURL: undefined, email: "user@gmail.com", uid: "user@gmail.com", @@ -489,8 +490,9 @@ describe("identity", () => { }); describe("parseAuthEventContext", () => { + const TEST_RECAPTCHA_SCORE = 0.9; const rawUserInfo = { - name: "John Doe", + name: TEST_NAME, granted_scopes: "openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile", id: "123456789", @@ -516,6 +518,7 @@ describe("identity", () => { user_agent: "USER_AGENT", locale: "en", raw_user_info: JSON.stringify(rawUserInfo), + recaptcha_score: TEST_RECAPTCHA_SCORE, }; const context = { locale: "en", @@ -534,6 +537,7 @@ describe("identity", () => { profile: rawUserInfo, username: undefined, isNewUser: false, + recaptchaScore: TEST_RECAPTCHA_SCORE, }, credential: null, params: {}, @@ -563,6 +567,7 @@ describe("identity", () => { oauth_refresh_token: "REFRESH_TOKEN", oauth_token_secret: "OAUTH_TOKEN_SECRET", oauth_expires_in: 3600, + recaptcha_score: TEST_RECAPTCHA_SCORE, }; const context = { locale: "en", @@ -581,6 +586,7 @@ describe("identity", () => { profile: rawUserInfo, username: undefined, isNewUser: false, + recaptchaScore: TEST_RECAPTCHA_SCORE, }, credential: { claims: undefined, @@ -619,14 +625,14 @@ describe("identity", () => { uid: "abcdefghijklmnopqrstuvwxyz", email: "user@gmail.com", email_verified: true, - display_name: "John Doe", + display_name: TEST_NAME, phone_number: "+11234567890", provider_data: [ { provider_id: "oidc.provider", email: "user@gmail.com", uid: "user@gmail.com", - display_name: "John Doe", + display_name: TEST_NAME, }, ], photo_url: "https://lh3.googleusercontent.com/1234567890/photo.jpg", @@ -647,6 +653,7 @@ describe("identity", () => { oauth_token_secret: "OAUTH_TOKEN_SECRET", oauth_expires_in: 3600, raw_user_info: JSON.stringify(rawUserInfo), + recaptcha_score: TEST_RECAPTCHA_SCORE, }; const context = { locale: "en", @@ -665,6 +672,7 @@ describe("identity", () => { providerId: "oidc.provider", profile: rawUserInfo, isNewUser: true, + recaptchaScore: TEST_RECAPTCHA_SCORE, }, credential: { claims: undefined, @@ -762,4 +770,38 @@ describe("identity", () => { ); }); }); + + describe("generateRequestPayload", () => { + const DISPLAY_NAME_FILED = "displayName"; + const TEST_RESPONSE = { + displayName: TEST_NAME, + recaptchaPassed: false, + } as identity.BeforeCreateResponse; + + const EXPECT_PAYLOAD = { + userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FILED }, + recaptchaPassed: false, + }; + + const TEST_RESPONSE_RECAPTCHA_UNDEFINED = { + displayName: TEST_NAME, + } as identity.BeforeSignInResponse; + + const EXPECT_PAYLOAD_UNDEFINED = { + userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FILED }, + }; + it("should return empty string on undefined response", () => { + expect(identity.generateRequestPayload()).to.eq(""); + }); + + it("should exclude recaptchaPass field from updateMask", () => { + expect(identity.generateRequestPayload(TEST_RESPONSE)).to.deep.equal(EXPECT_PAYLOAD); + }); + + it("should not return recaptchaPass if undefined", () => { + const payload = identity.generateRequestPayload(TEST_RESPONSE_RECAPTCHA_UNDEFINED); + expect(payload.hasOwnProperty("recaptchaPassed")).to.be.false; + expect(payload).to.deep.equal(EXPECT_PAYLOAD_UNDEFINED); + }); + }); }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index b90c5b549..aa3bc884f 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -310,6 +310,7 @@ export interface AdditionalUserInfo { profile?: any; username?: string; isNewUser: boolean; + recaptchaScore?: number; } /** The credential component of the auth event context */ @@ -338,8 +339,13 @@ export interface AuthBlockingEvent extends AuthEventContext { data: AuthUserRecord; } +/** The base handler response type for beforeCreate and beforeSignIn blocking events*/ +export interface BlockingFunctionResponse { + recaptchaPassed?: boolean; +} + /** The handler response type for beforeCreate blocking events */ -export interface BeforeCreateResponse { +export interface BeforeCreateResponse extends BlockingFunctionResponse { displayName?: string; disabled?: boolean; emailVerified?: boolean; @@ -423,6 +429,7 @@ export interface DecodedPayload { oauth_refresh_token?: string; oauth_token_secret?: string; oauth_expires_in?: number; + recaptcha_score?: number; [key: string]: any; } @@ -640,9 +647,38 @@ function parseAdditionalUserInfo(decodedJWT: DecodedPayload): AdditionalUserInfo profile, username, isNewUser: decodedJWT.event_type === "beforeCreate" ? true : false, + recaptchaScore: decodedJWT.recaptcha_score, }; } +/** Helper to generate payload to GCIP from client request. + * @internal + */ +export function generateRequestPayload( + authResponse?: BeforeCreateResponse | BeforeSignInResponse +): any { + if (!authResponse) { + return ""; + } + + const { recaptchaPassed, ...formattedAuthResponse } = authResponse; + const result = {} as any; + const updateMask = getUpdateMask(formattedAuthResponse); + + if (updateMask.length !== 0) { + result.userRecord = { + ...formattedAuthResponse, + updateMask, + }; + } + + if (recaptchaPassed !== undefined) { + result.recaptchaPassed = recaptchaPassed; + } + + return result; +} + /** Helper to get the Credential from the decoded jwt */ function parseAuthCredential(decodedJWT: DecodedPayload, time: number): Credential { if ( @@ -801,7 +837,6 @@ export function wrapHandler(eventType: AuthBlockingEventType, handler: HandlerV1 : handler.length === 2 ? await auth.getAuth(getApp())._verifyAuthBlockingToken(req.body.data.jwt) : await auth.getAuth(getApp())._verifyAuthBlockingToken(req.body.data.jwt, "run.app"); - const authUserRecord = parseAuthUserRecord(decodedPayload.user_record); const authEventContext = parseAuthEventContext(decodedPayload, projectId); @@ -818,16 +853,7 @@ export function wrapHandler(eventType: AuthBlockingEventType, handler: HandlerV1 } validateAuthResponse(eventType, authResponse); - const updateMask = getUpdateMask(authResponse); - const result = - updateMask.length === 0 - ? {} - : { - userRecord: { - ...authResponse, - updateMask, - }, - }; + const result = generateRequestPayload(authResponse); res.status(200); res.setHeader("Content-Type", "application/json"); From ecdc6e1eac27638f965c60bc640a49c2de351f2c Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Fri, 25 Aug 2023 16:17:25 -0700 Subject: [PATCH 2/6] addressing PR feedbacks --- spec/common/providers/identity.spec.ts | 28 +++++++++++++++++++------- src/common/providers/identity.ts | 24 ++++++++++++++++------ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index bc3d7ffdb..40083b69b 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -772,34 +772,48 @@ describe("identity", () => { }); describe("generateRequestPayload", () => { - const DISPLAY_NAME_FILED = "displayName"; + const DISPLAY_NAME_FIELD = "displayName"; const TEST_RESPONSE = { displayName: TEST_NAME, recaptchaPassed: false, } as identity.BeforeCreateResponse; const EXPECT_PAYLOAD = { - userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FILED }, + userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FIELD }, recaptchaPassed: false, }; + const TEST_RESPONSE_RECAPTCHA_TRUE = { + recaptchaPassed: true, + } as identity.BeforeCreateResponse; + + const EXPECT_PAYLOAD_RECAPTCHA_TRUE = { + recaptchaPassed: true, + }; + const TEST_RESPONSE_RECAPTCHA_UNDEFINED = { displayName: TEST_NAME, } as identity.BeforeSignInResponse; const EXPECT_PAYLOAD_UNDEFINED = { - userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FILED }, + userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FIELD }, }; - it("should return empty string on undefined response", () => { - expect(identity.generateRequestPayload()).to.eq(""); + it("should return empty object on undefined response", () => { + expect(identity.generateResponsePayload()).to.eql({}); }); it("should exclude recaptchaPass field from updateMask", () => { - expect(identity.generateRequestPayload(TEST_RESPONSE)).to.deep.equal(EXPECT_PAYLOAD); + expect(identity.generateResponsePayload(TEST_RESPONSE)).to.deep.equal(EXPECT_PAYLOAD); + }); + + it("should return recaptchaPass when it is true on response", () => { + expect(identity.generateResponsePayload(TEST_RESPONSE_RECAPTCHA_TRUE)).to.deep.equal( + EXPECT_PAYLOAD_RECAPTCHA_TRUE + ); }); it("should not return recaptchaPass if undefined", () => { - const payload = identity.generateRequestPayload(TEST_RESPONSE_RECAPTCHA_UNDEFINED); + const payload = identity.generateResponsePayload(TEST_RESPONSE_RECAPTCHA_UNDEFINED); expect(payload.hasOwnProperty("recaptchaPassed")).to.be.false; expect(payload).to.deep.equal(EXPECT_PAYLOAD_UNDEFINED); }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index aa3bc884f..85ae94cef 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -433,6 +433,17 @@ export interface DecodedPayload { [key: string]: any; } +/** @internal */ +export interface ResponsePayload { + userRecord?: UserRecordResponsePayload; + recaptchaPassed?: boolean; +} + +/** @internal */ +export interface UserRecordResponsePayload extends Omit { + updateMask?: string; +} + type HandlerV1 = ( user: AuthUserRecord, context: AuthEventContext @@ -651,18 +662,19 @@ function parseAdditionalUserInfo(decodedJWT: DecodedPayload): AdditionalUserInfo }; } -/** Helper to generate payload to GCIP from client request. +/** + * Helper to generate a response from the blocking function to the Firebase Auth backend. * @internal */ -export function generateRequestPayload( +export function generateResponsePayload( authResponse?: BeforeCreateResponse | BeforeSignInResponse -): any { +): ResponsePayload { if (!authResponse) { - return ""; + return {}; } const { recaptchaPassed, ...formattedAuthResponse } = authResponse; - const result = {} as any; + const result = {} as ResponsePayload; const updateMask = getUpdateMask(formattedAuthResponse); if (updateMask.length !== 0) { @@ -853,7 +865,7 @@ export function wrapHandler(eventType: AuthBlockingEventType, handler: HandlerV1 } validateAuthResponse(eventType, authResponse); - const result = generateRequestPayload(authResponse); + const result = generateResponsePayload(authResponse); res.status(200); res.setHeader("Content-Type", "application/json"); From 0de4e7ecd6fd54e74e47c04bb07a67f24efec419 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Mon, 18 Sep 2023 14:27:47 -0700 Subject: [PATCH 3/6] updated with API proposal guidence --- spec/common/providers/identity.spec.ts | 28 ++++++++++++++------------ src/common/providers/identity.ts | 28 ++++++++++++++++---------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 40083b69b..cfbaca770 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -27,6 +27,8 @@ import * as identity from "../../../src/common/providers/identity"; const EVENT = "EVENT_TYPE"; const now = new Date(); const TEST_NAME = "John Doe"; +const ALLOW = "ALLOW"; +const BLOCK = "BLOCK"; describe("identity", () => { describe("userRecordConstructor", () => { @@ -771,24 +773,24 @@ describe("identity", () => { }); }); - describe("generateRequestPayload", () => { + describe("generateResponsePayload", () => { const DISPLAY_NAME_FIELD = "displayName"; const TEST_RESPONSE = { displayName: TEST_NAME, - recaptchaPassed: false, + recaptchaActionOverride: BLOCK, } as identity.BeforeCreateResponse; const EXPECT_PAYLOAD = { userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FIELD }, - recaptchaPassed: false, + recaptchaActionOverride: BLOCK, }; - const TEST_RESPONSE_RECAPTCHA_TRUE = { - recaptchaPassed: true, + const TEST_RESPONSE_RECAPTCHA_ALLOW = { + recaptchaActionOverride: ALLOW, } as identity.BeforeCreateResponse; - const EXPECT_PAYLOAD_RECAPTCHA_TRUE = { - recaptchaPassed: true, + const EXPECT_PAYLOAD_RECAPTCHA_ALLOW = { + recaptchaActionOverride: ALLOW, }; const TEST_RESPONSE_RECAPTCHA_UNDEFINED = { @@ -802,19 +804,19 @@ describe("identity", () => { expect(identity.generateResponsePayload()).to.eql({}); }); - it("should exclude recaptchaPass field from updateMask", () => { + it("should exclude recaptchaActionOverride field from updateMask", () => { expect(identity.generateResponsePayload(TEST_RESPONSE)).to.deep.equal(EXPECT_PAYLOAD); }); - it("should return recaptchaPass when it is true on response", () => { - expect(identity.generateResponsePayload(TEST_RESPONSE_RECAPTCHA_TRUE)).to.deep.equal( - EXPECT_PAYLOAD_RECAPTCHA_TRUE + it("should return recaptchaActionOverride when it is true on response", () => { + expect(identity.generateResponsePayload(TEST_RESPONSE_RECAPTCHA_ALLOW)).to.deep.equal( + EXPECT_PAYLOAD_RECAPTCHA_ALLOW ); }); - it("should not return recaptchaPass if undefined", () => { + it("should not return recaptchaActionOverride if undefined", () => { const payload = identity.generateResponsePayload(TEST_RESPONSE_RECAPTCHA_UNDEFINED); - expect(payload.hasOwnProperty("recaptchaPassed")).to.be.false; + expect(payload.hasOwnProperty("recaptchaActionOverride")).to.be.false; expect(payload).to.deep.equal(EXPECT_PAYLOAD_UNDEFINED); }); }); diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 85ae94cef..6d278b1b2 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -339,18 +339,19 @@ export interface AuthBlockingEvent extends AuthEventContext { data: AuthUserRecord; } -/** The base handler response type for beforeCreate and beforeSignIn blocking events*/ -export interface BlockingFunctionResponse { - recaptchaPassed?: boolean; -} +/** + * The reCACPTCHA action options. + */ +export type RecatpchaActionOptions = "ALLOW" | "BLOCK"; /** The handler response type for beforeCreate blocking events */ -export interface BeforeCreateResponse extends BlockingFunctionResponse { +export interface BeforeCreateResponse { displayName?: string; disabled?: boolean; emailVerified?: boolean; photoURL?: string; customClaims?: object; + recaptchaActionOverride?: RecatpchaActionOptions; } /** The handler response type for beforeSignIn blocking events */ @@ -433,14 +434,18 @@ export interface DecodedPayload { [key: string]: any; } -/** @internal */ +/** + * This interface defines the payload to send back to GCIP. + * The nesting structure different than what customers returned. + * @internal */ export interface ResponsePayload { userRecord?: UserRecordResponsePayload; - recaptchaPassed?: boolean; + recaptchaActionOverride?: RecatpchaActionOptions; } /** @internal */ -export interface UserRecordResponsePayload extends Omit { +export interface UserRecordResponsePayload + extends Omit { updateMask?: string; } @@ -673,7 +678,8 @@ export function generateResponsePayload( return {}; } - const { recaptchaPassed, ...formattedAuthResponse } = authResponse; + const { recaptchaActionOverride: recaptchaActionOverride, ...formattedAuthResponse } = + authResponse; const result = {} as ResponsePayload; const updateMask = getUpdateMask(formattedAuthResponse); @@ -684,8 +690,8 @@ export function generateResponsePayload( }; } - if (recaptchaPassed !== undefined) { - result.recaptchaPassed = recaptchaPassed; + if (recaptchaActionOverride !== undefined) { + result.recaptchaActionOverride = recaptchaActionOverride; } return result; From bbe4ff022f877d0a21401107f8a0297a8292d17e Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Mon, 18 Sep 2023 16:01:57 -0700 Subject: [PATCH 4/6] Added api reference for ResponsePayload --- src/common/providers/identity.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 6d278b1b2..77d6a019e 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -435,8 +435,9 @@ export interface DecodedPayload { } /** - * This interface defines the payload to send back to GCIP. - * The nesting structure different than what customers returned. + * Internal definition to include all the fields that can be sent as + * a response from the blocking function to the backend. + * This is added mainly to have a type definition for 'generateResponsePayload' * @internal */ export interface ResponsePayload { userRecord?: UserRecordResponsePayload; From a0d53f28a8f736dd100c0c59c3e5dc7617c839d4 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 20 Sep 2023 10:20:51 -0700 Subject: [PATCH 5/6] fix recaptcha typo --- src/common/providers/identity.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 77d6a019e..780481827 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -340,9 +340,9 @@ export interface AuthBlockingEvent extends AuthEventContext { } /** - * The reCACPTCHA action options. + * The reCAPTCHA action options. */ -export type RecatpchaActionOptions = "ALLOW" | "BLOCK"; +export type RecaptchaActionOptions = "ALLOW" | "BLOCK"; /** The handler response type for beforeCreate blocking events */ export interface BeforeCreateResponse { @@ -351,7 +351,7 @@ export interface BeforeCreateResponse { emailVerified?: boolean; photoURL?: string; customClaims?: object; - recaptchaActionOverride?: RecatpchaActionOptions; + recaptchaActionOverride?: RecaptchaActionOptions; } /** The handler response type for beforeSignIn blocking events */ @@ -441,7 +441,7 @@ export interface DecodedPayload { * @internal */ export interface ResponsePayload { userRecord?: UserRecordResponsePayload; - recaptchaActionOverride?: RecatpchaActionOptions; + recaptchaActionOverride?: RecaptchaActionOptions; } /** @internal */ @@ -679,7 +679,7 @@ export function generateResponsePayload( return {}; } - const { recaptchaActionOverride: recaptchaActionOverride, ...formattedAuthResponse } = + const { recaptchaActionOverride, ...formattedAuthResponse } = authResponse; const result = {} as ResponsePayload; const updateMask = getUpdateMask(formattedAuthResponse); From 398cfc008d650c6d6f71c02cc7b63c7f13a89aab Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 20 Sep 2023 10:26:31 -0700 Subject: [PATCH 6/6] fix lint --- src/common/providers/identity.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 780481827..374e4f8bd 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -679,8 +679,7 @@ export function generateResponsePayload( return {}; } - const { recaptchaActionOverride, ...formattedAuthResponse } = - authResponse; + const { recaptchaActionOverride, ...formattedAuthResponse } = authResponse; const result = {} as ResponsePayload; const updateMask = getUpdateMask(formattedAuthResponse);