-
-
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 interaction-session management flow
implement a new interaction-session management flow for experience api use
- Loading branch information
Showing
10 changed files
with
415 additions
and
6 deletions.
There are no files selected for viewing
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,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(); | ||
} | ||
); | ||
|
||
router.post(`${experienceApiRoutesPrefix}/submit`, async (ctx, next) => { | ||
ctx.status = 200; | ||
return next(); | ||
}); | ||
} |
120 changes: 120 additions & 0 deletions
120
packages/core/src/routes/experience/interaction-session.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,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); | ||
} | ||
|
||
/** 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 | ||
|
||
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) | ||
); | ||
} | ||
|
||
public getVerificationRecord(verificationId: string) { | ||
return this.verificationRecords.find((record) => record.id === verificationId); | ||
} | ||
|
||
/** 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 } | ||
); | ||
} | ||
|
||
/** 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 GitHub Actions / ESLint Report Analysispackages/core/src/routes/experience/interaction-session.ts#L99
|
||
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 }; | ||
} | ||
|
||
/** 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()), | ||
}; | ||
} | ||
} |
27 changes: 27 additions & 0 deletions
27
packages/core/src/routes/experience/middleware/koa-interaction-session.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,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(); | ||
}; | ||
} |
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,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
35
packages/core/src/routes/experience/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 { 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); | ||
} | ||
} | ||
}; |
99 changes: 99 additions & 0 deletions
99
packages/core/src/routes/experience/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,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, | ||
}); | ||
} | ||
|
||
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; | ||
} | ||
|
||
/** Returns true if a userId is set */ | ||
get isVerified() { | ||
return this.userId !== undefined; | ||
} | ||
|
||
get verifiedUserId() { | ||
return this.userId; | ||
} | ||
|
||
/** 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; | ||
} | ||
|
||
toJson(): PasswordVerificationRecordData { | ||
return { | ||
id: this.id, | ||
type: this.type, | ||
identifier: this.identifier, | ||
userId: this.userId, | ||
}; | ||
} | ||
} |
21 changes: 21 additions & 0 deletions
21
packages/core/src/routes/experience/verifications/utils.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 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); | ||
}; | ||
Oops, something went wrong.