diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json index 87596697069..d91895d8ebf 100644 --- a/packages/core/src/routes/profile/index.openapi.json +++ b/packages/core/src/routes/profile/index.openapi.json @@ -246,6 +246,31 @@ } } } + }, + "/api/profile/identities/{target}": { + "delete": { + "operationId": "DeleteIdentity", + "summary": "Delete a user identity", + "description": "Delete an identity (social identity) from the user, a verification record is required for checking sensitive permissions.", + "parameters": [ + { + "name": "verificationRecordId", + "in": "query", + "description": "The verification record ID for checking sensitive permissions." + } + ], + "responses": { + "204": { + "description": "The identity was deleted successfully." + }, + "400": { + "description": "The verification record is invalid." + }, + "404": { + "description": "The identity does not exist." + } + } + } } } } diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts index e298ec7447a..5f5542b1ed3 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; import { EnvSet } from '../../env-set/index.js'; +import RequestError from '../../errors/RequestError/index.js'; import { encryptUserPassword } from '../../libraries/user.utils.js'; import { buildVerificationRecordByIdAndType, @@ -20,7 +21,7 @@ export default function profileRoutes( ...[router, { queries, libraries }]: RouterInitArgs ) { const { - users: { updateUserById, findUserById }, + users: { updateUserById, findUserById, deleteUserIdentity }, signInExperiences: { findDefaultSignInExperience }, } = queries; @@ -295,4 +296,47 @@ export default function profileRoutes( return next(); } ); + + router.delete( + '/profile/identities/:target', + koaGuard({ + params: z.object({ target: z.string() }), + query: z.object({ + // TODO: Move all sensitive permission checks to the header + verificationRecordId: z.string(), + }), + status: [204, 400, 401, 404], + }), + async (ctx, next) => { + const { id: userId, scopes } = ctx.auth; + const { verificationRecordId } = ctx.guard.query; + const { target } = ctx.guard.params; + assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized'); + + await verifyUserSensitivePermission({ + userId, + id: verificationRecordId, + queries, + libraries, + }); + + const user = await findUserById(userId); + + assertThat( + user.identities[target], + new RequestError({ + code: 'user.identity_not_exist', + status: 404, + }) + ); + + const updatedUser = await deleteUserIdentity(userId, target); + + ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser }); + + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/integration-tests/src/api/profile.ts b/packages/integration-tests/src/api/profile.ts index a5152132fe3..3bbf2276bb3 100644 --- a/packages/integration-tests/src/api/profile.ts +++ b/packages/integration-tests/src/api/profile.ts @@ -36,6 +36,15 @@ export const updateIdentities = async ( json: { verificationRecordId, newIdentifierVerificationRecordId }, }); +export const deleteIdentity = async ( + api: KyInstance, + target: string, + verificationRecordId: string +) => + api.delete(`api/profile/identities/${target}`, { + searchParams: { verificationRecordId }, + }); + export const updateUser = async (api: KyInstance, body: Record) => api.patch('api/profile', { json: body }).json>(); diff --git a/packages/integration-tests/src/tests/api/profile/social.test.ts b/packages/integration-tests/src/tests/api/profile/social.test.ts index 6a9f6de1784..e053fcfafbf 100644 --- a/packages/integration-tests/src/tests/api/profile/social.test.ts +++ b/packages/integration-tests/src/tests/api/profile/social.test.ts @@ -6,7 +6,7 @@ import { mockSocialConnectorId, mockSocialConnectorTarget, } from '#src/__mocks__/connectors-mock.js'; -import { getUserInfo, updateIdentities } from '#src/api/profile.js'; +import { deleteIdentity, getUserInfo, updateIdentities } from '#src/api/profile.js'; import { createSocialVerificationRecord, createVerificationRecordByPassword, @@ -166,4 +166,83 @@ describe('profile (social)', () => { }); }); }); + + describe('DELETE /profile/identities/:target', () => { + it('should fail if scope is missing', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + + await expectRejects( + deleteIdentity(api, mockSocialConnectorTarget, 'invalid-verification-record-id'), + { + code: 'auth.unauthorized', + status: 400, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should fail if verification record is invalid', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Identities], + }); + + await expectRejects( + deleteIdentity(api, mockSocialConnectorTarget, 'invalid-verification-record-id'), + { + code: 'verification_record.permission_denied', + status: 401, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should fail if identity does not exist', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Identities], + }); + const verificationRecordId = await createVerificationRecordByPassword(api, password); + + await expectRejects(deleteIdentity(api, mockSocialConnectorTarget, verificationRecordId), { + code: 'user.identity_not_exist', + status: 404, + }); + + await deleteDefaultTenantUser(user.id); + }); + + it('should be able to delete social identity', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Identities], + }); + const verificationRecordId = await createVerificationRecordByPassword(api, password); + + // Link social identity to the user + const { verificationRecordId: newVerificationRecordId } = + await createSocialVerificationRecord( + api, + connectorIdMap.get(mockSocialConnectorId)!, + state, + redirectUri + ); + await verifySocialAuthorization(api, newVerificationRecordId, { + code: authorizationCode, + }); + await updateIdentities(api, verificationRecordId, newVerificationRecordId); + const userInfo = await getUserInfo(api); + expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget); + + await deleteIdentity(api, mockSocialConnectorTarget, verificationRecordId); + + const updatedUserInfo = await getUserInfo(api); + expect(updatedUserInfo.identities).not.toHaveProperty(mockSocialConnectorTarget); + + await deleteDefaultTenantUser(user.id); + }); + }); });