-
-
Notifications
You must be signed in to change notification settings - Fork 454
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): implement new experience API routes (#5992)
* 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
Showing
20 changed files
with
780 additions
and
14 deletions.
There are no files selected for viewing
171 changes: 171 additions & 0 deletions
171
packages/core/src/routes/experience/classes/experience-interaction.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
35
packages/core/src/routes/experience/classes/verifications/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
}; |
102 changes: 102 additions & 0 deletions
102
packages/core/src/routes/experience/classes/verifications/password-verification.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} | ||
} |
21 changes: 21 additions & 0 deletions
21
packages/core/src/routes/experience/classes/verifications/verification-record.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`, | ||
}); |
Oops, something went wrong.