mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
feat(core): implement new interaction-session management flow
implement a new interaction-session management flow for experience api use
This commit is contained in:
parent
0874b70433
commit
59403aa606
10 changed files with 415 additions and 6 deletions
60
packages/core/src/routes/experience/index.ts
Normal file
60
packages/core/src/routes/experience/index.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* @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 type Router from 'koa-router';
|
||||
|
||||
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 koaInteractionSession, {
|
||||
type WithInteractionSessionContext,
|
||||
} from './middleware/koa-interaction-session.js';
|
||||
import { signInPayloadGuard } from './type.js';
|
||||
|
||||
const experienceApiRoutesPrefix = '/experience';
|
||||
|
||||
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||
|
||||
export default function experienceApiRoutes<T extends AnonymousRouter>(
|
||||
...[anonymousRouter, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const { queries } = tenant;
|
||||
|
||||
const router =
|
||||
// @ts-expect-error for good koa types
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(anonymousRouter as Router<unknown, WithInteractionSessionContext<RouterContext<T>>>).use(
|
||||
koaAuditLog(queries),
|
||||
koaInteractionSession(tenant)
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${experienceApiRoutesPrefix}/sign-in`,
|
||||
koaGuard({
|
||||
body: signInPayloadGuard,
|
||||
status: [204, 400, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { identifier, verification } = ctx.guard.body;
|
||||
|
||||
ctx.status = 204;
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(`${experienceApiRoutesPrefix}/submit`, async (ctx, next) => {
|
||||
ctx.status = 200;
|
||||
return next();
|
||||
});
|
||||
}
|
120
packages/core/src/routes/experience/interaction-session.ts
Normal file
120
packages/core/src/routes/experience/interaction-session.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { InteractionEvent } 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 './type.js';
|
||||
import {
|
||||
buildVerificationRecord,
|
||||
verificationRecordDataGuard,
|
||||
type Verification,
|
||||
} from './verifications/index.js';
|
||||
|
||||
const interactionSessionResultGuard = z.object({
|
||||
event: z.nativeEnum(InteractionEvent).optional(),
|
||||
accountId: z.string().optional(),
|
||||
profile: z.object({}).optional(),
|
||||
verificationRecords: verificationRecordDataGuard.array().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* InteractionSession status management
|
||||
*
|
||||
* @overview
|
||||
* Interaction session is a session that is initiated when a user starts an interaction flow with the Logto platform.
|
||||
* This class is used to manage all the interaction session data and status.
|
||||
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004.
|
||||
*
|
||||
*/
|
||||
export default class InteractionSession {
|
||||
/**
|
||||
* Factory method to create a new InteractionSession using context
|
||||
*/
|
||||
static async create(ctx: WithLogContext, tenant: TenantContext) {
|
||||
const { provider } = tenant;
|
||||
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
return new InteractionSession(ctx, tenant, interactionDetails);
|
||||
}
|
||||
|
||||
/** The interaction event for the current interaction session */
|
||||
readonly interactionEvent?: InteractionEvent;
|
||||
/** The user verification record list for the current interaction session */
|
||||
private readonly verificationRecords: Verification[] = [];
|
||||
/** The accountId of the user for the current interaction session. Only available once the user is identified */
|
||||
private readonly accountId?: string;
|
||||
/** The user provided profile data in the current interaction session that needs to be stored to user DB */
|
||||
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 = interactionSessionResultGuard.safeParse(interactionDetails.result);
|
||||
|
||||
assertThat(
|
||||
result.success,
|
||||
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||
);
|
||||
|
||||
const { verificationRecords = [], profile, accountId, event } = result.data;
|
||||
|
||||
this.interactionEvent = event;
|
||||
this.accountId = accountId;
|
||||
this.profile = profile;
|
||||
|
||||
this.verificationRecords = verificationRecords.map((record) =>
|
||||
buildVerificationRecord(libraries, queries, record)
|
||||
);
|
||||
}
|
||||
|
||||
public getVerificationRecord(verificationId: string) {
|
||||
return this.verificationRecords.find((record) => record.id === verificationId);
|
||||
}
|
||||
|
||||
/** Save the current interaction session result */
|
||||
public async storeToResult() {
|
||||
// The "mergeWithLastSubmission" will only merge current request's interaction results,
|
||||
// manually merge with previous interaction results
|
||||
// refer to: 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 session result to the OIDC provider and clear the session */
|
||||
public async assignResult() {
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/** Convert the current interaction session to JSON, so that it can be stored as the OIDC provider interaction result */
|
||||
private toJson() {
|
||||
return {
|
||||
event: this.interactionEvent,
|
||||
accountId: this.accountId,
|
||||
profile: this.profile,
|
||||
verificationRecords: this.verificationRecords.map((record) => record.toJson()),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
|
||||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
|
||||
import InteractionSession from '../interaction-session.js';
|
||||
|
||||
export type WithInteractionSessionContext<ContextT extends WithLogContext = WithLogContext> =
|
||||
ContextT & {
|
||||
interactionSession: InteractionSession;
|
||||
};
|
||||
|
||||
/**
|
||||
* @overview This middleware initializes the interaction session for the current request.
|
||||
* The interaction session is used to manage all the data related to the current interaction.
|
||||
* All the session data is stored using the oidc-provider's interaction session
|
||||
* @see {@link https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#user-flows}
|
||||
*/
|
||||
export default function koaInteractionSession<StateT, ContextT extends WithLogContext, ResponseT>(
|
||||
tenant: TenantContext
|
||||
): MiddlewareType<StateT, WithInteractionSessionContext<ContextT>, ResponseT> {
|
||||
return async (ctx, next) => {
|
||||
ctx.interactionSession = await InteractionSession.create(ctx, tenant);
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
16
packages/core/src/routes/experience/type.ts
Normal file
16
packages/core/src/routes/experience/type.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import type Provider from 'oidc-provider';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { passwordIdentifierGuard, VerificationType } from './verifications/index.js';
|
||||
|
||||
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||
|
||||
const passwordSignInPayload = z.object({
|
||||
identifier: passwordIdentifierGuard,
|
||||
verification: z.object({
|
||||
type: z.literal(VerificationType.Password),
|
||||
value: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const signInPayloadGuard = passwordSignInPayload;
|
35
packages/core/src/routes/experience/verifications/index.ts
Normal file
35
packages/core/src/routes/experience/verifications/index.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
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 { VerificationType } from './verification.js';
|
||||
|
||||
export { type Verification } from './verification.js';
|
||||
|
||||
export { passwordIdentifierGuard } from './password-verification.js';
|
||||
|
||||
export { VerificationType } from './verification.js';
|
||||
|
||||
type VerificationRecordData = PasswordVerificationRecordData;
|
||||
|
||||
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
|
||||
passwordVerificationRecordDataGuard,
|
||||
]);
|
||||
|
||||
export const buildVerificationRecord = (
|
||||
libraries: Libraries,
|
||||
queries: Queries,
|
||||
data: VerificationRecordData
|
||||
) => {
|
||||
switch (data.type) {
|
||||
case VerificationType.Password: {
|
||||
return new PasswordVerification(libraries, queries, data);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,99 @@
|
|||
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 { VerificationType, type Verification } from './verification.js';
|
||||
|
||||
// Password supports all types of direct identifiers
|
||||
type PasswordIdentifier = {
|
||||
type: 'username' | 'email' | 'phone';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type PasswordVerificationRecordData = {
|
||||
id: string;
|
||||
type: VerificationType.Password;
|
||||
identifier: PasswordIdentifier;
|
||||
/** The userId of the user that was verified. The password verification is considered verified if this is set */
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export const passwordIdentifierGuard = z.object({
|
||||
type: z.enum(['username', 'email', 'phone']),
|
||||
value: z.string(),
|
||||
}) satisfies ToZodObject<PasswordIdentifier>;
|
||||
|
||||
export const passwordVerificationRecordDataGuard = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal(VerificationType.Password),
|
||||
identifier: passwordIdentifierGuard,
|
||||
userId: z.string().optional(),
|
||||
}) satisfies ToZodObject<PasswordVerificationRecordData>;
|
||||
|
||||
/**
|
||||
* PasswordVerification is a verification record that verifies a user's identity
|
||||
* using identifier and password
|
||||
*/
|
||||
export class PasswordVerification implements Verification {
|
||||
/** Factory method to create a new PasswordVerification record using the given identifier */
|
||||
static create(libraries: Libraries, queries: Queries, identifier: PasswordIdentifier) {
|
||||
return new PasswordVerification(libraries, queries, {
|
||||
id: generateStandardId(),
|
||||
type: VerificationType.Password,
|
||||
identifier,
|
||||
});
|
||||
}
|
||||
|
||||
readonly type = VerificationType.Password;
|
||||
public readonly identifier: PasswordIdentifier;
|
||||
public readonly id: string;
|
||||
private userId?: string;
|
||||
|
||||
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 the password and sets the userId */
|
||||
async verify(password: string) {
|
||||
const user = await findUserByIdentifier(this.queries.users, this.identifier);
|
||||
|
||||
// Throws an 422 error if the user is not found or the password is incorrect
|
||||
const { isSuspended, id } = await this.libraries.users.verifyUserPassword(user, password);
|
||||
|
||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
|
||||
this.userId = id;
|
||||
}
|
||||
|
||||
toJson(): PasswordVerificationRecordData {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
identifier: this.identifier,
|
||||
userId: this.userId,
|
||||
};
|
||||
}
|
||||
}
|
21
packages/core/src/routes/experience/verifications/utils.ts
Normal file
21
packages/core/src/routes/experience/verifications/utils.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
||||
type IdentifierPayload = {
|
||||
type: 'username' | 'email' | 'phone';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const findUserByIdentifier = async (
|
||||
userQuery: Queries['users'],
|
||||
{ type, value }: IdentifierPayload
|
||||
) => {
|
||||
if (type === 'username') {
|
||||
return userQuery.findUserByUsername(value);
|
||||
}
|
||||
|
||||
if (type === 'email') {
|
||||
return userQuery.findUserByEmail(value);
|
||||
}
|
||||
|
||||
return userQuery.findUserByPhone(value);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
export enum VerificationType {
|
||||
Password = 'Password',
|
||||
VerificationCode = 'VerificationCode',
|
||||
Social = 'Social',
|
||||
TOTP = 'Totp',
|
||||
WebAuthn = 'WebAuthn',
|
||||
BackupCode = 'BackupCode',
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent class for all verification records
|
||||
*/
|
||||
export abstract class Verification {
|
||||
abstract readonly id: string;
|
||||
abstract readonly type: VerificationType;
|
||||
|
||||
abstract get isVerified(): boolean;
|
||||
|
||||
abstract toJson(): {
|
||||
id: string;
|
||||
type: VerificationType;
|
||||
} & Record<string, unknown>;
|
||||
}
|
|
@ -23,6 +23,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';
|
||||
|
@ -44,8 +45,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));
|
||||
|
@ -87,7 +94,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,7 @@ export const getInteractionFromProviderByJti = async (
|
|||
return interaction;
|
||||
};
|
||||
|
||||
/** Since we don't have the oidc provider context here, can not use provider.interactionResult */
|
||||
export const assignResultToInteraction = async (
|
||||
interaction: Interaction,
|
||||
result: InteractionResults
|
||||
|
|
Loading…
Add table
Reference in a new issue