0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(core): trigger user create DataHook event on user registration (#5837)

* feat(core): trigger user data hook event on interaction api call

trigger user data hook event on interaction api call

* chore(core): refine comments

refine comments

* fix(core): fix the interactionHookMiddleware

fix the interactionHookMiddleware

* test(core): add integration tests

add integration tests for interaction hooks

* chore(test): remove legacy test

remove legacy test
This commit is contained in:
simeng-li 2024-05-15 11:17:46 +08:00 committed by GitHub
parent 7b5a4e3fb4
commit 5462ab4765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 578 additions and 468 deletions

View file

@ -35,7 +35,7 @@ type InteractionHookMetadata = {
* In the `koaInteractionHooks` middleware,
* if we get an interaction hook result after the interaction is processed, related hooks will be triggered.
*/
export type InteractionHookResult = {
type InteractionHookResult = {
userId: string;
};

View file

@ -1,5 +1,5 @@
import { defaults, parseAffiliateData } from '@logto/affiliate';
import { type CreateUser, type User, adminTenantId } from '@logto/schemas';
import { adminTenantId, type CreateUser, type User } from '@logto/schemas';
import { conditional, trySafe } from '@silverhand/essentials';
import { type IRouterContext } from 'koa-router';
@ -15,8 +15,8 @@ import { type OmitAutoSetFields } from '#src/utils/sql.js';
import {
type Identifier,
type SocialIdentifier,
type VerifiedSignInInteractionResult,
type VerifiedRegisterInteractionResult,
type VerifiedSignInInteractionResult,
} from '../types/index.js';
import { categorizeIdentifiers } from '../utils/interaction.js';
@ -149,3 +149,12 @@ export const postAffiliateLogs = async (
getConsoleLogFromContext(ctx).info('Affiliate logs posted', userId);
}
};
/* Verify if user has updated profile */
export const hasUpdatedProfile = ({
lastSignInAt,
...profile
}: Omit<OmitAutoSetFields<CreateUser>, 'id'>) => {
// Check if the lastSignInAt is the only field in the updated profile
return Object.keys(profile).length > 0;
};

View file

@ -73,6 +73,7 @@ describe('submit action', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
interactionDetails: { params: {} } as Awaited<ReturnType<Provider['interactionDetails']>>,
assignInteractionHookResult: jest.fn(),
assignDataHookContext: jest.fn(),
};
const profile = {
username: 'username',

View file

@ -1,4 +1,11 @@
import { InteractionEvent, adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
/* eslint-disable max-lines */
import {
InteractionEvent,
adminConsoleApplicationId,
adminTenantId,
type CreateUser,
type User,
} from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
@ -8,9 +15,9 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type {
Identifier,
VerifiedForgotPasswordInteractionResult,
VerifiedRegisterInteractionResult,
VerifiedSignInInteractionResult,
VerifiedForgotPasswordInteractionResult,
} from '../types/index.js';
import { userMfaDataKey } from '../verifications/mfa-verification.js';
@ -45,7 +52,7 @@ const userQueries = {
identities: { google: { userId: 'googleId', details: {} } },
mfaVerifications: [],
}),
updateUserById: jest.fn(),
updateUserById: jest.fn(async (id: string, user: Partial<User>) => user as User),
hasActiveUsers: jest.fn().mockResolvedValue(true),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
@ -53,7 +60,10 @@ const userQueries = {
const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = userQueries;
const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn() };
const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'),
insertUser: jest.fn(async (user: CreateUser) => user as User),
};
const { generateUserId, insertUser } = userLibraries;
const submitInteraction = await pickDefault(import('./submit-interaction.js'));
@ -74,6 +84,7 @@ describe('submit action', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
interactionDetails: { params: {} } as Awaited<ReturnType<Provider['interactionDetails']>>,
assignInteractionHookResult: jest.fn(),
assignDataHookContext: jest.fn(),
};
const profile = {
username: 'username',
@ -141,6 +152,14 @@ describe('submit action', () => {
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'uid' },
});
expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Created',
user: {
id: 'uid',
...upsertProfile,
},
});
});
it('register and use pendingAccountId', async () => {
@ -168,6 +187,14 @@ describe('submit action', () => {
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'pending-account-id' },
});
expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Created',
user: {
id: 'pending-account-id',
...upsertProfile,
},
});
});
it('register with mfaSkipped', async () => {
@ -294,11 +321,30 @@ describe('submit action', () => {
});
});
it('sign-in without new profile', async () => {
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
identifiers: [{ key: 'accountId', value: 'foo' }],
};
await submitInteraction(interaction, ctx, tenant);
expect(updateUserById).toBeCalledWith('foo', {
lastSignInAt: now,
});
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
expect(ctx.assignDataHookContext).not.toBeCalled();
});
it('sign-in with new profile', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
@ -311,7 +357,7 @@ describe('submit action', () => {
expect(encryptUserPassword).toBeCalledWith('password');
expect(getLogtoConnectorById).toBeCalledWith('logto');
expect(updateUserById).toBeCalledWith('foo', {
const updateProfile = {
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
identities: {
@ -319,10 +365,16 @@ describe('submit action', () => {
google: { userId: 'googleId', details: {} },
},
lastSignInAt: now,
});
};
expect(updateUserById).toBeCalledWith('foo', updateProfile);
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Updated',
user: updateProfile,
});
});
it('sign-in with mfaSkipped', async () => {
@ -380,6 +432,15 @@ describe('submit action', () => {
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Updated',
user: {
primaryEmail: 'email',
name: userInfo.name,
avatar: userInfo.avatar,
lastSignInAt: now,
},
});
});
it('reset password', async () => {
@ -392,12 +453,18 @@ describe('submit action', () => {
await submitInteraction(interaction, ctx, tenant);
expect(encryptUserPassword).toBeCalledWith('password');
expect(updateUserById).toBeCalledWith('foo', {
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
});
expect(assignInteractionResults).not.toBeCalled();
expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Updated',
user: {
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
},
});
});
});
/* eslint-enable max-lines */

View file

@ -3,17 +3,17 @@ import { appInsights } from '@logto/app-insights/node';
import type { User, UserOnboardingData } from '@logto/schemas';
import {
AdminTenantRole,
SignInMode,
defaultTenantId,
adminTenantId,
InteractionEvent,
adminConsoleApplicationId,
MfaFactor,
OrganizationInvitationStatus,
SignInMode,
TenantRole,
adminConsoleApplicationId,
adminTenantId,
defaultManagementApiAdminName,
defaultTenantId,
getTenantOrganizationId,
getTenantRole,
TenantRole,
defaultManagementApiAdminName,
OrganizationInvitationStatus,
userOnboardingDataKey,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
@ -32,13 +32,13 @@ import type { WithInteractionDetailsContext } from '../middleware/koa-interactio
import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js';
import type {
VerifiedInteractionResult,
VerifiedSignInInteractionResult,
VerifiedRegisterInteractionResult,
VerifiedSignInInteractionResult,
} from '../types/index.js';
import { clearInteractionStorage } from '../utils/interaction.js';
import { userMfaDataKey } from '../verifications/mfa-verification.js';
import { postAffiliateLogs, parseUserProfile } from './helpers.js';
import { hasUpdatedProfile, parseUserProfile, postAffiliateLogs } from './helpers.js';
const parseBindMfas = ({
bindMfas,
@ -133,7 +133,7 @@ async function handleSubmitRegister(
(invitation) => invitation.status === OrganizationInvitationStatus.Pending
);
await insertUser(
const user = await insertUser(
{
id,
...userProfile,
@ -184,10 +184,13 @@ async function handleSubmitRegister(
}
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
ctx.assignInteractionHookResult({ userId: id });
ctx.assignDataHookContext({ event: 'User.Created', user });
log?.append({ userId: id });
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) });
void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => {
getConsoleLogFromContext(ctx).warn('Failed to post affiliate logs', error);
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
@ -211,7 +214,7 @@ async function handleSubmitSignIn(
const mfaVerifications = parseBindMfas(interaction);
const { mfaSkipped } = interaction;
await updateUserById(accountId, {
const updatedUser = await updateUserById(accountId, {
...updateUserProfile,
...conditional(
mfaVerifications.length > 0 && {
@ -229,8 +232,14 @@ async function handleSubmitSignIn(
}
),
});
await assignInteractionResults(ctx, provider, { login: { accountId } });
ctx.assignInteractionHookResult({ userId: accountId });
// Trigger user.updated data hook event if the user profile or mfa data is updated
if (hasUpdatedProfile(updateUserProfile) || mfaVerifications.length > 0) {
ctx.assignDataHookContext({ event: 'User.Updated', user: updatedUser });
}
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.SignIn) });
}
@ -261,8 +270,10 @@ export default async function submitInteraction(
profile.password
);
await updateUserById(accountId, { passwordEncrypted, passwordEncryptionMethod });
const user = await updateUserById(accountId, { passwordEncrypted, passwordEncryptionMethod });
ctx.assignInteractionHookResult({ userId: accountId });
ctx.assignDataHookContext({ event: 'User.Updated', user });
await clearInteractionStorage(ctx, provider);
ctx.status = 204;
}

View file

@ -1,10 +1,12 @@
import { conditionalString, trySafe } from '@silverhand/essentials';
import { userInfoSelectFields, type DataHookEvent, type User } from '@logto/schemas';
import { conditional, conditionalString, noop, pick, trySafe } from '@silverhand/essentials';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import {
DataHookContextManager,
InteractionHookContextManager,
type InteractionHookResult,
} from '#src/libraries/hook/context-manager.js';
import type Libraries from '#src/tenants/Libraries.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
@ -13,11 +15,18 @@ import { getInteractionStorage } from '../utils/interaction.js';
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
type AssignInteractionHookResult = (result: InteractionHookResult) => void;
type AssignDataHookContext = (payload: {
event: DataHookEvent;
user?: User;
data?: Record<string, unknown>;
}) => void;
export type WithInteractionHooksContext<
ContextT extends IRouterParamContext = IRouterParamContext,
> = ContextT & { assignInteractionHookResult: AssignInteractionHookResult };
> = ContextT & {
assignInteractionHookResult: InteractionHookContextManager['assignInteractionHookResult'];
assignDataHookContext: AssignDataHookContext;
};
/**
* The factory to create a new interaction hook middleware function.
@ -29,9 +38,10 @@ export default function koaInteractionHooks<
ContextT extends WithInteractionDetailsContext,
ResponseT,
>({
hooks: { triggerInteractionHooks },
hooks: { triggerInteractionHooks, triggerDataHooks },
}: Libraries): MiddlewareType<StateT, WithInteractionHooksContext<ContextT>, ResponseT> {
return async (ctx, next) => {
const { isDevFeaturesEnabled } = EnvSet.values;
const { event: interactionEvent } = getInteractionStorage(ctx.interactionDetails.result);
const {
@ -40,18 +50,40 @@ export default function koaInteractionHooks<
ip,
} = ctx;
const interactionHookContext = new InteractionHookContextManager({
const interactionApiMetadata = {
interactionEvent,
userAgent,
userIp: ip,
applicationId: conditionalString(interactionDetails.params.client_id),
sessionId: interactionDetails.jti,
};
const interactionHookContext = new InteractionHookContextManager({
...interactionApiMetadata,
userIp: ip,
});
ctx.assignInteractionHookResult =
interactionHookContext.assignInteractionHookResult.bind(interactionHookContext);
// TODO: @simeng-li Add DataHookContext to the interaction hook middleware as well
const dataHookContext = new DataHookContextManager({
...interactionApiMetadata,
ip,
});
// Assign user and event data to the data hook context
const assignDataHookContext: AssignDataHookContext = ({ event, user, data: extraData }) => {
dataHookContext.appendContext({
event,
data: {
// Only return the selected user fields
...conditional(user && pick(user, ...userInfoSelectFields)),
...extraData,
},
});
};
// TODO: remove dev features check
ctx.assignDataHookContext = isDevFeaturesEnabled ? assignDataHookContext : noop;
await next();
@ -59,5 +91,11 @@ export default function koaInteractionHooks<
// Hooks should not crash the app
void trySafe(triggerInteractionHooks(getConsoleLogFromContext(ctx), interactionHookContext));
}
// TODO: remove dev features check
if (isDevFeaturesEnabled && dataHookContext.contextArray.length > 0) {
// Hooks should not crash the app
void trySafe(triggerDataHooks(getConsoleLogFromContext(ctx), dataHookContext));
}
};
}

View file

@ -135,6 +135,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
async (ctx, next) => {
const {
assignInteractionHookResult,
assignDataHookContext,
guard: { params },
} = ctx;
const {
@ -153,10 +154,14 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
params.connectorId
);
const accountId = await registerWithSsoAuthentication(ctx, tenant, authenticationResult);
const user = await registerWithSsoAuthentication(ctx, tenant, authenticationResult);
const { id: accountId } = user;
await assignInteractionResults(ctx, provider, { login: { accountId } });
// Trigger webhooks
assignInteractionHookResult({ userId: accountId });
assignDataHookContext({ event: 'User.Created', user });
return next();
}

View file

@ -35,7 +35,7 @@ class MockOidcSsoConnector extends OidcSsoConnector {
override getUserInfo = getUserInfoMock;
}
const { storeInteractionResult: storeInteractionResultMock } = mockEsm('./interaction.js', () => ({
mockEsm('./interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
@ -290,13 +290,13 @@ describe('Single sign on util methods tests', () => {
it('should register if no related user account found', async () => {
insertUserMock.mockResolvedValueOnce({ id: 'foo' });
const accountId = await registerWithSsoAuthentication(mockContext, tenant, {
const { id } = await registerWithSsoAuthentication(mockContext, tenant, {
connectorId: wellConfiguredSsoConnector.id,
issuer: mockIssuer,
userInfo: mockSsoUserInfo,
});
expect(accountId).toBe('foo');
expect(id).toBe('foo');
// Should create new user
expect(insertUserMock).toBeCalledWith(

View file

@ -2,9 +2,9 @@ import { ConnectorError, type SocialUserInfo } from '@logto/connector-kit';
import { validateRedirectUrl } from '@logto/core-kit';
import {
InteractionEvent,
type SupportedSsoConnector,
type User,
type UserSsoIdentity,
type SupportedSsoConnector,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
@ -19,8 +19,8 @@ import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import {
getSingleSignOnSessionResult,
assignSingleSignOnAuthenticationResult,
getSingleSignOnSessionResult,
} from './single-sign-on-session.js';
import { assignConnectorSessionResult } from './social-verification.js';
@ -308,7 +308,7 @@ export const registerWithSsoAuthentication = async (
};
// Insert new user
const { id: userId } = await usersLibrary.insertUser(
const user = await usersLibrary.insertUser(
{
id: await usersLibrary.generateUserId(),
...syncingProfile,
@ -317,6 +317,8 @@ export const registerWithSsoAuthentication = async (
[]
);
const { id: userId } = user;
// Insert new user SSO identity
await userSsoIdentitiesQueries.insert({
id: generateStandardId(),
@ -340,5 +342,5 @@ export const registerWithSsoAuthentication = async (
},
});
return userId;
return user;
};

View file

@ -1,14 +1,14 @@
import type { ConnectorType } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import {
mockEmailConnectorConfig,
mockEmailConnectorId,
mockSmsConnectorConfig,
mockSmsConnectorId,
mockSocialConnectorId,
mockSocialConnectorConfig,
mockSocialConnectorId,
} from '#src/__mocks__/connectors-mock.js';
import { listConnectors, deleteConnectorById, postConnector } from '#src/api/index.js';
import { deleteConnectorById, listConnectors, postConnector } from '#src/api/index.js';
import { deleteSsoConnectorById, getSsoConnectors } from '#src/api/sso-connector.js';
export const clearConnectorsByTypes = async (types: ConnectorType[]) => {
@ -41,3 +41,8 @@ export const setSocialConnector = async () =>
connectorId: mockSocialConnectorId,
config: mockSocialConnectorConfig,
});
export const resetPasswordlessConnectors = async () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
await Promise.all([setEmailConnector(), setSmsConnector()]);
};

View file

@ -1,4 +1,6 @@
import { type Hook, type HookConfig, type HookEvent } from '@logto/schemas';
import { type CreateHook, type Hook, type HookConfig, type HookEvent } from '@logto/schemas';
import { authedAdminApi } from '#src/api/api.js';
type HookCreationPayload = Pick<Hook, 'name' | 'events'> & {
config: HookConfig;
@ -15,3 +17,31 @@ export const getHookCreationPayload = (
headers: { foo: 'bar' },
},
});
export class WebHookApiTest {
readonly #hooks = new Map<string, Hook>();
get hooks(): Map<string, Hook> {
return this.#hooks;
}
async create(json: Omit<CreateHook, 'id'>): Promise<Hook> {
const hook = await authedAdminApi.post('hooks', { json }).json<Hook>();
this.#hooks.set(hook.name, hook);
return hook;
}
async delete(name: string): Promise<void> {
const hook = this.#hooks.get(name);
if (hook) {
await authedAdminApi.delete(`hooks/${hook.id}`);
this.#hooks.delete(name);
}
}
async cleanUp(): Promise<void> {
await Promise.all(Array.from(this.#hooks.keys()).map(async (name) => this.delete(name)));
}
}

View file

@ -1,20 +1,20 @@
import type {
UsernamePasswordPayload,
EmailPasswordPayload,
PhonePasswordPayload,
UsernamePasswordPayload,
} from '@logto/schemas';
import { InteractionEvent } from '@logto/schemas';
import {
putInteraction,
createSocialAuthorizationUri,
patchInteractionIdentifiers,
putInteraction,
putInteractionProfile,
sendVerificationCode,
} from '#src/api/index.js';
import { generateUserId } from '#src/utils.js';
import { initClient, processSession, logoutClient } from './client.js';
import { initClient, logoutClient, processSession } from './client.js';
import { expectRejects, readConnectorMessage } from './index.js';
import { enableAllPasswordSignInMethods } from './sign-in-experience.js';
import { generateNewUser } from './user.js';
@ -90,6 +90,43 @@ export const createNewSocialUserWithUsernameAndPassword = async (connectorId: st
return processSession(client, redirectTo);
};
export const signInWithUsernamePasswordAndUpdateEmailOrPhone = async (
username: string,
password: string,
profile: { email: string } | { phone: string }
) => {
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username,
password,
},
});
await expectRejects(client.submitInteraction(), {
code: 'user.missing_profile',
status: 422,
});
await client.successSend(sendVerificationCode, profile);
const { code } = await readConnectorMessage('email' in profile ? 'Email' : 'Sms');
await client.successSend(patchInteractionIdentifiers, {
...profile,
verificationCode: code,
});
await client.successSend(putInteractionProfile, profile);
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
};
export const resetPassword = async (
profile: { email: string } | { phone: string },
newPassword: string

View file

@ -1,5 +1,9 @@
import { createHmac } from 'node:crypto';
import { createServer, type RequestListener, type Server } from 'node:http';
import { hookEventGuard } from '@logto/schemas';
import { z } from 'zod';
/**
* A mock server that listens for incoming requests and responds with the request body.
*
@ -28,11 +32,14 @@ class WebhookMockServer {
request.on('end', () => {
response.writeHead(200, { 'Content-Type': 'application/json' });
const payload: unknown = JSON.parse(Buffer.concat(data).toString());
// Keep the raw payload for signature verification
const rawPayload = Buffer.concat(data).toString();
const payload: unknown = JSON.parse(rawPayload);
const body = JSON.stringify({
signature: request.headers['logto-signature-sha-256'],
payload,
rawPayload,
});
requestCallback?.(body);
@ -61,4 +68,24 @@ class WebhookMockServer {
}
}
export const mockHookResponseGuard = z.object({
body: z.object({
signature: z.string(),
payload: z
.object({
event: hookEventGuard,
createdAt: z.string(),
hookId: z.string(),
})
.catchall(z.any()),
// Use the raw payload for signature verification
rawPayload: z.string(),
}),
});
export default WebhookMockServer;
export const verifySignature = (payload: string, secret: string, signature: string) => {
const calculatedSignature = createHmac('sha256', secret).update(payload).digest('hex');
return calculatedSignature === signature;
};

View file

@ -4,14 +4,15 @@ import {
hookEvents,
jsonGuard,
managementApiHooksRegistration,
type Hook,
type Role,
} from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { z } from 'zod';
import { authedAdminApi } from '#src/api/api.js';
import { createResource } from '#src/api/resource.js';
import { createScope } from '#src/api/scope.js';
import { WebHookApiTest } from '#src/helpers/hook.js';
import {
OrganizationApiTest,
OrganizationRoleApiTest,
@ -20,7 +21,7 @@ import {
import { UserApiTest, generateNewUser } from '#src/helpers/user.js';
import { generateName, waitFor } from '#src/utils.js';
import WebhookMockServer from './WebhookMockServer.js';
import WebhookMockServer, { verifySignature } from './WebhookMockServer.js';
import {
organizationDataHookTestCases,
organizationRoleDataHookTestCases,
@ -32,24 +33,28 @@ import {
const mockHookResponseGuard = z.object({
signature: z.string(),
payload: z.object({
event: hookEventGuard,
createdAt: z.string(),
hookId: z.string(),
data: jsonGuard.optional(),
method: z
.string()
.optional()
.transform((value) => value?.toUpperCase()),
matchedRoute: z.string().optional(),
}),
payload: z
.object({
event: hookEventGuard,
createdAt: z.string(),
hookId: z.string(),
data: jsonGuard.optional(),
method: z
.string()
.optional()
.transform((value) => value?.toUpperCase()),
matchedRoute: z.string().optional(),
})
.catchall(z.any()),
// Keep the raw payload for signature verification
rawPayload: z.string(),
});
type MockHookResponse = z.infer<typeof mockHookResponseGuard>;
const hookName = 'management-api-hook';
const webhooks = new Map<string, Hook>();
const webhookResults = new Map<string, MockHookResponse>();
const webHookApi = new WebHookApiTest();
// Record the hook response to the webhookResults map.
// Compare the webhookResults map with the managementApiHooksRegistration to verify all hook is triggered.
@ -80,27 +85,17 @@ const webhookServer = new WebhookMockServer(9999, webhookResponseHandler);
beforeAll(async () => {
await webhookServer.listen();
const webhookInstance = await authedAdminApi
.post('hooks', {
json: {
name: hookName,
events: [...hookEvents],
config: {
url: webhookServer.endpoint,
headers: { foo: 'bar' },
},
},
})
.json<Hook>();
webhooks.set(hookName, webhookInstance);
await webHookApi.create({
name: hookName,
events: [...hookEvents],
config: {
url: webhookServer.endpoint,
},
});
});
afterAll(async () => {
await Promise.all(
Array.from(webhooks.values()).map(async (hook) => authedAdminApi.delete(`hooks/${hook.id}`))
);
await webHookApi.cleanUp();
await webhookServer.close();
});
@ -332,11 +327,17 @@ describe('organization role data hook events', () => {
);
});
describe('data hook events coverage', () => {
describe('data hook events coverage and signature verification', () => {
const keys = Object.keys(managementApiHooksRegistration);
it.each(keys)('should have test case for %s', async (key) => {
const webhook = webHookApi.hooks.get(hookName)!;
const webhookResult = await getWebhookResult(key);
expect(webhookResult).toBeDefined();
expect(webhookResult?.signature).toBeDefined();
assert(webhookResult, new Error('webhookResult is undefined'));
const { signature, rawPayload } = webhookResult;
expect(verifySignature(rawPayload, webhook.signingKey, signature)).toBeTruthy();
});
});

View file

@ -1,270 +1,295 @@
import { createHmac } from 'node:crypto';
import { type RequestListener } from 'node:http';
import {
ConnectorType,
InteractionEvent,
InteractionHookEvent,
LogResult,
SignInIdentifier,
hookEvents,
type Hook,
type Log,
type LogContextPayload,
type LogKey,
type HookEvent,
} from '@logto/schemas';
import { type Optional } from '@silverhand/essentials';
import { assert } from '@silverhand/essentials';
import { deleteUser } from '#src/api/admin-user.js';
import { authedAdminApi } from '#src/api/api.js';
import { getWebhookRecentLogs } from '#src/api/logs.js';
import { resetPasswordlessConnectors } from '#src/helpers/connector.js';
import { WebHookApiTest } from '#src/helpers/hook.js';
import {
clearConnectorsByTypes,
setEmailConnector,
setSmsConnector,
} from '#src/helpers/connector.js';
import { getHookCreationPayload } from '#src/helpers/hook.js';
import { createMockServer } from '#src/helpers/index.js';
import { registerNewUser, resetPassword, signInWithPassword } from '#src/helpers/interactions.js';
registerNewUser,
resetPassword,
signInWithPassword,
signInWithUsernamePasswordAndUpdateEmailOrPhone,
} from '#src/helpers/interactions.js';
import {
enableAllPasswordSignInMethods,
enableAllVerificationCodeSignInMethods,
} from '#src/helpers/sign-in-experience.js';
import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js';
import { generatePassword, waitFor } from '#src/utils.js';
import { UserApiTest, generateNewUserProfile } from '#src/helpers/user.js';
import { generateEmail, generatePassword } from '#src/utils.js';
type HookSecureData = {
signature: string;
payload: string;
import WebhookMockServer, { mockHookResponseGuard, verifySignature } from './WebhookMockServer.js';
const webbHookMockServer = new WebhookMockServer(9999);
const userNamePrefix = 'hookTriggerTestUser';
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 assertHookLogResult = async (
{ id: hookId, signingKey }: Hook,
event: HookEvent,
assertions: {
errorMessage?: string;
toBeUndefined?: boolean;
hookPayload?: Record<string, unknown>;
}
) => {
const logs = await getWebhookRecentLogs(
hookId,
new URLSearchParams({ logKey: `TriggerHook.${event}`, page_size: '10' })
);
const logEntry = logs[0];
if (assertions.toBeUndefined) {
expect(logEntry).toBeUndefined();
return;
}
expect(logEntry).toBeTruthy();
assert(logEntry, new Error('Log entry not found'));
const { payload } = logEntry;
expect(payload.hookId).toEqual(hookId);
expect(payload.key).toEqual(`TriggerHook.${event}`);
const { result, error } = payload;
if (result === LogResult.Success) {
expect(payload.response).toBeTruthy();
const { body } = mockHookResponseGuard.parse(payload.response);
expect(verifySignature(body.rawPayload, signingKey, body.signature)).toBeTruthy();
if (assertions.hookPayload) {
expect(body.payload).toEqual(expect.objectContaining(assertions.hookPayload));
}
}
if (assertions.errorMessage) {
expect(result).toEqual(LogResult.Error);
expect(error).toContain(assertions.errorMessage);
}
};
// Note: return hook payload and signature for webhook security testing
const hookServerRequestListener: RequestListener = (request, response) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
response.statusCode = 204;
const data: Uint8Array[] = [];
request.on('data', (chunk: Uint8Array) => {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
data.push(chunk);
});
request.on('end', () => {
response.writeHead(200, { 'Content-Type': 'application/json' });
const payload = Buffer.concat(data).toString();
response.end(
JSON.stringify({
signature: request.headers['logto-signature-sha-256'] as string,
payload,
} satisfies HookSecureData)
);
});
};
const assertHookLogError = ({ result, error }: LogContextPayload, errorMessage: string) =>
result === LogResult.Error && typeof error === 'string' && error.includes(errorMessage);
describe('trigger hooks', () => {
const { listen, close } = createMockServer(9999, hookServerRequestListener);
beforeAll(async () => {
await enableAllPasswordSignInMethods({
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()]);
});
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({ username, password });
const hook = webHookApi.hooks.get('invalidHookEventListener')!;
await assertHookLogResult(hook, InteractionHookEvent.PostSignIn, {
errorMessage: 'Failed to parse URL from not_work_url',
});
await listen();
});
afterAll(async () => {
await close();
await webHookApi.cleanUp();
});
});
it('should trigger sign-in hook and record error when interaction finished', async () => {
const createdHook = await authedAdminApi
.post('hooks', { json: getHookCreationPayload(InteractionHookEvent.PostSignIn) })
.json<Hook>();
const logKey: LogKey = 'TriggerHook.PostSignIn';
const {
userProfile: { username, password },
user,
} = await generateNewUser({ username: true, password: true });
await signInWithPassword({ username, password });
// Check hook trigger log
const logs = await getWebhookRecentLogs(
createdHook.id,
new URLSearchParams({ logKey, page_size: '100' })
);
const hookLog = logs.find(({ payload: { hookId } }) => hookId === createdHook.id);
expect(hookLog).toBeTruthy();
if (hookLog) {
expect(
assertHookLogError(hookLog.payload, 'Failed to parse URL from not_work_url')
).toBeTruthy();
}
// Clean up
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(user.id);
});
it('should trigger multiple register hooks and record properly when interaction finished', async () => {
const [hook1, hook2, hook3] = await Promise.all([
authedAdminApi
.post('hooks', { json: getHookCreationPayload(InteractionHookEvent.PostRegister) })
.json<Hook>(),
authedAdminApi
.post('hooks', {
json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>(),
// Using the old API to create a hook
authedAdminApi
.post('hooks', {
json: {
event: InteractionHookEvent.PostRegister,
config: { url: 'http://localhost:9999', retries: 2 },
},
})
.json<Hook>(),
describe('interaction api trigger hooks', () => {
// 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 },
}),
]);
const logKey: LogKey = 'TriggerHook.PostRegister';
});
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 registerNewUser(username, password);
type HookRequest = {
body: {
userIp?: string;
} & Record<string, unknown>;
const interactionHookEventPayload: Record<string, unknown> = {
event: InteractionHookEvent.PostRegister,
interactionEvent: InteractionEvent.Register,
sessionId: expect.any(String),
user: expect.objectContaining({ id: userId, username }),
};
// Check hook trigger log
for (const [hook, expectedResult, expectedError] of [
[hook1, LogResult.Error, 'Failed to parse URL from not_work_url'],
[hook2, LogResult.Success, undefined],
[hook3, LogResult.Success, undefined],
] satisfies Array<[Hook, LogResult, Optional<string>]>) {
// eslint-disable-next-line no-await-in-loop
const logs = await getWebhookRecentLogs(
hook.id,
new URLSearchParams({ logKey, page_size: '100' })
);
await assertHookLogResult(interactionHook, InteractionHookEvent.PostRegister, {
hookPayload: interactionHookEventPayload,
});
const log = logs.find(({ payload: { hookId } }) => hookId === hook.id);
// Verify multiple hooks can be triggered with the same event
await assertHookLogResult(registerHook, InteractionHookEvent.PostRegister, {
hookPayload: interactionHookEventPayload,
});
expect(log).toBeTruthy();
// 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 }),
},
});
// Skip the test if the log is not found
if (!log) {
return;
}
// Assert user ip is in the hook request
expect((log.payload.hookRequest as HookRequest).body.userIp).toBeTruthy();
// Assert the log result and error message
expect(log.payload.result).toEqual(expectedResult);
if (expectedError) {
expect(assertHookLogError(log.payload, expectedError)).toBeTruthy();
}
}
// Assert user updated event is not triggered
await assertHookLogResult(dataHook, 'User.Updated', {
toBeUndefined: true,
});
// Clean up
await Promise.all([
authedAdminApi.delete(`hooks/${hook1.id}`),
authedAdminApi.delete(`hooks/${hook2.id}`),
authedAdminApi.delete(`hooks/${hook3.id}`),
]);
await deleteUser(userId);
await authedAdminApi.delete(`users/${userId}`);
});
it('should secure webhook payload data successfully', async () => {
const createdHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>();
it('user sign in interaction API without profile update', async () => {
await signInWithPassword({ username, password });
const { username, password } = generateNewUserProfile({ username: true, password: true });
const userId = await registerNewUser(username, password);
const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!;
const dataHook = webHookApi.hooks.get('dataHookEventListener')!;
const user = userApi.users.find(({ username: name }) => name === username)!;
const logs = await authedAdminApi
.get(`hooks/${createdHook.id}/recent-logs?page_size=100`)
.json<Log[]>();
const interactionHookEventPayload: Record<string, unknown> = {
event: InteractionHookEvent.PostSignIn,
interactionEvent: InteractionEvent.SignIn,
sessionId: expect.any(String),
user: expect.objectContaining({ id: user.id, username }),
};
const log = logs.find(({ payload: { hookId } }) => hookId === createdHook.id);
expect(log).toBeTruthy();
await assertHookLogResult(interactionHook, InteractionHookEvent.PostSignIn, {
hookPayload: interactionHookEventPayload,
});
const response = log?.payload.response;
expect(response).toBeTruthy();
// Verify user create data hook is not triggered
await assertHookLogResult(dataHook, 'User.Created', {
toBeUndefined: true,
});
const {
body: { signature, payload },
} = response as { body: HookSecureData };
expect(signature).toBeTruthy();
expect(payload).toBeTruthy();
const calculateSignature = createHmac('sha256', createdHook.signingKey)
.update(payload)
.digest('hex');
expect(calculateSignature).toEqual(signature);
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(userId);
await assertHookLogResult(dataHook, 'User.Updated', {
toBeUndefined: true,
});
});
it('should trigger reset password hook and record properly when interaction finished', async () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
await setEmailConnector();
await setSmsConnector();
it('user sign in interaction API with profile update', async () => {
await enableAllVerificationCodeSignInMethods({
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
identifiers: [SignInIdentifier.Email],
password: true,
verify: true,
});
// Create a reset password hook
const resetPasswordHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(
InteractionHookEvent.PostResetPassword,
'http://localhost:9999'
),
})
.json<Hook>();
const logKey: LogKey = 'TriggerHook.PostResetPassword';
const { user, userProfile } = await generateNewUser({
primaryPhone: true,
primaryEmail: true,
password: true,
const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!;
const dataHook = webHookApi.hooks.get('dataHookEventListener')!;
const user = userApi.users.find(({ username: name }) => name === username)!;
await signInWithUsernamePasswordAndUpdateEmailOrPhone(username, password, {
email,
});
// Reset Password by Email
await resetPassword({ email: userProfile.primaryEmail }, generatePassword());
// Reset Password by Phone
await resetPassword({ phone: userProfile.primaryPhone }, generatePassword());
// Wait for the hook to be trigged
await waitFor(1000);
const relatedLogs = await getWebhookRecentLogs(
resetPasswordHook.id,
new URLSearchParams({ logKey, page_size: '100' })
);
const succeedLogs = relatedLogs.filter(
({ payload: { result } }) => result === LogResult.Success
);
const interactionHookEventPayload: Record<string, unknown> = {
event: InteractionHookEvent.PostSignIn,
interactionEvent: InteractionEvent.SignIn,
sessionId: expect.any(String),
user: expect.objectContaining({ id: user.id, username }),
};
expect(succeedLogs).toHaveLength(2);
await assertHookLogResult(interactionHook, InteractionHookEvent.PostSignIn, {
hookPayload: interactionHookEventPayload,
});
await authedAdminApi.delete(`hooks/${resetPasswordHook.id}`);
await deleteUser(user.id);
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
// Verify user create data hook is not triggered
await assertHookLogResult(dataHook, 'User.Created', {
toBeUndefined: true,
});
await assertHookLogResult(dataHook, 'User.Updated', {
hookPayload: {
event: 'User.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)!;
await resetPassword({ email }, newPassword);
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.Updated', {
hookPayload: {
event: 'User.Updated',
interactionEvent: InteractionEvent.ForgotPassword,
sessionId: expect.any(String),
data: expect.objectContaining({ id: user.id, username, primaryEmail: email }),
},
});
});
});

View file

@ -1,24 +1,16 @@
import {
InteractionEvent,
ConnectorType,
SignInIdentifier,
UsersPasswordEncryptionMethod,
} from '@logto/schemas';
import { ConnectorType, SignInIdentifier, UsersPasswordEncryptionMethod } from '@logto/schemas';
import {
putInteraction,
sendVerificationCode,
patchInteractionIdentifiers,
putInteractionProfile,
deleteUser,
} from '#src/api/index.js';
import { initClient, processSession, logoutClient } from '#src/helpers/client.js';
import { deleteUser } from '#src/api/index.js';
import {
clearConnectorsByTypes,
setSmsConnector,
setEmailConnector,
setSmsConnector,
} from '#src/helpers/connector.js';
import { readConnectorMessage, expectRejects, createUserByAdmin } from '#src/helpers/index.js';
import { createUserByAdmin } from '#src/helpers/index.js';
import {
signInWithPassword,
signInWithUsernamePasswordAndUpdateEmailOrPhone,
} from '#src/helpers/interactions.js';
import {
enableAllPasswordSignInMethods,
enableAllVerificationCodeSignInMethods,
@ -40,20 +32,8 @@ describe('Sign-in flow using password identifiers', () => {
it('sign-in with username and password', async () => {
const { userProfile, user } = await generateNewUser({ username: true, password: true });
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username: userProfile.username,
password: userProfile.password,
},
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
await signInWithPassword({ username: userProfile.username, password: userProfile.password });
await deleteUser(user.id);
});
@ -61,81 +41,31 @@ describe('Sign-in flow using password identifiers', () => {
it('sign-in with username and password twice to test algorithm transition', async () => {
const username = generateUsername();
const password = 'password';
const user = await createUserByAdmin({
username,
passwordDigest: '5f4dcc3b5aa765d61d8327deb882cf99',
passwordAlgorithm: UsersPasswordEncryptionMethod.MD5,
});
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username,
password,
},
});
await signInWithPassword({ username, password });
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
const client2 = await initClient();
await client2.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username,
password,
},
});
const { redirectTo: redirectTo2 } = await client2.submitInteraction();
await processSession(client2, redirectTo2);
await logoutClient(client2);
await signInWithPassword({ username, password });
await deleteUser(user.id);
});
it('sign-in with email and password', async () => {
const { userProfile, user } = await generateNewUser({ primaryEmail: true, password: true });
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
email: userProfile.primaryEmail,
password: userProfile.password,
},
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
await signInWithPassword({ email: userProfile.primaryEmail, password: userProfile.password });
await deleteUser(user.id);
});
it('sign-in with phone and password', async () => {
const { userProfile, user } = await generateNewUser({ primaryPhone: true, password: true });
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
phone: userProfile.primaryPhone,
password: userProfile.password,
},
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
await signInWithPassword({ phone: userProfile.primaryPhone, password: userProfile.password });
await deleteUser(user.id);
});
@ -149,54 +79,16 @@ describe('Sign-in flow using password identifiers', () => {
const { userProfile, user } = await generateNewUser({ username: true, password: true });
const { primaryEmail } = generateNewUserProfile({ primaryEmail: true });
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username: userProfile.username,
password: userProfile.password,
},
});
await expectRejects(client.submitInteraction(), {
code: 'user.missing_profile',
status: 422,
});
await client.successSend(sendVerificationCode, {
email: primaryEmail,
});
const { code } = await readConnectorMessage('Email');
await client.successSend(patchInteractionIdentifiers, {
email: primaryEmail,
verificationCode: code,
});
await client.successSend(putInteractionProfile, {
email: primaryEmail,
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
// SignIn with email and password
await client.initSession();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
await signInWithUsernamePasswordAndUpdateEmailOrPhone(
userProfile.username,
userProfile.password,
{
email: primaryEmail,
password: userProfile.password,
},
});
}
);
const { redirectTo: redirectTo2 } = await client.submitInteraction();
await processSession(client, redirectTo2);
await logoutClient(client);
await signInWithPassword({ email: primaryEmail, password: userProfile.password });
await deleteUser(user.id);
});
@ -211,54 +103,14 @@ describe('Sign-in flow using password identifiers', () => {
const { userProfile, user } = await generateNewUser({ username: true, password: true });
const { primaryPhone } = generateNewUserProfile({ primaryPhone: true });
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username: userProfile.username,
password: userProfile.password,
},
});
await expectRejects(client.submitInteraction(), {
code: 'user.missing_profile',
status: 422,
});
await client.successSend(sendVerificationCode, {
phone: primaryPhone,
});
const { code } = await readConnectorMessage('Sms');
await client.successSend(patchInteractionIdentifiers, {
phone: primaryPhone,
verificationCode: code,
});
await client.successSend(putInteractionProfile, {
phone: primaryPhone,
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
// SignIn with new phone and password
await client.initSession();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
await signInWithUsernamePasswordAndUpdateEmailOrPhone(
userProfile.username,
userProfile.password,
{
phone: primaryPhone,
password: userProfile.password,
},
});
const { redirectTo: redirectTo2 } = await client.submitInteraction();
await processSession(client, redirectTo2);
await logoutClient(client);
}
);
await deleteUser(user.id);
});