0
Fork 0
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:
simeng-li 2024-06-05 18:10:44 +08:00
parent 0874b70433
commit 59403aa606
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
10 changed files with 415 additions and 6 deletions

View 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();
});
}

View 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()),
};
}
}

View file

@ -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();
};
}

View 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;

View 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);
}
}
};

View file

@ -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,
};
}
}

View 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);
};

View file

@ -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>;
}

View file

@ -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 {

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,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