From 3b4da16247d0e6d0af1da795918586c8ee2d9ff9 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 31 Jul 2024 14:20:31 +0800 Subject: [PATCH] feat(core): add webhooks middleware to experience api (#6357) * refactor(core): refactor backup code generate flow refactor backup code generate flow * fix(core): fix api payload fix api payload * fix(core): fix rebase issue fix rebase issue * feat(core): add hooks middleware to experience APIs add interaction hooks to experience APIs * refactor(core): refactor experience API context type refactor experience API context type --- .../middleware/koa-interaction-details.ts | 0 .../classes/experience-interaction.test.ts | 10 +- .../classes/experience-interaction.ts | 27 +- .../classes/libraries/provision-library.ts | 13 +- packages/core/src/routes/experience/index.ts | 15 +- .../koa-experience-interaction-hooks.ts | 83 +++++ .../middleware/koa-experience-interaction.ts | 15 +- .../src/routes/experience/profile-routes.ts | 8 +- packages/core/src/routes/experience/types.ts | 15 + .../backup-code-verification.ts | 7 +- .../enterprise-sso-verification.ts | 10 +- .../new-password-identity-verification.ts | 10 +- .../password-verification.ts | 7 +- .../social-verification.ts | 7 +- .../verification-routes/totp-verification.ts | 7 +- .../verification-routes/verification-code.ts | 7 +- .../web-authn-verification.ts | 7 +- packages/core/src/routes/init.ts | 2 + .../interaction/actions/submit-interaction.ts | 2 +- .../core/src/routes/interaction/additional.ts | 2 +- .../src/routes/interaction/consent/index.ts | 8 +- packages/core/src/routes/interaction/index.ts | 24 +- packages/core/src/routes/interaction/mfa.ts | 2 +- .../middleware/koa-interaction-hooks.ts | 11 +- .../src/routes/interaction/single-sign-on.ts | 2 +- .../interaction/utils/single-sign-on.test.ts | 2 +- .../mandatory-user-profile-validation.ts | 8 +- .../verifications/mfa-verification.ts | 2 +- .../src/helpers/experience/index.ts | 31 ++ .../api/hook/hook.trigger.experience.test.ts | 336 ++++++++++++++++++ 30 files changed, 574 insertions(+), 106 deletions(-) rename packages/core/src/{routes/interaction => }/middleware/koa-interaction-details.ts (100%) create mode 100644 packages/core/src/routes/experience/middleware/koa-experience-interaction-hooks.ts create mode 100644 packages/integration-tests/src/tests/api/hook/hook.trigger.experience.test.ts diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-details.ts b/packages/core/src/middleware/koa-interaction-details.ts similarity index 100% rename from packages/core/src/routes/interaction/middleware/koa-interaction-details.ts rename to packages/core/src/middleware/koa-interaction-details.ts diff --git a/packages/core/src/routes/experience/classes/experience-interaction.test.ts b/packages/core/src/routes/experience/classes/experience-interaction.test.ts index 737d4da42..e354cb847 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.test.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.test.ts @@ -17,6 +17,8 @@ import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; +import { type WithHooksAndLogsContext } from '../types.js'; + import { EmailCodeVerification } from './verifications/code-verification.js'; const { jest } = import.meta; @@ -36,7 +38,7 @@ const userQueries = { const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn(async (user: CreateUser): Promise => [user as User]), - provisionOrganizations: jest.fn(), + provisionOrganizations: jest.fn().mockResolvedValue([]), }; const ssoConnectors = { getAvailableSsoConnectors: jest.fn().mockResolvedValue([]), @@ -69,7 +71,11 @@ describe('ExperienceInteraction class', () => { undefined, { users: userLibraries, ssoConnectors } ); - const ctx = { + + // @ts-expect-error --mock test context + const ctx: WithHooksAndLogsContext = { + assignInteractionHookResult: jest.fn(), + appendDataHookContext: jest.fn(), ...createContextWithRouteParameters(), ...createMockLogContext(), }; diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index 5082970fa..4e98b0d4e 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -5,7 +5,6 @@ import { conditional } from '@silverhand/essentials'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; @@ -14,6 +13,7 @@ import { type Interaction, type InteractionContext, type InteractionProfile, + type WithHooksAndLogsContext, } from '../types.js'; import { @@ -77,13 +77,17 @@ export default class ExperienceInteraction { /** * Restore experience interaction from the interaction storage. */ - constructor(ctx: WithLogContext, tenant: TenantContext, interactionDetails: Interaction); + constructor(ctx: WithHooksAndLogsContext, tenant: TenantContext, interactionDetails: Interaction); /** * Create a new `ExperienceInteraction` instance. */ - constructor(ctx: WithLogContext, tenant: TenantContext, interactionEvent: InteractionEvent); constructor( - private readonly ctx: WithLogContext, + ctx: WithHooksAndLogsContext, + tenant: TenantContext, + interactionEvent: InteractionEvent + ); + constructor( + private readonly ctx: WithHooksAndLogsContext, private readonly tenant: TenantContext, interactionData: Interaction | InteractionEvent ) { @@ -302,14 +306,15 @@ export default class ExperienceInteraction { new RequestError({ code: 'user.new_password_required_in_profile', status: 422 }) ); - await userQueries.updateUserById(user.id, { + const updatedUser = await userQueries.updateUserById(user.id, { passwordEncrypted, passwordEncryptionMethod, }); await this.cleanUp(); - // TODO: User data updated hook + this.ctx.assignInteractionHookResult({ userId: user.id }); + this.ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser }); return; } @@ -333,7 +338,7 @@ export default class ExperienceInteraction { const { mfaSkipped, mfaVerifications } = this.mfa.toUserMfaVerifications(); // Update user profile - await userQueries.updateUserById(user.id, { + const updatedUser = await userQueries.updateUserById(user.id, { ...rest, ...conditional( socialIdentity && { @@ -371,9 +376,13 @@ export default class ExperienceInteraction { login: { accountId: user.id }, }); - // TODO: PostInteractionHooks - this.ctx.body = { redirectTo }; + + this.ctx.assignInteractionHookResult({ userId: user.id }); + + if (Object.keys(this.profile.data).length > 0 || mfaVerifications.length > 0) { + this.ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser }); + } } /** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */ diff --git a/packages/core/src/routes/experience/classes/libraries/provision-library.ts b/packages/core/src/routes/experience/classes/libraries/provision-library.ts index 98d6d957f..425f76482 100644 --- a/packages/core/src/routes/experience/classes/libraries/provision-library.ts +++ b/packages/core/src/routes/experience/classes/libraries/provision-library.ts @@ -19,13 +19,12 @@ import { generateStandardId } from '@logto/shared'; import { conditional, conditionalArray, trySafe } from '@silverhand/essentials'; import { EnvSet } from '#src/env-set/index.js'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import { getConsoleLogFromContext } from '#src/utils/console.js'; import { buildAppInsightsTelemetry } from '#src/utils/request.js'; import { getTenantId } from '#src/utils/tenant.js'; -import { type InteractionProfile } from '../../types.js'; +import { type InteractionProfile, type WithHooksAndLogsContext } from '../../types.js'; import { postAffiliateLogs } from '../helpers.js'; import { toUserSocialIdentityData } from '../utils.js'; @@ -42,7 +41,7 @@ type OrganizationProvisionPayload = export class ProvisionLibrary { constructor( private readonly tenantContext: TenantContext, - private readonly ctx: WithLogContext + private readonly ctx: WithHooksAndLogsContext ) {} /** @@ -88,7 +87,7 @@ export class ProvisionLibrary { await this.provisionNewUserJitOrganizations(user.id, profile); - // TODO: New user created hooks + this.ctx.appendDataHookContext('User.Created', { user }); // TODO: log this.triggerAnalyticReports(user); @@ -255,7 +254,11 @@ export class ProvisionLibrary { const provisionedOrganizations = await usersLibraries.provisionOrganizations(payload); - // TODO: trigger hooks event + for (const { organizationId } of provisionedOrganizations) { + this.ctx.appendDataHookContext('Organization.Membership.Updated', { + organizationId, + }); + } return provisionedOrganizations; } diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index d83e0efa7..e1169e1f8 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -14,17 +14,17 @@ import { identificationApiPayloadGuard, InteractionEvent } from '@logto/schemas' import type Router from 'koa-router'; import { z } from 'zod'; -import koaAuditLog from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import koaInteractionDetails from '#src/middleware/koa-interaction-details.js'; import { type AnonymousRouter, type RouterInitArgs } from '../types.js'; import ExperienceInteraction from './classes/experience-interaction.js'; import { experienceRoutes } from './const.js'; -import koaExperienceInteraction, { - type WithExperienceInteractionContext, -} from './middleware/koa-experience-interaction.js'; +import { koaExperienceInteractionHooks } from './middleware/koa-experience-interaction-hooks.js'; +import koaExperienceInteraction from './middleware/koa-experience-interaction.js'; import profileRoutes from './profile-routes.js'; +import { type ExperienceInteractionRouterContext } from './types.js'; import backupCodeVerificationRoutes from './verification-routes/backup-code-verification.js'; import enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js'; import newPasswordIdentityVerificationRoutes from './verification-routes/new-password-identity-verification.js'; @@ -39,13 +39,14 @@ type RouterContext = T extends Router ? Context : nev export default function experienceApiRoutes( ...[anonymousRouter, tenant]: RouterInitArgs ) { - const { queries } = tenant; + const { provider, libraries } = tenant; const experienceRouter = // @ts-expect-error for good koa types // eslint-disable-next-line no-restricted-syntax - (anonymousRouter as Router>>).use( - koaAuditLog(queries), + (anonymousRouter as Router>>).use( + koaInteractionDetails(provider), + koaExperienceInteractionHooks(libraries), koaExperienceInteraction(tenant) ); diff --git a/packages/core/src/routes/experience/middleware/koa-experience-interaction-hooks.ts b/packages/core/src/routes/experience/middleware/koa-experience-interaction-hooks.ts new file mode 100644 index 000000000..c3642f318 --- /dev/null +++ b/packages/core/src/routes/experience/middleware/koa-experience-interaction-hooks.ts @@ -0,0 +1,83 @@ +import { InteractionEvent } from '@logto/schemas'; +import { conditionalString, noop, trySafe } from '@silverhand/essentials'; +import { type MiddlewareType } from 'koa'; +import { type IRouterParamContext } from 'koa-router'; +import { z } from 'zod'; + +import { + DataHookContextManager, + InteractionHookContextManager, +} from '#src/libraries/hook/context-manager.js'; +import { type WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; +import type Libraries from '#src/tenants/Libraries.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; + +const interactionEventGuard = z.object({ + interactionEvent: z.nativeEnum(InteractionEvent), +}); + +export type WithExperienceInteractionHooksContext< + ContextT extends IRouterParamContext = IRouterParamContext, +> = ContextT & { + assignInteractionHookResult: InteractionHookContextManager['assignInteractionHookResult']; + appendDataHookContext: DataHookContextManager['appendContext']; +}; + +export function koaExperienceInteractionHooks< + StateT, + ContextT extends WithInteractionDetailsContext, + ResponseT, +>({ + hooks: { triggerInteractionHooks, triggerDataHooks }, +}: Libraries): MiddlewareType, ResponseT> { + return async (ctx, next) => { + const { + interactionDetails, + header: { 'user-agent': userAgent }, + ip, + } = ctx; + + // Get the interaction event from the interaction details + const result = interactionEventGuard.safeParse(interactionDetails.result ?? {}); + + if (!result.success) { + ctx.assignInteractionHookResult = noop; + ctx.appendDataHookContext = noop; + return next(); + } + + const { interactionEvent } = result.data; + const interactionApiMetadata = { + interactionEvent, + userAgent, + applicationId: conditionalString(interactionDetails.params.client_id), + sessionId: interactionDetails.jti, + }; + const interactionHookContext = new InteractionHookContextManager({ + ...interactionApiMetadata, + userIp: ip, + }); + + ctx.assignInteractionHookResult = + interactionHookContext.assignInteractionHookResult.bind(interactionHookContext); + + const dataHookContext = new DataHookContextManager({ + ...interactionApiMetadata, + ip, + }); + + ctx.appendDataHookContext = dataHookContext.appendContext.bind(dataHookContext); + + await next(); + + if (interactionHookContext.interactionHookResult) { + // Hooks should not crash the app + void trySafe(triggerInteractionHooks(getConsoleLogFromContext(ctx), interactionHookContext)); + } + + if (dataHookContext.contextArray.length > 0) { + // Hooks should not crash the app + void trySafe(triggerDataHooks(getConsoleLogFromContext(ctx), dataHookContext)); + } + }; +} diff --git a/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts b/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts index 3fa3d1edd..b48aa7ca9 100644 --- a/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts +++ b/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts @@ -5,6 +5,7 @@ import type TenantContext from '#src/tenants/TenantContext.js'; import ExperienceInteraction from '../classes/experience-interaction.js'; import { experienceRoutes } from '../const.js'; +import { type WithHooksAndLogsContext } from '../types.js'; export type WithExperienceInteractionContext = ContextT & { @@ -20,23 +21,23 @@ export type WithExperienceInteractionContext( tenant: TenantContext ): MiddlewareType, ResponseT> { return async (ctx, next) => { - const { method, path } = ctx.request; + const { + interactionDetails, + request: { method, path }, + } = ctx; - // Should not apply the koaExperienceInteraction middleware to the PUT /experience route. - // New ExperienceInteraction instance are supposed to be created in the PUT /experience route. + // Should not retrieve the interaction details for the PUT /experience request. + // New ExperienceInteraction instance supposed to be created for this request. if (method === 'PUT' && path === `${experienceRoutes.prefix}`) { return next(); } - const { provider } = tenant; - const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res); - ctx.experienceInteraction = new ExperienceInteraction(ctx, tenant, interactionDetails); return next(); diff --git a/packages/core/src/routes/experience/profile-routes.ts b/packages/core/src/routes/experience/profile-routes.ts index d62c86251..c6959ed50 100644 --- a/packages/core/src/routes/experience/profile-routes.ts +++ b/packages/core/src/routes/experience/profile-routes.ts @@ -16,7 +16,7 @@ import assertThat from '#src/utils/assert-that.js'; import { identifierCodeVerificationTypeMap } from './classes/verifications/code-verification.js'; import { experienceRoutes } from './const.js'; -import { type WithExperienceInteractionContext } from './middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from './types.js'; /** * @throws {RequestError} with status 400 if current interaction is ForgotPassword @@ -27,7 +27,7 @@ function verifiedInteractionGuard< StateT, ContextT extends WithLogContext, ResponseT, ->(): MiddlewareType, ResponseT> { +>(): MiddlewareType, ResponseT> { return async (ctx, next) => { const { experienceInteraction } = ctx; @@ -47,8 +47,8 @@ function verifiedInteractionGuard< }; } -export default function interactionProfileRoutes( - router: Router>, +export default function interactionProfileRoutes( + router: Router, tenant: TenantContext ) { router.post( diff --git a/packages/core/src/routes/experience/types.ts b/packages/core/src/routes/experience/types.ts index 383cf71f5..eb7486412 100644 --- a/packages/core/src/routes/experience/types.ts +++ b/packages/core/src/routes/experience/types.ts @@ -9,7 +9,12 @@ import { import type Provider from 'oidc-provider'; import { z } from 'zod'; +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import { type WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; + import { type VerificationRecordMap } from './classes/verifications/index.js'; +import { type WithExperienceInteractionHooksContext } from './middleware/koa-experience-interaction-hooks.js'; +import { type WithExperienceInteractionContext } from './middleware/koa-experience-interaction.js'; export type Interaction = Awaited>; @@ -70,3 +75,13 @@ export type InteractionContext = { verificationId: string ) => VerificationRecordMap[K]; }; + +export type ExperienceInteractionRouterContext = + ContextT & + WithInteractionDetailsContext & + WithExperienceInteractionHooksContext & + WithExperienceInteractionContext; + +export type WithHooksAndLogsContext = ContextT & + WithInteractionDetailsContext & + WithExperienceInteractionHooksContext; diff --git a/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts b/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts index 496995d08..64732aec0 100644 --- a/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts @@ -2,17 +2,16 @@ import { backupCodeVerificationVerifyPayloadGuard } from '@logto/schemas'; import type Router from 'koa-router'; import { z } from 'zod'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { BackupCodeVerification } from '../classes/verifications/backup-code-verification.js'; import { experienceRoutes } from '../const.js'; -import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from '../types.js'; -export default function backupCodeVerificationRoutes( - router: Router>, +export default function backupCodeVerificationRoutes( + router: Router, tenantContext: TenantContext ) { const { libraries, queries } = tenantContext; diff --git a/packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts b/packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts index ce65b992f..72b4a8c1f 100644 --- a/packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts @@ -7,19 +7,17 @@ import type Router from 'koa-router'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { EnterpriseSsoVerification } from '../classes/verifications/enterprise-sso-verification.js'; import { experienceRoutes } from '../const.js'; -import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from '../types.js'; -export default function enterpriseSsoVerificationRoutes( - router: Router>, - tenantContext: TenantContext -) { +export default function enterpriseSsoVerificationRoutes< + T extends ExperienceInteractionRouterContext, +>(router: Router, tenantContext: TenantContext) { const { libraries, queries } = tenantContext; router.post( diff --git a/packages/core/src/routes/experience/verification-routes/new-password-identity-verification.ts b/packages/core/src/routes/experience/verification-routes/new-password-identity-verification.ts index 2d41e7549..3699c754d 100644 --- a/packages/core/src/routes/experience/verification-routes/new-password-identity-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/new-password-identity-verification.ts @@ -2,18 +2,16 @@ import { newPasswordIdentityVerificationPayloadGuard } from '@logto/schemas'; import type Router from 'koa-router'; import { z } from 'zod'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import { NewPasswordIdentityVerification } from '../classes/verifications/new-password-identity-verification.js'; import { experienceRoutes } from '../const.js'; -import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from '../types.js'; -export default function newPasswordIdentityVerificationRoutes( - router: Router>, - { libraries, queries }: TenantContext -) { +export default function newPasswordIdentityVerificationRoutes< + T extends ExperienceInteractionRouterContext, +>(router: Router, { libraries, queries }: TenantContext) { router.post( `${experienceRoutes.verification}/new-password-identity`, koaGuard({ diff --git a/packages/core/src/routes/experience/verification-routes/password-verification.ts b/packages/core/src/routes/experience/verification-routes/password-verification.ts index 871f6b682..a52980e8a 100644 --- a/packages/core/src/routes/experience/verification-routes/password-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/password-verification.ts @@ -2,16 +2,15 @@ import { passwordVerificationPayloadGuard } from '@logto/schemas'; import type Router from 'koa-router'; import { z } from 'zod'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import { PasswordVerification } from '../classes/verifications/password-verification.js'; import { experienceRoutes } from '../const.js'; -import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from '../types.js'; -export default function passwordVerificationRoutes( - router: Router>, +export default function passwordVerificationRoutes( + router: Router, { libraries, queries }: TenantContext ) { router.post( diff --git a/packages/core/src/routes/experience/verification-routes/social-verification.ts b/packages/core/src/routes/experience/verification-routes/social-verification.ts index ef77607da..45d4352c1 100644 --- a/packages/core/src/routes/experience/verification-routes/social-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/social-verification.ts @@ -7,17 +7,16 @@ import type Router from 'koa-router'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { SocialVerification } from '../classes/verifications/social-verification.js'; import { experienceRoutes } from '../const.js'; -import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from '../types.js'; -export default function socialVerificationRoutes( - router: Router>, +export default function socialVerificationRoutes( + router: Router, tenantContext: TenantContext ) { const { libraries, queries } = tenantContext; diff --git a/packages/core/src/routes/experience/verification-routes/totp-verification.ts b/packages/core/src/routes/experience/verification-routes/totp-verification.ts index ed826e223..66f342093 100644 --- a/packages/core/src/routes/experience/verification-routes/totp-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/totp-verification.ts @@ -3,17 +3,16 @@ import type Router from 'koa-router'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { TotpVerification } from '../classes/verifications/totp-verification.js'; import { experienceRoutes } from '../const.js'; -import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from '../types.js'; -export default function totpVerificationRoutes( - router: Router>, +export default function totpVerificationRoutes( + router: Router, tenantContext: TenantContext ) { const { libraries, queries } = tenantContext; diff --git a/packages/core/src/routes/experience/verification-routes/verification-code.ts b/packages/core/src/routes/experience/verification-routes/verification-code.ts index 711546d38..f49a31601 100644 --- a/packages/core/src/routes/experience/verification-routes/verification-code.ts +++ b/packages/core/src/routes/experience/verification-routes/verification-code.ts @@ -2,17 +2,16 @@ import { InteractionEvent, verificationCodeIdentifierGuard } from '@logto/schema import type Router from 'koa-router'; import { z } from 'zod'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import { codeVerificationIdentifierRecordTypeMap } from '../classes/utils.js'; import { createNewCodeVerificationRecord } from '../classes/verifications/code-verification.js'; import { experienceRoutes } from '../const.js'; -import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from '../types.js'; -export default function verificationCodeRoutes( - router: Router>, +export default function verificationCodeRoutes( + router: Router, { libraries, queries }: TenantContext ) { router.post( diff --git a/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts b/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts index 4c8cdcf3e..3185c8d76 100644 --- a/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts @@ -8,17 +8,16 @@ import type Router from 'koa-router'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; -import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { WebAuthnVerification } from '../classes/verifications/web-authn-verification.js'; import { experienceRoutes } from '../const.js'; -import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; +import { type ExperienceInteractionRouterContext } from '../types.js'; -export default function webAuthnVerificationRoute( - router: Router>, +export default function webAuthnVerificationRoute( + router: Router, tenantContext: TenantContext ) { const { libraries, queries } = tenantContext; diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 48eb5c32b..7f3420f83 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -3,6 +3,7 @@ import Koa from 'koa'; import Router from 'koa-router'; import { EnvSet } from '#src/env-set/index.js'; +import koaAuditLog from '#src/middleware/koa-audit-log.js'; import koaBodyEtag from '#src/middleware/koa-body-etag.js'; import koaCors from '#src/middleware/koa-cors.js'; import { koaManagementApiHooks } from '#src/middleware/koa-management-api-hooks.js'; @@ -53,6 +54,7 @@ const createRouters = (tenant: TenantContext) => { const experienceRouter: AnonymousRouter = new Router(); if (EnvSet.values.isDevFeaturesEnabled) { + experienceRouter.use(koaAuditLog(tenant.queries)); experienceApiRoutes(experienceRouter, tenant); } diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 3c37e9e80..f6ae89223 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -23,12 +23,12 @@ import { EnvSet } from '#src/env-set/index.js'; import { assignInteractionResults } from '#src/libraries/session.js'; import { encryptUserPassword } from '#src/libraries/user.utils.js'; import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js'; +import type { WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import { getConsoleLogFromContext } from '#src/utils/console.js'; import { buildAppInsightsTelemetry } from '#src/utils/request.js'; import { getTenantId } from '#src/utils/tenant.js'; -import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; import type { VerifiedInteractionResult, diff --git a/packages/core/src/routes/interaction/additional.ts b/packages/core/src/routes/interaction/additional.ts index dc128cb72..a335620e6 100644 --- a/packages/core/src/routes/interaction/additional.ts +++ b/packages/core/src/routes/interaction/additional.ts @@ -12,6 +12,7 @@ import { authenticator } from 'otplib'; import qrcode from 'qrcode'; import { z } from 'zod'; +import type { WithInteractionDetailsContext } from '#src//middleware/koa-interaction-details.js'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -20,7 +21,6 @@ import assertThat from '#src/utils/assert-that.js'; import { parseUserProfile } from './actions/helpers.js'; import { interactionPrefix, verificationPath } from './const.js'; -import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; import { socialAuthorizationUrlPayloadGuard } from './types/guard.js'; import { getInteractionStorage, diff --git a/packages/core/src/routes/interaction/consent/index.ts b/packages/core/src/routes/interaction/consent/index.ts index dd21ca6c4..0c3a6737c 100644 --- a/packages/core/src/routes/interaction/consent/index.ts +++ b/packages/core/src/routes/interaction/consent/index.ts @@ -1,11 +1,11 @@ import { UserScope } from '@logto/core-kit'; import { - consentInfoResponseGuard, - publicApplicationGuard, - publicUserInfoGuard, applicationSignInExperienceGuard, type ConsentInfoResponse, + consentInfoResponseGuard, Organizations, + publicApplicationGuard, + publicUserInfoGuard, } from '@logto/schemas'; import { conditional, deduplicate } from '@silverhand/essentials'; import type Router from 'koa-router'; @@ -15,11 +15,11 @@ import { z } from 'zod'; import { consent, getMissingScopes } from '#src/libraries/session.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import type { WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { interactionPrefix } from '../const.js'; -import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import { filterAndParseMissingResourceScopes } from './utils.js'; diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 0697cc8e0..69c0288a1 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -7,6 +7,8 @@ import RequestError from '#src/errors/RequestError/index.js'; import { assignInteractionResults } from '#src/libraries/session.js'; import koaAuditLog from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import type { WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; +import koaInteractionDetails from '#src/middleware/koa-interaction-details.js'; import assertThat from '#src/utils/assert-that.js'; import type { AnonymousRouter, RouterInitArgs } from '../types.js'; @@ -16,34 +18,32 @@ import additionalRoutes from './additional.js'; import consentRoutes from './consent/index.js'; import { interactionPrefix } from './const.js'; import mfaRoutes from './mfa.js'; -import koaInteractionDetails from './middleware/koa-interaction-details.js'; -import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; import koaInteractionHooks from './middleware/koa-interaction-hooks.js'; import koaInteractionSie from './middleware/koa-interaction-sie.js'; import singleSignOnRoutes from './single-sign-on.js'; import { getInteractionStorage, - storeInteractionResult, - mergeIdentifiers, isForgotPasswordInteractionResult, isSignInInteractionResult, + mergeIdentifiers, + storeInteractionResult, } from './utils/interaction.js'; import { - verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings, + verifySignInModeSettings, } from './utils/sign-in-experience-validation.js'; import { verifySsoOnlyEmailIdentifier } from './utils/single-sign-on-guard.js'; import { validatePassword } from './utils/validate-password.js'; import { - verifyIdentifierPayload, - verifyIdentifier, - verifyProfile, - validateMandatoryUserProfile, - validateMandatoryBindMfa, - verifyBindMfa, - verifyMfa, validateBindMfaBackupCode, + validateMandatoryBindMfa, + validateMandatoryUserProfile, + verifyBindMfa, + verifyIdentifier, + verifyIdentifierPayload, + verifyMfa, + verifyProfile, } from './verifications/index.js'; export type RouterContext = T extends Router ? Context : never; diff --git a/packages/core/src/routes/interaction/mfa.ts b/packages/core/src/routes/interaction/mfa.ts index 3e08d7813..6b5b55677 100644 --- a/packages/core/src/routes/interaction/mfa.ts +++ b/packages/core/src/routes/interaction/mfa.ts @@ -12,11 +12,11 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import type { WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { interactionPrefix } from './const.js'; -import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; import koaInteractionSie from './middleware/koa-interaction-sie.js'; import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js'; import { verifyMfaSettings } from './utils/sign-in-experience-validation.js'; diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts index 654c92d6e..7a204ba58 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts @@ -1,26 +1,17 @@ -import { type User } from '@logto/schemas'; import { conditionalString, trySafe } from '@silverhand/essentials'; import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; import { - type DataHookContext, DataHookContextManager, InteractionHookContextManager, } from '#src/libraries/hook/context-manager.js'; +import type { WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; import type Libraries from '#src/tenants/Libraries.js'; import { getConsoleLogFromContext } from '#src/utils/console.js'; import { getInteractionStorage } from '../utils/interaction.js'; -import type { WithInteractionDetailsContext } from './koa-interaction-details.js'; - -type AppendDataHookContext = ( - payload: DataHookContext & { - user?: User; - } -) => void; - export type WithInteractionHooksContext< ContextT extends IRouterParamContext = IRouterParamContext, > = ContextT & { diff --git a/packages/core/src/routes/interaction/single-sign-on.ts b/packages/core/src/routes/interaction/single-sign-on.ts index a3dce05ea..bd81ef9ce 100644 --- a/packages/core/src/routes/interaction/single-sign-on.ts +++ b/packages/core/src/routes/interaction/single-sign-on.ts @@ -7,11 +7,11 @@ import RequestError from '#src/errors/RequestError/index.js'; import { assignInteractionResults } from '#src/libraries/session.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import type { WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { interactionPrefix, ssoPath } from './const.js'; -import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; import koaInteractionHooks from './middleware/koa-interaction-hooks.js'; import koaInteractionSie from './middleware/koa-interaction-sie.js'; import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js'; diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts index 62cdd464a..17d62e5e0 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts @@ -4,6 +4,7 @@ import type Provider from 'oidc-provider'; import { mockSsoConnector, wellConfiguredSsoConnector } from '#src/__mocks__/sso.js'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import { type WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; import { OidcSsoConnector } from '#src/sso/OidcSsoConnector/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js'; import { type SingleSignOnConnectorData } from '#src/sso/types/connector.js'; @@ -12,7 +13,6 @@ import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; const { jest } = import.meta; diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts index 616faef01..96246c93b 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts @@ -4,16 +4,16 @@ import type { Nullable } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; +import { type WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; -import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import type { WithInteractionSieContext } from '../middleware/koa-interaction-sie.js'; import type { - SocialIdentifier, - VerifiedSignInInteractionResult, - VerifiedRegisterInteractionResult, Identifier, + SocialIdentifier, + VerifiedRegisterInteractionResult, + VerifiedSignInInteractionResult, } from '../types/index.js'; import { isUserPasswordSet } from '../utils/index.js'; import { mergeIdentifiers } from '../utils/interaction.js'; diff --git a/packages/core/src/routes/interaction/verifications/mfa-verification.ts b/packages/core/src/routes/interaction/verifications/mfa-verification.ts index 2504b6256..95c062545 100644 --- a/packages/core/src/routes/interaction/verifications/mfa-verification.ts +++ b/packages/core/src/routes/interaction/verifications/mfa-verification.ts @@ -10,10 +10,10 @@ import type Provider from 'oidc-provider'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; +import { type WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; -import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import { type WithInteractionSieContext } from '../middleware/koa-interaction-sie.js'; import { type AccountVerifiedInteractionResult, diff --git a/packages/integration-tests/src/helpers/experience/index.ts b/packages/integration-tests/src/helpers/experience/index.ts index f3c310c2a..303d9de26 100644 --- a/packages/integration-tests/src/helpers/experience/index.ts +++ b/packages/integration-tests/src/helpers/experience/index.ts @@ -141,6 +141,22 @@ export const registerNewUserWithVerificationCode = async ( return userId; }; +export const identifyUserWithEmailVerificationCode = async ( + client: ExperienceClient, + email: string +) => { + const { verificationId, code } = await successfullySendVerificationCode(client, { + identifier: { type: SignInIdentifier.Email, value: email }, + interactionEvent: InteractionEvent.ForgotPassword, + }); + await successfullyVerifyVerificationCode(client, { + identifier: { type: SignInIdentifier.Email, value: email }, + verificationId, + code, + }); + await client.identifyUser({ verificationId }); +}; + /** * * @param socialUserInfo The social user info that will be returned by the social connector. @@ -265,3 +281,18 @@ export const registerNewUserUsernamePassword = async (username: string, password return userId; }; + +export const fulfillUserEmail = async (client: ExperienceClient, email: string) => { + const { verificationId, code } = await successfullySendVerificationCode(client, { + identifier: { type: SignInIdentifier.Email, value: email }, + interactionEvent: InteractionEvent.Register, + }); + + await successfullyVerifyVerificationCode(client, { + identifier: { type: SignInIdentifier.Email, value: email }, + verificationId, + code, + }); + + await client.updateProfile({ type: SignInIdentifier.Email, verificationId }); +}; diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.experience.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.experience.test.ts new file mode 100644 index 000000000..8cbc2a171 --- /dev/null +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.experience.test.ts @@ -0,0 +1,336 @@ +import { + hookEvents, + InteractionEvent, + InteractionHookEvent, + SignInIdentifier, +} from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { SsoConnectorApi } from '#src/api/sso-connector.js'; +import { initExperienceClient, logoutClient, processSession } from '#src/helpers/client.js'; +import { resetPasswordlessConnectors } from '#src/helpers/connector.js'; +import { + fulfillUserEmail, + identifyUserWithEmailVerificationCode, + identifyUserWithUsernamePassword, + registerNewUserUsernamePassword, + registerNewUserWithVerificationCode, + signInWithEnterpriseSso, + signInWithPassword, +} from '#src/helpers/experience/index.js'; +import { WebHookApiTest } from '#src/helpers/hook.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { + enableAllPasswordSignInMethods, + enableAllVerificationCodeSignInMethods, +} from '#src/helpers/sign-in-experience.js'; +import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js'; +import { devFeatureTest, generateEmail, generatePassword, randomString } from '#src/utils.js'; + +import WebhookMockServer from './WebhookMockServer.js'; +import { assertHookLogResult } from './utils.js'; + +const webbHookMockServer = new WebhookMockServer(9999); +const userNamePrefix = 'experienceApiHookTriggerTestUser'; +const username = `${userNamePrefix}_0`; +const password = generatePassword(); +// For email fulfilling and reset password use +const email = generateEmail(); + +const userApi = new UserApiTest(); +const webHookApi = new WebHookApiTest(); +const organizationApi = new OrganizationApiTest(); +const ssoConnectorApi = new SsoConnectorApi(); + +beforeAll(async () => { + await Promise.all([ + resetPasswordlessConnectors(), + enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }), + webbHookMockServer.listen(), + userApi.create({ username, password }), + ]); +}); + +afterAll(async () => { + await Promise.all([userApi.cleanUp(), webbHookMockServer.close()]); +}); + +afterEach(async () => { + await Promise.all([organizationApi.cleanUp(), ssoConnectorApi.cleanUp()]); +}); + +devFeatureTest.describe('trigger invalid hook', () => { + beforeAll(async () => { + await webHookApi.create({ + name: 'invalidHookEventListener', + events: [InteractionHookEvent.PostSignIn], + config: { url: 'not_work_url' }, + }); + }); + + it('should log invalid hook url error', async () => { + await signInWithPassword({ + identifier: { + type: SignInIdentifier.Username, + value: username, + }, + password, + }); + + const hook = webHookApi.hooks.get('invalidHookEventListener')!; + + await assertHookLogResult(hook, InteractionHookEvent.PostSignIn, { + errorMessage: 'Failed to parse URL from not_work_url', + }); + }); + + afterAll(async () => { + await webHookApi.cleanUp(); + }); +}); + +devFeatureTest.describe('experience api hook trigger', () => { + // Use new hooks for each test to ensure test isolation + beforeEach(async () => { + await Promise.all([ + webHookApi.create({ + name: 'interactionHookEventListener', + events: Object.values(InteractionHookEvent), + config: { url: webbHookMockServer.endpoint }, + }), + webHookApi.create({ + name: 'dataHookEventListener', + events: hookEvents.filter((event) => !(event in InteractionHookEvent)), + config: { url: webbHookMockServer.endpoint }, + }), + webHookApi.create({ + name: 'registerOnlyInteractionHookEventListener', + events: [InteractionHookEvent.PostRegister], + config: { url: webbHookMockServer.endpoint }, + }), + ]); + }); + + afterEach(async () => { + await webHookApi.cleanUp(); + }); + + it('new user registration interaction API', async () => { + const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!; + const registerHook = webHookApi.hooks.get('registerOnlyInteractionHookEventListener')!; + const dataHook = webHookApi.hooks.get('dataHookEventListener')!; + + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const userId = await registerNewUserUsernamePassword(username, password); + + const interactionHookEventPayload: Record = { + event: InteractionHookEvent.PostRegister, + interactionEvent: InteractionEvent.Register, + sessionId: expect.any(String), + user: expect.objectContaining({ id: userId, username }), + }; + + await assertHookLogResult(interactionHook, InteractionHookEvent.PostRegister, { + hookPayload: interactionHookEventPayload, + }); + + // Verify multiple hooks can be triggered with the same event + await assertHookLogResult(registerHook, InteractionHookEvent.PostRegister, { + hookPayload: interactionHookEventPayload, + }); + + // Verify data hook is triggered + await assertHookLogResult(dataHook, 'User.Created', { + hookPayload: { + event: 'User.Created', + interactionEvent: InteractionEvent.Register, + sessionId: expect.any(String), + data: expect.objectContaining({ id: userId, username }), + }, + }); + + // Assert user updated event is not triggered + await assertHookLogResult(dataHook, 'User.Data.Updated', { + toBeUndefined: true, + }); + + // Clean up + await deleteUser(userId); + }); + + it('user sign in interaction API without profile update', async () => { + await signInWithPassword({ + identifier: { + type: SignInIdentifier.Username, + value: username, + }, + password, + }); + + const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!; + const dataHook = webHookApi.hooks.get('dataHookEventListener')!; + const user = userApi.users.find(({ username: name }) => name === username)!; + + const interactionHookEventPayload: Record = { + event: InteractionHookEvent.PostSignIn, + interactionEvent: InteractionEvent.SignIn, + sessionId: expect.any(String), + user: expect.objectContaining({ id: user.id, username }), + }; + + await assertHookLogResult(interactionHook, InteractionHookEvent.PostSignIn, { + hookPayload: interactionHookEventPayload, + }); + + // Verify user create data hook is not triggered + await assertHookLogResult(dataHook, 'User.Created', { + toBeUndefined: true, + }); + + await assertHookLogResult(dataHook, 'User.Data.Updated', { + toBeUndefined: true, + }); + }); + + it('user sign in interaction API with profile update', async () => { + await enableAllVerificationCodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }); + + const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!; + const dataHook = webHookApi.hooks.get('dataHookEventListener')!; + const user = userApi.users.find(({ username: name }) => name === username)!; + + const client = await initExperienceClient(); + + await identifyUserWithUsernamePassword(client, username, password); + await fulfillUserEmail(client, email); + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + await logoutClient(client); + + const interactionHookEventPayload: Record = { + event: InteractionHookEvent.PostSignIn, + interactionEvent: InteractionEvent.SignIn, + sessionId: expect.any(String), + user: expect.objectContaining({ id: user.id, username }), + }; + + await assertHookLogResult(interactionHook, InteractionHookEvent.PostSignIn, { + hookPayload: interactionHookEventPayload, + }); + + // Verify user create data hook is not triggered + await assertHookLogResult(dataHook, 'User.Created', { + toBeUndefined: true, + }); + + await assertHookLogResult(dataHook, 'User.Data.Updated', { + hookPayload: { + event: 'User.Data.Updated', + interactionEvent: InteractionEvent.SignIn, + sessionId: expect.any(String), + data: expect.objectContaining({ id: user.id, username, primaryEmail: email }), + }, + }); + }); + + it('password reset interaction API', async () => { + const newPassword = generatePassword(); + const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!; + const dataHook = webHookApi.hooks.get('dataHookEventListener')!; + const user = userApi.users.find(({ username: name }) => name === username)!; + + const client = await initExperienceClient(InteractionEvent.ForgotPassword); + await identifyUserWithEmailVerificationCode(client, email); + await client.resetPassword({ password: newPassword }); + await client.submitInteraction(); + + const interactionHookEventPayload: Record = { + event: InteractionHookEvent.PostResetPassword, + interactionEvent: InteractionEvent.ForgotPassword, + sessionId: expect.any(String), + user: expect.objectContaining({ id: user.id, username, primaryEmail: email }), + }; + + await assertHookLogResult(interactionHook, InteractionHookEvent.PostResetPassword, { + hookPayload: interactionHookEventPayload, + }); + + await assertHookLogResult(dataHook, 'User.Data.Updated', { + hookPayload: { + event: 'User.Data.Updated', + interactionEvent: InteractionEvent.ForgotPassword, + sessionId: expect.any(String), + data: expect.objectContaining({ id: user.id, username, primaryEmail: email }), + }, + }); + }); +}); + +devFeatureTest.describe('organization jit provisioning hook trigger', () => { + const hookName = 'organizationJitProvisioningHookEventListener'; + + beforeAll(async () => { + await webHookApi.create({ + name: hookName, + events: ['Organization.Membership.Updated'], + config: { url: webbHookMockServer.endpoint }, + }); + }); + + const assertOrganizationMembershipUpdated = async (organizationId: string) => + assertHookLogResult(webHookApi.hooks.get(hookName)!, 'Organization.Membership.Updated', { + hookPayload: { + event: 'Organization.Membership.Updated', + organizationId, + }, + }); + + it('should trigger `Organization.Membership.Updated` event when user is provisioned by experience', async () => { + const organization = await organizationApi.create({ name: 'foo' }); + const domain = 'example.com'; + await organizationApi.jit.addEmailDomain(organization.id, domain); + + const userId = await registerNewUserWithVerificationCode({ + type: SignInIdentifier.Email, + value: generateEmail(domain), + }); + await assertOrganizationMembershipUpdated(organization.id); + await deleteUser(userId); + }); + + it('should trigger `Organization.Membership.Updated` event when user is provisioned by SSO', async () => { + const organization = await organizationApi.create({ name: 'bar' }); + const domain = 'sso_example.com'; + const connector = await ssoConnectorApi.createMockOidcConnector([domain]); + await organizationApi.jit.ssoConnectors.add(organization.id, [connector.id]); + const email = generateEmail(domain); + const sub = randomString(); + + await updateSignInExperience({ + singleSignOnEnabled: true, + }); + + const userId = await signInWithEnterpriseSso( + ssoConnectorApi.firstConnectorId!, + { + sub, + email, + email_verified: true, + }, + true + ); + + await assertOrganizationMembershipUpdated(organization.id); + + await deleteUser(userId); + }); +});