diff --git a/packages/connectors/connector-mock-social/src/index.ts b/packages/connectors/connector-mock-social/src/index.ts index b9c76058c35..77c24b645a3 100644 --- a/packages/connectors/connector-mock-social/src/index.ts +++ b/packages/connectors/connector-mock-social/src/index.ts @@ -2,9 +2,9 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import type { + CreateConnector, GetAuthorizationUri, GetUserInfo, - CreateConnector, SocialConnector, } from '@logto/connector-kit'; import { @@ -17,11 +17,23 @@ import { import { defaultMetadata } from './constant.js'; import { mockSocialConfigGuard } from './types.js'; -const getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => { +const getAuthorizationUri: GetAuthorizationUri = async ( + { state, redirectUri, connectorId }, + setSession +) => { + try { + await setSession({ state, redirectUri, connectorId }); + } catch (error: unknown) { + // Ignore the error if the method is not implemented + if (!(error instanceof ConnectorError && error.code === ConnectorErrorCodes.NotImplemented)) { + throw error; + } + } + return `http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`; }; -const getUserInfo: GetUserInfo = async (data) => { +const getUserInfo: GetUserInfo = async (data, getSession) => { const dataGuard = z.object({ code: z.string(), userId: z.optional(z.string()), @@ -34,6 +46,19 @@ const getUserInfo: GetUserInfo = async (data) => { throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(data)); } + try { + const connectorSession = await getSession(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!connectorSession) { + throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed); + } + } catch (error: unknown) { + // Ignore the error if the method is not implemented + if (!(error instanceof ConnectorError && error.code === ConnectorErrorCodes.NotImplemented)) { + throw error; + } + } + const { code, userId, ...rest } = result.data; // For mock use only. Use to track the created user entity diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index 0e28075dfe9..c6be23bfb26 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -90,6 +90,7 @@ export default class ExperienceInteraction { ); // Throws an 404 error if the user is not found by the given verification record + // TODO: refactor using real-time user verification. Static verifiedUserId will be removed. assertThat( verificationRecord.verifiedUserId, new RequestError({ diff --git a/packages/core/src/routes/experience/classes/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts index 0eee9557cec..472c807da18 100644 --- a/packages/core/src/routes/experience/classes/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -14,8 +14,16 @@ import { passwordVerificationRecordDataGuard, type PasswordVerificationRecordData, } from './password-verification.js'; +import { + SocialVerification, + socialVerificationRecordDataGuard, + type SocialVerificationRecordData, +} from './social-verification.js'; -type VerificationRecordData = PasswordVerificationRecordData | CodeVerificationRecordData; +type VerificationRecordData = + | PasswordVerificationRecordData + | CodeVerificationRecordData + | SocialVerificationRecordData; /** * Union type for all verification record types @@ -25,11 +33,12 @@ type VerificationRecordData = PasswordVerificationRecordData | CodeVerificationR * This union type is used to narrow down the type of the verification record. * Used in the ExperienceInteraction class to store and manage all the verification records. With a given verification type, we can narrow down the type of the verification record. */ -export type VerificationRecord = PasswordVerification | CodeVerification; +export type VerificationRecord = PasswordVerification | CodeVerification | SocialVerification; export const verificationRecordDataGuard = z.discriminatedUnion('type', [ passwordVerificationRecordDataGuard, codeVerificationRecordDataGuard, + socialVerificationRecordDataGuard, ]); /** @@ -47,5 +56,8 @@ export const buildVerificationRecord = ( case VerificationType.VerificationCode: { return new CodeVerification(libraries, queries, data); } + case VerificationType.Social: { + return new SocialVerification(libraries, queries, data); + } } }; diff --git a/packages/core/src/routes/experience/classes/verifications/social-verification.ts b/packages/core/src/routes/experience/classes/verifications/social-verification.ts new file mode 100644 index 00000000000..2044ba6fbeb --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/social-verification.ts @@ -0,0 +1,186 @@ +import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit'; +import { + VerificationType, + type JsonObject, + type SocialAuthorizationUrlPayload, + type User, +} from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { z } from 'zod'; + +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import { + createSocialAuthorizationUrl, + verifySocialIdentity, +} from '#src/routes/interaction/utils/social-verification.js'; +import type Libraries from '#src/tenants/Libraries.js'; +import type Queries from '#src/tenants/Queries.js'; +import type TenantContext from '#src/tenants/TenantContext.js'; + +import { type VerificationRecord } from './verification-record.js'; + +/** The JSON data type for the SocialVerification record stored in the interaction storage */ +export type SocialVerificationRecordData = { + id: string; + connectorId: string; + type: VerificationType.Social; + /** + * The social identity returned by the connector. + */ + socialUserInfo?: SocialUserInfo; + userId?: string; +}; + +export const socialVerificationRecordDataGuard = z.object({ + id: z.string(), + connectorId: z.string(), + type: z.literal(VerificationType.Social), + socialUserInfo: socialUserInfoGuard.optional(), + userId: z.string().optional(), +}) satisfies ToZodObject; + +export class SocialVerification implements VerificationRecord { + /** + * Factory method to create a new SocialVerification instance + */ + static create(libraries: Libraries, queries: Queries, connectorId: string) { + return new SocialVerification(libraries, queries, { + id: generateStandardId(), + connectorId, + type: VerificationType.Social, + }); + } + + public readonly id: string; + public readonly type = VerificationType.Social; + public readonly connectorId: string; + public socialUserInfo?: SocialUserInfo; + + /** + * The userId of the user that has been verified by the social identity. + * @deprecated will be removed in the coming PR + */ + public userId?: string; + + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries, + data: SocialVerificationRecordData + ) { + const { id, connectorId, socialUserInfo, userId } = + socialVerificationRecordDataGuard.parse(data); + + this.id = id; + this.connectorId = connectorId; + this.socialUserInfo = socialUserInfo; + this.userId = userId; + } + + /** + * Returns true if the social identity has been verified + */ + get isVerified() { + return Boolean(this.socialUserInfo); + } + + get verifiedUserId() { + return this.userId; + } + + /** + * Create the authorization URL for the social connector. + * Store the connector session result in the provider's interaction storage. + * + * @remarks + * Refers to the {@link createSocialAuthorizationUrl} method in the interaction/utils/social-verification.ts file. + * Currently, all the intermediate connector session results are stored in the provider's interactionDetails separately, + * apart from the new verification record. + * For compatibility reasons, we keep using the old {@link createSocialAuthorizationUrl} method here as a single source of truth. + * Especially for the SAML connectors, + * SAML ACS endpoint will find the connector session result by the jti and assign it to the interaction storage. + * We will need to update the SAML ACS endpoint before move the logic to this new SocialVerification class. + * + * TODO: Consider store the connector session result in the verification record directly. + * SAML ACS endpoint will find the verification record by the jti and assign the connector session result to the verification record. + */ + async createAuthorizationUrl( + ctx: WithLogContext, + tenantContext: TenantContext, + { state, redirectUri }: SocialAuthorizationUrlPayload + ) { + return createSocialAuthorizationUrl(ctx, tenantContext, { + connectorId: this.connectorId, + state, + redirectUri, + }); + } + + /** + * Verify the social identity and store the social identity in the verification record. + * + * - Store the social identity in the verification record. + * - Find the user by the social identity and store the userId in the verification record if the user exists. + * + * @remarks + * Refer to the {@link verifySocialIdentity} method in the interaction/utils/social-verification.ts file. + * For compatibility reasons, we keep using the old {@link verifySocialIdentity} method here as a single source of truth. + * See the above {@link createAuthorizationUrl} method for more details. + * + * TODO: check the log event + */ + async verify(ctx: WithLogContext, tenantContext: TenantContext, connectorData: JsonObject) { + const socialUserInfo = await verifySocialIdentity( + { connectorId: this.connectorId, connectorData }, + ctx, + tenantContext + ); + + this.socialUserInfo = socialUserInfo; + + const user = await this.findUserBySocialIdentity(); + this.userId = user?.id; + } + + async findUserBySocialIdentity(): Promise { + const { socials } = this.libraries; + const { + users: { findUserByIdentity }, + } = this.queries; + + if (!this.socialUserInfo) { + return; + } + + const { + metadata: { target }, + } = await socials.getConnector(this.connectorId); + + const user = await findUserByIdentity(target, this.socialUserInfo.id); + + return user ?? undefined; + } + + /** + * Find the related user using the social identity's verified email or phone number. + */ + async findRelatedUserBySocialIdentity(): ReturnType { + const { socials } = this.libraries; + + if (!this.socialUserInfo) { + return null; + } + + return socials.findSocialRelatedUser(this.socialUserInfo); + } + + toJson(): SocialVerificationRecordData { + const { id, connectorId, socialUserInfo, type } = this; + + return { + id, + connectorId, + type, + socialUserInfo, + }; + } +} diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 9d679590022..6690dfcd037 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -24,6 +24,7 @@ import koaExperienceInteraction, { type WithExperienceInteractionContext, } from './middleware/koa-experience-interaction.js'; import passwordVerificationRoutes from './verification-routes/password-verification.js'; +import socialVerificationRoutes from './verification-routes/social-verification.js'; import verificationCodeRoutes from './verification-routes/verification-code.js'; type RouterContext = T extends Router ? Context : never; @@ -52,6 +53,10 @@ export default function experienceApiRoutes( ctx.experienceInteraction.setInteractionEvent(interactionEvent); + // TODO: SIE verification method check + // TODO: forgot password verification method check, only allow email and phone verification code + // TODO: user suspension check + ctx.experienceInteraction.identifyUser(verificationId); await ctx.experienceInteraction.save(); @@ -79,4 +84,5 @@ export default function experienceApiRoutes( passwordVerificationRoutes(router, tenant); verificationCodeRoutes(router, tenant); + socialVerificationRoutes(router, tenant); } diff --git a/packages/core/src/routes/experience/verification-routes/social-verification.ts b/packages/core/src/routes/experience/verification-routes/social-verification.ts new file mode 100644 index 00000000000..88dd3b9613e --- /dev/null +++ b/packages/core/src/routes/experience/verification-routes/social-verification.ts @@ -0,0 +1,99 @@ +import { + VerificationType, + socialAuthorizationUrlPayloadGuard, + socialVerificationCallbackPayloadGuard, +} from '@logto/schemas'; +import type Router from 'koa-router'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import type TenantContext from '#src/tenants/TenantContext.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { SocialVerification } from '../classes/verifications/social-verification.js'; +import { experienceRoutes } from '../const.js'; +import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; + +export default function socialVerificationRoutes( + router: Router>, + tenantContext: TenantContext +) { + const { libraries, queries } = tenantContext; + + router.post( + `${experienceRoutes.verification}/social/:connectorId/authorization-uri`, + koaGuard({ + params: z.object({ + connectorId: z.string(), + }), + body: socialAuthorizationUrlPayloadGuard, + response: z.object({ + authorizationUri: z.string(), + verificationId: z.string(), + }), + status: [200, 400, 404, 500], + }), + async (ctx, next) => { + const { connectorId } = ctx.guard.params; + + const socialVerification = SocialVerification.create(libraries, queries, connectorId); + + const authorizationUri = await socialVerification.createAuthorizationUrl( + ctx, + tenantContext, + ctx.guard.body + ); + + ctx.experienceInteraction.setVerificationRecord(socialVerification); + + await ctx.experienceInteraction.save(); + + ctx.body = { + authorizationUri, + verificationId: socialVerification.id, + }; + + return next(); + } + ); + + router.post( + `${experienceRoutes.verification}/social/:connectorId/verify`, + koaGuard({ + params: z.object({ + connectorId: z.string(), + }), + body: socialVerificationCallbackPayloadGuard, + response: z.object({ + verificationId: z.string(), + }), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { connectorId } = ctx.params; + const { connectorData, verificationId } = ctx.guard.body; + + const socialVerificationRecord = + ctx.experienceInteraction.getVerificationRecordById(verificationId); + + assertThat( + socialVerificationRecord && + socialVerificationRecord.type === VerificationType.Social && + socialVerificationRecord.connectorId === connectorId, + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + + await socialVerificationRecord.verify(ctx, tenantContext, connectorData); + + await ctx.experienceInteraction.save(); + + ctx.body = { + verificationId, + }; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts index a1bf7d6e35c..1ddbcc6ed7b 100644 --- a/packages/core/src/routes/interaction/types/index.ts +++ b/packages/core/src/routes/interaction/types/index.ts @@ -30,6 +30,12 @@ export type PasswordIdentifierPayload = export type SocialVerifiedIdentifierPayload = SocialEmailPayload | SocialPhonePayload; +/** + * @deprecated + * Legacy type for the interaction API. + * Use the latest experience API instead. + * Moved to `@logto/schemas` + */ export type SocialAuthorizationUrlPayload = z.infer; /* Interaction Types */ diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts index 27dcd6734f9..b01cd60f8a7 100644 --- a/packages/integration-tests/src/client/experience/index.ts +++ b/packages/integration-tests/src/client/experience/index.ts @@ -72,4 +72,34 @@ export class ExperienceClient extends MockClient { }) .json<{ verificationId: string }>(); } + + public async getSocialAuthorizationUri( + connectorId: string, + payload: { + redirectUri: string; + state: string; + } + ) { + return api + .post(`${experienceRoutes.verification}/social/${connectorId}/authorization-uri`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json<{ authorizationUri: string; verificationId: string }>(); + } + + public async verifySocialAuthorization( + connectorId: string, + payload: { + verificationId: string; + connectorData: Record; + } + ) { + return api + .post(`${experienceRoutes.verification}/social/${connectorId}/verify`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json<{ verificationId: string }>(); + } } diff --git a/packages/integration-tests/src/helpers/experience/social-verification.ts b/packages/integration-tests/src/helpers/experience/social-verification.ts new file mode 100644 index 00000000000..a12e9199524 --- /dev/null +++ b/packages/integration-tests/src/helpers/experience/social-verification.ts @@ -0,0 +1,41 @@ +import { type ExperienceClient } from '#src/client/experience/index.js'; + +export const successFullyCreateSocialVerification = async ( + client: ExperienceClient, + connectorId: string, + payload: { + redirectUri: string; + state: string; + } +) => { + const { authorizationUri, verificationId } = await client.getSocialAuthorizationUri( + connectorId, + payload + ); + + expect(verificationId).toBeTruthy(); + expect(authorizationUri).toBeTruthy(); + + return { + verificationId, + authorizationUri, + }; +}; + +export const successFullyVerifySocialAuthorization = async ( + client: ExperienceClient, + connectorId: string, + payload: { + verificationId: string; + connectorData: Record; + } +) => { + const { verificationId: verifiedVerificationId } = await client.verifySocialAuthorization( + connectorId, + payload + ); + + expect(verifiedVerificationId).toBeTruthy(); + + return verifiedVerificationId; +}; diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/social-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/social-verification.test.ts new file mode 100644 index 00000000000..6962924e3d9 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/social-verification.test.ts @@ -0,0 +1,197 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { InteractionEvent, InteractionIdentifierType } from '@logto/schemas'; + +import { mockEmailConnectorId, mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js'; +import { initExperienceClient } from '#src/helpers/client.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSocialConnector, +} from '#src/helpers/connector.js'; +import { + successFullyCreateSocialVerification, + successFullyVerifySocialAuthorization, +} from '#src/helpers/experience/social-verification.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { devFeatureTest } from '#src/utils.js'; + +devFeatureTest.describe('social verification', () => { + const state = 'fake_state'; + const redirectUri = 'http://localhost:3000/redirect'; + const authorizationCode = 'fake_code'; + const connectorIdMap = new Map(); + + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email]); + + const { id: emailConnectorId } = await setEmailConnector(); + const { id: socialConnectorId } = await setSocialConnector(); + connectorIdMap.set(mockSocialConnectorId, socialConnectorId); + connectorIdMap.set(mockEmailConnectorId, emailConnectorId); + }); + + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email]); + }); + + describe('getSocialAuthorizationUri', () => { + it('should throw if the state or redirectUri is empty', async () => { + const client = await initExperienceClient(); + const connectorId = connectorIdMap.get(mockSocialConnectorId)!; + + await expectRejects( + client.getSocialAuthorizationUri(connectorId, { + redirectUri, + state: '', + }), + { + code: 'session.insufficient_info', + status: 400, + } + ); + + await expectRejects( + client.getSocialAuthorizationUri(connectorId, { + redirectUri: '', + state, + }), + { + code: 'session.insufficient_info', + status: 400, + } + ); + }); + + it('should throw if the connector is not a social connector', async () => { + const client = await initExperienceClient(); + const connectorId = connectorIdMap.get(mockEmailConnectorId)!; + + await expectRejects( + client.getSocialAuthorizationUri(connectorId, { + redirectUri, + state, + }), + { + code: 'connector.unexpected_type', + status: 400, + } + ); + }); + + it('should throw if the connector is not found', async () => { + const client = await initExperienceClient(); + + await expectRejects( + client.getSocialAuthorizationUri('invalid_connector_id', { + redirectUri, + state, + }), + { + code: 'entity.not_found', + status: 404, + } + ); + }); + + it('should return the authorizationUri and verificationId', async () => { + const client = await initExperienceClient(); + const connectorId = connectorIdMap.get(mockSocialConnectorId)!; + + await successFullyCreateSocialVerification(client, connectorId, { + redirectUri, + state, + }); + }); + }); + + describe('verifySocialAuthorization', () => { + it('should throw if the verification record is not found', async () => { + const client = await initExperienceClient(); + const connectorId = connectorIdMap.get(mockSocialConnectorId)!; + + await successFullyCreateSocialVerification(client, connectorId, { + redirectUri, + state, + }); + + await expectRejects( + client.verifySocialAuthorization(connectorId, { + verificationId: 'invalid_verification_id', + connectorData: { + authorizationCode, + }, + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should throw if the verification type is not social', async () => { + const client = await initExperienceClient(); + const connectorId = connectorIdMap.get(mockEmailConnectorId)!; + + const { verificationId } = await client.sendVerificationCode({ + identifier: { + type: InteractionIdentifierType.Email, + value: 'foo', + }, + interactionEvent: InteractionEvent.SignIn, + }); + + await expectRejects( + client.verifySocialAuthorization(connectorId, { + verificationId, + connectorData: { + authorizationCode, + }, + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should throw if the connectorId is different', async () => { + const client = await initExperienceClient(); + const connectorId = connectorIdMap.get(mockSocialConnectorId)!; + + const { verificationId } = await client.getSocialAuthorizationUri(connectorId, { + redirectUri, + state, + }); + + await expectRejects( + client.verifySocialAuthorization('invalid_connector_id', { + verificationId, + connectorData: { + authorizationCode, + }, + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should successfully verify the social authorization', async () => { + const client = await initExperienceClient(); + const connectorId = connectorIdMap.get(mockSocialConnectorId)!; + + const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, { + redirectUri, + state, + }); + + await successFullyVerifySocialAuthorization(client, connectorId, { + verificationId, + connectorData: { + code: authorizationCode, + }, + }); + }); + }); +}); diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index ce0bb26fd44..4991b7738f1 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -57,6 +57,7 @@ export enum VerificationType { Password = 'Password', VerificationCode = 'VerificationCode', Social = 'Social', + EnterpriseSso = 'EnterpriseSso', TOTP = 'Totp', WebAuthn = 'WebAuthn', BackupCode = 'BackupCode', @@ -64,11 +65,34 @@ export enum VerificationType { // REMARK: API payload guard +/** Payload type for `POST /api/experience/verification/social/:connectorId/authorization-uri`. */ +export type SocialAuthorizationUrlPayload = { + state: string; + redirectUri: string; +}; +export const socialAuthorizationUrlPayloadGuard = z.object({ + state: z.string(), + redirectUri: z.string(), +}) satisfies ToZodObject; + +/** Payload type for `POST /api/experience/verification/social/:connectorId/verify`. */ +export type SocialVerificationCallbackPayload = { + /** The callback data from the social connector. */ + connectorData: Record; + /** The verification ID returned from the authorization URI. */ + verificationId: string; +}; +export const socialVerificationCallbackPayloadGuard = z.object({ + connectorData: jsonObjectGuard, + verificationId: z.string(), +}) satisfies ToZodObject; + /** Payload type for `POST /api/experience/verification/password`. */ export type PasswordVerificationPayload = { identifier: InteractionIdentifier; password: string; }; + export const passwordVerificationPayloadGuard = z.object({ identifier: interactionIdentifierGuard, password: z.string().min(1), @@ -85,7 +109,7 @@ export const identificationApiPayloadGuard = z.object({ verificationId: z.string(), }) satisfies ToZodObject; -// ====== Experience API payload guards and type definitions end ====== +// ====== Experience API payload guard and types definitions end ====== /** * Legacy interaction identifier payload guard