0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

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
This commit is contained in:
simeng-li 2024-07-31 14:20:31 +08:00 committed by GitHub
parent dbc5512c0b
commit 3b4da16247
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 574 additions and 106 deletions

View file

@ -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<InsertUserResult> => [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(),
};

View file

@ -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 */

View file

@ -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;
}

View file

@ -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> = T extends Router<unknown, infer Context> ? Context : nev
export default function experienceApiRoutes<T extends AnonymousRouter>(
...[anonymousRouter, tenant]: RouterInitArgs<T>
) {
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<unknown, WithExperienceInteractionContext<RouterContext<T>>>).use(
koaAuditLog(queries),
(anonymousRouter as Router<unknown, ExperienceInteractionRouterContext<RouterContext<T>>>).use(
koaInteractionDetails(provider),
koaExperienceInteractionHooks(libraries),
koaExperienceInteraction(tenant)
);

View file

@ -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<StateT, WithExperienceInteractionHooksContext<ContextT>, 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));
}
};
}

View file

@ -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 extends WithLogContext = WithLogContext> =
ContextT & {
@ -20,23 +21,23 @@ export type WithExperienceInteractionContext<ContextT extends WithLogContext = W
*/
export default function koaExperienceInteraction<
StateT,
ContextT extends WithLogContext,
ContextT extends WithHooksAndLogsContext,
ResponseT,
>(
tenant: TenantContext
): MiddlewareType<StateT, WithExperienceInteractionContext<ContextT>, 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();

View file

@ -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<StateT, WithExperienceInteractionContext<ContextT>, ResponseT> {
>(): MiddlewareType<StateT, ExperienceInteractionRouterContext<ContextT>, ResponseT> {
return async (ctx, next) => {
const { experienceInteraction } = ctx;
@ -47,8 +47,8 @@ function verifiedInteractionGuard<
};
}
export default function interactionProfileRoutes<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
export default function interactionProfileRoutes<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
tenant: TenantContext
) {
router.post(

View file

@ -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<ReturnType<Provider['interactionDetails']>>;
@ -70,3 +75,13 @@ export type InteractionContext = {
verificationId: string
) => VerificationRecordMap[K];
};
export type ExperienceInteractionRouterContext<ContextT extends WithLogContext = WithLogContext> =
ContextT &
WithInteractionDetailsContext &
WithExperienceInteractionHooksContext &
WithExperienceInteractionContext;
export type WithHooksAndLogsContext<ContextT extends WithLogContext = WithLogContext> = ContextT &
WithInteractionDetailsContext &
WithExperienceInteractionHooksContext;

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
export default function backupCodeVerificationRoutes<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
tenantContext: TenantContext
) {
const { libraries, queries } = tenantContext;

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
tenantContext: TenantContext
) {
export default function enterpriseSsoVerificationRoutes<
T extends ExperienceInteractionRouterContext,
>(router: Router<unknown, T>, tenantContext: TenantContext) {
const { libraries, queries } = tenantContext;
router.post(

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
{ libraries, queries }: TenantContext
) {
export default function newPasswordIdentityVerificationRoutes<
T extends ExperienceInteractionRouterContext,
>(router: Router<unknown, T>, { libraries, queries }: TenantContext) {
router.post(
`${experienceRoutes.verification}/new-password-identity`,
koaGuard({

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
export default function passwordVerificationRoutes<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
{ libraries, queries }: TenantContext
) {
router.post(

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
export default function socialVerificationRoutes<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
tenantContext: TenantContext
) {
const { libraries, queries } = tenantContext;

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
export default function totpVerificationRoutes<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
tenantContext: TenantContext
) {
const { libraries, queries } = tenantContext;

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
export default function verificationCodeRoutes<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
{ libraries, queries }: TenantContext
) {
router.post(

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
export default function webAuthnVerificationRoute<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
tenantContext: TenantContext
) {
const { libraries, queries } = tenantContext;

View file

@ -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);
}

View file

@ -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,

View file

@ -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,

View file

@ -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';

View file

@ -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> = T extends Router<unknown, infer Context> ? Context : never;

View file

@ -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';

View file

@ -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 & {

View file

@ -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';

View file

@ -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;

View file

@ -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';

View file

@ -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,

View file

@ -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 });
};

View file

@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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);
});
});