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:
parent
1c90671451
commit
aec2cf4f5b
20 changed files with 780 additions and 14 deletions
|
@ -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()),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
7
packages/core/src/routes/experience/const.ts
Normal file
7
packages/core/src/routes/experience/const.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
const prefix = '/experience';
|
||||
|
||||
export const experienceRoutes = Object.freeze({
|
||||
prefix,
|
||||
identification: `${prefix}/identification`,
|
||||
verification: `${prefix}/verification`,
|
||||
});
|
79
packages/core/src/routes/experience/index.ts
Normal file
79
packages/core/src/routes/experience/index.ts
Normal 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);
|
||||
}
|
|
@ -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();
|
||||
};
|
||||
}
|
3
packages/core/src/routes/experience/types.ts
Normal file
3
packages/core/src/routes/experience/types.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import type Provider from 'oidc-provider';
|
||||
|
||||
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
20
packages/core/src/routes/experience/utils.ts
Normal file
20
packages/core/src/routes/experience/utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
const prefix = 'experience';
|
||||
|
||||
export const experienceRoutes = {
|
||||
verification: `${prefix}/verification`,
|
||||
identification: `${prefix}/identification`,
|
||||
prefix,
|
||||
};
|
45
packages/integration-tests/src/client/experience/index.ts
Normal file
45
packages/integration-tests/src/client/experience/index.ts
Normal 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 }>();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
32
packages/integration-tests/src/helpers/experience/index.ts
Normal file
32
packages/integration-tests/src/helpers/experience/index.ts
Normal 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);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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.',
|
||||
|
|
|
@ -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([
|
||||
|
|
Loading…
Add table
Reference in a new issue