Skip to content

Commit

Permalink
refactor(core,schemas): refactor the experience API
Browse files Browse the repository at this point in the history
refactor the exerpience API structure
  • Loading branch information
simeng-li committed Jun 21, 2024
1 parent 7d8ad8e commit 219a69b
Show file tree
Hide file tree
Showing 16 changed files with 118 additions and 107 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ 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 type { Interaction } from '../type.js';

import {
buildVerificationRecord,
verificationRecordDataGuard,
type Verification,
type VerificationRecord,
} from './verifications/index.js';

const interactionSessionResultGuard = z.object({
Expand All @@ -21,11 +22,11 @@ const interactionSessionResultGuard = z.object({
});

/**
* InteractionSession status management
* InteractionSession
*
* @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.
* Interaction 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 data and status.
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004.
*
*/
Expand All @@ -42,7 +43,7 @@ export default class InteractionSession {
/** The interaction event for the current interaction session */
private interactionEvent?: InteractionEvent;
/** The user verification record list for the current interaction session */
private readonly verificationRecords: Set<Verification>;
private readonly verificationRecords: Set<VerificationRecord>;
/** The accountId of the user for the current interaction session. Only available once the user is identified */
private accountId?: string;
/** The user provided profile data in the current interaction session that needs to be stored to user DB */
Expand Down Expand Up @@ -84,18 +85,32 @@ export default class InteractionSession {
const verificationRecord = this.getVerificationRecordById(verificationId);

assertThat(
verificationRecord?.verifiedUserId,
verificationRecord,
new RequestError({ code: 'session.identifier_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,
},
{
identifier: verificationRecord.identifier.value,
}
)
);

this.accountId = verificationRecord.verifiedUserId;
}

/**
* Append a new verification record to the current interaction session.
* @remark If a record with the same type already exists, it will be replaced.
*/
public appendVerificationRecord(record: Verification) {
public appendVerificationRecord(record: VerificationRecord) {
const { type } = record;

const existingRecord = this.getVerificationRecordByType(type);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ import {
type PasswordVerificationRecordData,
} from './password-verification.js';

export { Verification } from './verification.js';
export { PasswordVerification } from './password-verification.js';

type VerificationRecordData = PasswordVerificationRecordData;

export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
]);

export type VerificationRecord = PasswordVerification;

export const buildVerificationRecord = (
libraries: Libraries,
queries: Queries,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { VerificationType, passwordIdentifierGuard, type PasswordIdentifier } from '@logto/schemas';
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';
Expand All @@ -8,21 +12,22 @@ 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 { findUserByIdentifier } from '../../utils.js';

import { type Verification } from './verification.js';

export type PasswordVerificationRecordData = {
id: string;
type: VerificationType.Password;
identifier: PasswordIdentifier;
// The userId of the user that has been verified
identifier: InteractionIdentifier;
/* The userId of the user that has been verified */
userId?: string;
};

export const passwordVerificationRecordDataGuard = z.object({
id: z.string(),
type: z.literal(VerificationType.Password),
identifier: passwordIdentifierGuard,
identifier: interactionIdentifierGuard,
userId: z.string().optional(),
}) satisfies ToZodObject<PasswordVerificationRecordData>;

Expand All @@ -32,7 +37,7 @@ export const passwordVerificationRecordDataGuard = z.object({
*/
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) {
static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) {
return new PasswordVerification(libraries, queries, {
id: generateStandardId(),
type: VerificationType.Password,
Expand All @@ -41,7 +46,7 @@ export class PasswordVerification implements Verification {
}

readonly type = VerificationType.Password;
public readonly identifier: PasswordIdentifier;
public readonly identifier: InteractionIdentifier;
public readonly id: string;
private userId?: string;

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/routes/experience/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const experienceApiRoutesPrefix = '/experience';
17 changes: 7 additions & 10 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,19 @@
* The experience APIs can be used by developers to build custom user interaction experiences.
*/

import { InteractionEvent, signInPayloadGuard } from '@logto/schemas';
import { InteractionEvent, passwordSignInPayloadGuard } from '@logto/schemas';
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 { PasswordVerification } from './classes/verifications/index.js';
import { experienceApiRoutesPrefix } from './const.js';
import koaInteractionSession, {
type WithInteractionSessionContext,
} from './middleware/koa-interaction-session.js';
import { PasswordVerification } from './verifications/password-verification.js';

const experienceApiRoutesPrefix = '/experience';

type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;

Expand All @@ -41,20 +40,18 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
);

router.post(
`${experienceApiRoutesPrefix}/sign-in`,
`${experienceApiRoutesPrefix}/sign-in/password`,
koaGuard({
body: signInPayloadGuard,
body: passwordSignInPayloadGuard,
status: [204, 400, 404, 422],
}),
async (ctx, next) => {
const { identifier, verification } = ctx.guard.body;
const { identifier, password } = ctx.guard.body;

ctx.interactionSession.setInteractionEvent(InteractionEvent.SignIn);

// TODO: Add support for other verification types
const { value } = verification;
const passwordVerification = PasswordVerification.create(libraries, queries, identifier);
await passwordVerification.verify(value);
await passwordVerification.verify(password);
ctx.interactionSession.appendVerificationRecord(passwordVerification);

ctx.interactionSession.identifyUser(passwordVerification.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ 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';
import InteractionSession from '../classes/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
* @overview This middleware initializes the InteractionSession for the current request.
* The InteractionSession instance is used to manage all the data related to the current interaction.
* All the interaction 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>(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { type DirectIdentifier } from '@logto/schemas';
import { type InteractionIdentifier } from '@logto/schemas';

import type Queries from '#src/tenants/Queries.js';

type IdentifierPayload = {
type: DirectIdentifier;
value: string;
};

export const findUserByIdentifier = async (
userQuery: Queries['users'],
{ type, value }: IdentifierPayload
{ type, value }: InteractionIdentifier
) => {
if (type === 'username') {
return userQuery.findUserByUsername(value);
Expand Down
15 changes: 0 additions & 15 deletions packages/integration-tests/src/api/experience-api.ts

This file was deleted.

2 changes: 2 additions & 0 deletions packages/integration-tests/src/api/experience-api/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const experienceApiPrefix = 'experience';
export const experienceVerificationApiRoutesPrefix = `${experienceApiPrefix}/verification`;
17 changes: 17 additions & 0 deletions packages/integration-tests/src/api/experience-api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type PasswordSignInPayload } from '@logto/schemas';

import api from '../api.js';

import { experienceApiPrefix } from './const.js';

type RedirectResponse = {
redirectTo: string;
};

export const signInWithPassword = async (cookie: string, payload: PasswordSignInPayload) =>
api
.post(`${experienceApiPrefix}/sign-in/password`, { headers: { cookie }, json: payload })
.json();

export const submit = async (cookie: string) =>
api.post(`${experienceApiPrefix}/submit`, { headers: { cookie } }).json<RedirectResponse>();
2 changes: 1 addition & 1 deletion packages/integration-tests/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Nullable, Optional } from '@silverhand/essentials';
import { assert } from '@silverhand/essentials';
import ky, { type KyInstance } from 'ky';

import { submit } from '#src/api/experience-api.js';
import { submit } from '#src/api/experience-api/index.js';
import { submitInteraction } from '#src/api/index.js';
import { demoAppRedirectUri, logtoUrl } from '#src/constants.js';

Expand Down
29 changes: 29 additions & 0 deletions packages/integration-tests/src/helpers/experience/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @fileoverview This file contains the successful interaction flow helper functions that use the experience APIs.
*/

import { type InteractionIdentifier } from '@logto/schemas';

import { signInWithPassword as signInWithPasswordApi } from '#src/api/experience-api/index.js';

import { initClient, logoutClient, processSession } from '../client.js';

export const signInWithPassword = async ({
identifier,
password,
}: {
identifier: InteractionIdentifier;
password: string;
}) => {
const client = await initClient();

await client.successSend(signInWithPasswordApi, {
identifier,
password,
});

const { redirectTo } = await client.submitInteraction('v2');

await processSession(client, redirectTo);
await logoutClient(client);
};

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { deleteUser } from '#src/api/admin-user.js';
import { signInWithPassword } from '#src/helpers/interactions-using-experience-api.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';

Expand Down
Loading

0 comments on commit 219a69b

Please sign in to comment.