From 3eede7893f9587693ce4709e5527405721d6c023 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Thu, 10 Oct 2024 10:30:20 +0800 Subject: [PATCH] feat(core): get user profile (#6650) --- .../src/routes/profile/index.openapi.json | 10 +++ packages/core/src/routes/profile/index.ts | 31 +++++---- .../profile/utils/get-scoped-profile.ts | 69 +++++++++++++++++++ packages/integration-tests/src/api/profile.ts | 5 +- .../tests/api/profile/email-and-phone.test.ts | 4 +- .../src/tests/api/profile/index.test.ts | 67 +++++++++++++++++- 6 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/routes/profile/utils/get-scoped-profile.ts diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json index 465a3eaf8..de5c1b8c4 100644 --- a/packages/core/src/routes/profile/index.openapi.json +++ b/packages/core/src/routes/profile/index.openapi.json @@ -10,6 +10,16 @@ ], "paths": { "/api/profile": { + "get": { + "operationId": "GetProfile", + "summary": "Get profile", + "description": "Get profile for the user.", + "responses": { + "200": { + "description": "The profile was retrieved successfully." + } + } + }, "patch": { "operationId": "UpdateProfile", "summary": "Update profile", diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts index 87a82f20b..b934a13d1 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -1,6 +1,5 @@ import { emailRegEx, usernameRegEx, UserScope } from '@logto/core-kit'; -import { VerificationType } from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; +import { VerificationType, userProfileResponseGuard } from '@logto/schemas'; import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -15,6 +14,8 @@ import assertThat from '../../utils/assert-that.js'; import { PasswordValidator } from '../experience/classes/libraries/password-validator.js'; import type { UserRouter, RouterInitArgs } from '../types.js'; +import { getScopedProfile } from './utils/get-scoped-profile.js'; + export default function profileRoutes( ...[router, { queries, libraries }]: RouterInitArgs ) { @@ -31,6 +32,19 @@ export default function profileRoutes( return; } + router.get( + '/profile', + koaGuard({ + response: userProfileResponseGuard.partial(), + status: [200], + }), + async (ctx, next) => { + const { id: userId, scopes } = ctx.auth; + ctx.body = await getScopedProfile(queries, libraries, scopes, userId); + return next(); + } + ); + router.patch( '/profile', koaGuard({ @@ -39,11 +53,7 @@ export default function profileRoutes( avatar: z.string().url().nullable().optional(), username: z.string().regex(usernameRegEx).optional(), }), - response: z.object({ - name: z.string().nullable().optional(), - avatar: z.string().nullable().optional(), - username: z.string().optional(), - }), + response: userProfileResponseGuard.partial(), status: [200, 400, 422], }), async (ctx, next) => { @@ -61,12 +71,7 @@ export default function profileRoutes( ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser }); - // Only return the fields that were actually updated - ctx.body = { - ...conditional(name !== undefined && { name: updatedUser.name }), - ...conditional(avatar !== undefined && { avatar: updatedUser.avatar }), - ...conditional(username !== undefined && { username: updatedUser.username }), - }; + ctx.body = await getScopedProfile(queries, libraries, scopes, userId); return next(); } diff --git a/packages/core/src/routes/profile/utils/get-scoped-profile.ts b/packages/core/src/routes/profile/utils/get-scoped-profile.ts new file mode 100644 index 000000000..d4d481dd8 --- /dev/null +++ b/packages/core/src/routes/profile/utils/get-scoped-profile.ts @@ -0,0 +1,69 @@ +import { UserScope } from '@logto/core-kit'; +import { type UserProfileResponse } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; + +import type Libraries from '../../../tenants/Libraries.js'; +import type Queries from '../../../tenants/Queries.js'; +import { transpileUserProfileResponse } from '../../../utils/user.js'; + +/** + * Get the user profile, and filter the fields according to the scopes. + * The scopes and fields are defined in the core-kit, see packages/toolkit/core-kit/src/openid.ts + */ +export const getScopedProfile = async ( + queries: Queries, + libraries: Libraries, + scopes: Set, + userId: string +): Promise> => { + const user = await queries.users.findUserById(userId); + + const ssoIdentities = scopes.has(UserScope.Identities) && [ + ...(await libraries.users.findUserSsoIdentities(userId)), + ]; + + const { + id, + username, + primaryEmail, + primaryPhone, + name, + avatar, + customData, + identities, + lastSignInAt, + createdAt, + updatedAt, + profile: { address, ...restProfile }, + applicationId, + isSuspended, + hasPassword, + } = transpileUserProfileResponse(user); + + return { + id, + ...conditional(ssoIdentities), + ...conditional(scopes.has(UserScope.Identities) && { identities }), + ...conditional(scopes.has(UserScope.CustomData) && { customData }), + ...conditional(scopes.has(UserScope.Email) && { primaryEmail }), + ...conditional(scopes.has(UserScope.Phone) && { primaryPhone }), + ...conditional( + // Basic profile and all custom claims not defined in the scope are included + scopes.has(UserScope.Profile) && { + name, + avatar, + username, + profile: { + ...restProfile, + ...conditional(scopes.has(UserScope.Address) && { address }), + }, + lastSignInAt, + createdAt, + updatedAt, + applicationId, + isSuspended, + hasPassword, + } + ), + }; +}; diff --git a/packages/integration-tests/src/api/profile.ts b/packages/integration-tests/src/api/profile.ts index 4a1e5d429..e9b4f0a2a 100644 --- a/packages/integration-tests/src/api/profile.ts +++ b/packages/integration-tests/src/api/profile.ts @@ -1,4 +1,4 @@ -import { type UserInfoResponse } from '@logto/js'; +import { type UserProfileResponse } from '@logto/schemas'; import { type KyInstance } from 'ky'; export const updatePassword = async ( @@ -24,4 +24,5 @@ export const updateUser = async (api: KyInstance, body: Record) username?: string; }>(); -export const getUserInfo = async (api: KyInstance) => api.get('oidc/me').json(); +export const getUserInfo = async (api: KyInstance) => + api.get('api/profile').json>(); diff --git a/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts b/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts index 22cdd7288..213e87454 100644 --- a/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts +++ b/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts @@ -104,7 +104,7 @@ describe('profile (email and phone)', () => { await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId); const userInfo = await getUserInfo(api); - expect(userInfo).toHaveProperty('email', newEmail); + expect(userInfo).toHaveProperty('primaryEmail', newEmail); await deleteDefaultTenantUser(user.id); }); @@ -130,7 +130,7 @@ describe('profile (email and phone)', () => { await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId); const userInfo = await getUserInfo(api); - expect(userInfo).toHaveProperty('email', newEmail); + expect(userInfo).toHaveProperty('primaryEmail', newEmail); await deleteDefaultTenantUser(user.id); }); diff --git a/packages/integration-tests/src/tests/api/profile/index.test.ts b/packages/integration-tests/src/tests/api/profile/index.test.ts index a54991d7d..774ed438a 100644 --- a/packages/integration-tests/src/tests/api/profile/index.test.ts +++ b/packages/integration-tests/src/tests/api/profile/index.test.ts @@ -1,3 +1,4 @@ +import { UserScope } from '@logto/core-kit'; import { hookEvents } from '@logto/schemas'; import { getUserInfo, updatePassword, updateUser } from '#src/api/profile.js'; @@ -44,6 +45,69 @@ describe('profile', () => { await webHookApi.cleanUp(); }); + describe('GET /profile', () => { + it('should be able to get profile with default scopes', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + const response = await getUserInfo(api); + expect(response).toMatchObject({ username }); + expect(response).not.toHaveProperty('customData'); + expect(response).not.toHaveProperty('identities'); + expect(response).not.toHaveProperty('primaryEmail'); + expect(response).not.toHaveProperty('primaryPhone'); + + await deleteDefaultTenantUser(user.id); + }); + + it('should return profile data based on scopes (email)', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Email], + }); + + const response = await getUserInfo(api); + expect(response).toHaveProperty('primaryEmail'); + + await deleteDefaultTenantUser(user.id); + }); + + it('should return profile data based on scopes (phone)', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Phone], + }); + + const response = await getUserInfo(api); + expect(response).toHaveProperty('primaryPhone'); + + await deleteDefaultTenantUser(user.id); + }); + + it('should return profile data based on scopes (identities)', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Identities], + }); + + const response = await getUserInfo(api); + expect(response).toHaveProperty('identities'); + + await deleteDefaultTenantUser(user.id); + }); + + it('should return profile data based on scopes (custom data)', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.CustomData], + }); + + const response = await getUserInfo(api); + expect(response).toHaveProperty('customData'); + + await deleteDefaultTenantUser(user.id); + }); + }); + describe('PATCH /profile', () => { it('should be able to update name', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); @@ -79,8 +143,7 @@ describe('profile', () => { expect(response).toMatchObject({ avatar: newAvatar }); const userInfo = await getUserInfo(api); - // In OIDC, the avatar is mapped to the `picture` field - expect(userInfo).toHaveProperty('picture', newAvatar); + expect(userInfo).toHaveProperty('avatar', newAvatar); await deleteDefaultTenantUser(user.id); });