mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -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:
parent
af44e87ebd
commit
d16bc9b2e5
11 changed files with 633 additions and 6 deletions
|
@ -2,9 +2,9 @@ import { randomUUID } from 'node:crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CreateConnector,
|
||||||
GetAuthorizationUri,
|
GetAuthorizationUri,
|
||||||
GetUserInfo,
|
GetUserInfo,
|
||||||
CreateConnector,
|
|
||||||
SocialConnector,
|
SocialConnector,
|
||||||
} from '@logto/connector-kit';
|
} from '@logto/connector-kit';
|
||||||
import {
|
import {
|
||||||
|
@ -17,11 +17,23 @@ import {
|
||||||
import { defaultMetadata } from './constant.js';
|
import { defaultMetadata } from './constant.js';
|
||||||
import { mockSocialConfigGuard } from './types.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}`;
|
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({
|
const dataGuard = z.object({
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
userId: z.optional(z.string()),
|
userId: z.optional(z.string()),
|
||||||
|
@ -34,6 +46,19 @@ const getUserInfo: GetUserInfo = async (data) => {
|
||||||
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(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;
|
const { code, userId, ...rest } = result.data;
|
||||||
|
|
||||||
// For mock use only. Use to track the created user entity
|
// For mock use only. Use to track the created user entity
|
||||||
|
|
|
@ -90,6 +90,7 @@ export default class ExperienceInteraction {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throws an 404 error if the user is not found by the given verification record
|
// 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(
|
assertThat(
|
||||||
verificationRecord.verifiedUserId,
|
verificationRecord.verifiedUserId,
|
||||||
new RequestError({
|
new RequestError({
|
||||||
|
|
|
@ -14,8 +14,16 @@ import {
|
||||||
passwordVerificationRecordDataGuard,
|
passwordVerificationRecordDataGuard,
|
||||||
type PasswordVerificationRecordData,
|
type PasswordVerificationRecordData,
|
||||||
} from './password-verification.js';
|
} 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
|
* 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.
|
* 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.
|
* 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', [
|
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
|
||||||
passwordVerificationRecordDataGuard,
|
passwordVerificationRecordDataGuard,
|
||||||
codeVerificationRecordDataGuard,
|
codeVerificationRecordDataGuard,
|
||||||
|
socialVerificationRecordDataGuard,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -47,5 +56,8 @@ export const buildVerificationRecord = (
|
||||||
case VerificationType.VerificationCode: {
|
case VerificationType.VerificationCode: {
|
||||||
return new CodeVerification(libraries, queries, data);
|
return new CodeVerification(libraries, queries, data);
|
||||||
}
|
}
|
||||||
|
case VerificationType.Social: {
|
||||||
|
return new SocialVerification(libraries, queries, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,7 @@ import koaExperienceInteraction, {
|
||||||
type WithExperienceInteractionContext,
|
type WithExperienceInteractionContext,
|
||||||
} from './middleware/koa-experience-interaction.js';
|
} from './middleware/koa-experience-interaction.js';
|
||||||
import passwordVerificationRoutes from './verification-routes/password-verification.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';
|
import verificationCodeRoutes from './verification-routes/verification-code.js';
|
||||||
|
|
||||||
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
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);
|
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);
|
ctx.experienceInteraction.identifyUser(verificationId);
|
||||||
|
|
||||||
await ctx.experienceInteraction.save();
|
await ctx.experienceInteraction.save();
|
||||||
|
@ -79,4 +84,5 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
||||||
|
|
||||||
passwordVerificationRoutes(router, tenant);
|
passwordVerificationRoutes(router, tenant);
|
||||||
verificationCodeRoutes(router, tenant);
|
verificationCodeRoutes(router, tenant);
|
||||||
|
socialVerificationRoutes(router, tenant);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -30,6 +30,12 @@ export type PasswordIdentifierPayload =
|
||||||
|
|
||||||
export type SocialVerifiedIdentifierPayload = SocialEmailPayload | SocialPhonePayload;
|
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>;
|
export type SocialAuthorizationUrlPayload = z.infer<typeof socialAuthorizationUrlPayloadGuard>;
|
||||||
|
|
||||||
/* Interaction Types */
|
/* Interaction Types */
|
||||||
|
|
|
@ -72,4 +72,34 @@ export class ExperienceClient extends MockClient {
|
||||||
})
|
})
|
||||||
.json<{ verificationId: string }>();
|
.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 }>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -57,6 +57,7 @@ export enum VerificationType {
|
||||||
Password = 'Password',
|
Password = 'Password',
|
||||||
VerificationCode = 'VerificationCode',
|
VerificationCode = 'VerificationCode',
|
||||||
Social = 'Social',
|
Social = 'Social',
|
||||||
|
EnterpriseSso = 'EnterpriseSso',
|
||||||
TOTP = 'Totp',
|
TOTP = 'Totp',
|
||||||
WebAuthn = 'WebAuthn',
|
WebAuthn = 'WebAuthn',
|
||||||
BackupCode = 'BackupCode',
|
BackupCode = 'BackupCode',
|
||||||
|
@ -64,11 +65,34 @@ export enum VerificationType {
|
||||||
|
|
||||||
// REMARK: API payload guard
|
// 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`. */
|
/** Payload type for `POST /api/experience/verification/password`. */
|
||||||
export type PasswordVerificationPayload = {
|
export type PasswordVerificationPayload = {
|
||||||
identifier: InteractionIdentifier;
|
identifier: InteractionIdentifier;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const passwordVerificationPayloadGuard = z.object({
|
export const passwordVerificationPayloadGuard = z.object({
|
||||||
identifier: interactionIdentifierGuard,
|
identifier: interactionIdentifierGuard,
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
|
@ -85,7 +109,7 @@ export const identificationApiPayloadGuard = z.object({
|
||||||
verificationId: z.string(),
|
verificationId: z.string(),
|
||||||
}) satisfies ToZodObject<IdentificationApiPayload>;
|
}) satisfies ToZodObject<IdentificationApiPayload>;
|
||||||
|
|
||||||
// ====== Experience API payload guards and type definitions end ======
|
// ====== Experience API payload guard and types definitions end ======
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy interaction identifier payload guard
|
* Legacy interaction identifier payload guard
|
||||||
|
|
Loading…
Add table
Reference in a new issue