From 6ce19465ce010fa7cb0cfa899ca9867ee5db002e Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 5 Jun 2024 18:10:44 +0800 Subject: [PATCH] feat(core): implement new interaction-session management flow implement a new interaction-session management flow for experience api use --- packages/core/src/routes/experience/index.ts | 60 +++++++++ .../routes/experience/interaction-session.ts | 120 ++++++++++++++++++ .../middleware/koa-interaction-session.ts | 27 ++++ packages/core/src/routes/experience/type.ts | 16 +++ .../routes/experience/verifications/index.ts | 35 +++++ .../verifications/password-verification.ts | 99 +++++++++++++++ .../routes/experience/verifications/utils.ts | 21 +++ .../experience/verifications/verification.ts | 23 ++++ packages/core/src/routes/init.ts | 9 +- .../routes/interaction/utils/interaction.ts | 11 +- 10 files changed, 415 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/routes/experience/index.ts create mode 100644 packages/core/src/routes/experience/interaction-session.ts create mode 100644 packages/core/src/routes/experience/middleware/koa-interaction-session.ts create mode 100644 packages/core/src/routes/experience/type.ts create mode 100644 packages/core/src/routes/experience/verifications/index.ts create mode 100644 packages/core/src/routes/experience/verifications/password-verification.ts create mode 100644 packages/core/src/routes/experience/verifications/utils.ts create mode 100644 packages/core/src/routes/experience/verifications/verification.ts diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts new file mode 100644 index 000000000000..b5deea0aa8e9 --- /dev/null +++ b/packages/core/src/routes/experience/index.ts @@ -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 extends Router ? Context : never; + +export default function experienceApiRoutes( + ...[anonymousRouter, tenant]: RouterInitArgs +) { + const { queries } = tenant; + + const router = + // @ts-expect-error for good koa types + // eslint-disable-next-line no-restricted-syntax + (anonymousRouter as Router>>).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(); + }); +} diff --git a/packages/core/src/routes/experience/interaction-session.ts b/packages/core/src/routes/experience/interaction-session.ts new file mode 100644 index 000000000000..600430e0b98a --- /dev/null +++ b/packages/core/src/routes/experience/interaction-session.ts @@ -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; // 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()), + }; + } +} diff --git a/packages/core/src/routes/experience/middleware/koa-interaction-session.ts b/packages/core/src/routes/experience/middleware/koa-interaction-session.ts new file mode 100644 index 000000000000..1aa3f5990178 --- /dev/null +++ b/packages/core/src/routes/experience/middleware/koa-interaction-session.ts @@ -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 & { + 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( + tenant: TenantContext +): MiddlewareType, ResponseT> { + return async (ctx, next) => { + ctx.interactionSession = await InteractionSession.create(ctx, tenant); + + return next(); + }; +} diff --git a/packages/core/src/routes/experience/type.ts b/packages/core/src/routes/experience/type.ts new file mode 100644 index 000000000000..9e3bec4972e3 --- /dev/null +++ b/packages/core/src/routes/experience/type.ts @@ -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>; + +const passwordSignInPayload = z.object({ + identifier: passwordIdentifierGuard, + verification: z.object({ + type: z.literal(VerificationType.Password), + value: z.string(), + }), +}); + +export const signInPayloadGuard = passwordSignInPayload; diff --git a/packages/core/src/routes/experience/verifications/index.ts b/packages/core/src/routes/experience/verifications/index.ts new file mode 100644 index 000000000000..4b6947fdff4e --- /dev/null +++ b/packages/core/src/routes/experience/verifications/index.ts @@ -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); + } + } +}; diff --git a/packages/core/src/routes/experience/verifications/password-verification.ts b/packages/core/src/routes/experience/verifications/password-verification.ts new file mode 100644 index 000000000000..864c81393893 --- /dev/null +++ b/packages/core/src/routes/experience/verifications/password-verification.ts @@ -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; + +export const passwordVerificationRecordDataGuard = z.object({ + id: z.string(), + type: z.literal(VerificationType.Password), + identifier: passwordIdentifierGuard, + userId: z.string().optional(), +}) satisfies ToZodObject; + +/** + * 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, + }; + } +} diff --git a/packages/core/src/routes/experience/verifications/utils.ts b/packages/core/src/routes/experience/verifications/utils.ts new file mode 100644 index 000000000000..57b02bdd2b40 --- /dev/null +++ b/packages/core/src/routes/experience/verifications/utils.ts @@ -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); +}; diff --git a/packages/core/src/routes/experience/verifications/verification.ts b/packages/core/src/routes/experience/verifications/verification.ts new file mode 100644 index 000000000000..4e944a678b44 --- /dev/null +++ b/packages/core/src/routes/experience/verifications/verification.ts @@ -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; +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index d5f25b16b305..9f767a588dee 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -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 { diff --git a/packages/core/src/routes/interaction/utils/interaction.ts b/packages/core/src/routes/interaction/utils/interaction.ts index 151c203bf538..15bebed0261d 100644 --- a/packages/core/src/routes/interaction/utils/interaction.ts +++ b/packages/core/src/routes/interaction/utils/interaction.ts @@ -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