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:
parent
7b5a4e3fb4
commit
5462ab4765
16 changed files with 578 additions and 468 deletions
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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()]);
|
||||
};
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue