diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index a5112b6c3..cfbaca770 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -26,6 +26,9 @@ 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", () => { @@ -232,14 +235,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 +343,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 +369,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 +410,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 +438,7 @@ describe("identity", () => { }, { providerId: "password", - displayName: "John Doe", + displayName: TEST_NAME, photoURL: undefined, email: "user@gmail.com", uid: "user@gmail.com", @@ -489,8 +492,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 +520,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 +539,7 @@ describe("identity", () => { profile: rawUserInfo, username: undefined, isNewUser: false, + recaptchaScore: TEST_RECAPTCHA_SCORE, }, credential: null, params: {}, @@ -563,6 +569,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 +588,7 @@ describe("identity", () => { profile: rawUserInfo, username: undefined, isNewUser: false, + recaptchaScore: TEST_RECAPTCHA_SCORE, }, credential: { claims: undefined, @@ -619,14 +627,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 +655,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 +674,7 @@ describe("identity", () => { providerId: "oidc.provider", profile: rawUserInfo, isNewUser: true, + recaptchaScore: TEST_RECAPTCHA_SCORE, }, credential: { claims: undefined, @@ -762,4 +772,52 @@ describe("identity", () => { ); }); }); + + describe("generateResponsePayload", () => { + const DISPLAY_NAME_FIELD = "displayName"; + const TEST_RESPONSE = { + displayName: TEST_NAME, + recaptchaActionOverride: BLOCK, + } as identity.BeforeCreateResponse; + + const EXPECT_PAYLOAD = { + userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FIELD }, + recaptchaActionOverride: BLOCK, + }; + + const TEST_RESPONSE_RECAPTCHA_ALLOW = { + recaptchaActionOverride: ALLOW, + } as identity.BeforeCreateResponse; + + const EXPECT_PAYLOAD_RECAPTCHA_ALLOW = { + recaptchaActionOverride: ALLOW, + }; + + const TEST_RESPONSE_RECAPTCHA_UNDEFINED = { + displayName: TEST_NAME, + } as identity.BeforeSignInResponse; + + const EXPECT_PAYLOAD_UNDEFINED = { + userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FIELD }, + }; + it("should return empty object on undefined response", () => { + expect(identity.generateResponsePayload()).to.eql({}); + }); + + it("should exclude recaptchaActionOverride field from updateMask", () => { + expect(identity.generateResponsePayload(TEST_RESPONSE)).to.deep.equal(EXPECT_PAYLOAD); + }); + + 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 recaptchaActionOverride if undefined", () => { + const payload = identity.generateResponsePayload(TEST_RESPONSE_RECAPTCHA_UNDEFINED); + 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 b90c5b549..374e4f8bd 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,6 +339,11 @@ export interface AuthBlockingEvent extends AuthEventContext { data: AuthUserRecord; } +/** + * The reCAPTCHA action options. + */ +export type RecaptchaActionOptions = "ALLOW" | "BLOCK"; + /** The handler response type for beforeCreate blocking events */ export interface BeforeCreateResponse { displayName?: string; @@ -345,6 +351,7 @@ export interface BeforeCreateResponse { emailVerified?: boolean; photoURL?: string; customClaims?: object; + recaptchaActionOverride?: RecaptchaActionOptions; } /** The handler response type for beforeSignIn blocking events */ @@ -423,9 +430,26 @@ export interface DecodedPayload { oauth_refresh_token?: string; oauth_token_secret?: string; oauth_expires_in?: number; + recaptcha_score?: number; [key: string]: any; } +/** + * 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; + recaptchaActionOverride?: RecaptchaActionOptions; +} + +/** @internal */ +export interface UserRecordResponsePayload + extends Omit { + updateMask?: string; +} + type HandlerV1 = ( user: AuthUserRecord, context: AuthEventContext @@ -640,9 +664,39 @@ function parseAdditionalUserInfo(decodedJWT: DecodedPayload): AdditionalUserInfo profile, username, isNewUser: decodedJWT.event_type === "beforeCreate" ? true : false, + recaptchaScore: decodedJWT.recaptcha_score, }; } +/** + * Helper to generate a response from the blocking function to the Firebase Auth backend. + * @internal + */ +export function generateResponsePayload( + authResponse?: BeforeCreateResponse | BeforeSignInResponse +): ResponsePayload { + if (!authResponse) { + return {}; + } + + const { recaptchaActionOverride, ...formattedAuthResponse } = authResponse; + const result = {} as ResponsePayload; + const updateMask = getUpdateMask(formattedAuthResponse); + + if (updateMask.length !== 0) { + result.userRecord = { + ...formattedAuthResponse, + updateMask, + }; + } + + if (recaptchaActionOverride !== undefined) { + result.recaptchaActionOverride = recaptchaActionOverride; + } + + return result; +} + /** Helper to get the Credential from the decoded jwt */ function parseAuthCredential(decodedJWT: DecodedPayload, time: number): Credential { if ( @@ -801,7 +855,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 +871,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 = generateResponsePayload(authResponse); res.status(200); res.setHeader("Content-Type", "application/json");