From 3f26008d8e8d3c7bc8200e410204c0dde5b91bc3 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 23 Aug 2023 14:48:02 -0700 Subject: [PATCH 1/9] 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 308845172..52844fef9 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 9997d7569..43f2234ff 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 c3a2c400b5716df601c15c866d085081af3442df Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Fri, 25 Aug 2023 16:17:25 -0700 Subject: [PATCH 2/9] 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 52844fef9..65abf9c62 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 43f2234ff..0f53150d5 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 c177b42a6142d7c2dedb84a88b5fb3ae7abe3db7 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Mon, 18 Sep 2023 14:27:47 -0700 Subject: [PATCH 3/9] 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 65abf9c62..2dfb6aa10 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 0f53150d5..db781292b 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 33aba4cefa618a3f093d981c2e282c0e2bbd35a4 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Mon, 18 Sep 2023 16:01:57 -0700 Subject: [PATCH 4/9] 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 db781292b..b9d2ec9c6 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 b3fc63abc736d62f5c94af8e53cd2c3c5c2f2dc9 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 20 Sep 2023 10:20:51 -0700 Subject: [PATCH 5/9] 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 b9d2ec9c6..2bd3a5a7f 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 74e914849392ee5140797c88eeaabfa1de3c9478 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 20 Sep 2023 10:26:31 -0700 Subject: [PATCH 6/9] 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 2bd3a5a7f..63fb298e7 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); From 5f32571007e7d3adc2a205e35a6ec4bf771f4ff7 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Wed, 20 Sep 2023 18:58:54 -0400 Subject: [PATCH 7/9] new beforeemailtrigger with merge --- src/common/providers/identity.ts | 24 ++++-------- src/v2/providers/identity.ts | 65 +++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 63fb298e7..d39d55317 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -55,7 +55,7 @@ const CLAIMS_MAX_PAYLOAD_SIZE = 1000; * @hidden * @alpha */ -export type AuthBlockingEventType = "beforeCreate" | "beforeSignIn"; +export type AuthBlockingEventType = "beforeCreate" | "beforeSignIn" | "beforeSendEmail"; const EVENT_MAPPING: Record = { beforeCreate: "providers/cloud.auth/eventTypes/user.beforeCreate", @@ -336,7 +336,7 @@ export interface AuthEventContext extends EventContext { /** Defines the auth event for v2 blocking events */ export interface AuthBlockingEvent extends AuthEventContext { - data: AuthUserRecord; + data?: AuthUserRecord; } /** @@ -344,6 +344,10 @@ export interface AuthBlockingEvent extends AuthEventContext { */ export type RecaptchaActionOptions = "ALLOW" | "BLOCK"; +export interface BeforeEmailResponse { + recaptchaActionOverride?: RecaptchaActionOptions; +} + /** The handler response type for beforeCreate blocking events */ export interface BeforeCreateResponse { displayName?: string; @@ -453,23 +457,11 @@ export interface UserRecordResponsePayload type HandlerV1 = ( user: AuthUserRecord, context: AuthEventContext -) => - | BeforeCreateResponse - | BeforeSignInResponse - | void - | Promise - | Promise - | Promise; +) => BeforeEmailResponse | void | Promise | Promise; type HandlerV2 = ( event: AuthBlockingEvent -) => - | BeforeCreateResponse - | BeforeSignInResponse - | void - | Promise - | Promise - | Promise; +) => BeforeEmailResponse | void | Promise | Promise; /** * Checks for a valid identity platform web request, otherwise throws an HttpsError diff --git a/src/v2/providers/identity.ts b/src/v2/providers/identity.ts index 3a0b1b7fc..3b890c010 100644 --- a/src/v2/providers/identity.ts +++ b/src/v2/providers/identity.ts @@ -31,6 +31,7 @@ import { AuthUserRecord, BeforeCreateResponse, BeforeSignInResponse, + BeforeEmailResponse, HttpsError, wrapHandler, } from "../../common/providers/identity"; @@ -240,6 +241,46 @@ export function beforeUserSignedIn( return beforeOperation("beforeSignIn", optsOrHandler, handler); } +/** + * Handles an event that is triggered before an email is sent to a user. + * @param handler - Event handler that is run before an email is sent to a user. + */ +export function beforeEmailSent( + handler: ( + event: AuthBlockingEvent + ) => BeforeEmailResponse | Promise | void | Promise +): BlockingFunction; + +/** + * Handles an event that is triggered before an email is sent to a user. + * @param opts - Object containing function options + * @param handler - Event handler that is run before an email is sent to a user. + */ +export function beforeEmailSent( + opts: BlockingOptions, + handler: ( + event: AuthBlockingEvent + ) => BeforeEmailResponse | Promise | void | Promise +): BlockingFunction; + +/** + * Handles an event that is triggered before an email is sent to a user. + * @param optsOrHandler- Either an object containing function options, or an event handler that is run before an email is sent to a user. + * @param handler - Event handler that is run before an email is sent to a user. + */ +export function beforeEmailSent( + optsOrHandler: + | BlockingOptions + | (( + event: AuthBlockingEvent + ) => BeforeEmailResponse | Promise | void | Promise), + handler?: ( + event: AuthBlockingEvent + ) => BeforeEmailResponse | Promise | void | Promise +): BlockingFunction { + return beforeOperation("beforeSendEmail", optsOrHandler, handler); +} + /** @hidden */ export function beforeOperation( eventType: AuthBlockingEventType, @@ -247,33 +288,15 @@ export function beforeOperation( | BlockingOptions | (( event: AuthBlockingEvent - ) => - | BeforeCreateResponse - | BeforeSignInResponse - | void - | Promise - | Promise - | Promise), + ) => BeforeEmailResponse | void | Promise | Promise), handler: ( event: AuthBlockingEvent - ) => - | BeforeCreateResponse - | BeforeSignInResponse - | void - | Promise - | Promise - | Promise + ) => BeforeEmailResponse | void | Promise | Promise ): BlockingFunction { if (!handler || typeof optsOrHandler === "function") { handler = optsOrHandler as ( event: AuthBlockingEvent - ) => - | BeforeCreateResponse - | BeforeSignInResponse - | void - | Promise - | Promise - | Promise; + ) => BeforeEmailResponse | void | Promise | Promise; optsOrHandler = {}; } From d183eeb31e5ffe3c66c78fc82efd6c0af68db5f0 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Mon, 6 Nov 2023 15:49:46 -0500 Subject: [PATCH 8/9] add support for v1 beforeEmail trigger --- src/common/providers/identity.ts | 51 ++++++++++++++++++-------- src/v1/providers/auth.ts | 35 ++++++++---------- src/v2/providers/identity.ts | 62 +++++++++++--------------------- 3 files changed, 71 insertions(+), 77 deletions(-) diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index d39d55317..e9598caa0 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -311,6 +311,7 @@ export interface AdditionalUserInfo { username?: string; isNewUser: boolean; recaptchaScore?: number; + email?: string; } /** The credential component of the auth event context */ @@ -325,6 +326,9 @@ export interface Credential { signInMethod: string; } +/** Possible types of emails as described by the GCIP backend. */ +export type EmailType = "EMAIL_SIGNIN" | "PASSWORD_RESET"; + /** Defines the auth event context for blocking events */ export interface AuthEventContext extends EventContext { locale?: string; @@ -332,6 +336,7 @@ export interface AuthEventContext extends EventContext { userAgent: string; additionalUserInfo?: AdditionalUserInfo; credential?: Credential; + emailType?: EmailType; } /** Defines the auth event for v2 blocking events */ @@ -339,9 +344,7 @@ export interface AuthBlockingEvent extends AuthEventContext { data?: AuthUserRecord; } -/** - * The reCAPTCHA action options. - */ +/** The reCAPTCHA action options. */ export type RecaptchaActionOptions = "ALLOW" | "BLOCK"; export interface BeforeEmailResponse { @@ -394,6 +397,7 @@ interface DecodedPayloadUserRecordEnrolledFactors { export interface DecodedPayloadUserRecord { uid: string; email?: string; + email_type?: string; email_verified?: boolean; phone_number?: string; display_name?: string; @@ -435,6 +439,8 @@ export interface DecodedPayload { oauth_token_secret?: string; oauth_expires_in?: number; recaptcha_score?: number; + email?: string; + email_type?: string; [key: string]: any; } @@ -454,14 +460,23 @@ export interface UserRecordResponsePayload updateMask?: string; } -type HandlerV1 = ( - user: AuthUserRecord, - context: AuthEventContext -) => BeforeEmailResponse | void | Promise | Promise; +export type MaybeAsync = T | Promise; -type HandlerV2 = ( +// N.B. As we add support for new auth blocking functions, some auth blocking event handlers +// will not receive a user record object. However, we can't make the user record parameter +// optional because it is listed before the required context parameter. +export type HandlerV1 = ( + userOrContext: AuthUserRecord | AuthEventContext, + context?: AuthEventContext +) => MaybeAsync; + +export type HandlerV2 = ( event: AuthBlockingEvent -) => BeforeEmailResponse | void | Promise | Promise; +) => MaybeAsync; + +export type AgnosticHandler = (HandlerV1 | HandlerV2) & { + platform: string; +}; /** * Checks for a valid identity platform web request, otherwise throws an HttpsError @@ -657,6 +672,7 @@ function parseAdditionalUserInfo(decodedJWT: DecodedPayload): AdditionalUserInfo username, isNewUser: decodedJWT.event_type === "beforeCreate" ? true : false, recaptchaScore: decodedJWT.recaptcha_score, + email: decodedJWT.email, }; } @@ -743,6 +759,7 @@ export function parseAuthEventContext( timestamp: new Date(decodedJWT.iat * 1000).toUTCString(), additionalUserInfo: parseAdditionalUserInfo(decodedJWT), credential: parseAuthCredential(decodedJWT, time), + emailType: decodedJWT.email_type as EmailType, params: {}, }; } @@ -827,7 +844,7 @@ export function getUpdateMask(authResponse?: BeforeCreateResponse | BeforeSignIn } /** @internal */ -export function wrapHandler(eventType: AuthBlockingEventType, handler: HandlerV1 | HandlerV2) { +export function wrapHandler(eventType: AuthBlockingEventType, handler: AgnosticHandler) { return async (req: express.Request, res: express.Response): Promise => { try { const projectId = process.env.GCLOUD_PROJECT; @@ -844,16 +861,20 @@ export function wrapHandler(eventType: AuthBlockingEventType, handler: HandlerV1 const decodedPayload: DecodedPayload = isDebugFeatureEnabled("skipTokenVerification") ? unsafeDecodeAuthBlockingToken(req.body.data.jwt) - : handler.length === 2 + : handler.platform === "gcfv1" ? 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); + let authUserRecord: AuthUserRecord | undefined; + if (decodedPayload.user_record) { + authUserRecord = parseAuthUserRecord(decodedPayload.user_record); + } const authEventContext = parseAuthEventContext(decodedPayload, projectId); let authResponse; - if (handler.length === 2) { - authResponse = - (await (handler as HandlerV1)(authUserRecord, authEventContext)) || undefined; + if (handler.platform === "gcfv1") { + authResponse = authUserRecord + ? (await (handler as HandlerV1)(authUserRecord, authEventContext)) || undefined + : (await (handler as HandlerV1)(authEventContext)) || undefined; } else { authResponse = (await (handler as HandlerV2)({ diff --git a/src/v1/providers/auth.ts b/src/v1/providers/auth.ts index edef7b0bb..c4435b798 100644 --- a/src/v1/providers/auth.ts +++ b/src/v1/providers/auth.ts @@ -25,8 +25,12 @@ import { AuthEventContext, AuthUserRecord, BeforeCreateResponse, + BeforeEmailResponse, BeforeSignInResponse, + AgnosticHandler, + HandlerV1, HttpsError, + MaybeAsync, UserInfo, UserRecord, userRecordConstructor, @@ -151,7 +155,7 @@ export class UserBuilder { handler: ( user: AuthUserRecord, context: AuthEventContext - ) => BeforeCreateResponse | void | Promise | Promise + ) => MaybeAsync ): BlockingFunction { return this.beforeOperation(handler, "beforeCreate"); } @@ -167,11 +171,17 @@ export class UserBuilder { handler: ( user: AuthUserRecord, context: AuthEventContext - ) => BeforeSignInResponse | void | Promise | Promise + ) => MaybeAsync ): BlockingFunction { return this.beforeOperation(handler, "beforeSignIn"); } + beforeEmail( + handler: (context: AuthEventContext) => MaybeAsync + ): BlockingFunction { + return this.beforeOperation(handler, "beforeSendEmail"); + } + private onOperation( handler: (user: UserRecord, context: EventContext) => PromiseLike | any, eventType: string @@ -189,28 +199,13 @@ export class UserBuilder { }); } - private beforeOperation( - handler: ( - user: AuthUserRecord, - context: AuthEventContext - ) => - | BeforeCreateResponse - | BeforeSignInResponse - | void - | Promise - | Promise - | Promise, - eventType: AuthBlockingEventType - ): BlockingFunction { + private beforeOperation(handler: HandlerV1, eventType: AuthBlockingEventType): BlockingFunction { const accessToken = this.userOptions?.blockingOptions?.accessToken || false; const idToken = this.userOptions?.blockingOptions?.idToken || false; const refreshToken = this.userOptions?.blockingOptions?.refreshToken || false; - // Create our own function that just calls the provided function so we know for sure that - // handler takes two arguments. This is something common/providers/identity depends on. - const wrappedHandler = (user: AuthUserRecord, context: AuthEventContext) => - handler(user, context); - const func: any = wrapHandler(eventType, wrappedHandler); + const annotatedHandler: AgnosticHandler = Object.assign(handler, { platform: "gcfv1" }); + const func: any = wrapHandler(eventType, annotatedHandler); const legacyEventType = `providers/cloud.auth/eventTypes/user.${eventType}`; diff --git a/src/v2/providers/identity.ts b/src/v2/providers/identity.ts index 3b890c010..5f0876bd5 100644 --- a/src/v2/providers/identity.ts +++ b/src/v2/providers/identity.ts @@ -32,8 +32,11 @@ import { BeforeCreateResponse, BeforeSignInResponse, BeforeEmailResponse, + HandlerV2, HttpsError, wrapHandler, + MaybeAsync, + AgnosticHandler, } from "../../common/providers/identity"; import { BlockingFunction } from "../../v1/cloud-functions"; import { wrapTraceContext } from "../trace"; @@ -166,9 +169,7 @@ export interface BlockingOptions { * @param handler - Event handler which is run every time before a user is created */ export function beforeUserCreated( - handler: ( - event: AuthBlockingEvent - ) => BeforeCreateResponse | Promise | void | Promise + handler: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction; /** @@ -178,9 +179,7 @@ export function beforeUserCreated( */ export function beforeUserCreated( opts: BlockingOptions, - handler: ( - event: AuthBlockingEvent - ) => BeforeCreateResponse | Promise | void | Promise + handler: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction; /** @@ -191,12 +190,8 @@ export function beforeUserCreated( export function beforeUserCreated( optsOrHandler: | BlockingOptions - | (( - event: AuthBlockingEvent - ) => BeforeCreateResponse | Promise | void | Promise), - handler?: ( - event: AuthBlockingEvent - ) => BeforeCreateResponse | Promise | void | Promise + | ((event: AuthBlockingEvent) => MaybeAsync), + handler?: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction { return beforeOperation("beforeCreate", optsOrHandler, handler); } @@ -206,9 +201,7 @@ export function beforeUserCreated( * @param handler - Event handler which is run every time before a user is signed in */ export function beforeUserSignedIn( - handler: ( - event: AuthBlockingEvent - ) => BeforeSignInResponse | Promise | void | Promise + handler: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction; /** @@ -218,9 +211,7 @@ export function beforeUserSignedIn( */ export function beforeUserSignedIn( opts: BlockingOptions, - handler: ( - event: AuthBlockingEvent - ) => BeforeSignInResponse | Promise | void | Promise + handler: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction; /** @@ -231,12 +222,8 @@ export function beforeUserSignedIn( export function beforeUserSignedIn( optsOrHandler: | BlockingOptions - | (( - event: AuthBlockingEvent - ) => BeforeSignInResponse | Promise | void | Promise), - handler?: ( - event: AuthBlockingEvent - ) => BeforeSignInResponse | Promise | void | Promise + | ((event: AuthBlockingEvent) => MaybeAsync), + handler?: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction { return beforeOperation("beforeSignIn", optsOrHandler, handler); } @@ -246,9 +233,7 @@ export function beforeUserSignedIn( * @param handler - Event handler that is run before an email is sent to a user. */ export function beforeEmailSent( - handler: ( - event: AuthBlockingEvent - ) => BeforeEmailResponse | Promise | void | Promise + handler: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction; /** @@ -258,9 +243,7 @@ export function beforeEmailSent( */ export function beforeEmailSent( opts: BlockingOptions, - handler: ( - event: AuthBlockingEvent - ) => BeforeEmailResponse | Promise | void | Promise + handler: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction; /** @@ -271,12 +254,8 @@ export function beforeEmailSent( export function beforeEmailSent( optsOrHandler: | BlockingOptions - | (( - event: AuthBlockingEvent - ) => BeforeEmailResponse | Promise | void | Promise), - handler?: ( - event: AuthBlockingEvent - ) => BeforeEmailResponse | Promise | void | Promise + | ((event: AuthBlockingEvent) => MaybeAsync), + handler?: (event: AuthBlockingEvent) => MaybeAsync ): BlockingFunction { return beforeOperation("beforeSendEmail", optsOrHandler, handler); } @@ -288,10 +267,8 @@ export function beforeOperation( | BlockingOptions | (( event: AuthBlockingEvent - ) => BeforeEmailResponse | void | Promise | Promise), - handler: ( - event: AuthBlockingEvent - ) => BeforeEmailResponse | void | Promise | Promise + ) => MaybeAsync), + handler: HandlerV2 ): BlockingFunction { if (!handler || typeof optsOrHandler === "function") { handler = optsOrHandler as ( @@ -304,8 +281,9 @@ export function beforeOperation( // Create our own function that just calls the provided function so we know for sure that // handler takes one argument. This is something common/providers/identity depends on. - const wrappedHandler = (event: AuthBlockingEvent) => handler(event); - const func: any = wrapTraceContext(wrapHandler(eventType, wrappedHandler)); + // const wrappedHandler = (event: AuthBlockingEvent) => handler(event); + const annotatedHandler: AgnosticHandler = Object.assign(handler, { platform: "gcfv2" }); + const func: any = wrapTraceContext(wrapHandler(eventType, annotatedHandler)); const legacyEventType = `providers/cloud.auth/eventTypes/user.${eventType}`; From 3b93669ce1291de9b75882a945c7ca7d85501386 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Thu, 9 Nov 2023 16:06:51 -0500 Subject: [PATCH 9/9] add unit tests for before email triggers (v1 and v2) --- spec/common/providers/identity.spec.ts | 50 ++++++++++++++ spec/v1/providers/auth.spec.ts | 90 ++++++++++++++++++++++++ spec/v2/providers/identity.spec.ts | 95 ++++++++++++++++++++++++++ src/common/providers/identity.ts | 5 +- 4 files changed, 238 insertions(+), 2 deletions(-) diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 2dfb6aa10..73fb208d3 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -528,6 +528,7 @@ describe("identity", () => { userAgent: "USER_AGENT", eventId: "EVENT_ID", eventType: EVENT, + emailType: undefined, authType: "UNAUTHENTICATED", resource: { service: "identitytoolkit.googleapis.com", @@ -540,6 +541,7 @@ describe("identity", () => { username: undefined, isNewUser: false, recaptchaScore: TEST_RECAPTCHA_SCORE, + email: undefined, }, credential: null, params: {}, @@ -577,6 +579,7 @@ describe("identity", () => { userAgent: "USER_AGENT", eventId: "EVENT_ID", eventType: "providers/cloud.auth/eventTypes/user.beforeSignIn:password", + emailType: undefined, authType: "UNAUTHENTICATED", resource: { service: "identitytoolkit.googleapis.com", @@ -589,6 +592,7 @@ describe("identity", () => { username: undefined, isNewUser: false, recaptchaScore: TEST_RECAPTCHA_SCORE, + email: undefined, }, credential: { claims: undefined, @@ -663,6 +667,7 @@ describe("identity", () => { userAgent: "USER_AGENT", eventId: "EVENT_ID", eventType: "providers/cloud.auth/eventTypes/user.beforeCreate:oidc.provider", + emailType: undefined, authType: "USER", resource: { service: "identitytoolkit.googleapis.com", @@ -675,6 +680,7 @@ describe("identity", () => { profile: rawUserInfo, isNewUser: true, recaptchaScore: TEST_RECAPTCHA_SCORE, + email: undefined, }, credential: { claims: undefined, @@ -691,6 +697,50 @@ describe("identity", () => { expect(identity.parseAuthEventContext(decodedJwt, "project-id", time)).to.deep.equal(context); }); + + it("should parse a beforeSendEmail event", () => { + const time = now.getTime(); + const decodedJwt = { + iss: "https://securetoken.google.com/project_id", + aud: "https://us-east1-project_id.cloudfunctions.net/function-1", + iat: 1, + exp: 60 * 60 + 1, + event_id: "EVENT_ID", + event_type: "beforeSendEmail", + user_agent: "USER_AGENT", + ip_address: "1.2.3.4", + locale: "en", + recaptcha_score: TEST_RECAPTCHA_SCORE, + email_type: "RESET_PASSWORD", + email: "johndoe@gmail.com", + }; + const context = { + locale: "en", + ipAddress: "1.2.3.4", + userAgent: "USER_AGENT", + eventId: "EVENT_ID", + eventType: "providers/cloud.auth/eventTypes/user.beforeSendEmail", + emailType: "RESET_PASSWORD", + authType: "UNAUTHENTICATED", + resource: { + service: "identitytoolkit.googleapis.com", + name: "projects/project-id", + }, + timestamp: new Date(1000).toUTCString(), + additionalUserInfo: { + isNewUser: false, + profile: undefined, + providerId: undefined, + username: undefined, + recaptchaScore: TEST_RECAPTCHA_SCORE, + email: "johndoe@gmail.com", + }, + credential: null, + params: {}, + }; + + expect(identity.parseAuthEventContext(decodedJwt, "project-id", time)).to.deep.equal(context); + }); }); describe("validateAuthResponse", () => { diff --git a/spec/v1/providers/auth.spec.ts b/spec/v1/providers/auth.spec.ts index f5f6a806d..6901e3fdf 100644 --- a/spec/v1/providers/auth.spec.ts +++ b/spec/v1/providers/auth.spec.ts @@ -305,6 +305,96 @@ describe("Auth Functions", () => { }); }); + describe("beforeEmail", () => { + it("should create function without options", () => { + const fn = auth.user().beforeEmail(() => Promise.resolve()); + + expect(fn.__trigger).to.deep.equal({ + labels: {}, + blockingTrigger: { + eventType: "providers/cloud.auth/eventTypes/user.beforeSendEmail", + options: { + accessToken: false, + idToken: false, + refreshToken: false, + }, + }, + }); + expect(fn.__endpoint).to.deep.equal({ + ...MINIMAL_V1_ENDPOINT, + platform: "gcfv1", + labels: {}, + blockingTrigger: { + eventType: "providers/cloud.auth/eventTypes/user.beforeSendEmail", + options: { + accessToken: false, + idToken: false, + refreshToken: false, + }, + }, + }); + expect(fn.__requiredAPIs).to.deep.equal([ + { + api: "identitytoolkit.googleapis.com", + reason: "Needed for auth blocking functions", + }, + ]); + }); + + it("should create the function with options", () => { + const fn = functions + .region("us-east1") + .runWith({ + timeoutSeconds: 90, + memory: "256MB", + }) + .auth.user({ + blockingOptions: { + accessToken: true, + refreshToken: false, + }, + }) + .beforeEmail(() => Promise.resolve()); + + expect(fn.__trigger).to.deep.equal({ + labels: {}, + regions: ["us-east1"], + availableMemoryMb: 256, + timeout: "90s", + blockingTrigger: { + eventType: "providers/cloud.auth/eventTypes/user.beforeSendEmail", + options: { + accessToken: true, + idToken: false, + refreshToken: false, + }, + }, + }); + expect(fn.__endpoint).to.deep.equal({ + ...MINIMAL_V1_ENDPOINT, + platform: "gcfv1", + labels: {}, + region: ["us-east1"], + availableMemoryMb: 256, + timeoutSeconds: 90, + blockingTrigger: { + eventType: "providers/cloud.auth/eventTypes/user.beforeSendEmail", + options: { + accessToken: true, + idToken: false, + refreshToken: false, + }, + }, + }); + expect(fn.__requiredAPIs).to.deep.equal([ + { + api: "identitytoolkit.googleapis.com", + reason: "Needed for auth blocking functions", + }, + ]); + }); + }); + describe("#_dataConstructor", () => { let cloudFunctionDelete: CloudFunction; diff --git a/spec/v2/providers/identity.spec.ts b/spec/v2/providers/identity.spec.ts index 7559a4133..7d18f0762 100644 --- a/spec/v2/providers/identity.spec.ts +++ b/spec/v2/providers/identity.spec.ts @@ -41,6 +41,15 @@ const BEFORE_SIGN_IN_TRIGGER = { }, }; +const BEFORE_EMAIL_TRIGGER = { + eventType: "providers/cloud.auth/eventTypes/user.beforeSendEmail", + options: { + accessToken: false, + idToken: false, + refreshToken: false, + }, +}; + const opts: identity.BlockingOptions = { accessToken: true, refreshToken: false, @@ -137,6 +146,50 @@ describe("identity", () => { }); }); + describe("beforeEmailSent", () => { + it("should accept a handler", () => { + const fn = identity.beforeEmailSent(() => Promise.resolve()); + + expect(fn.__endpoint).to.deep.equal({ + ...MINIMAL_V2_ENDPOINT, + platform: "gcfv2", + labels: {}, + blockingTrigger: BEFORE_EMAIL_TRIGGER, + }); + expect(fn.__requiredAPIs).to.deep.equal([ + { + api: "identitytoolkit.googleapis.com", + reason: "Needed for auth blocking functions", + }, + ]); + }); + }); + + it("should accept options and a handler", () => { + const fn = identity.beforeEmailSent(opts, () => Promise.resolve()); + + expect(fn.__endpoint).to.deep.equal({ + ...MINIMAL_V2_ENDPOINT, + platform: "gcfv2", + labels: {}, + minInstances: 1, + region: ["us-west1"], + blockingTrigger: { + ...BEFORE_EMAIL_TRIGGER, + options: { + ...BEFORE_EMAIL_TRIGGER.options, + accessToken: true, + }, + }, + }); + expect(fn.__requiredAPIs).to.deep.equal([ + { + api: "identitytoolkit.googleapis.com", + reason: "Needed for auth blocking functions", + }, + ]); + }); + describe("beforeOperation", () => { it("should handle eventType and handler for before create events", () => { const fn = identity.beforeOperation("beforeCreate", () => Promise.resolve(), undefined); @@ -172,6 +225,23 @@ describe("identity", () => { ]); }); + it("should handle eventType and handler for before email events", () => { + const fn = identity.beforeOperation("beforeSendEmail", () => Promise.resolve(), undefined); + + expect(fn.__endpoint).to.deep.equal({ + ...MINIMAL_V2_ENDPOINT, + platform: "gcfv2", + labels: {}, + blockingTrigger: BEFORE_EMAIL_TRIGGER, + }); + expect(fn.__requiredAPIs).to.deep.equal([ + { + api: "identitytoolkit.googleapis.com", + reason: "Needed for auth blocking functions", + }, + ]); + }); + it("should handle eventType, options, and handler for before create events", () => { const fn = identity.beforeOperation("beforeCreate", opts, () => Promise.resolve()); @@ -221,6 +291,31 @@ describe("identity", () => { }, ]); }); + + it("should handle eventType, options, and handler for before send email events", () => { + const fn = identity.beforeOperation("beforeSendEmail", opts, () => Promise.resolve()); + + expect(fn.__endpoint).to.deep.equal({ + ...MINIMAL_V2_ENDPOINT, + platform: "gcfv2", + labels: {}, + minInstances: 1, + region: ["us-west1"], + blockingTrigger: { + ...BEFORE_EMAIL_TRIGGER, + options: { + ...BEFORE_EMAIL_TRIGGER.options, + accessToken: true, + }, + }, + }); + expect(fn.__requiredAPIs).to.deep.equal([ + { + api: "identitytoolkit.googleapis.com", + reason: "Needed for auth blocking functions", + }, + ]); + }); }); describe("getOpts", () => { diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index e9598caa0..ed7b8d275 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -60,6 +60,7 @@ export type AuthBlockingEventType = "beforeCreate" | "beforeSignIn" | "beforeSen const EVENT_MAPPING: Record = { beforeCreate: "providers/cloud.auth/eventTypes/user.beforeCreate", beforeSignIn: "providers/cloud.auth/eventTypes/user.beforeSignIn", + beforeSendEmail: "providers/cloud.auth/eventTypes/user.beforeSendEmail", }; /** @@ -306,7 +307,7 @@ export interface AuthUserRecord { /** The additional user info component of the auth event context */ export interface AdditionalUserInfo { - providerId: string; + providerId?: string; profile?: any; username?: string; isNewUser: boolean; @@ -420,7 +421,7 @@ export interface DecodedPayload { exp: number; iat: number; iss: string; - sub: string; + sub?: string; event_id: string; event_type: string; ip_address: string;