Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 0e9eedc

Browse files
authoredJun 10, 2025··
feat(core,console,schemas): add interaction context to custom claims (#7437)
* feat(core,console,schemas): add interaction context to custom claims add user interaction context to the custom token claims script * fix(schemas): refine the context type refine the context type * fix(core): add jsonGuard swagger transformer add jsonGuard swagger transformer * chore(test): add interaction context to sample jwt customizer add interaction context to sample jwt customizer * refactor(test): update get access token test case update get access token integration test case * fix(test): add dev feature guard to test case add dev feature guard to test case
1 parent 3cf7ee1 commit 0e9eedc

File tree

15 files changed

+305
-23
lines changed

15 files changed

+305
-23
lines changed
 

‎packages/console/scripts/generate-jwt-customizer-type-definition.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
clientCredentialsPayloadGuard,
66
jwtCustomizerGrantContextGuard,
77
jwtCustomizerUserContextGuard,
8+
jwtCustomizerUserInteractionContextGuard,
89
} from '@logto/schemas';
910
import prettier from 'prettier';
1011
import { type ZodTypeAny } from 'zod';
@@ -17,6 +18,7 @@ const filePath = 'src/consts/jwt-customizer-type-definition.ts';
1718
const typeIdentifiers = `export enum JwtCustomizerTypeDefinitionKey {
1819
JwtCustomizerUserContext = 'JwtCustomizerUserContext',
1920
JwtCustomizerGrantContext = 'JwtCustomizerGrantContext',
21+
JwtCustomizerUserInteractionContext = 'JwtCustomizerUserInteractionContext',
2022
AccessTokenPayload = 'AccessTokenPayload',
2123
ClientCredentialsPayload = 'ClientCredentialsPayload',
2224
EnvironmentVariables = 'EnvironmentVariables',
@@ -41,6 +43,30 @@ const inferTsDefinitionFromZod = (zodSchema: ZodTypeAny, identifier: string): st
4143
return `type ${identifier} = ${typeDefinition};`;
4244
};
4345

46+
/**
47+
* EnterpriseSsoUserInfo zod guard uses `catchall(jsonGuard)` to extend the type to allow any additional properties.
48+
* However, the `catchall()` schema is not recognized by zod-to-ts,
49+
* so it will not generate the index signature for the type.
50+
* To fix this, we manually add the index signature to the type definition.
51+
*
52+
* Map the `enterpriseSsoUserInfo?: { ... } | undefined;` to
53+
* `enterpriseSsoUserInfo?: { ...; [k: string]?: unknown; } | undefined;`
54+
*/
55+
const addIndexSignatureToEnterpriseSsoUserInfo = (source: string) => {
56+
// 1. Capture in segments: prefix = "enterpriseSsoUserInfo?: {"
57+
// body = {...original properties...}
58+
// suffix = "} | undefined;" (may include a semicolon/space)
59+
const blockReg = /(\benterpriseSsoUserInfo\?\s*:\s*{)([\S\s]*?)(}\s*\|\s*undefined\s*;?)/g;
60+
61+
return source.replaceAll(blockReg, (full, prefix: string, body: string, suffix: string) => {
62+
// 2. Add the fallback index signature to the body
63+
const indent = ' ';
64+
const addition = `${indent}[k: string]?: unknown;\n`;
65+
66+
return `${prefix}${body}${addition}${suffix}`;
67+
});
68+
};
69+
4470
// Create the jwt-customizer-type-definition.ts file
4571
const createJwtCustomizerTypeDefinitions = async () => {
4672
const jwtCustomizerUserContextTypeDefinition = inferTsDefinitionFromZod(
@@ -53,6 +79,14 @@ const createJwtCustomizerTypeDefinitions = async () => {
5379
'JwtCustomizerGrantContext'
5480
);
5581

82+
const jwtCustomizerUserInteractionContextTypeDefinition =
83+
addIndexSignatureToEnterpriseSsoUserInfo(
84+
inferTsDefinitionFromZod(
85+
jwtCustomizerUserInteractionContextGuard,
86+
'JwtCustomizerUserInteractionContext'
87+
)
88+
);
89+
5690
const accessTokenPayloadTypeDefinition = inferTsDefinitionFromZod(
5791
accessTokenPayloadGuard,
5892
'AccessTokenPayload'
@@ -70,6 +104,8 @@ export const jwtCustomizerUserContextTypeDefinition = \`${jwtCustomizerUserConte
70104
71105
export const jwtCustomizerGrantContextTypeDefinition = \`${jwtCustomizerGrantContextTypeDefinition}\`;
72106
107+
export const jwtCustomizerUserInteractionContextTypeDefinition = \`${jwtCustomizerUserInteractionContextTypeDefinition}\`;
108+
73109
export const accessTokenPayloadTypeDefinition = \`${accessTokenPayloadTypeDefinition}\`;
74110
75111
export const clientCredentialsPayloadTypeDefinition = \`${clientCredentialsPayloadTypeDefinition}\`;

‎packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/GuideCard/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import styles from './index.module.scss';
1010
export enum CardType {
1111
UserData = 'user_data',
1212
GrantData = 'grant_data',
13+
InteractionData = 'interaction_data',
1314
TokenData = 'token_data',
1415
FetchExternalData = 'fetch_external_data',
1516
EnvironmentVariables = 'environment_variables',

‎packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useState } from 'react';
55
import { useFormContext } from 'react-hook-form';
66
import { useTranslation } from 'react-i18next';
77

8+
import { isDevFeaturesEnabled } from '@/consts/env';
89
import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type';
910
import {
1011
denyAccessCodeExample,
@@ -18,6 +19,7 @@ import {
1819
clientCredentialsPayloadTypeDefinition,
1920
jwtCustomizerUserContextTypeDefinition,
2021
jwtCustomizerGrantContextTypeDefinition,
22+
jwtCustomizerUserInteractionContextTypeDefinition,
2123
} from '@/pages/CustomizeJwtDetails/utils/type-definitions';
2224

2325
import tabContentStyles from '../index.module.scss';
@@ -97,6 +99,24 @@ function InstructionTab({ isActive }: Props) {
9799
/>
98100
</GuideCard>
99101
)}
102+
{tokenType === LogtoJwtTokenKeyType.AccessToken && isDevFeaturesEnabled && (
103+
<GuideCard
104+
name={CardType.InteractionData}
105+
isExpanded={expendCard === CardType.InteractionData}
106+
setExpanded={(expand) => {
107+
setExpendCard(expand ? CardType.InteractionData : undefined);
108+
}}
109+
>
110+
<Editor
111+
language="typescript"
112+
className={styles.sampleCode}
113+
value={`declare ${jwtCustomizerUserInteractionContextTypeDefinition}`}
114+
height="400px"
115+
theme="logto-dark"
116+
options={typeDefinitionCodeEditorOptions}
117+
/>
118+
</GuideCard>
119+
)}
100120
<GuideCard
101121
name={CardType.FetchExternalData}
102122
isExpanded={expendCard === CardType.FetchExternalData}

‎packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@ import {
44
type ClientCredentialsPayload,
55
type JwtCustomizerUserContext,
66
type JwtCustomizerGrantContext,
7+
type JwtCustomizerUserInteractionContext,
8+
InteractionEvent,
79
} from '@logto/schemas';
810
import { type EditorProps } from '@monaco-editor/react';
11+
import { conditional } from '@silverhand/essentials';
912

1013
import TokenFileIcon from '@/assets/icons/token-file-icon.svg?react';
1114
import UserFileIcon from '@/assets/icons/user-file-icon.svg?react';
15+
import { isDevFeaturesEnabled } from '@/consts/env.js';
1216

1317
import type { ModelSettings } from '../MainContent/MonacoCodeEditor/type.js';
1418

@@ -29,6 +33,8 @@ declare interface CustomJwtClaims extends Record<string, any> {}
2933
/** Logto internal data that can be used to pass additional information
3034
*
3135
* @param {${JwtCustomizerTypeDefinitionKey.JwtCustomizerUserContext}} user - The user info associated with the token.
36+
* @param {${JwtCustomizerTypeDefinitionKey.JwtCustomizerGrantContext}} [grant] - The grant context associated with the token.
37+
* @param {${JwtCustomizerTypeDefinitionKey.JwtCustomizerUserInteractionContext}} [interaction] - The user interaction context associated with the token.
3238
*/
3339
declare type Context = {
3440
/**
@@ -39,6 +45,10 @@ declare type Context = {
3945
* The grant context associated with the token.
4046
*/
4147
grant?: ${JwtCustomizerTypeDefinitionKey.JwtCustomizerGrantContext};
48+
/**
49+
* The user interaction context associated with the token.
50+
*/
51+
interaction?: ${JwtCustomizerTypeDefinitionKey.JwtCustomizerUserInteractionContext};
4252
}
4353
4454
declare type Payload = {
@@ -51,6 +61,7 @@ declare type Payload = {
5161
*
5262
* @params {${JwtCustomizerTypeDefinitionKey.JwtCustomizerUserContext}} user
5363
* @params {${JwtCustomizerTypeDefinitionKey.JwtCustomizerGrantContext}} [grant]
64+
* @params {${JwtCustomizerTypeDefinitionKey.JwtCustomizerUserInteractionContext}} [interaction]
5465
*/
5566
context: Context;
5667
/**
@@ -256,9 +267,15 @@ const defaultGrantContext: Partial<JwtCustomizerGrantContext> = {
256267
},
257268
};
258269

270+
const defaultUserInteractionContext: Partial<JwtCustomizerUserInteractionContext> = {
271+
interactionEvent: InteractionEvent.SignIn,
272+
userId: '123',
273+
};
274+
259275
export const defaultUserTokenContextData = {
260276
user: defaultUserContext,
261277
grant: defaultGrantContext,
278+
...conditional(isDevFeaturesEnabled && { interaction: defaultUserInteractionContext }),
262279
};
263280

264281
export const accessTokenPayloadTestModel: ModelSettings = {

‎packages/console/src/pages/CustomizeJwtDetails/utils/type-definitions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
clientCredentialsPayloadTypeDefinition,
55
jwtCustomizerUserContextTypeDefinition,
66
jwtCustomizerGrantContextTypeDefinition,
7+
jwtCustomizerUserInteractionContextTypeDefinition,
78
jwtCustomizerApiContextTypeDefinition,
89
} from '@/consts/jwt-customizer-type-definition';
910

@@ -15,6 +16,7 @@ export {
1516
clientCredentialsPayloadTypeDefinition,
1617
jwtCustomizerUserContextTypeDefinition,
1718
jwtCustomizerGrantContextTypeDefinition,
19+
jwtCustomizerUserInteractionContextTypeDefinition,
1820
} from '@/consts/jwt-customizer-type-definition';
1921

2022
export const buildAccessTokenJwtCustomizerContextTsDefinition = () => {
@@ -24,7 +26,9 @@ export const buildAccessTokenJwtCustomizerContextTsDefinition = () => {
2426
2527
declare ${jwtCustomizerApiContextTypeDefinition}
2628
27-
declare ${accessTokenPayloadTypeDefinition}`;
29+
declare ${accessTokenPayloadTypeDefinition}
30+
31+
declare ${jwtCustomizerUserInteractionContextTypeDefinition}`;
2832
};
2933

3034
export const buildClientCredentialsJwtCustomizerContextTsDefinition = () =>

‎packages/core/src/oidc/extra-token-claims.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ import {
77
type CustomJwtFetcher,
88
GrantType,
99
CustomJwtErrorCode,
10+
jwtCustomizerUserInteractionContextGuard,
1011
} from '@logto/schemas';
1112
import { generateStandardId } from '@logto/shared';
1213
import { conditional, trySafe } from '@silverhand/essentials';
1314
import { ResponseError } from '@withtyped/client';
14-
import { errors, type KoaContextWithOIDC, type UnknownObject } from 'oidc-provider';
15+
import {
16+
type AccessToken,
17+
errors,
18+
type KoaContextWithOIDC,
19+
type UnknownObject,
20+
} from 'oidc-provider';
1521
import { z } from 'zod';
1622

1723
import { EnvSet } from '#src/env-set/index.js';
@@ -84,6 +90,42 @@ export const getExtraTokenClaimsForTokenExchange = async (
8490
return result.data;
8591
};
8692

93+
/**
94+
* Retrieves the user's lasted submitted interaction data from the OIDC session extension.
95+
*
96+
* @returns The formatted interaction data if available for the given session UID and account ID.
97+
* Otherwise, returns `undefined`.
98+
*
99+
*/
100+
const getInteractionLastSubmission = async (
101+
queries: Queries,
102+
{ accountId, sessionUid }: AccessToken
103+
) => {
104+
if (!EnvSet.values.isDevFeaturesEnabled) {
105+
return;
106+
}
107+
108+
// Session UID and account ID are required to fetch the interaction data.
109+
if (!accountId || !sessionUid) {
110+
return;
111+
}
112+
113+
const { oidcSessionExtensions } = queries;
114+
const sessionExtension = await oidcSessionExtensions.findBySessionUid(sessionUid);
115+
if (!sessionExtension || sessionExtension.accountId !== accountId) {
116+
return;
117+
}
118+
119+
const { lastSubmission } = sessionExtension;
120+
const interactionData = jwtCustomizerUserInteractionContextGuard.safeParse(lastSubmission);
121+
122+
if (!interactionData.success) {
123+
return;
124+
}
125+
126+
return interactionData.data;
127+
};
128+
87129
/* eslint-disable complexity */
88130
export const getExtraTokenClaimsForJwtCustomization = async (
89131
ctx: KoaContextWithOIDC,
@@ -147,6 +189,10 @@ export const getExtraTokenClaimsForJwtCustomization = async (
147189
(await libraries.jwtCustomizers.getUserContext(token.accountId))
148190
);
149191

192+
const interactionContext = isTokenClientCredentials
193+
? undefined
194+
: await getInteractionLastSubmission(queries, token);
195+
150196
const subjectTokenResult = z
151197
.object({
152198
subjectTokenId: z.string(),
@@ -180,6 +226,11 @@ export const getExtraTokenClaimsForJwtCustomization = async (
180226
},
181227
}
182228
),
229+
...conditional(
230+
interactionContext && {
231+
interaction: interactionContext,
232+
}
233+
),
183234
},
184235
}),
185236
};

‎packages/core/src/queries/oidc-session-extensions.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { OidcSessionExtensions } from '@logto/schemas';
1+
import { OidcSessionExtensions, type OidcSessionExtension } from '@logto/schemas';
22
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
33

44
import { buildInsertIntoWithPool } from '../database/insert-into.js';
@@ -30,4 +30,12 @@ export class OidcSessionExtensionsQueries {
3030
where ${fields.accountId} = ${accountId}
3131
`);
3232
}
33+
34+
async findBySessionUid(sessionUid: string) {
35+
return this.pool.maybeOne<OidcSessionExtension>(sql`
36+
select ${sql.join(Object.values(fields), sql`, `)}
37+
from ${table}
38+
where ${fields.sessionUid} = ${sessionUid}
39+
`);
40+
}
3341
}

‎packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type User,
77
type UserSsoIdentity,
88
type EnterpriseSsoVerificationRecordData,
9-
enterPriseSsoVerificationRecordDataGuard,
9+
enterpriseSsoVerificationRecordDataGuard,
1010
} from '@logto/schemas';
1111
import { generateStandardId } from '@logto/shared';
1212
import { conditional } from '@silverhand/essentials';
@@ -30,7 +30,7 @@ import { type IdentifierVerificationRecord } from './verification-record.js';
3030

3131
export {
3232
type EnterpriseSsoVerificationRecordData,
33-
enterPriseSsoVerificationRecordDataGuard,
33+
enterpriseSsoVerificationRecordDataGuard,
3434
} from '@logto/schemas';
3535

3636
export class EnterpriseSsoVerification
@@ -58,7 +58,7 @@ export class EnterpriseSsoVerification
5858
data: EnterpriseSsoVerificationRecordData
5959
) {
6060
const { id, connectorId, enterpriseSsoUserInfo, issuer } =
61-
enterPriseSsoVerificationRecordDataGuard.parse(data);
61+
enterpriseSsoVerificationRecordDataGuard.parse(data);
6262

6363
this.id = id;
6464
this.connectorId = connectorId;

‎packages/core/src/routes/experience/classes/verifications/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from './code-verification.js';
1919
import {
2020
EnterpriseSsoVerification,
21-
enterPriseSsoVerificationRecordDataGuard,
21+
enterpriseSsoVerificationRecordDataGuard,
2222
type EnterpriseSsoVerificationRecordData,
2323
} from './enterprise-sso-verification.js';
2424
import {
@@ -100,7 +100,7 @@ export const verificationRecordDataGuard = z.discriminatedUnion('type', [
100100
emailCodeVerificationRecordDataGuard,
101101
phoneCodeVerificationRecordDataGuard,
102102
socialVerificationRecordDataGuard,
103-
enterPriseSsoVerificationRecordDataGuard,
103+
enterpriseSsoVerificationRecordDataGuard,
104104
totpVerificationRecordDataGuard,
105105
backupCodeVerificationRecordDataGuard,
106106
webAuthnVerificationRecordDataGuard,

‎packages/core/src/utils/zod.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { languages, languageTagGuard } from '@logto/language-kit';
2-
import { jsonObjectGuard, translationGuard } from '@logto/schemas';
2+
import { jsonGuard, jsonObjectGuard, translationGuard } from '@logto/schemas';
33
import type { ValuesOf } from '@silverhand/essentials';
44
import { conditional } from '@silverhand/essentials';
55
import type { OpenAPIV3 } from 'openapi-types';
@@ -155,6 +155,36 @@ export const zodTypeToSwagger = (
155155
};
156156
}
157157

158+
if (config === jsonGuard) {
159+
return {
160+
oneOf: [
161+
{
162+
type: 'object',
163+
description: 'arbitrary JSON object',
164+
},
165+
{
166+
type: 'array',
167+
items: {
168+
oneOf: [
169+
{ type: 'string' },
170+
{ type: 'number' },
171+
{ type: 'boolean' },
172+
{ nullable: true },
173+
{
174+
type: 'object',
175+
description: 'arbitrary JSON object',
176+
},
177+
],
178+
},
179+
},
180+
{ type: 'string' },
181+
{ type: 'number' },
182+
{ type: 'boolean' },
183+
],
184+
nullable: true,
185+
};
186+
}
187+
158188
if (config === translationGuard) {
159189
return {
160190
$ref: '#/components/schemas/TranslationObject',

‎packages/integration-tests/src/__mocks__/jwt-customizer.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { type AccessTokenPayload, type ClientCredentialsPayload } from '@logto/schemas';
1+
import {
2+
InteractionEvent,
3+
type JwtCustomizerUserInteractionContext,
4+
SignInIdentifier,
5+
VerificationType,
6+
type AccessTokenPayload,
7+
type ClientCredentialsPayload,
8+
} from '@logto/schemas';
29

310
const standardTokenPayloadData = {
411
jti: 'f1d3d2d1-1f2d-3d4e-5d6f-7d8a9d0e1d2',
@@ -51,11 +58,30 @@ export const accessTokenJwtCustomizerPayload = {
5158
organizations: [],
5259
organizationRoles: [],
5360
},
61+
interaction: {
62+
interactionEvent: InteractionEvent.SignIn,
63+
userId: '123',
64+
verificationRecords: [
65+
{
66+
id: 'verification_123',
67+
type: VerificationType.Password,
68+
identifier: {
69+
type: SignInIdentifier.Email,
70+
value: 'foo@example.com',
71+
},
72+
verified: true,
73+
},
74+
],
75+
} satisfies JwtCustomizerUserInteractionContext,
5476
},
5577
};
5678

5779
export const accessTokenSampleScript = `const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
58-
return { user_id: context?.user?.id ?? 'unknown', hasPassword: context?.user?.hasPassword };
80+
const { interaction } = context;
81+
82+
const verificationRecord = interaction?.verificationRecords?.[0];
83+
84+
return { user_id: context?.user?.id ?? 'unknown', hasPassword: context?.user?.hasPassword, interactionEvent: interaction?.interactionEvent, verificationType: verificationRecord?.type };
5985
};`;
6086

6187
export const accessTokenAccessDeniedSampleScript = `const getCustomJwtClaims = async ({ token, context, environmentVariables, api }) => {

‎packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import path from 'node:path';
22

33
import { fetchTokenByRefreshToken } from '@logto/js';
4-
import { InteractionEvent, type Resource, RoleType } from '@logto/schemas';
4+
import {
5+
InteractionEvent,
6+
type Resource,
7+
RoleType,
8+
SignInIdentifier,
9+
VerificationType,
10+
} from '@logto/schemas';
511
import { assert } from '@silverhand/essentials';
612
import { createRemoteJWKSet, jwtVerify } from 'jose';
713

@@ -20,8 +26,8 @@ import {
2026
import { assignUsersToRole, createRole, deleteRole } from '#src/api/role.js';
2127
import { createScope, deleteScope } from '#src/api/scope.js';
2228
import MockClient, { defaultConfig } from '#src/client/index.js';
23-
import { logtoUrl } from '#src/constants.js';
24-
import { processSession } from '#src/helpers/client.js';
29+
import { isDevFeaturesEnabled, logtoUrl } from '#src/constants.js';
30+
import { initExperienceClient, processSession } from '#src/helpers/client.js';
2531
import { createUserByAdmin } from '#src/helpers/index.js';
2632
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
2733
import { generateUsername, generatePassword, getAccessTokenPayload } from '#src/utils.js';
@@ -107,15 +113,24 @@ describe('get access token', () => {
107113
script: accessTokenSampleScript,
108114
});
109115

110-
const client = new MockClient({
111-
resources: [testApiResourceInfo.indicator],
112-
scopes: testApiScopeNames,
116+
const client = await initExperienceClient({
117+
config: {
118+
resources: [testApiResourceInfo.indicator],
119+
scopes: testApiScopeNames,
120+
},
113121
});
114-
await client.initSession();
115-
await client.successSend(putInteraction, {
116-
event: InteractionEvent.SignIn,
117-
identifier: { username: guestUsername, password },
122+
123+
const { verificationId } = await client.verifyPassword({
124+
identifier: {
125+
type: SignInIdentifier.Username,
126+
value: guestUsername,
127+
},
128+
password,
129+
});
130+
await client.identifyUser({
131+
verificationId,
118132
});
133+
119134
const { redirectTo } = await client.submitInteraction();
120135
await processSession(client, redirectTo);
121136
const accessToken = await client.getAccessToken(testApiResourceInfo.indicator);
@@ -128,6 +143,17 @@ describe('get access token', () => {
128143
// The guest user has password.
129144
expect(getAccessTokenPayload(accessToken)).toHaveProperty('hasPassword', true);
130145

146+
if (isDevFeaturesEnabled) {
147+
expect(getAccessTokenPayload(accessToken)).toHaveProperty(
148+
'interactionEvent',
149+
InteractionEvent.SignIn
150+
);
151+
expect(getAccessTokenPayload(accessToken)).toHaveProperty(
152+
'verificationType',
153+
VerificationType.Password
154+
);
155+
}
156+
131157
await deleteJwtCustomizer('access-token');
132158
});
133159

‎packages/phrases/src/locales/en/translation/admin-console/jwt-claims.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ const jwt_claims = {
3535
subtitle:
3636
'Use `context.grant` input parameter to provide vital grant info, only available for token exchange.',
3737
},
38+
interaction_data: {
39+
title: 'User interaction context',
40+
subtitle:
41+
"Use the context.interaction parameter to retrieve details about the user's sign-in interaction during the current session.",
42+
},
3843
token_data: {
3944
title: 'Token payload',
4045
subtitle: 'Use `token` input parameter for current access token payload. ',

‎packages/schemas/src/types/logto-config/jwt-customizer.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { jsonObjectGuard } from '@logto/connector-kit';
1+
import { jsonGuard, jsonObjectGuard, socialUserInfoGuard } from '@logto/connector-kit';
22
import { type ZodType, z } from 'zod';
33

44
import {
@@ -10,9 +10,22 @@ import {
1010
type UserSsoIdentity,
1111
} from '../../db-entries/index.js';
1212
import { mfaFactorsGuard, type MfaFactors } from '../../foundations/index.js';
13+
import { InteractionEvent } from '../interactions.js';
1314
import { GrantType } from '../oidc-config.js';
1415
import { scopeResponseGuard, type ScopeResponse } from '../scope.js';
1516
import { userInfoGuard, type UserInfo } from '../user.js';
17+
import { backupCodeVerificationRecordDataGuard } from '../verification-records/backup-code-verification.js';
18+
import {
19+
emailCodeVerificationRecordDataGuard,
20+
phoneCodeVerificationRecordDataGuard,
21+
} from '../verification-records/code-verification.js';
22+
import { enterpriseSsoVerificationRecordDataGuard } from '../verification-records/enterprise-sso-verification.js';
23+
import { newPasswordIdentityVerificationRecordDataGuard } from '../verification-records/new-password-identity-verification.js';
24+
import { oneTimeTokenVerificationRecordDataGuard } from '../verification-records/one-time-token-verification.js';
25+
import { passwordVerificationRecordDataGuard } from '../verification-records/password-verification.js';
26+
import { socialVerificationRecordDataGuard } from '../verification-records/social-verification.js';
27+
import { totpVerificationRecordDataGuard } from '../verification-records/totp-verification.js';
28+
import { webAuthnVerificationRecordDataGuard } from '../verification-records/web-authn-verification.js';
1629

1730
import { accessTokenPayloadGuard, clientCredentialsPayloadGuard } from './oidc-provider.js';
1831

@@ -75,6 +88,50 @@ export const jwtCustomizerGrantContextGuard = z.object({
7588

7689
export type JwtCustomizerGrantContext = z.infer<typeof jwtCustomizerGrantContextGuard>;
7790

91+
// Unlike the verification record guard defined in experience interaction,
92+
// we need to omit sensitive fields like MFA code and secrets from some of the verification record.
93+
const jwtCustomizerUserInteractionVerificationRecordGuard = z.discriminatedUnion('type', [
94+
passwordVerificationRecordDataGuard,
95+
emailCodeVerificationRecordDataGuard,
96+
phoneCodeVerificationRecordDataGuard,
97+
socialVerificationRecordDataGuard.omit({
98+
connectorSession: true,
99+
}),
100+
enterpriseSsoVerificationRecordDataGuard.extend({
101+
// The original `enterpriseSsoUserInfo` field type is extended with `socialUserInfo` with `catchall(unknown)`.
102+
// However, the unknown type may cause error when using the `sql.jsonb` function in Slonik.
103+
// See {@logto/cli/src/queries/logto-config.ts#updateValueByKey} for more reference.
104+
// So we use `socialUserInfoGuard.catchall(jsonGuard)` to ensure the type is JSON serializable.
105+
enterpriseSsoUserInfo: socialUserInfoGuard.catchall(jsonGuard).optional(),
106+
}),
107+
totpVerificationRecordDataGuard.omit({
108+
secret: true,
109+
}),
110+
backupCodeVerificationRecordDataGuard.omit({
111+
backupCodes: true,
112+
}),
113+
webAuthnVerificationRecordDataGuard.omit({
114+
registrationChallenge: true,
115+
authenticationChallenge: true,
116+
registrationInfo: true,
117+
}),
118+
oneTimeTokenVerificationRecordDataGuard,
119+
newPasswordIdentityVerificationRecordDataGuard.omit({
120+
passwordEncrypted: true,
121+
passwordEncryptionMethod: true,
122+
}),
123+
]);
124+
125+
export const jwtCustomizerUserInteractionContextGuard = z.object({
126+
interactionEvent: z.nativeEnum(InteractionEvent),
127+
userId: z.string(),
128+
verificationRecords: jwtCustomizerUserInteractionVerificationRecordGuard.array(),
129+
});
130+
131+
export type JwtCustomizerUserInteractionContext = z.infer<
132+
typeof jwtCustomizerUserInteractionContextGuard
133+
>;
134+
78135
export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard
79136
.extend({
80137
// Use partial token guard since users customization may not rely on all fields.
@@ -83,6 +140,7 @@ export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard
83140
.object({
84141
user: jwtCustomizerUserContextGuard.partial(),
85142
grant: jwtCustomizerGrantContextGuard.partial().optional(),
143+
interaction: jwtCustomizerUserInteractionContextGuard.partial().optional(),
86144
})
87145
.optional(),
88146
})

‎packages/schemas/src/types/verification-records/enterprise-sso-verification.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type EnterpriseSsoVerificationRecordData = {
1717
issuer?: string;
1818
};
1919

20-
export const enterPriseSsoVerificationRecordDataGuard = z.object({
20+
export const enterpriseSsoVerificationRecordDataGuard = z.object({
2121
id: z.string(),
2222
connectorId: z.string(),
2323
type: z.literal(VerificationType.EnterpriseSso),

0 commit comments

Comments
 (0)
Please sign in to comment.