diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json index 383be79e92f..4ee010c2aa7 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 f7d346b08c0..2035adb15a6 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -1,5 +1,5 @@ import { usernameRegEx, UserScope } from '@logto/core-kit'; -import { conditional } from '@silverhand/essentials'; +import { userProfileResponseGuard } from '@logto/schemas'; import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -10,6 +10,8 @@ import { buildUserVerificationRecordById } from '../../libraries/verification.js import assertThat from '../../utils/assert-that.js'; import type { UserRouter, RouterInitArgs } from '../types.js'; +import { getScopedProfile } from './utils/get-scoped-profile.js'; + export default function profileRoutes( ...[router, { queries, libraries }]: RouterInitArgs ) { @@ -25,6 +27,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({ @@ -33,11 +48,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) => { @@ -55,12 +66,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 00000000000..d4d481dd8fa --- /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 2a6bc44e531..6776d75d7d6 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 ( @@ -14,4 +14,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/index.test.ts b/packages/integration-tests/src/tests/api/profile/index.test.ts index c44d1d06ddf..6e5a9966dfa 100644 --- a/packages/integration-tests/src/tests/api/profile/index.test.ts +++ b/packages/integration-tests/src/tests/api/profile/index.test.ts @@ -44,6 +44,17 @@ describe('profile', () => { await webHookApi.cleanUp(); }); + describe('GET /profile', () => { + it('should be able to get profile', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + const response = await getUserInfo(api); + expect(response).toMatchObject({ username }); + + await deleteDefaultTenantUser(user.id); + }); + }); + describe('PATCH /profile', () => { it('should be able to update name', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); @@ -79,8 +90,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); });