diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index 071f9f92c..569720486 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 2dee303e1..5ea11e742 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,13 @@ -import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit'; +import { + type ConnectorSession, + connectorSessionGuard, + socialUserInfoGuard, + type SocialUserInfo, + type ToZodObject, + ConnectorType, + type SocialConnector, + GoogleConnector, +} from '@logto/connector-kit'; import { VerificationType, type JsonObject, @@ -34,6 +43,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,8 +54,11 @@ export const socialVerificationRecordDataGuard = z.object({ connectorId: z.string(), type: z.literal(VerificationType.Social), socialUserInfo: socialUserInfoGuard.optional(), + connectorSession: connectorSessionGuard.optional(), }) satisfies ToZodObject; +type SocialAuthorizationSessionStorageType = 'interactionSession' | 'verificationRecord'; + export class SocialVerification implements IdentifierVerificationRecord { /** * Factory method to create a new SocialVerification instance @@ -59,7 +75,7 @@ export class SocialVerification implements IdentifierVerificationRecord> { const { getConnector } = this.libraries.socials; this.connectorDataCache ||= await getConnector(this.connectorId); + assertThat(this.connectorDataCache.type === ConnectorType.Social, 'connector.unexpected_type'); + return this.connectorDataCache; } + + /** + * Internal method to create a social authorization session. + * + * @remarks + * This method is a alternative to the {@link createSocialAuthorizationUrl} method in the interaction/utils/social-verification.ts file. + * Generate the social authorization URL and store the connector session result in the current verification record directly. + * This social connector session result will be used to verify the social response later. + * This method can be used for both experience and profile APIs, w/o OIDC interaction context. + * + */ + private async createSocialAuthorizationSession( + ctx: WithLogContext, + { state, redirectUri }: SocialAuthorizationUrlPayload + ) { + assertThat(state && redirectUri, 'session.insufficient_info'); + + const connector = await this.getConnectorData(); + + const { + headers: { 'user-agent': userAgent }, + } = ctx.request; + + return connector.getAuthorizationUri( + { + state, + redirectUri, + connectorId: this.connectorId, + connectorFactoryId: connector.metadata.id, + // Instead of getting the jti from the interaction details, use the current verification record's id as the jti. + jti: this.id, + headers: { userAgent }, + }, + async (connectorSession) => { + // Store the connector session result in the current verification record directly. + this.connectorSession = connectorSession; + } + ); + } + + /** + * Internal method to verify the social identity. + * + * @remarks + * This method is a alternative to the {@link verifySocialIdentity} method in the interaction/utils/social-verification.ts file. + * Verify the social identity using the connector data received from the client and the connector session stored in the current verification record. + * This method can be used for both experience and profile APIs, w/o OIDC interaction context. + */ + private async verifySocialIdentityInternally(connectorData: JsonObject, ctx: WithLogContext) { + const connector = await this.getConnectorData(); + + // Verify the CSRF token if it's a Google connector and has credential (a Google One Tap verification) + if ( + connector.metadata.id === GoogleConnector.factoryId && + connectorData[GoogleConnector.oneTapParams.credential] + ) { + const csrfToken = connectorData[GoogleConnector.oneTapParams.csrfToken]; + const value = ctx.cookies.get(GoogleConnector.oneTapParams.csrfToken); + assertThat(value === csrfToken, 'session.csrf_token_mismatch'); + } + + // Verify the social authorization session exists + assertThat(this.connectorSession, 'session.connector_validation_session_not_found'); + + const socialUserInfo = await this.libraries.socials.getUserInfo( + this.connectorId, + connectorData, + async () => this.connectorSession ?? {} + ); + + return socialUserInfo; + } } diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json index 8609a2fa4..875966970 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 7d6585876..e298ec744 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 bfaf71ba9..73eeb8e7b 100644 --- a/packages/core/src/routes/verification/index.openapi.json +++ b/packages/core/src/routes/verification/index.openapi.json @@ -129,6 +129,88 @@ } } } + }, + "/api/verifications/social": { + "post": { + "operationId": "CreateVerificationBySocial", + "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." + }, + "422": { + "description": "The connector specified by connectorId is not a valid social connector." + } + } + } + }, + "/api/verifications/social/verify": { + "post": { + "operationId": "VerifyVerificationBySocial", + "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 6b9439227..ebbfc2927 100644 --- a/packages/core/src/routes/verification/index.ts +++ b/packages/core/src/routes/verification/index.ts @@ -3,6 +3,8 @@ import { AdditionalIdentifier, 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; } @@ -157,4 +162,79 @@ 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, 422], + }), + async (ctx, next) => { + const { connectorId, ...rest } = ctx.guard.body; + + const socialVerification = SocialVerification.create(libraries, queries, connectorId); + + const authorizationUri = await socialVerification.createAuthorizationUrl( + ctx, + tenantContext, + rest, + 'verificationRecord' + ); + + 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, 'verificationRecord'); + + 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 0ee1df3e0..a5152132f 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 29af8f799..7b7ea91ee 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 000000000..6a9f6de17 --- /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, ConnectorType.Email]); + }); + + 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: 'session.invalid_connector_id', + status: 422, + } + ); + + 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); + }); + }); + }); +}); diff --git a/packages/phrases/src/locales/en/errors/verification-record.ts b/packages/phrases/src/locales/en/errors/verification-record.ts index 3574da8bd..0f3718556 100644 --- a/packages/phrases/src/locales/en/errors/verification-record.ts +++ b/packages/phrases/src/locales/en/errors/verification-record.ts @@ -1,6 +1,7 @@ const verification_record = { not_found: 'Verification record not found.', permission_denied: 'Permission denied, please re-authenticate.', + not_supported_for_google_one_tap: 'This API does not support Google One Tap.', }; export default Object.freeze(verification_record);