diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index 071f9f92cc4..56972048626 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -29,6 +29,7 @@ export const createUserLibrary = (queries: Queries) => { hasUserWithEmail, hasUserWithId, hasUserWithPhone, + hasUserWithIdentity, findUsersByIds, updateUserById, findUserById, @@ -91,10 +92,11 @@ export const createUserLibrary = (queries: Queries) => { username?: Nullable; primaryEmail?: Nullable; primaryPhone?: Nullable; + identity?: Nullable<{ target: string; id: string }>; }, excludeUserId?: string ) => { - const { primaryEmail, primaryPhone, username } = identifiers; + const { primaryEmail, primaryPhone, username, identity } = identifiers; if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) { throw new RequestError({ code: 'user.email_already_in_use', status: 422 }); @@ -107,6 +109,10 @@ export const createUserLibrary = (queries: Queries) => { if (username && (await hasUser(username, excludeUserId))) { throw new RequestError({ code: 'user.username_already_in_use', status: 422 }); } + + if (identity && (await hasUserWithIdentity(identity.target, identity.id, excludeUserId))) { + throw new RequestError({ code: 'user.identity_already_in_use', status: 422 }); + } }; const findUsersByRoleName = async (roleName: string) => { diff --git a/packages/core/src/routes/experience/classes/verifications/social-verification.ts b/packages/core/src/routes/experience/classes/verifications/social-verification.ts index 2dee303e168..ae390d0cdcd 100644 --- a/packages/core/src/routes/experience/classes/verifications/social-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/social-verification.ts @@ -1,4 +1,10 @@ -import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit'; +import { + type ConnectorSession, + connectorSessionGuard, + socialUserInfoGuard, + type SocialUserInfo, + type ToZodObject, +} from '@logto/connector-kit'; import { VerificationType, type JsonObject, @@ -34,6 +40,10 @@ export type SocialVerificationRecordData = { * The social identity returned by the connector. */ socialUserInfo?: SocialUserInfo; + /** + * The connector session result + */ + connectorSession?: ConnectorSession; }; export const socialVerificationRecordDataGuard = z.object({ @@ -41,6 +51,7 @@ export const socialVerificationRecordDataGuard = z.object({ connectorId: z.string(), type: z.literal(VerificationType.Social), socialUserInfo: socialUserInfoGuard.optional(), + connectorSession: connectorSessionGuard.optional(), }) satisfies ToZodObject; export class SocialVerification implements IdentifierVerificationRecord { @@ -59,7 +70,7 @@ export class SocialVerification implements IdentifierVerificationRecord { + this.connectorSession = connectorSession; + }, + jti: this.id, + } + ); } /** @@ -119,11 +142,17 @@ export class SocialVerification implements IdentifierVerificationRecord this.connectorSession ?? {}, skipInteractionLogging } ); this.socialUserInfo = socialUserInfo; @@ -235,13 +264,14 @@ export class SocialVerification implements IdentifierVerificationRecord Promise; jti?: string } = {} ) => { const { getLogtoConnectorById } = connectors; @@ -30,7 +34,7 @@ export const createSocialAuthorizationUrl = async ( headers: { 'user-agent': userAgent }, } = ctx.request; - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { jti: finalJti } = jti ? { jti } : await provider.interactionDetails(ctx.req, ctx.res); return connector.getAuthorizationUri( { @@ -43,25 +47,34 @@ export const createSocialAuthorizationUrl = async ( */ connectorId, connectorFactoryId: connector.metadata.id, - jti, + jti: finalJti, headers: { userAgent }, }, - async (connectorStorage: ConnectorSession) => - assignConnectorSessionResult(ctx, provider, connectorStorage) + // TODO(LOG-10266): remove this and migrate all connector session result to verification record + setSession ?? + (async (connectorStorage: ConnectorSession) => + assignConnectorSessionResult(ctx, provider, connectorStorage)) ); }; export const verifySocialIdentity = async ( { connectorId, connectorData }: SocialConnectorPayload, ctx: WithLogContext, - { provider, libraries }: TenantContext + { provider, libraries }: TenantContext, + { + getSession, + skipInteractionLogging, + }: { getSession?: () => Promise; skipInteractionLogging?: boolean } = {} ): Promise => { const { socials: { getUserInfo, getConnector }, } = libraries; - const log = ctx.createLog('Interaction.SignIn.Identifier.Social.Submit'); - log.append({ connectorId, connectorData }); + // TODO(LOG-10268): move logging to experience api layer + const log = skipInteractionLogging + ? undefined + : ctx.createLog('Interaction.SignIn.Identifier.Social.Submit'); + log?.append({ connectorId, connectorData }); const connector = await getConnector(connectorId); @@ -76,11 +89,13 @@ export const verifySocialIdentity = async ( assertThat(value === csrfToken, 'session.csrf_token_mismatch'); } - const userInfo = await getUserInfo(connectorId, connectorData, async () => - getConnectorSessionResult(ctx, provider) + const userInfo = await getUserInfo( + connectorId, + connectorData, + getSession ?? (async () => getConnectorSessionResult(ctx, provider)) ); - log.append(userInfo); + log?.append(userInfo); return userInfo; }; diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json index 8609a2fa444..87596697069 100644 --- a/packages/core/src/routes/profile/index.openapi.json +++ b/packages/core/src/routes/profile/index.openapi.json @@ -218,6 +218,34 @@ } } } + }, + "/api/profile/identities": { + "post": { + "operationId": "AddUserIdentities", + "summary": "Add a user identity", + "description": "Add an identity (social identity) to the user, a verification record is required for checking sensitive permissions, and a verification record for the social identity is required.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "verificationRecordId": { + "description": "The verification record ID for checking sensitive permissions." + }, + "newIdentifierVerificationRecordId": { + "description": "The identifier verification record ID for the new social identity ownership verification." + } + } + } + } + } + }, + "responses": { + "204": { + "description": "The identity was added successfully." + } + } + } } } } diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts index 7d658587631..e298ec7447a 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -236,4 +236,63 @@ export default function profileRoutes( return next(); } ); + + router.post( + '/profile/identities', + koaGuard({ + body: z.object({ + verificationRecordId: z.string(), + newIdentifierVerificationRecordId: z.string(), + }), + status: [204, 400, 401], + }), + async (ctx, next) => { + const { id: userId, scopes } = ctx.auth; + const { verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body; + + assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized'); + + await verifyUserSensitivePermission({ + userId, + id: verificationRecordId, + queries, + libraries, + }); + + // Check new identifier + const newVerificationRecord = await buildVerificationRecordByIdAndType({ + type: VerificationType.Social, + id: newIdentifierVerificationRecordId, + queries, + libraries, + }); + assertThat(newVerificationRecord.isVerified, 'verification_record.not_found'); + + const { + socialIdentity: { target, userInfo }, + } = await newVerificationRecord.toUserProfile(); + + await checkIdentifierCollision({ identity: { target, id: userInfo.id } }, userId); + + const user = await findUserById(userId); + + assertThat(!user.identities[target], 'user.identity_already_in_use'); + + const updatedUser = await updateUserById(userId, { + identities: { + ...user.identities, + [target]: { + userId: userInfo.id, + details: userInfo, + }, + }, + }); + + ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser }); + + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/core/src/routes/verification/index.openapi.json b/packages/core/src/routes/verification/index.openapi.json index bfaf71ba98c..6e45ce93380 100644 --- a/packages/core/src/routes/verification/index.openapi.json +++ b/packages/core/src/routes/verification/index.openapi.json @@ -129,6 +129,85 @@ } } } + }, + "/api/verifications/social": { + "post": { + "operationId": "CreateSocialVerification", + "summary": "Create a social verification record", + "description": "Create a social verification record and return the authorization URI.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "connectorId": { + "description": "The Logto connector ID." + }, + "redirectUri": { + "description": "The URI to navigate back to after the user is authenticated by the connected social identity provider and has granted access to the connector." + }, + "state": { + "description": "A random string generated on the client side to prevent CSRF (Cross-Site Request Forgery) attacks." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Successfully created the social verification record and returned the authorization URI.", + "content": { + "application/json": { + "schema": { + "properties": { + "verificationRecordId": { + "description": "The ID of the verification record." + }, + "authorizationUri": { + "description": "The authorization URI to navigate to for authentication and authorization in the connected social identity provider." + }, + "expiresAt": { + "description": "The expiration date and time of the verification record." + } + } + } + } + } + }, + "404": { + "description": "The connector specified by connectorId is not found." + } + } + } + }, + "/api/verifications/social/verify": { + "post": { + "operationId": "VerifySocialVerification", + "summary": "Verify a social verification record", + "description": "Verify a social verification record by callback connector data, and save the user information to the record.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "connectorData": { + "description": "A json object constructed from the url query params returned by the social platform. Typically it contains `code`, `state` and `redirectUri` fields." + }, + "verificationId": { + "description": "The verification ID of the SocialVerification record." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The social verification record has been successfully verified and the user information has been saved." + } + } + } } } } diff --git a/packages/core/src/routes/verification/index.ts b/packages/core/src/routes/verification/index.ts index 88fdf45cc20..b5a79d1c44d 100644 --- a/packages/core/src/routes/verification/index.ts +++ b/packages/core/src/routes/verification/index.ts @@ -3,6 +3,8 @@ import { InteractionEvent, SentinelActivityAction, SignInIdentifier, + socialAuthorizationUrlPayloadGuard, + socialVerificationCallbackPayloadGuard, verificationCodeIdentifierGuard, VerificationType, } from '@logto/schemas'; @@ -19,11 +21,14 @@ import { import { withSentinel } from '../experience/classes/libraries/sentinel-guard.js'; import { createNewCodeVerificationRecord } from '../experience/classes/verifications/code-verification.js'; import { PasswordVerification } from '../experience/classes/verifications/password-verification.js'; +import { SocialVerification } from '../experience/classes/verifications/social-verification.js'; import type { UserRouter, RouterInitArgs } from '../types.js'; export default function verificationRoutes( - ...[router, { queries, libraries, sentinel }]: RouterInitArgs + ...[router, tenantContext]: RouterInitArgs ) { + const { queries, libraries, sentinel } = tenantContext; + if (!EnvSet.values.isDevFeaturesEnabled) { return; } @@ -159,4 +164,78 @@ export default function verificationRoutes( return next(); } ); + + router.post( + '/verifications/social', + koaGuard({ + body: socialAuthorizationUrlPayloadGuard.extend({ + connectorId: z.string(), + }), + response: z.object({ + verificationRecordId: z.string(), + authorizationUri: z.string(), + expiresAt: z.string(), + }), + status: [201, 400, 404], + }), + async (ctx, next) => { + const { connectorId, ...rest } = ctx.guard.body; + + const socialVerification = SocialVerification.create(libraries, queries, connectorId); + + const authorizationUri = await socialVerification.createAuthorizationUrl( + ctx, + tenantContext, + rest + ); + + const { expiresAt } = await insertVerificationRecord(socialVerification, queries); + + ctx.body = { + verificationRecordId: socialVerification.id, + authorizationUri, + expiresAt: new Date(expiresAt).toISOString(), + }; + ctx.status = 201; + + return next(); + } + ); + + router.post( + '/verifications/social/verify', + koaGuard({ + body: socialVerificationCallbackPayloadGuard + .pick({ + connectorData: true, + }) + .extend({ + verificationRecordId: z.string(), + }), + response: z.object({ + verificationRecordId: z.string(), + }), + status: [200, 400, 404, 422], + }), + async (ctx, next) => { + const { connectorData, verificationRecordId } = ctx.guard.body; + + const socialVerification = await buildVerificationRecordByIdAndType({ + type: VerificationType.Social, + id: verificationRecordId, + queries, + libraries, + }); + + await socialVerification.verify(ctx, tenantContext, connectorData, true); + + await updateVerificationRecord(socialVerification, queries); + + ctx.body = { + verificationRecordId, + }; + + return next(); + } + ); } diff --git a/packages/integration-tests/src/api/profile.ts b/packages/integration-tests/src/api/profile.ts index 0ee1df3e02b..a5152132fe3 100644 --- a/packages/integration-tests/src/api/profile.ts +++ b/packages/integration-tests/src/api/profile.ts @@ -27,6 +27,15 @@ export const updatePrimaryPhone = async ( json: { phone, verificationRecordId, newIdentifierVerificationRecordId }, }); +export const updateIdentities = async ( + api: KyInstance, + verificationRecordId: string, + newIdentifierVerificationRecordId: string +) => + api.post('api/profile/identities', { + json: { verificationRecordId, newIdentifierVerificationRecordId }, + }); + export const updateUser = async (api: KyInstance, body: Record) => api.patch('api/profile', { json: body }).json>(); diff --git a/packages/integration-tests/src/api/verification-record.ts b/packages/integration-tests/src/api/verification-record.ts index 29af8f7997e..7b7ea91eec0 100644 --- a/packages/integration-tests/src/api/verification-record.ts +++ b/packages/integration-tests/src/api/verification-record.ts @@ -70,3 +70,31 @@ export const createAndVerifyVerificationCode = async ( return verificationRecordId; }; + +export const createSocialVerificationRecord = async ( + api: KyInstance, + connectorId: string, + state: string, + redirectUri: string +) => { + const { verificationRecordId, authorizationUri, expiresAt } = await api + .post('api/verifications/social', { + json: { connectorId, state, redirectUri }, + }) + .json<{ verificationRecordId: string; authorizationUri: string; expiresAt: string }>(); + + expect(expiresAt).toBeTruthy(); + expect(authorizationUri).toBeTruthy(); + + return { verificationRecordId, authorizationUri }; +}; + +export const verifySocialAuthorization = async ( + api: KyInstance, + verificationRecordId: string, + connectorData: Record +) => { + await api.post('api/verifications/social/verify', { + json: { verificationRecordId, connectorData }, + }); +}; diff --git a/packages/integration-tests/src/tests/api/profile/social.test.ts b/packages/integration-tests/src/tests/api/profile/social.test.ts new file mode 100644 index 00000000000..ba8c6ea0d82 --- /dev/null +++ b/packages/integration-tests/src/tests/api/profile/social.test.ts @@ -0,0 +1,169 @@ +import { UserScope } from '@logto/core-kit'; +import { ConnectorType } from '@logto/schemas'; + +import { + mockEmailConnectorId, + mockSocialConnectorId, + mockSocialConnectorTarget, +} from '#src/__mocks__/connectors-mock.js'; +import { getUserInfo, updateIdentities } from '#src/api/profile.js'; +import { + createSocialVerificationRecord, + createVerificationRecordByPassword, + verifySocialAuthorization, +} from '#src/api/verification-record.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSocialConnector, +} from '#src/helpers/connector.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { + createDefaultTenantUserWithPassword, + deleteDefaultTenantUser, + signInAndGetUserApi, +} from '#src/helpers/profile.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { devFeatureTest } from '#src/utils.js'; + +const { describe, it } = devFeatureTest; + +describe('profile (social)', () => { + const state = 'fake_state'; + const redirectUri = 'http://localhost:3000/redirect'; + const authorizationCode = 'fake_code'; + const connectorIdMap = new Map(); + + beforeAll(async () => { + await enableAllPasswordSignInMethods(); + + await clearConnectorsByTypes([ConnectorType.Social]); + const { id: socialConnectorId } = await setSocialConnector(); + const { id: emailConnectorId } = await setEmailConnector(); + connectorIdMap.set(mockSocialConnectorId, socialConnectorId); + connectorIdMap.set(mockEmailConnectorId, emailConnectorId); + }); + + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Social]); + }); + + describe('POST /profile/identities', () => { + it('should fail if scope is missing', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + + await expectRejects( + updateIdentities(api, 'invalid-verification-record-id', 'new-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( + updateIdentities(api, 'invalid-verification-record-id', 'new-verification-record-id'), + { + code: 'verification_record.permission_denied', + status: 401, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should fail if new identifier verification record is invalid', 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( + updateIdentities(api, verificationRecordId, 'new-verification-record-id'), + { + code: 'verification_record.not_found', + status: 400, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + describe('create social verification record', () => { + it('should throw if the connector is not found', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Identities], + }); + + await expectRejects( + createSocialVerificationRecord(api, 'invalid-connector-id', state, redirectUri), + { + code: 'entity.not_found', + status: 404, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should throw if the connector is not a social connector', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Identities], + }); + + await expectRejects( + createSocialVerificationRecord( + api, + connectorIdMap.get(mockEmailConnectorId)!, + state, + redirectUri + ), + { + code: 'connector.unexpected_type', + status: 400, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should be able to verify social authorization and update user identities', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Identities], + }); + + const { verificationRecordId: newVerificationRecordId } = + await createSocialVerificationRecord( + api, + connectorIdMap.get(mockSocialConnectorId)!, + state, + redirectUri + ); + + await verifySocialAuthorization(api, newVerificationRecordId, { + code: authorizationCode, + }); + + const verificationRecordId = await createVerificationRecordByPassword(api, password); + await updateIdentities(api, verificationRecordId, newVerificationRecordId); + const userInfo = await getUserInfo(api); + expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget); + + await deleteDefaultTenantUser(user.id); + }); + }); + }); +});