Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): implement new experience API routes #5992

Merged
merged 16 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}

Check warning on line 38 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L35-L38

Added lines #L35 - L38 were not covered by tests

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

Check warning on line 47 in packages/core/src/routes/experience/classes/experience-interaction.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/experience-interaction.ts#L47

[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 = 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

Check warning on line 66 in packages/core/src/routes/experience/classes/experience-interaction.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/experience-interaction.ts#L66

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

Check warning on line 75 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L50-L75

Added lines #L50 - L75 were not covered by tests

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

Check warning on line 79 in packages/core/src/routes/experience/classes/experience-interaction.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/experience-interaction.ts#L79

[no-warning-comments] Unexpected 'todo' comment: 'TODO: conflict event check (e.g. reset...'.
this.interactionEvent = event;
}

Check warning on line 81 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L79-L81

Added lines #L79 - L81 were not covered by tests

/** 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
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
if (this.accountId) {
assertThat(
this.accountId === verificationRecord.verifiedUserId,
new RequestError({ code: 'session.identity_conflict', status: 409 })
);
return;
}

this.accountId = verificationRecord.verifiedUserId;
}

Check warning on line 111 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L85-L111

Added lines #L85 - L111 were not covered by tests

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

Check warning on line 121 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L118-L121

Added lines #L118 - L121 were not covered by tests

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

Check warning on line 125 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L124-L125

Added lines #L124 - L125 were not covered by tests
gao-sun marked this conversation as resolved.
Show resolved Hide resolved

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

Check warning on line 142 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L129-L142

Added lines #L129 - L142 were not covered by tests

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

Check warning on line 146 in packages/core/src/routes/experience/classes/experience-interaction.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/experience-interaction.ts#L146

[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 156 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L146-L156

Added lines #L146 - L156 were not covered by tests

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

Check warning on line 160 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L159-L160

Added lines #L159 - L160 were not covered by tests

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

Check warning on line 170 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L164-L170

Added lines #L164 - L170 were not covered by tests
}
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);
}
}
};

Check warning on line 35 in packages/core/src/routes/experience/classes/verifications/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/index.ts#L26-L35

Added lines #L26 - L35 were not covered by tests
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 = {
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
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,
});
}

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

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/password-verification.ts#L37-L42

Added lines #L37 - L42 were not covered by tests

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
gao-sun marked this conversation as resolved.
Show resolved Hide resolved
) {
const { id, identifier, userId } = data;

this.id = id;
this.identifier = identifier;
this.userId = userId;
}

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

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/password-verification.ts#L56-L65

Added lines #L56 - L65 were not covered by tests

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

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

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/password-verification.ts#L69-L70

Added lines #L69 - L70 were not covered by tests

get verifiedUserId() {
return this.userId;
}

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

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L73 - L74 were not covered by tests

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

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

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/password-verification.ts#L84-L90

Added lines #L84 - L90 were not covered by tests

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

return {
id,
type,
identifier,
userId,
};
}

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

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/password-verification.ts#L93-L101

Added lines #L93 - L101 were not covered by tests
}
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;
}

Check warning on line 21 in packages/core/src/routes/experience/classes/verifications/verification-record.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/verification-record.ts#L1-L21

Added lines #L1 - L21 were not covered by tests
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
Loading