Skip to content

Commit

Permalink
feat(core): implement new experience API routes (#5992)
Browse files Browse the repository at this point in the history
* feat(core): implement new interaction-session management flow

implement a new interaction-session management flow for experience api use

* feat(core): implement password sign-in flow

implement password sign-in flow

* test(core,schemas): add sign-in password tests

add sign-in password tests

* chore(core): update comments

update comments

* refactor(core): rename the password input value key

rename the password input value key

* refactor(core,schemas): refactor the experience API

refactor the exerpience API structure

* chore(test): add devFeature test

add devFeature test

* refactor(core): rename the path

rename the path

* refactor(core,schemas): refactor using the latest API design

refactor using the latest API design

* chore(test): replace using devFeature test statement

replace using devFeature test statement

* fix(core): fix lint error

fix lint error

* refactor(core): refactor experience API implementations

refactor experience API implementations

* refactor(core): replace with switch

replace object map with switch

* refactor: apply suggestions from code review

* refactor(core): refactor the interaction class
 refactor the interaction class

* refactor(core): update the user identification logic

update the user identification logic

---------

Co-authored-by: Gao Sun <[email protected]>
  • Loading branch information
simeng-li and gao-sun authored Jul 5, 2024
1 parent 1c90671 commit aec2cf4
Show file tree
Hide file tree
Showing 20 changed files with 780 additions and 14 deletions.
171 changes: 171 additions & 0 deletions packages/core/src/routes/experience/classes/experience-interaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { InteractionEvent, type VerificationType } 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 '../types.js';

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

const interactionStorageGuard = z.object({
event: z.nativeEnum(InteractionEvent).optional(),
accountId: z.string().optional(),
profile: z.object({}).optional(),
verificationRecords: verificationRecordDataGuard.array().optional(),
});

/**
* Interaction is a short-lived session 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.
*/
export default class ExperienceInteraction {
/**
* Factory method to create a new `ExperienceInteraction` using the current context.
*/
static async create(ctx: WithLogContext, tenant: TenantContext) {
const { provider } = tenant;
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
return new ExperienceInteraction(ctx, tenant, interactionDetails);
}

/** The interaction event for the current interaction. */
private interactionEvent?: InteractionEvent;
/** The user verification record list for the current interaction. */
private readonly verificationRecords: Map<VerificationType, VerificationRecord>;
/** The accountId of the user for the current interaction. Only available once the user is identified. */
private accountId?: string;
/** The user provided profile data in the current interaction that needs to be stored to database. */
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 = interactionStorageGuard.safeParse(interactionDetails.result ?? {});

assertThat(
result.success,
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);

const { verificationRecords = [], profile, accountId, event } = result.data;

this.interactionEvent = event;
this.accountId = accountId; // TODO: @simeng-li replace with userId
this.profile = profile;

this.verificationRecords = new Map();

for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record);
this.verificationRecords.set(instance.type, instance);
}
}

/** Set the interaction event for the current interaction */
public setInteractionEvent(event: InteractionEvent) {
// TODO: conflict event check (e.g. reset password session can't be used for sign in)
this.interactionEvent = event;
}

/** Set the verified `accountId` of the current interaction from the verification record */
public identifyUser(verificationId: string) {
const verificationRecord = this.getVerificationRecordById(verificationId);

assertThat(
verificationRecord,
new RequestError({ code: 'session.verification_session_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,
})
);

// Throws an 409 error if the current session has already identified a different user
if (this.accountId) {
assertThat(
this.accountId === verificationRecord.verifiedUserId,
new RequestError({ code: 'session.identity_conflict', status: 409 })
);
return;
}

this.accountId = verificationRecord.verifiedUserId;
}

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

this.verificationRecords.set(type, record);
}

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

/** Save the current interaction result. */
public async save() {
// `mergeWithLastSubmission` will only merge current request's interaction results.
// Manually merge with previous interaction results here.
// @see {@link 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 result to the OIDC provider and clear the interaction data */
public async submit() {
// 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 };
}

private get verificationRecordsArray() {
return [...this.verificationRecords.values()];
}

/** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */
public toJson() {
return {
event: this.interactionEvent,
accountId: this.accountId,
profile: this.profile,
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
};
}
}
35 changes: 35 additions & 0 deletions packages/core/src/routes/experience/classes/verifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { VerificationType } from '@logto/schemas';
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 { type VerificationRecord } from './verification-record.js';

export { type VerificationRecord } from './verification-record.js';

type VerificationRecordData = PasswordVerificationRecordData;

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

/**
* The factory method to build a new `VerificationRecord` instance based on the provided `VerificationRecordData`.
*/
export const buildVerificationRecord = <T extends VerificationRecordData>(
libraries: Libraries,
queries: Queries,
data: T
): VerificationRecord<T['type']> => {
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,102 @@
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';

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 { type VerificationRecord } from './verification-record.js';

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

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

export class PasswordVerification implements VerificationRecord<VerificationType.Password> {
/** Factory method to create a new `PasswordVerification` record using an identifier */
static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) {
return new PasswordVerification(libraries, queries, {
id: generateStandardId(),
type: VerificationType.Password,
identifier,
});
}

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

/**
* The constructor method is intended to be used internally by the interaction class
* to instantiate a `VerificationRecord` object from existing `PasswordVerificationRecordData`.
* It directly sets the instance properties based on the provided data.
* For creating a new verification record from context, use the static `create` method instead.
*/
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 if the password matches the record in database with the current identifier.
* `userId` will be set if the password can be verified.
*
* @throws RequestError with 401 status if user id suspended.
* @throws RequestError with 422 status if the user is not found or the password is incorrect.
*/
async verify(password: string) {
const user = await findUserByIdentifier(this.queries.users, this.identifier);
const { isSuspended, id } = await this.libraries.users.verifyUserPassword(user, password);

assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));

this.userId = id;
}

toJson(): PasswordVerificationRecordData {
const { id, type, identifier, userId } = this;

return {
id,
type,
identifier,
userId,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { VerificationType } from '@logto/schemas';

type Data<T> = {
id: string;
type: T;
};

/** The abstract class for all verification records. */
export abstract class VerificationRecord<
T extends VerificationType = VerificationType,
Json extends Data<T> = Data<T>,
> {
abstract readonly id: string;
abstract readonly type: T;

abstract get isVerified(): boolean;
/** @deprecated will be removed in the coming PR */
abstract get verifiedUserId(): string | undefined;

abstract toJson(): Json;
}
7 changes: 7 additions & 0 deletions packages/core/src/routes/experience/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const prefix = '/experience';

export const experienceRoutes = Object.freeze({
prefix,
identification: `${prefix}/identification`,
verification: `${prefix}/verification`,
});
Loading

0 comments on commit aec2cf4

Please sign in to comment.