mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): trigger webhook event in profile api (#6641)
This commit is contained in:
parent
76acfe332c
commit
37b05f9d49
7 changed files with 145 additions and 40 deletions
|
@ -11,6 +11,7 @@ import koaTenantGuard from '#src/middleware/koa-tenant-guard.js';
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
|
||||||
import koaAuth from '../middleware/koa-auth/index.js';
|
import koaAuth from '../middleware/koa-auth/index.js';
|
||||||
|
import koaOidcAuth from '../middleware/koa-auth/koa-oidc-auth.js';
|
||||||
|
|
||||||
import adminUserRoutes from './admin-user/index.js';
|
import adminUserRoutes from './admin-user/index.js';
|
||||||
import applicationOrganizationRoutes from './applications/application-organization.js';
|
import applicationOrganizationRoutes from './applications/application-organization.js';
|
||||||
|
@ -99,6 +100,9 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
const anonymousRouter: AnonymousRouter = new Router();
|
||||||
|
|
||||||
const userRouter: UserRouter = new Router();
|
const userRouter: UserRouter = new Router();
|
||||||
|
userRouter.use(koaOidcAuth(tenant.provider));
|
||||||
|
// TODO(LOG-10147): Rename to koaApiHooks, this middleware is used for both management API and user API
|
||||||
|
userRouter.use(koaManagementApiHooks(tenant.libraries.hooks));
|
||||||
profileRoutes(userRouter, tenant);
|
profileRoutes(userRouter, tenant);
|
||||||
verificationRoutes(userRouter, tenant);
|
verificationRoutes(userRouter, tenant);
|
||||||
|
|
||||||
|
|
|
@ -7,12 +7,11 @@ import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import { EnvSet } from '../../env-set/index.js';
|
import { EnvSet } from '../../env-set/index.js';
|
||||||
import { encryptUserPassword } from '../../libraries/user.utils.js';
|
import { encryptUserPassword } from '../../libraries/user.utils.js';
|
||||||
import { buildUserVerificationRecordById } from '../../libraries/verification.js';
|
import { buildUserVerificationRecordById } from '../../libraries/verification.js';
|
||||||
import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js';
|
|
||||||
import assertThat from '../../utils/assert-that.js';
|
import assertThat from '../../utils/assert-that.js';
|
||||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
export default function profileRoutes<T extends UserRouter>(
|
export default function profileRoutes<T extends UserRouter>(
|
||||||
...[router, { provider, queries, libraries }]: RouterInitArgs<T>
|
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
users: { updateUserById },
|
users: { updateUserById },
|
||||||
|
@ -22,8 +21,6 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
users: { checkIdentifierCollision },
|
users: { checkIdentifierCollision },
|
||||||
} = libraries;
|
} = libraries;
|
||||||
|
|
||||||
router.use(koaOidcAuth(provider));
|
|
||||||
|
|
||||||
if (!EnvSet.values.isDevFeaturesEnabled) {
|
if (!EnvSet.values.isDevFeaturesEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -56,7 +53,7 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
|
|
||||||
const updatedUser = await updateUserById(userId, { name, avatar, username });
|
const updatedUser = await updateUserById(userId, { name, avatar, username });
|
||||||
|
|
||||||
// TODO(LOG-10005): trigger user updated webhook
|
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
||||||
|
|
||||||
// Only return the fields that were actually updated
|
// Only return the fields that were actually updated
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -80,7 +77,6 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
const { password, verificationRecordId } = ctx.guard.body;
|
const { password, verificationRecordId } = ctx.guard.body;
|
||||||
|
|
||||||
// TODO(LOG-9947): apply password policy
|
// TODO(LOG-9947): apply password policy
|
||||||
// TODO(LOG-10005): trigger user updated webhook
|
|
||||||
|
|
||||||
const verificationRecord = await buildUserVerificationRecordById(
|
const verificationRecord = await buildUserVerificationRecordById(
|
||||||
userId,
|
userId,
|
||||||
|
@ -91,7 +87,12 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
assertThat(verificationRecord.isVerified, 'verification_record.not_found');
|
assertThat(verificationRecord.isVerified, 'verification_record.not_found');
|
||||||
|
|
||||||
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
||||||
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
|
const updatedUser = await updateUserById(userId, {
|
||||||
|
passwordEncrypted,
|
||||||
|
passwordEncryptionMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
||||||
|
|
||||||
ctx.status = 204;
|
ctx.status = 204;
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext &
|
||||||
|
|
||||||
export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;
|
export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;
|
||||||
|
|
||||||
export type UserRouter = Router<unknown, ManagementApiRouterContext>;
|
export type UserRouter = Router<unknown, ManagementApiRouterContext & WithHookContext>;
|
||||||
|
|
||||||
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
|
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
|
||||||
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;
|
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;
|
||||||
|
|
|
@ -5,7 +5,6 @@ import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
|
||||||
import { EnvSet } from '../../env-set/index.js';
|
import { EnvSet } from '../../env-set/index.js';
|
||||||
import { saveVerificationRecord } from '../../libraries/verification.js';
|
import { saveVerificationRecord } from '../../libraries/verification.js';
|
||||||
import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js';
|
|
||||||
import { withSentinel } from '../experience/classes/libraries/sentinel-guard.js';
|
import { withSentinel } from '../experience/classes/libraries/sentinel-guard.js';
|
||||||
import { PasswordVerification } from '../experience/classes/verifications/password-verification.js';
|
import { PasswordVerification } from '../experience/classes/verifications/password-verification.js';
|
||||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
@ -13,8 +12,6 @@ import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
export default function verificationRoutes<T extends UserRouter>(
|
export default function verificationRoutes<T extends UserRouter>(
|
||||||
...[router, { provider, queries, libraries, sentinel }]: RouterInitArgs<T>
|
...[router, { provider, queries, libraries, sentinel }]: RouterInitArgs<T>
|
||||||
) {
|
) {
|
||||||
router.use(koaOidcAuth(provider));
|
|
||||||
|
|
||||||
if (!EnvSet.values.isDevFeaturesEnabled) {
|
if (!EnvSet.values.isDevFeaturesEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,8 +10,8 @@ const api = ky.extend({
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|
||||||
export const baseAdminTenantApi = ky.extend({
|
export const baseApi = ky.extend({
|
||||||
prefixUrl: new URL(logtoConsoleUrl),
|
prefixUrl: new URL(logtoUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: @gao rename
|
// TODO: @gao rename
|
||||||
|
|
|
@ -1,18 +1,75 @@
|
||||||
import { type LogtoConfig } from '@logto/node';
|
import { type LogtoConfig } from '@logto/node';
|
||||||
|
import { demoAppApplicationId, InteractionEvent, type User } from '@logto/schemas';
|
||||||
|
|
||||||
import { baseAdminTenantApi } from '../api/api.js';
|
import { type InteractionPayload } from '#src/api/interaction.js';
|
||||||
|
import { demoAppRedirectUri, logtoUrl } from '#src/constants.js';
|
||||||
|
import { generatePassword, generateUsername } from '#src/utils.js';
|
||||||
|
|
||||||
import { initClientAndSignIn } from './admin-tenant.js';
|
import api, { baseApi, authedAdminApi } from '../api/api.js';
|
||||||
|
|
||||||
|
import { initClient } from './client.js';
|
||||||
|
|
||||||
|
export const createDefaultTenantUserWithPassword = async () => {
|
||||||
|
const username = generateUsername();
|
||||||
|
const password = generatePassword();
|
||||||
|
const user = await authedAdminApi
|
||||||
|
.post('users', {
|
||||||
|
json: { username, password },
|
||||||
|
})
|
||||||
|
.json<User>();
|
||||||
|
|
||||||
|
return { user, username, password };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDefaultTenantUser = async (id: string) => {
|
||||||
|
await authedAdminApi.delete(`users/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const putInteraction = async (cookie: string, payload: InteractionPayload) =>
|
||||||
|
api
|
||||||
|
.put('interaction', {
|
||||||
|
headers: { cookie },
|
||||||
|
json: payload,
|
||||||
|
redirect: 'manual',
|
||||||
|
throwHttpErrors: false,
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
|
||||||
|
export const initClientAndSignInForDefaultTenant = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
config?: Partial<LogtoConfig>
|
||||||
|
) => {
|
||||||
|
const client = await initClient(
|
||||||
|
{
|
||||||
|
endpoint: logtoUrl,
|
||||||
|
appId: demoAppApplicationId,
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
demoAppRedirectUri
|
||||||
|
);
|
||||||
|
await client.successSend(putInteraction, {
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
identifier: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
await client.processSession(redirectTo);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
};
|
||||||
|
|
||||||
export const signInAndGetUserApi = async (
|
export const signInAndGetUserApi = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
config?: Partial<LogtoConfig>
|
config?: Partial<LogtoConfig>
|
||||||
) => {
|
) => {
|
||||||
const client = await initClientAndSignIn(username, password, config);
|
const client = await initClientAndSignInForDefaultTenant(username, password, config);
|
||||||
const accessToken = await client.getAccessToken();
|
const accessToken = await client.getAccessToken();
|
||||||
|
|
||||||
return baseAdminTenantApi.extend({
|
return baseApi.extend({
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,25 +1,52 @@
|
||||||
|
import { hookEvents } from '@logto/schemas';
|
||||||
|
|
||||||
import { getUserInfo, updatePassword, updateUser } from '#src/api/profile.js';
|
import { getUserInfo, updatePassword, updateUser } from '#src/api/profile.js';
|
||||||
import { createVerificationRecordByPassword } from '#src/api/verification-record.js';
|
import { createVerificationRecordByPassword } from '#src/api/verification-record.js';
|
||||||
import {
|
import { WebHookApiTest } from '#src/helpers/hook.js';
|
||||||
createUserWithPassword,
|
|
||||||
deleteUser,
|
|
||||||
initClientAndSignIn,
|
|
||||||
} from '#src/helpers/admin-tenant.js';
|
|
||||||
import { expectRejects } from '#src/helpers/index.js';
|
import { expectRejects } from '#src/helpers/index.js';
|
||||||
import { signInAndGetUserApi } from '#src/helpers/profile.js';
|
import {
|
||||||
|
createDefaultTenantUserWithPassword,
|
||||||
|
deleteDefaultTenantUser,
|
||||||
|
initClientAndSignInForDefaultTenant,
|
||||||
|
signInAndGetUserApi,
|
||||||
|
} from '#src/helpers/profile.js';
|
||||||
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||||
import { devFeatureTest, generatePassword, generateUsername } from '#src/utils.js';
|
import { devFeatureTest, generatePassword, generateUsername } from '#src/utils.js';
|
||||||
|
|
||||||
|
import WebhookMockServer from '../hook/WebhookMockServer.js';
|
||||||
|
import { assertHookLogResult } from '../hook/utils.js';
|
||||||
|
|
||||||
const { describe, it } = devFeatureTest;
|
const { describe, it } = devFeatureTest;
|
||||||
|
|
||||||
describe('profile', () => {
|
describe('profile', () => {
|
||||||
|
const webHookMockServer = new WebhookMockServer(9999);
|
||||||
|
const webHookApi = new WebHookApiTest();
|
||||||
|
const hookName = 'profileApiHookEventListener';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
await webHookMockServer.listen();
|
||||||
await enableAllPasswordSignInMethods();
|
await enableAllPasswordSignInMethods();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await webHookMockServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await webHookApi.create({
|
||||||
|
name: hookName,
|
||||||
|
events: [...hookEvents],
|
||||||
|
config: { url: webHookMockServer.endpoint },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await webHookApi.cleanUp();
|
||||||
|
});
|
||||||
|
|
||||||
describe('PATCH /profile', () => {
|
describe('PATCH /profile', () => {
|
||||||
it('should be able to update name', async () => {
|
it('should be able to update name', async () => {
|
||||||
const { user, username, password } = await createUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
const newName = generateUsername();
|
const newName = generateUsername();
|
||||||
|
|
||||||
|
@ -29,11 +56,22 @@ describe('profile', () => {
|
||||||
const userInfo = await getUserInfo(api);
|
const userInfo = await getUserInfo(api);
|
||||||
expect(userInfo).toHaveProperty('name', newName);
|
expect(userInfo).toHaveProperty('name', newName);
|
||||||
|
|
||||||
await deleteUser(user.id);
|
// Check if the hook is triggered
|
||||||
|
const hook = webHookApi.hooks.get(hookName)!;
|
||||||
|
await assertHookLogResult(hook, 'User.Data.Updated', {
|
||||||
|
hookPayload: {
|
||||||
|
event: 'User.Data.Updated',
|
||||||
|
data: expect.objectContaining({
|
||||||
|
name: newName,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to update picture', async () => {
|
it('should be able to update picture', async () => {
|
||||||
const { user, username, password } = await createUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
const newAvatar = 'https://example.com/avatar.png';
|
const newAvatar = 'https://example.com/avatar.png';
|
||||||
|
|
||||||
|
@ -44,11 +82,11 @@ describe('profile', () => {
|
||||||
// In OIDC, the avatar is mapped to the `picture` field
|
// In OIDC, the avatar is mapped to the `picture` field
|
||||||
expect(userInfo).toHaveProperty('picture', newAvatar);
|
expect(userInfo).toHaveProperty('picture', newAvatar);
|
||||||
|
|
||||||
await deleteUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to update username', async () => {
|
it('should be able to update username', async () => {
|
||||||
const { user, username, password } = await createUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
const newUsername = generateUsername();
|
const newUsername = generateUsername();
|
||||||
|
|
||||||
|
@ -56,14 +94,14 @@ describe('profile', () => {
|
||||||
expect(response).toMatchObject({ username: newUsername });
|
expect(response).toMatchObject({ username: newUsername });
|
||||||
|
|
||||||
// Sign in with new username
|
// Sign in with new username
|
||||||
await initClientAndSignIn(newUsername, password);
|
await initClientAndSignInForDefaultTenant(newUsername, password);
|
||||||
|
|
||||||
await deleteUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if username is already in use', async () => {
|
it('should fail if username is already in use', async () => {
|
||||||
const { user, username, password } = await createUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const { user: user2, username: username2 } = await createUserWithPassword();
|
const { user: user2, username: username2 } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
|
|
||||||
await expectRejects(updateUser(api, { username: username2 }), {
|
await expectRejects(updateUser(api, { username: username2 }), {
|
||||||
|
@ -71,14 +109,14 @@ describe('profile', () => {
|
||||||
status: 422,
|
status: 422,
|
||||||
});
|
});
|
||||||
|
|
||||||
await deleteUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
await deleteUser(user2.id);
|
await deleteDefaultTenantUser(user2.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /profile/password', () => {
|
describe('POST /profile/password', () => {
|
||||||
it('should fail if verification record is invalid', async () => {
|
it('should fail if verification record is invalid', async () => {
|
||||||
const { user, username, password } = await createUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
const newPassword = generatePassword();
|
const newPassword = generatePassword();
|
||||||
|
|
||||||
|
@ -87,21 +125,29 @@ describe('profile', () => {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
await deleteUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to update password', async () => {
|
it('should be able to update password', async () => {
|
||||||
const { user, username, password } = await createUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
const newPassword = generatePassword();
|
const newPassword = generatePassword();
|
||||||
|
|
||||||
await updatePassword(api, verificationRecordId, newPassword);
|
await updatePassword(api, verificationRecordId, newPassword);
|
||||||
|
|
||||||
// Sign in with new password
|
// Check if the hook is triggered
|
||||||
await initClientAndSignIn(username, newPassword);
|
const hook = webHookApi.hooks.get(hookName)!;
|
||||||
|
await assertHookLogResult(hook, 'User.Data.Updated', {
|
||||||
|
hookPayload: {
|
||||||
|
event: 'User.Data.Updated',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await deleteUser(user.id);
|
// Sign in with new password
|
||||||
|
await initClientAndSignInForDefaultTenant(username, newPassword);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue