0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(core): implement new experience API routes (#5992)

* feat(core): implement new interaction-session management flow

implement a new interaction-session management flow for experience api use

* feat(core): implement password sign-in flow

implement password sign-in flow

* test(core,schemas): add sign-in password tests

add sign-in password tests

* chore(core): update comments

update comments

* refactor(core): rename the password input value key

rename the password input value key

* refactor(core,schemas): refactor the experience API

refactor the exerpience API structure

* chore(test): add devFeature test

add devFeature test

* refactor(core): rename the path

rename the path

* refactor(core,schemas): refactor using the latest API design

refactor using the latest API design

* chore(test): replace using devFeature test statement

replace using devFeature test statement

* fix(core): fix lint error

fix lint error

* refactor(core): refactor experience API implementations

refactor experience API implementations

* refactor(core): replace with switch

replace object map with switch

* refactor: apply suggestions from code review

* refactor(core): refactor the interaction class
 refactor the interaction class

* refactor(core): update the user identification logic

update the user identification logic

---------

Co-authored-by: Gao Sun <gao@silverhand.io>
This commit is contained in:
simeng-li 2024-07-05 11:02:36 +08:00 committed by GitHub
parent 1c90671451
commit aec2cf4f5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 780 additions and 14 deletions

View file

@ -0,0 +1,171 @@
import { InteractionEvent, type VerificationType } from '@logto/schemas';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import type { Interaction } from '../types.js';
import {
buildVerificationRecord,
verificationRecordDataGuard,
type VerificationRecord,
} from './verifications/index.js';
const interactionStorageGuard = z.object({
event: z.nativeEnum(InteractionEvent).optional(),
accountId: z.string().optional(),
profile: z.object({}).optional(),
verificationRecords: verificationRecordDataGuard.array().optional(),
});
/**
* Interaction is a short-lived session session that is initiated when a user starts an interaction flow with the Logto platform.
* This class is used to manage all the interaction data and status.
*
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004.
*/
export default class ExperienceInteraction {
/**
* Factory method to create a new `ExperienceInteraction` using the current context.
*/
static async create(ctx: WithLogContext, tenant: TenantContext) {
const { provider } = tenant;
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
return new ExperienceInteraction(ctx, tenant, interactionDetails);
}
/** The interaction event for the current interaction. */
private interactionEvent?: InteractionEvent;
/** The user verification record list for the current interaction. */
private readonly verificationRecords: Map<VerificationType, VerificationRecord>;
/** The accountId of the user for the current interaction. Only available once the user is identified. */
private accountId?: string;
/** The user provided profile data in the current interaction that needs to be stored to database. */
private readonly profile?: Record<string, unknown>; // TODO: Fix the type
constructor(
private readonly ctx: WithLogContext,
private readonly tenant: TenantContext,
interactionDetails: Interaction
) {
const { libraries, queries } = tenant;
const result = interactionStorageGuard.safeParse(interactionDetails.result ?? {});
assertThat(
result.success,
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);
const { verificationRecords = [], profile, accountId, event } = result.data;
this.interactionEvent = event;
this.accountId = accountId; // TODO: @simeng-li replace with userId
this.profile = profile;
this.verificationRecords = new Map();
for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record);
this.verificationRecords.set(instance.type, instance);
}
}
/** Set the interaction event for the current interaction */
public setInteractionEvent(event: InteractionEvent) {
// TODO: conflict event check (e.g. reset password session can't be used for sign in)
this.interactionEvent = event;
}
/** Set the verified `accountId` of the current interaction from the verification record */
public identifyUser(verificationId: string) {
const verificationRecord = this.getVerificationRecordById(verificationId);
assertThat(
verificationRecord,
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
// Throws an 404 error if the user is not found by the given verification record
assertThat(
verificationRecord.verifiedUserId,
new RequestError({
code: 'user.user_not_exist',
status: 404,
})
);
// Throws an 409 error if the current session has already identified a different user
if (this.accountId) {
assertThat(
this.accountId === verificationRecord.verifiedUserId,
new RequestError({ code: 'session.identity_conflict', status: 409 })
);
return;
}
this.accountId = verificationRecord.verifiedUserId;
}
/**
* Append a new verification record to the current interaction.
* If a record with the same type already exists, it will be replaced.
*/
public setVerificationRecord(record: VerificationRecord) {
const { type } = record;
this.verificationRecords.set(type, record);
}
public getVerificationRecordById(verificationId: string) {
return this.verificationRecordsArray.find((record) => record.id === verificationId);
}
/** Save the current interaction result. */
public async save() {
// `mergeWithLastSubmission` will only merge current request's interaction results.
// Manually merge with previous interaction results here.
// @see {@link https://github.com/panva/node-oidc-provider/blob/c243bf6b6663c41ff3e75c09b95fb978eba87381/lib/actions/authorization/interactions.js#L106}
const { provider } = this.tenant;
const details = await provider.interactionDetails(this.ctx.req, this.ctx.res);
await provider.interactionResult(
this.ctx.req,
this.ctx.res,
{ ...details.result, ...this.toJson() },
{ mergeWithLastSubmission: true }
);
}
/** Submit the current interaction result to the OIDC provider and clear the interaction data */
public async submit() {
// TODO: refine the error code
assertThat(this.accountId, 'session.verification_session_not_found');
const { provider } = this.tenant;
const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
login: { accountId: this.accountId },
});
this.ctx.body = { redirectTo };
}
private get verificationRecordsArray() {
return [...this.verificationRecords.values()];
}
/** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */
public toJson() {
return {
event: this.interactionEvent,
accountId: this.accountId,
profile: this.profile,
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
};
}
}

View file

@ -0,0 +1,35 @@
import { VerificationType } from '@logto/schemas';
import { z } from 'zod';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import {
PasswordVerification,
passwordVerificationRecordDataGuard,
type PasswordVerificationRecordData,
} from './password-verification.js';
import { type VerificationRecord } from './verification-record.js';
export { type VerificationRecord } from './verification-record.js';
type VerificationRecordData = PasswordVerificationRecordData;
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
]);
/**
* The factory method to build a new `VerificationRecord` instance based on the provided `VerificationRecordData`.
*/
export const buildVerificationRecord = <T extends VerificationRecordData>(
libraries: Libraries,
queries: Queries,
data: T
): VerificationRecord<T['type']> => {
switch (data.type) {
case VerificationType.Password: {
return new PasswordVerification(libraries, queries, data);
}
}
};

View file

@ -0,0 +1,102 @@
import {
VerificationType,
interactionIdentifierGuard,
type InteractionIdentifier,
} from '@logto/schemas';
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import { findUserByIdentifier } from '../../utils.js';
import { type VerificationRecord } from './verification-record.js';
export type PasswordVerificationRecordData = {
id: string;
type: VerificationType.Password;
identifier: InteractionIdentifier;
/** The unique identifier of the user that has been verified. */
userId?: string;
};
export const passwordVerificationRecordDataGuard = z.object({
id: z.string(),
type: z.literal(VerificationType.Password),
identifier: interactionIdentifierGuard,
userId: z.string().optional(),
}) satisfies ToZodObject<PasswordVerificationRecordData>;
export class PasswordVerification implements VerificationRecord<VerificationType.Password> {
/** Factory method to create a new `PasswordVerification` record using an identifier */
static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) {
return new PasswordVerification(libraries, queries, {
id: generateStandardId(),
type: VerificationType.Password,
identifier,
});
}
readonly type = VerificationType.Password;
public readonly identifier: InteractionIdentifier;
public readonly id: string;
private userId?: string;
/**
* The constructor method is intended to be used internally by the interaction class
* to instantiate a `VerificationRecord` object from existing `PasswordVerificationRecordData`.
* It directly sets the instance properties based on the provided data.
* For creating a new verification record from context, use the static `create` method instead.
*/
constructor(
private readonly libraries: Libraries,
private readonly queries: Queries,
data: PasswordVerificationRecordData
) {
const { id, identifier, userId } = data;
this.id = id;
this.identifier = identifier;
this.userId = userId;
}
/** Returns true if a userId is set */
get isVerified() {
return this.userId !== undefined;
}
get verifiedUserId() {
return this.userId;
}
/**
* Verifies if the password matches the record in database with the current identifier.
* `userId` will be set if the password can be verified.
*
* @throws RequestError with 401 status if user id suspended.
* @throws RequestError with 422 status if the user is not found or the password is incorrect.
*/
async verify(password: string) {
const user = await findUserByIdentifier(this.queries.users, this.identifier);
const { isSuspended, id } = await this.libraries.users.verifyUserPassword(user, password);
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
this.userId = id;
}
toJson(): PasswordVerificationRecordData {
const { id, type, identifier, userId } = this;
return {
id,
type,
identifier,
userId,
};
}
}

View file

@ -0,0 +1,21 @@
import type { VerificationType } from '@logto/schemas';
type Data<T> = {
id: string;
type: T;
};
/** The abstract class for all verification records. */
export abstract class VerificationRecord<
T extends VerificationType = VerificationType,
Json extends Data<T> = Data<T>,
> {
abstract readonly id: string;
abstract readonly type: T;
abstract get isVerified(): boolean;
/** @deprecated will be removed in the coming PR */
abstract get verifiedUserId(): string | undefined;
abstract toJson(): Json;
}

View file

@ -0,0 +1,7 @@
const prefix = '/experience';
export const experienceRoutes = Object.freeze({
prefix,
identification: `${prefix}/identification`,
verification: `${prefix}/verification`,
});

View file

@ -0,0 +1,79 @@
/**
* @overview This file implements the routes for the user interaction experience (RFC 0004).
*
* Note the experience APIs also known as interaction APIs v2,
* are the new version of the interaction APIs with design improvements.
*
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004.
*
* @remarks
* The experience APIs can be used by developers to build custom user interaction experiences.
*/
import { identificationApiPayloadGuard } from '@logto/schemas';
import type Router from 'koa-router';
import { z } from 'zod';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import { type AnonymousRouter, type RouterInitArgs } from '../types.js';
import { experienceRoutes } from './const.js';
import koaExperienceInteraction, {
type WithExperienceInteractionContext,
} from './middleware/koa-experience-interaction.js';
import passwordVerificationRoutes from './verification-routes/password-verification.js';
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
export default function experienceApiRoutes<T extends AnonymousRouter>(
...[anonymousRouter, tenant]: RouterInitArgs<T>
) {
const { queries, libraries } = tenant;
const router =
// @ts-expect-error for good koa types
// eslint-disable-next-line no-restricted-syntax
(anonymousRouter as Router<unknown, WithExperienceInteractionContext<RouterContext<T>>>).use(
koaAuditLog(queries),
koaExperienceInteraction(tenant)
);
router.post(
experienceRoutes.identification,
koaGuard({
body: identificationApiPayloadGuard,
status: [204, 400, 404],
}),
async (ctx, next) => {
const { interactionEvent, verificationId } = ctx.guard.body;
ctx.experienceInteraction.setInteractionEvent(interactionEvent);
ctx.experienceInteraction.identifyUser(verificationId);
await ctx.experienceInteraction.save();
ctx.status = 204;
return next();
}
);
router.post(
`${experienceRoutes.prefix}/submit`,
koaGuard({
status: [200],
response: z.object({
redirectTo: z.string(),
}),
}),
async (ctx, next) => {
await ctx.experienceInteraction.submit();
ctx.status = 200;
return next();
}
);
passwordVerificationRoutes(router, tenant);
}

View file

@ -0,0 +1,32 @@
import type { MiddlewareType } from 'koa';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import ExperienceInteraction from '../classes/experience-interaction.js';
export type WithExperienceInteractionContext<ContextT extends WithLogContext = WithLogContext> =
ContextT & {
experienceInteraction: ExperienceInteraction;
};
/**
* @overview This middleware initializes the `ExperienceInteraction` for the current request.
* The `ExperienceInteraction` instance is used to manage all the data related to the current interaction.
* All the interaction data is stored using oidc-provider's interaction session.
*
* @see {@link https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#user-flows}
*/
export default function koaExperienceInteraction<
StateT,
ContextT extends WithLogContext,
ResponseT,
>(
tenant: TenantContext
): MiddlewareType<StateT, WithExperienceInteractionContext<ContextT>, ResponseT> {
return async (ctx, next) => {
ctx.experienceInteraction = await ExperienceInteraction.create(ctx, tenant);
return next();
};
}

View file

@ -0,0 +1,3 @@
import type Provider from 'oidc-provider';
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;

View file

@ -0,0 +1,20 @@
import { InteractionIdentifierType, type InteractionIdentifier } from '@logto/schemas';
import type Queries from '#src/tenants/Queries.js';
export const findUserByIdentifier = async (
userQuery: Queries['users'],
{ type, value }: InteractionIdentifier
) => {
switch (type) {
case InteractionIdentifierType.Username: {
return userQuery.findUserByUsername(value);
}
case InteractionIdentifierType.Email: {
return userQuery.findUserByEmail(value);
}
case InteractionIdentifierType.Phone: {
return userQuery.findUserByPhone(value);
}
}
};

View file

@ -0,0 +1,41 @@
import { passwordVerificationPayloadGuard } from '@logto/schemas';
import type Router from 'koa-router';
import { z } from 'zod';
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 { PasswordVerification } from '../classes/verifications/password-verification.js';
import { experienceRoutes } from '../const.js';
import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js';
export default function passwordVerificationRoutes<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
{ libraries, queries }: TenantContext
) {
router.post(
`${experienceRoutes.verification}/password`,
koaGuard({
body: passwordVerificationPayloadGuard,
status: [200, 400, 422],
response: z.object({
verificationId: z.string(),
}),
}),
async (ctx, next) => {
const { identifier, password } = ctx.guard.body;
const passwordVerification = PasswordVerification.create(libraries, queries, identifier);
await passwordVerification.verify(password);
ctx.experienceInteraction.setVerificationRecord(passwordVerification);
await ctx.experienceInteraction.save();
ctx.body = { verificationId: passwordVerification.id };
ctx.status = 200;
return next();
}
);
}

View file

@ -24,6 +24,7 @@ import connectorRoutes from './connector/index.js';
import customPhraseRoutes from './custom-phrase.js';
import dashboardRoutes from './dashboard.js';
import domainRoutes from './domain.js';
import experienceApiRoutes from './experience/index.js';
import hookRoutes from './hook.js';
import interactionRoutes from './interaction/index.js';
import logRoutes from './log.js';
@ -46,8 +47,14 @@ import wellKnownRoutes from './well-known.js';
const createRouters = (tenant: TenantContext) => {
const interactionRouter: AnonymousRouter = new Router();
/** @deprecated */
interactionRoutes(interactionRouter, tenant);
const experienceRouter: AnonymousRouter = new Router();
if (EnvSet.values.isDevFeaturesEnabled) {
experienceApiRoutes(experienceRouter, tenant);
}
const managementRouter: ManagementApiRouter = new Router();
managementRouter.use(koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id)));
managementRouter.use(koaTenantGuard(tenant.id, tenant.queries));
@ -91,7 +98,7 @@ const createRouters = (tenant: TenantContext) => {
// The swagger.json should contain all API routers.
swaggerRoutes(anonymousRouter, [interactionRouter, managementRouter, anonymousRouter]);
return [interactionRouter, managementRouter, anonymousRouter];
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter];
};
export default function initApis(tenant: TenantContext): Koa {

View file

@ -2,21 +2,21 @@ import type { Profile } from '@logto/schemas';
import { InteractionEvent } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import type { Context } from 'koa';
import { errors } from 'oidc-provider';
import type { InteractionResults } from 'oidc-provider';
import type Provider from 'oidc-provider';
import type { InteractionResults } from 'oidc-provider';
import { errors } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';
import { anonymousInteractionResultGuard } from '../types/guard.js';
import type {
Identifier,
AccountVerifiedInteractionResult,
AnonymousInteractionResult,
Identifier,
RegisterInteractionResult,
VerifiedForgotPasswordInteractionResult,
VerifiedInteractionResult,
RegisterInteractionResult,
AccountVerifiedInteractionResult,
VerifiedRegisterInteractionResult,
} from '../types/index.js';
@ -170,6 +170,11 @@ export const getInteractionFromProviderByJti = async (
return interaction;
};
/**
* Since we don't have the OIDC provider context here, `provider.interactionResult` cannot be used.
* This method is forked from the original implementation in `provide.interactionResult` in oidc-provider.
* Assign the result to the interaction and save it.
*/
export const assignResultToInteraction = async (
interaction: Interaction,
result: InteractionResults

View file

@ -0,0 +1,7 @@
const prefix = 'experience';
export const experienceRoutes = {
verification: `${prefix}/verification`,
identification: `${prefix}/identification`,
prefix,
};

View file

@ -0,0 +1,45 @@
import { type IdentificationApiPayload, type PasswordVerificationPayload } from '@logto/schemas';
import MockClient from '#src/client/index.js';
import api from '../../api/api.js';
import { experienceRoutes } from './const.js';
type RedirectResponse = {
redirectTo: string;
};
export const identifyUser = async (cookie: string, payload: IdentificationApiPayload) =>
api
.post(experienceRoutes.identification, {
headers: { cookie },
json: payload,
})
.json();
export class ExperienceClient extends MockClient {
public async identifyUser(payload: IdentificationApiPayload) {
return api
.post(experienceRoutes.identification, {
headers: { cookie: this.interactionCookie },
json: payload,
})
.json();
}
public override async submitInteraction(): Promise<RedirectResponse> {
return api
.post(`${experienceRoutes.prefix}/submit`, { headers: { cookie: this.interactionCookie } })
.json<RedirectResponse>();
}
public async verifyPassword(payload: PasswordVerificationPayload) {
return api
.post(`${experienceRoutes.verification}/password`, {
headers: { cookie: this.interactionCookie },
json: payload,
})
.json<{ verificationId: string }>();
}
}

View file

@ -1,6 +1,7 @@
import type { LogtoConfig, SignInOptions } from '@logto/node';
import { assert } from '@silverhand/essentials';
import { ExperienceClient } from '#src/client/experience/index.js';
import MockClient from '#src/client/index.js';
export const initClient = async (
@ -15,6 +16,18 @@ export const initClient = async (
return client;
};
export const initExperienceClient = async (
config?: Partial<LogtoConfig>,
redirectUri?: string,
options: Omit<SignInOptions, 'redirectUri'> = {}
) => {
const client = new ExperienceClient(config);
await client.initSession(redirectUri, options);
assert(client.interactionCookie, new Error('Session not found'));
return client;
};
export const processSession = async (client: MockClient, redirectTo: string) => {
await client.processSession(redirectTo);

View file

@ -0,0 +1,32 @@
/**
* @fileoverview This file contains the successful interaction flow helper functions that use the experience APIs.
*/
import { InteractionEvent, type InteractionIdentifier } from '@logto/schemas';
import { initExperienceClient, logoutClient, processSession } from '../client.js';
export const signInWithPassword = async ({
identifier,
password,
}: {
identifier: InteractionIdentifier;
password: string;
}) => {
const client = await initExperienceClient();
const { verificationId } = await client.verifyPassword({
identifier,
password,
});
await client.identifyUser({
interactionEvent: InteractionEvent.SignIn,
verificationId,
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
};

View file

@ -0,0 +1,39 @@
import { InteractionIdentifierType } from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
import { signInWithPassword } from '#src/helpers/experience/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest } from '#src/utils.js';
const identifiersTypeToUserProfile = Object.freeze({
username: 'username',
email: 'primaryEmail',
phone: 'primaryPhone',
});
devFeatureTest.describe('sign-in with password verification happy path', () => {
beforeAll(async () => {
await enableAllPasswordSignInMethods();
});
it.each(Object.values(InteractionIdentifierType))(
'should sign-in with password using %p',
async (identifier) => {
const { userProfile, user } = await generateNewUser({
[identifiersTypeToUserProfile[identifier]]: true,
password: true,
});
await signInWithPassword({
identifier: {
type: identifier,
value: userProfile[identifiersTypeToUserProfile[identifier]]!,
},
password: userProfile.password,
});
await deleteUser(user.id);
}
);
});

View file

@ -0,0 +1,38 @@
import { InteractionIdentifierType } from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
import { initExperienceClient } from '#src/helpers/client.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest } from '#src/utils.js';
const identifiersTypeToUserProfile = Object.freeze({
username: 'username',
email: 'primaryEmail',
phone: 'primaryPhone',
});
devFeatureTest.describe('password verifications', () => {
it.each(Object.values(InteractionIdentifierType))(
'should verify with password successfully using %p',
async (identifier) => {
const { userProfile, user } = await generateNewUser({
[identifiersTypeToUserProfile[identifier]]: true,
password: true,
});
const client = await initExperienceClient();
const { verificationId } = await client.verifyPassword({
identifier: {
type: identifier,
value: userProfile[identifiersTypeToUserProfile[identifier]]!,
},
password: userProfile.password,
});
expect(verificationId).toBeTruthy();
await deleteUser(user.id);
}
);
});

View file

@ -23,6 +23,8 @@ const session = {
interaction_not_found:
'Interaction session not found. Please go back and start the session again.',
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
identity_conflict:
'Identity mismatch detected. Please initiate a new session to proceed with a different identity.',
mfa: {
require_mfa_verification: 'Mfa verification is required to sign in.',
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',

View file

@ -2,6 +2,7 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import { z } from 'zod';
import { MfaFactor, jsonObjectGuard, webAuthnTransportGuard } from '../foundations/index.js';
import { type ToZodObject } from '../utils/zod.js';
import type {
EmailVerificationCodePayload,
@ -12,13 +13,86 @@ import {
phoneVerificationCodePayloadGuard,
} from './verification-code.js';
/**
* User interaction events defined in Logto RFC 0004.
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information.
*/
export enum InteractionEvent {
SignIn = 'SignIn',
Register = 'Register',
ForgotPassword = 'ForgotPassword',
}
// ====== Experience API payload guards and type definitions start ======
export enum InteractionIdentifierType {
Username = 'username',
Email = 'email',
Phone = 'phone',
}
/** Identifiers that can be used to uniquely identify a user. */
export type InteractionIdentifier = {
type: InteractionIdentifierType;
value: string;
};
export const interactionIdentifierGuard = z.object({
type: z.nativeEnum(InteractionIdentifierType),
value: z.string(),
}) satisfies ToZodObject<InteractionIdentifier>;
/** Logto supported interaction verification types. */
export enum VerificationType {
Password = 'Password',
VerificationCode = 'VerificationCode',
Social = 'Social',
TOTP = 'Totp',
WebAuthn = 'WebAuthn',
BackupCode = 'BackupCode',
}
// REMARK: API payload guard
/** 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),
}) satisfies ToZodObject<PasswordVerificationPayload>;
/** Payload type for `POST /api/experience/identification`. */
export type IdentificationApiPayload = {
interactionEvent: InteractionEvent;
verificationId: string;
};
export const identificationApiPayloadGuard = z.object({
interactionEvent: z.nativeEnum(InteractionEvent),
verificationId: z.string(),
}) satisfies ToZodObject<IdentificationApiPayload>;
// ====== Experience API payload guards and type definitions end ======
/**
* Legacy interaction identifier payload guard
*
* @remark
* Following are the types for legacy interaction APIs. They are all treated as deprecated, and can be removed
* once the new Experience API are fully implemented and migrated.
* =================================================================================================================
*/
/**
* Detailed interaction identifier payload guard
*/
export const usernamePasswordPayloadGuard = z.object({
const usernamePasswordPayloadGuard = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type UsernamePasswordPayload = z.infer<typeof usernamePasswordPayloadGuard>;
export const emailPasswordPayloadGuard = z.object({
@ -53,13 +127,6 @@ export const socialPhonePayloadGuard = z.object({
export type SocialPhonePayload = z.infer<typeof socialPhonePayloadGuard>;
// Interaction flow event types
export enum InteractionEvent {
SignIn = 'SignIn',
Register = 'Register',
ForgotPassword = 'ForgotPassword',
}
export const eventGuard = z.nativeEnum(InteractionEvent);
export const identifierPayloadGuard = z.union([