diff --git a/spec/v2/providers/identity.spec.ts b/spec/v2/providers/identity.spec.ts index dbda1189c..03e5a0f3d 100644 --- a/spec/v2/providers/identity.spec.ts +++ b/spec/v2/providers/identity.spec.ts @@ -57,6 +57,9 @@ const BEFORE_SMS_TRIGGER = { options: {}, }; +const USER_CREATED_EVENT = "google.firebase.auth.user.v2.created"; +const USER_DELETED_EVENT = "google.firebase.auth.user.v2.deleted"; + const opts: identity.BlockingOptions = { accessToken: true, refreshToken: false, @@ -65,6 +68,14 @@ const opts: identity.BlockingOptions = { }; describe("identity", () => { + beforeEach(() => { + process.env.GCLOUD_PROJECT = "aProject"; + }); + + afterEach(() => { + delete process.env.GCLOUD_PROJECT; + }); + describe("beforeUserCreated", () => { it("should accept a handler", () => { const fn = identity.beforeUserCreated(() => Promise.resolve()); @@ -467,4 +478,158 @@ describe("identity", () => { }); }); }); + + describe("onUserCreated", () => { + it("should create a minimal trigger/endpoint", () => { + const result = identity.onUserCreated(() => Promise.resolve()); + + + expect(result.__endpoint).to.deep.equal({ + ...MINIMAL_V2_ENDPOINT, + platform: "gcfv2", + labels: {}, + eventTrigger: { + eventType: USER_CREATED_EVENT, + retry: false, + eventFilters: {}, + }, + }); + }); + + it("should create a complex trigger/endpoint with tenantId", () => { + const result = identity.onUserCreated( + { ...opts, tenantId: "tid", retry: true }, + () => Promise.resolve() + ); + + expect(result.__endpoint).to.deep.equal({ + ...MINIMAL_V2_ENDPOINT, + platform: "gcfv2", + minInstances: 1, + region: [REGION], + labels: {}, + eventTrigger: { + eventType: USER_CREATED_EVENT, + retry: true, + eventFilters: { + tenantId: "tid", + }, + }, + }); + }); + }); + + describe("onUserDeleted", () => { + it("should create a minimal trigger/endpoint", () => { + const result = identity.onUserDeleted(() => Promise.resolve()); + + + expect(result.__endpoint).to.deep.equal({ + ...MINIMAL_V2_ENDPOINT, + platform: "gcfv2", + labels: {}, + eventTrigger: { + eventType: USER_DELETED_EVENT, + retry: false, + eventFilters: {}, + }, + }); + }); + + it("should create a complex trigger/endpoint with tenantId", () => { + const result = identity.onUserDeleted( + { ...opts, tenantId: "tid", retry: true }, + () => Promise.resolve() + ); + + expect(result.__endpoint).to.deep.equal({ + ...MINIMAL_V2_ENDPOINT, + platform: "gcfv2", + minInstances: 1, + region: [REGION], + labels: {}, + eventTrigger: { + eventType: USER_DELETED_EVENT, + retry: true, + eventFilters: { + tenantId: "tid", + }, + }, + }); + }); + }); + + describe("onOperation", () => { + it("should unwrap data.value if present", async () => { + const handler = (event: identity.AuthEvent) => { + return event.data.uid; + }; + const func = identity.onUserCreated(handler); + + const event = { + specversion: "1.0", + id: "event-id", + source: "firebase-auth", + type: "google.firebase.auth.user.v2.created", + time: "2023-10-31T00:00:00Z", + data: { + value: { + uid: "test-uid", + }, + }, + }; + + const result = await func(event as any); + expect(result).to.equal("test-uid"); + }); + + it("should unwrap data.oldValue if present", async () => { + const handler = (event: identity.AuthEvent) => { + return event.data.uid; + }; + const func = identity.onUserDeleted(handler); + + const event = { + specversion: "1.0", + id: "event-id", + source: "firebase-auth", + type: "google.firebase.auth.user.v2.deleted", + time: "2023-10-31T00:00:00Z", + data: { + oldValue: { + uid: "deleted-uid", + email: "deleted@example.com", + }, + }, + }; + + const result = await func(event as any); + expect(result).to.equal("deleted-uid"); + }); + + it("should normalize user data (e.g. createTime -> creationTime)", async () => { + const handler = (event: identity.AuthEvent) => { + return event.data.metadata.creationTime; + }; + const func = identity.onUserCreated(handler); + + const event = { + specversion: "1.0", + id: "event-id", + source: "firebase-auth", + type: "google.firebase.auth.user.v2.created", + time: "2023-10-31T00:00:00Z", + data: { + uid: "test-uid", + metadata: { + createTime: "2023-10-31T00:00:00Z", + lastSignInTime: "2023-10-31T01:00:00Z", + }, + }, + }; + + const result = await func(event as any); + expect(result).to.equal("Tue, 31 Oct 2023 00:00:00 GMT"); + }); + }); }); diff --git a/src/v2/providers/identity.ts b/src/v2/providers/identity.ts index b9e8737df..59d155ee9 100644 --- a/src/v2/providers/identity.ts +++ b/src/v2/providers/identity.ts @@ -24,6 +24,7 @@ * Cloud functions to handle events from Google Cloud Identity Platform. * @packageDocumentation */ +import { copyIfPresent } from "../../common/encoding"; import { ResetValue } from "../../common/options"; import { AuthBlockingEvent, @@ -40,34 +41,39 @@ import { } from "../../common/providers/identity"; import { BlockingFunction } from "../../v1/cloud-functions"; import { wrapTraceContext } from "../trace"; +import { CloudEvent, CloudFunction } from "../core"; import { Expression } from "../../params"; -import { initV2Endpoint } from "../../runtime/manifest"; +import { initV2Endpoint, ManifestEndpoint } from "../../runtime/manifest"; import * as options from "../options"; import { SecretParam } from "../../params/types"; import { withInit } from "../../common/onInit"; export { AuthUserRecord, AuthBlockingEvent, HttpsError }; -/** @hidden Internally used when parsing the options. */ -interface InternalOptions { - opts: options.GlobalOptions; - idToken: boolean; - accessToken: boolean; - refreshToken: boolean; -} - /** - * All function options plus idToken, accessToken, and refreshToken. + * The user data payload for an Auth event. */ -export interface BlockingOptions { - /** Pass the ID Token credential to the function. */ - idToken?: boolean; +export type User = AuthUserRecord; - /** Pass the Access Token credential to the function. */ - accessToken?: boolean; +/** + * The event object passed to the handler function. + */ +export interface AuthEvent extends CloudEvent { + /** The project identifier. */ + project: string; + /** The ID of the Identity Platform tenant associated with the event, if applicable. */ + tenantId?: string; +} - /** Pass the Refresh Token credential to the function. */ - refreshToken?: boolean; +/** + * Options for configuring an auth trigger. + */ +export interface AuthOptions extends options.EventHandlerOptions { + /** + * The ID of the Identity Platform tenant to scope the function to. + * If not set, the function triggers on users across all tenants. + */ + tenantId?: string; /** * If true, do not deploy or emulate this function. @@ -165,6 +171,22 @@ export interface BlockingOptions { secrets?: (string | SecretParam)[]; } + +/** + * All function options plus idToken, accessToken, and refreshToken. + */ +export interface BlockingOptions + extends Omit { + /** Pass the ID Token credential to the function. */ + idToken?: boolean; + + /** Pass the Access Token credential to the function. */ + accessToken?: boolean; + + /** Pass the Refresh Token credential to the function. */ + refreshToken?: boolean; +} + /** * Handles an event that is triggered before a user is created. * @param handler - Event handler which is run every time before a user is created. @@ -298,10 +320,10 @@ export function beforeOperation( optsOrHandler: | BlockingOptions | (( - event: AuthBlockingEvent - ) => MaybeAsync< - BeforeCreateResponse | BeforeSignInResponse | BeforeEmailResponse | BeforeSmsResponse | void - >), + event: AuthBlockingEvent + ) => MaybeAsync< + BeforeCreateResponse | BeforeSignInResponse | BeforeEmailResponse | BeforeSmsResponse | void + >), handler: HandlerV2 ): BlockingFunction { if (!handler || typeof optsOrHandler === "function") { @@ -320,27 +342,7 @@ export function beforeOperation( const annotatedHandler = Object.assign(handler, { platform: "gcfv2" as const }); const func: any = wrapTraceContext(withInit(wrapHandler(eventType, annotatedHandler))); - const legacyEventType = `providers/cloud.auth/eventTypes/user.${eventType}`; - - /** Endpoint */ - const baseOptsEndpoint = options.optionsToEndpoint(options.getGlobalOptions()); - const specificOptsEndpoint = options.optionsToEndpoint(opts); - func.__endpoint = { - ...initV2Endpoint(options.getGlobalOptions(), opts), - platform: "gcfv2", - ...baseOptsEndpoint, - ...specificOptsEndpoint, - labels: { - ...baseOptsEndpoint?.labels, - ...specificOptsEndpoint?.labels, - }, - blockingTrigger: { - eventType: legacyEventType, - options: { - ...((eventType === "beforeCreate" || eventType === "beforeSignIn") && blockingOptions), - }, - }, - }; + func.__endpoint = makeBlockingEndpoint(eventType, opts, blockingOptions); func.__requiredAPIs = [ { @@ -355,7 +357,12 @@ export function beforeOperation( } /** @hidden */ -export function getOpts(blockingOptions: BlockingOptions): InternalOptions { +export function getOpts(blockingOptions: BlockingOptions): { + opts: options.GlobalOptions; + idToken: boolean; + accessToken: boolean; + refreshToken: boolean; +} { const accessToken = blockingOptions.accessToken || false; const idToken = blockingOptions.idToken || false; const refreshToken = blockingOptions.refreshToken || false; @@ -370,3 +377,206 @@ export function getOpts(blockingOptions: BlockingOptions): InternalOptions { refreshToken, }; } + +/** + * Event handler that triggers when a Firebase Auth user is created. + * + * @param handler - Event handler which is run every time a Firebase Auth user is created. + * @returns A Cloud Function that you can export and deploy. + * + * @public + */ +export function onUserCreated( + handler: (event: AuthEvent) => any | Promise +): CloudFunction>; + +/** + * Event handler that triggers when a Firebase Auth user is created. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Firebase Auth user is created. + * @returns A Cloud Function that you can export and deploy. + * + * @public + */ +export function onUserCreated( + opts: AuthOptions, + handler: (event: AuthEvent) => any | Promise +): CloudFunction>; + +/** + * Event handler that triggers when a Firebase Auth user is created. + * + * @param optsOrHandler - Options or an event handler. + * @param handler - Event handler which is run every time a Firebase Auth user is created. + * @returns A Cloud Function that you can export and deploy. + * + * @public + */ +export function onUserCreated( + optsOrHandler: AuthOptions | ((event: AuthEvent) => any | Promise), + handler?: (event: AuthEvent) => any | Promise +): CloudFunction> { + let opts: AuthOptions; + let func: (event: AuthEvent) => any | Promise; + + if (typeof optsOrHandler === "function") { + opts = {}; + func = optsOrHandler; + } else { + opts = optsOrHandler; + func = handler as (event: AuthEvent) => any | Promise; + } + + return onOperation("google.firebase.auth.user.v2.created", opts, func); +} + +/** + * Event handler that triggers when a Firebase Auth user is deleted. + * + * @param handler - Event handler which is run every time a Firebase Auth user is deleted. + * @returns A Cloud Function that you can export and deploy. + * + * @public + */ +export function onUserDeleted( + handler: (event: AuthEvent) => any | Promise +): CloudFunction>; + +/** + * Event handler that triggers when a Firebase Auth user is deleted. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Firebase Auth user is deleted. + * @returns A Cloud Function that you can export and deploy. + * + * @public + */ +export function onUserDeleted( + opts: AuthOptions, + handler: (event: AuthEvent) => any | Promise +): CloudFunction>; + +/** + * Event handler that triggers when a Firebase Auth user is deleted. + * + * @param optsOrHandler - Options or an event handler. + * @param handler - Event handler which is run every time a Firebase Auth user is deleted. + * @returns A Cloud Function that you can export and deploy. + * + * @public + */ +export function onUserDeleted( + optsOrHandler: AuthOptions | ((event: AuthEvent) => any | Promise), + handler?: (event: AuthEvent) => any | Promise +): CloudFunction> { + let opts: AuthOptions; + let func: (event: AuthEvent) => any | Promise; + + if (typeof optsOrHandler === "function") { + opts = {}; + func = optsOrHandler; + } else { + opts = optsOrHandler; + func = handler as (event: AuthEvent) => any | Promise; + } + + return onOperation("google.firebase.auth.user.v2.deleted", opts, func); +} + +/** @hidden */ +function onOperation( + eventType: string, + opts: AuthOptions, + handler: (event: AuthEvent) => any | Promise +): CloudFunction> { + const func = (raw: CloudEvent) => { + if (raw.data && typeof raw.data === "object") { + if ("value" in raw.data) { + raw.data = (raw.data as any).value; + } else if ("oldValue" in raw.data) { + raw.data = (raw.data as any).oldValue; + } + } + // Normalize the data to match AuthUserRecord + const data = raw.data as any; + if (data && data.metadata) { + const creationTime = data.metadata.creationTime || data.metadata.createTime; + if (creationTime) { + data.metadata.creationTime = new Date(creationTime).toUTCString(); + delete data.metadata.createTime; + } + + const lastSignInTime = data.metadata.lastSignInTime; + if (lastSignInTime) { + data.metadata.lastSignInTime = new Date(lastSignInTime).toUTCString(); + } + } + return wrapTraceContext(withInit(handler))(raw as AuthEvent); + }; + + func.run = handler; + + func.__endpoint = makeEndpoint(eventType, opts); + + return func; +} + +/** @hidden */ +function makeEndpoint(eventType: string, opts: AuthOptions): ManifestEndpoint { + const baseOpts = options.optionsToEndpoint(options.getGlobalOptions()); + const specificOpts = options.optionsToEndpoint(opts); + + const eventFilters: Record = {}; + if (opts.tenantId) { + eventFilters.tenantId = opts.tenantId; + } + + const endpoint: ManifestEndpoint = { + ...initV2Endpoint(options.getGlobalOptions(), opts), + platform: "gcfv2", + ...baseOpts, + ...specificOpts, + labels: { + ...baseOpts?.labels, + ...specificOpts?.labels, + }, + eventTrigger: { + eventType, + eventFilters, + retry: false, + }, + }; + copyIfPresent(endpoint.eventTrigger, opts, "retry", "retry"); + + return endpoint; +} + +/** @hidden */ +function makeBlockingEndpoint( + eventType: AuthBlockingEventType, + opts: options.GlobalOptions, + blockingOptions: { idToken: boolean; accessToken: boolean; refreshToken: boolean } +): ManifestEndpoint { + const legacyEventType = `providers/cloud.auth/eventTypes/user.${eventType}`; + + const baseOptsEndpoint = options.optionsToEndpoint(options.getGlobalOptions()); + const specificOptsEndpoint = options.optionsToEndpoint(opts); + + return { + ...initV2Endpoint(options.getGlobalOptions(), opts), + platform: "gcfv2", + ...baseOptsEndpoint, + ...specificOptsEndpoint, + labels: { + ...baseOptsEndpoint?.labels, + ...specificOptsEndpoint?.labels, + }, + blockingTrigger: { + eventType: legacyEventType, + options: { + ...((eventType === "beforeCreate" || eventType === "beforeSignIn") && blockingOptions), + }, + }, + }; +}