Skip to content

Commit

Permalink
feat(core): implement new interaction-session management flow
Browse files Browse the repository at this point in the history
implement a new interaction-session management flow for experience api use
  • Loading branch information
simeng-li committed Jun 5, 2024
1 parent 223d9e3 commit 6ce1946
Show file tree
Hide file tree
Showing 10 changed files with 415 additions and 6 deletions.
60 changes: 60 additions & 0 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
@@ -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();
}

Check warning on line 53 in packages/core/src/routes/experience/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/index.ts#L49-L53

Added lines #L49 - L53 were not covered by tests
);

router.post(`${experienceApiRoutesPrefix}/submit`, async (ctx, next) => {
ctx.status = 200;
return next();

Check warning on line 58 in packages/core/src/routes/experience/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/index.ts#L57-L58

Added lines #L57 - L58 were not covered by tests
});
}
120 changes: 120 additions & 0 deletions packages/core/src/routes/experience/interaction-session.ts
Original file line number Diff line number Diff line change
@@ -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);
}

Check warning on line 40 in packages/core/src/routes/experience/interaction-session.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/interaction-session.ts#L37-L40

Added lines #L37 - L40 were not covered by tests

/** 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

Check warning on line 49 in packages/core/src/routes/experience/interaction-session.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/interaction-session.ts#L49

[no-warning-comments] Unexpected 'todo' comment: '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)
);
}

Check warning on line 74 in packages/core/src/routes/experience/interaction-session.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/interaction-session.ts#L52-L74

Added lines #L52 - L74 were not covered by tests

public getVerificationRecord(verificationId: string) {
return this.verificationRecords.find((record) => record.id === verificationId);
}

Check warning on line 78 in packages/core/src/routes/experience/interaction-session.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/interaction-session.ts#L77-L78

Added lines #L77 - L78 were not covered by tests

/** 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 }
);
}

Check warning on line 95 in packages/core/src/routes/experience/interaction-session.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/interaction-session.ts#L82-L95

Added lines #L82 - L95 were not covered by tests

/** Submit the current interaction session result to the OIDC provider and clear the session */
public async assignResult() {
// TODO: refine the error code

Check warning on line 99 in packages/core/src/routes/experience/interaction-session.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/interaction-session.ts#L99

[no-warning-comments] Unexpected 'todo' comment: '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 };
}

Check warning on line 109 in packages/core/src/routes/experience/interaction-session.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/interaction-session.ts#L99-L109

Added lines #L99 - L109 were not covered by tests

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

Check warning on line 119 in packages/core/src/routes/experience/interaction-session.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/interaction-session.ts#L113-L119

Added lines #L113 - L119 were not covered by tests
}
Original file line number Diff line number Diff line change
@@ -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 changes: 16 additions & 0 deletions packages/core/src/routes/experience/type.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 35 additions & 0 deletions packages/core/src/routes/experience/verifications/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
};
Original file line number Diff line number Diff line change
@@ -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,
});
}

Check warning on line 51 in packages/core/src/routes/experience/verifications/password-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verifications/password-verification.ts#L46-L51

Added lines #L46 - L51 were not covered by tests

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

Check warning on line 68 in packages/core/src/routes/experience/verifications/password-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verifications/password-verification.ts#L59-L68

Added lines #L59 - L68 were not covered by tests

/** Returns true if a userId is set */
get isVerified() {
return this.userId !== undefined;
}

Check warning on line 73 in packages/core/src/routes/experience/verifications/password-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verifications/password-verification.ts#L72-L73

Added lines #L72 - L73 were not covered by tests

get verifiedUserId() {
return this.userId;
}

Check warning on line 77 in packages/core/src/routes/experience/verifications/password-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verifications/password-verification.ts#L76-L77

Added lines #L76 - L77 were not covered by tests

/** 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;
}

Check warning on line 89 in packages/core/src/routes/experience/verifications/password-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verifications/password-verification.ts#L81-L89

Added lines #L81 - L89 were not covered by tests

toJson(): PasswordVerificationRecordData {
return {
id: this.id,
type: this.type,
identifier: this.identifier,
userId: this.userId,
};
}

Check warning on line 98 in packages/core/src/routes/experience/verifications/password-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verifications/password-verification.ts#L92-L98

Added lines #L92 - L98 were not covered by tests
}
21 changes: 21 additions & 0 deletions packages/core/src/routes/experience/verifications/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
};

Check warning on line 21 in packages/core/src/routes/experience/verifications/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verifications/utils.ts#L9-L21

Added lines #L9 - L21 were not covered by tests
Loading

0 comments on commit 6ce1946

Please sign in to comment.