0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core,schemas): implement social verification experience API endpoints (#6150)

feat(core,schemas): implement the social verification flow

implement the social verificaiton flow
This commit is contained in:
simeng-li 2024-07-05 16:36:40 +08:00 committed by GitHub
parent af44e87ebd
commit d16bc9b2e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 633 additions and 6 deletions

View file

@ -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

View file

@ -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({

View file

@ -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);
}
}
};

View file

@ -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<SocialVerificationRecordData>;
export class SocialVerification implements VerificationRecord<VerificationType.Social> {
/**
* 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<User | undefined> {
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<typeof socials.findSocialRelatedUser> {
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,
};
}
}

View file

@ -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> = T extends Router<unknown, infer Context> ? Context : never;
@ -52,6 +53,10 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
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<T extends AnonymousRouter>(
passwordVerificationRoutes(router, tenant);
verificationCodeRoutes(router, tenant);
socialVerificationRoutes(router, tenant);
}

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
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();
}
);
}

View file

@ -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<typeof socialAuthorizationUrlPayloadGuard>;
/* Interaction Types */

View file

@ -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<string, unknown>;
}
) {
return api
.post(`${experienceRoutes.verification}/social/${connectorId}/verify`, {
headers: { cookie: this.interactionCookie },
json: payload,
})
.json<{ verificationId: string }>();
}
}

View file

@ -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<string, unknown>;
}
) => {
const { verificationId: verifiedVerificationId } = await client.verifySocialAuthorization(
connectorId,
payload
);
expect(verifiedVerificationId).toBeTruthy();
return verifiedVerificationId;
};

View file

@ -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<string, string>();
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,
},
});
});
});
});

View file

@ -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<SocialAuthorizationUrlPayload>;
/** Payload type for `POST /api/experience/verification/social/:connectorId/verify`. */
export type SocialVerificationCallbackPayload = {
/** The callback data from the social connector. */
connectorData: Record<string, unknown>;
/** The verification ID returned from the authorization URI. */
verificationId: string;
};
export const socialVerificationCallbackPayloadGuard = z.object({
connectorData: jsonObjectGuard,
verificationId: z.string(),
}) satisfies ToZodObject<SocialVerificationCallbackPayload>;
/** 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<IdentificationApiPayload>;
// ====== Experience API payload guards and type definitions end ======
// ====== Experience API payload guard and types definitions end ======
/**
* Legacy interaction identifier payload guard