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:
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 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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
} 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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
* @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 */
|
||||
|
|
|
@ -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 }>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue