diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.tsx
index 5fdb266582bd..e1e07b3f3b93 100644
--- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.tsx
+++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.tsx
@@ -17,7 +17,7 @@ function ErrorContent({ testResult }: Props) {
)}
{testResult.payload && (
- {'JWT Payload: \n'}
+ {'Extra JWT claims: \n'}
{testResult.payload}
)}
diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/use-test-handler.ts b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/use-test-handler.ts
index 64a7a51d0d29..f0af293e7724 100644
--- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/use-test-handler.ts
+++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/use-test-handler.ts
@@ -10,6 +10,7 @@ import { formatFormDataToTestRequestPayload } from '@/pages/CustomizeJwtDetails/
const testEndpointPath = 'api/configs/jwt-customizer/test';
const jwtCustomizerGeneralErrorCode = 'jwt_customizer.general';
+const apiInvalidInputErrorCode = 'guard.invalid_input';
export type TestResultData = {
error?: string;
@@ -35,6 +36,8 @@ const useTestHandler = () => {
if (error instanceof HTTPError) {
const { response } = error;
const metadata = await response.clone().json();
+
+ // Get error message from cloud connection client.
if (metadata.code === jwtCustomizerGeneralErrorCode) {
const result = z.object({ message: z.string() }).safeParse(metadata.data);
if (result.success) {
@@ -44,6 +47,22 @@ const useTestHandler = () => {
return;
}
}
+
+ /**
+ * Get error message when the API request violates the request guard.
+ * Find details on the implementation of:
+ * 1. `RequestError`
+ * 2. `koaGuard`
+ */
+ if(metadata.code === apiInvalidInputErrorCode) {
+ const result = z.string().safeParse(metadata.details);
+ if (result.success) {
+ setTestResult({
+ error: result.data,
+ });
+ return;
+ }
+ }
}
setTestResult({
diff --git a/packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx b/packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx
index 54145b351ab5..d55c7d4bf2ad 100644
--- a/packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx
+++ b/packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx
@@ -209,20 +209,20 @@ export const defaultClientCredentialsPayload: ClientCredentialsPayload = {
const defaultUserContext: Partial = {
id: '123',
+ username: 'foo',
+ primaryEmail: 'foo@logto.io',
+ primaryPhone: '+1234567890',
name: 'Foo Bar',
- roles: [],
avatar: 'https://example.com/avatar.png',
- profile: {},
- username: 'foo',
customData: {},
identities: {},
- primaryEmail: 'foo@logto.io',
- primaryPhone: '+1234567890',
+ profile: {},
applicationId: 'my-app',
- organizations: [],
ssoIdentities: [],
- organizationRoles: [],
mfaVerificationFactors: [],
+ roles: [],
+ organizations: [],
+ organizationRoles: [],
};
export const defaultUserTokenContextData = {
diff --git a/packages/console/src/pages/CustomizeJwtDetails/utils/format.ts b/packages/console/src/pages/CustomizeJwtDetails/utils/format.ts
index 9e8e76899b63..51b1a9f46e40 100644
--- a/packages/console/src/pages/CustomizeJwtDetails/utils/format.ts
+++ b/packages/console/src/pages/CustomizeJwtDetails/utils/format.ts
@@ -1,4 +1,4 @@
-import { LogtoJwtTokenPath, type AccessTokenJwtCustomizer } from '@logto/schemas';
+import { LogtoJwtTokenPath, type AccessTokenJwtCustomizer, type Json } from '@logto/schemas';
import type { JwtCustomizer, JwtCustomizerForm } from '../type';
@@ -36,7 +36,7 @@ const formatEnvVariablesFormDataToRequest = (
return Object.fromEntries(entries.map(({ key, value }) => [key, value]));
};
-const formatSampleCodeJsonToString = (sampleJson?: AccessTokenJwtCustomizer['contextSample']) => {
+const formatSampleCodeJsonToString = (sampleJson?: Json) => {
if (!sampleJson) {
return;
}
@@ -106,15 +106,12 @@ export const formatFormDataToTestRequestPayload = ({
}: JwtCustomizerForm) => {
return {
tokenType,
- payload: {
- script,
- envVars: formatEnvVariablesFormDataToRequest(environmentVariables),
- tokenSample:
- formatSampleCodeStringToJson(testSample.tokenSample) ??
- defaultValues[tokenType].tokenSample,
- contextSample:
- formatSampleCodeStringToJson(testSample.contextSample) ??
- defaultValues[tokenType].contextSample,
- },
+ script,
+ envVars: formatEnvVariablesFormDataToRequest(environmentVariables),
+ token:
+ formatSampleCodeStringToJson(testSample.tokenSample) ?? defaultValues[tokenType].tokenSample,
+ context:
+ formatSampleCodeStringToJson(testSample.contextSample) ??
+ defaultValues[tokenType].contextSample,
};
};
diff --git a/packages/core/src/errors/RequestError/index.ts b/packages/core/src/errors/RequestError/index.ts
index aaf0b3066404..7f36865984bc 100644
--- a/packages/core/src/errors/RequestError/index.ts
+++ b/packages/core/src/errors/RequestError/index.ts
@@ -5,7 +5,7 @@ import { conditional, pick } from '@silverhand/essentials';
import i18next from 'i18next';
import { ZodError } from 'zod';
-const formatZodError = ({ issues }: ZodError): string[] =>
+export const formatZodError = ({ issues }: ZodError): string[] =>
issues.map((issue) => {
const base = `Error in key path "${issue.path.map(String).join('.')}": (${issue.code}) `;
diff --git a/packages/core/src/routes/logto-config/jwt-customizer.ts b/packages/core/src/routes/logto-config/jwt-customizer.ts
index 7cbf9d97d1eb..eb2b540d5a04 100644
--- a/packages/core/src/routes/logto-config/jwt-customizer.ts
+++ b/packages/core/src/routes/logto-config/jwt-customizer.ts
@@ -7,49 +7,16 @@ import {
adminTenantId,
jwtCustomizerConfigsGuard,
jwtCustomizerTestRequestBodyGuard,
- type JwtCustomizerTestRequestBody,
- type CustomJwtFetcher,
} from '@logto/schemas';
import { ResponseError } from '@withtyped/client';
-import { z } from 'zod';
+import { ZodError, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
-import RequestError from '#src/errors/RequestError/index.js';
+import RequestError, { formatZodError } from '#src/errors/RequestError/index.js';
import koaGuard, { parse } from '#src/middleware/koa-guard.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
-/**
- * Transpile the request body of the JWT customizer test API to the request body of the Cloud JWT customizer test API.
- *
- * @param body Core JWT customizer test API request body.
- * @returns Request body of the Cloud JWT customizer test API.
- */
-const transpileJwtCustomizerTestRequestBody = (
- body: JwtCustomizerTestRequestBody
-): CustomJwtFetcher => {
- const { tokenType, payload } = body;
- /**
- * We have to deal with the `tokenType` and `payload` at the same time since they are put together as one of the discriminated union type.
- * Otherwise the type inference will not work as expected.
- */
- if (tokenType === LogtoJwtTokenPath.AccessToken) {
- const { tokenSample: token, contextSample: context, ...rest } = payload;
- return {
- tokenType,
- token,
- context,
- ...rest,
- };
- }
- const { tokenSample: token, contextSample, ...rest } = payload;
- return {
- tokenType,
- token,
- ...rest,
- };
-};
-
const getJwtTokenKeyAndBody = (tokenPath: LogtoJwtTokenPath, body: unknown) => {
if (tokenPath === LogtoJwtTokenPath.AccessToken) {
return {
@@ -197,10 +164,14 @@ export default function logtoConfigJwtCustomizerRoutes(
try {
ctx.body = await client.post(`/api/services/custom-jwt`, {
- body: transpileJwtCustomizerTestRequestBody(body),
+ body,
});
} catch (error: unknown) {
/**
+ * All APIs should throw `RequestError` instead of `Error`.
+ * In the admin console, we caught the error and recognized the error with the code `jwt_customizer.general`,
+ * and then we extract and show the error message to the user.
+ *
* `ResponseError` comes from `@withtyped/client` and all `logto/core` API returns error in the
* format of `RequestError`, we manually transform it here to keep the error format consistent.
*/
@@ -209,6 +180,13 @@ export default function logtoConfigJwtCustomizerRoutes(
throw new RequestError({ code: 'jwt_customizer.general', status: 422 }, { message });
}
+ if (error instanceof ZodError) {
+ throw new RequestError(
+ { code: 'jwt_customizer.general', status: 422 },
+ { message: formatZodError(error) }
+ );
+ }
+
throw error;
}
diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts
index 54d51ad51e18..72c1058bf428 100644
--- a/packages/schemas/src/types/index.ts
+++ b/packages/schemas/src/types/index.ts
@@ -26,5 +26,4 @@ export * from './tenant.js';
export * from './tenant-organization.js';
export * from './mapi-proxy.js';
export * from './consent.js';
-export * from './jwt-customizer.js';
export * from './onboarding.js';
diff --git a/packages/schemas/src/types/logto-config/index.ts b/packages/schemas/src/types/logto-config/index.ts
index 3edb2c753b8e..e9f6710764f7 100644
--- a/packages/schemas/src/types/logto-config/index.ts
+++ b/packages/schemas/src/types/logto-config/index.ts
@@ -1,11 +1,15 @@
import type { ZodType } from 'zod';
import { z } from 'zod';
-import { jsonObjectGuard } from '../../foundations/index.js';
-
-import { accessTokenPayloadGuard, clientCredentialsPayloadGuard } from './oidc-provider.js';
+import {
+ type AccessTokenJwtCustomizer,
+ type ClientCredentialsJwtCustomizer,
+ accessTokenJwtCustomizerGuard,
+ clientCredentialsJwtCustomizerGuard,
+} from './jwt-customizer.js';
export * from './oidc-provider.js';
+export * from './jwt-customizer.js';
/**
* Logto OIDC signing key types, used mainly in REST API routes.
@@ -56,28 +60,6 @@ export enum LogtoJwtTokenKey {
ClientCredentials = 'jwt.clientCredentials',
}
-export const jwtCustomizerGuard = z
- .object({
- script: z.string(),
- envVars: z.record(z.string()),
- contextSample: jsonObjectGuard,
- })
- .partial();
-
-export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard.extend({
- // Use partial token guard since users customization may not rely on all fields.
- tokenSample: accessTokenPayloadGuard.partial().optional(),
-});
-
-export type AccessTokenJwtCustomizer = z.infer;
-
-export const clientCredentialsJwtCustomizerGuard = jwtCustomizerGuard.extend({
- // Use partial token guard since users customization may not rely on all fields.
- tokenSample: clientCredentialsPayloadGuard.partial().optional(),
-});
-
-export type ClientCredentialsJwtCustomizer = z.infer;
-
export type JwtCustomizerType = {
[LogtoJwtTokenKey.AccessToken]: AccessTokenJwtCustomizer;
[LogtoJwtTokenKey.ClientCredentials]: ClientCredentialsJwtCustomizer;
diff --git a/packages/schemas/src/types/jwt-customizer.ts b/packages/schemas/src/types/logto-config/jwt-customizer.ts
similarity index 56%
rename from packages/schemas/src/types/jwt-customizer.ts
rename to packages/schemas/src/types/logto-config/jwt-customizer.ts
index 492a2e7b0915..fdf5e61b2c16 100644
--- a/packages/schemas/src/types/jwt-customizer.ts
+++ b/packages/schemas/src/types/logto-config/jwt-customizer.ts
@@ -1,15 +1,11 @@
import { z } from 'zod';
-import { Roles, UserSsoIdentities, Organizations } from '../db-entries/index.js';
-import { jsonObjectGuard, mfaFactorsGuard } from '../foundations/index.js';
+import { Roles, UserSsoIdentities, Organizations } from '../../db-entries/index.js';
+import { jsonObjectGuard, mfaFactorsGuard } from '../../foundations/index.js';
+import { scopeResponseGuard } from '../scope.js';
+import { userInfoGuard } from '../user.js';
-import {
- jwtCustomizerGuard,
- accessTokenJwtCustomizerGuard,
- clientCredentialsJwtCustomizerGuard,
-} from './logto-config/index.js';
-import { scopeResponseGuard } from './scope.js';
-import { userInfoGuard } from './user.js';
+import { accessTokenPayloadGuard, clientCredentialsPayloadGuard } from './oidc-provider.js';
export const jwtCustomizerUserContextGuard = userInfoGuard.extend({
ssoIdentities: UserSsoIdentities.guard
@@ -36,6 +32,29 @@ export const jwtCustomizerUserContextGuard = userInfoGuard.extend({
export type JwtCustomizerUserContext = z.infer;
+export const jwtCustomizerGuard = z
+ .object({
+ script: z.string(),
+ envVars: z.record(z.string()),
+ contextSample: jsonObjectGuard,
+ })
+ .partial();
+
+export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard.extend({
+ // Use partial token guard since users customization may not rely on all fields.
+ tokenSample: accessTokenPayloadGuard.partial().optional(),
+ contextSample: z.object({ user: jwtCustomizerUserContextGuard.partial() }),
+});
+
+export type AccessTokenJwtCustomizer = z.infer;
+
+export const clientCredentialsJwtCustomizerGuard = jwtCustomizerGuard.extend({
+ // Use partial token guard since users customization may not rely on all fields.
+ tokenSample: clientCredentialsPayloadGuard.partial().optional(),
+});
+
+export type ClientCredentialsJwtCustomizer = z.infer;
+
export enum LogtoJwtTokenPath {
AccessToken = 'access-token',
ClientCredentials = 'client-credentials',
@@ -47,18 +66,22 @@ export enum LogtoJwtTokenPath {
export const jwtCustomizerTestRequestBodyGuard = z.discriminatedUnion('tokenType', [
z.object({
tokenType: z.literal(LogtoJwtTokenPath.AccessToken),
- payload: accessTokenJwtCustomizerGuard.required({
- script: true,
- tokenSample: true,
- contextSample: true,
- }),
+ ...accessTokenJwtCustomizerGuard
+ .required({
+ script: true,
+ })
+ .pick({ envVars: true, script: true }).shape,
+ token: accessTokenJwtCustomizerGuard.required().shape.tokenSample,
+ context: accessTokenJwtCustomizerGuard.required().shape.contextSample,
}),
z.object({
tokenType: z.literal(LogtoJwtTokenPath.ClientCredentials),
- payload: clientCredentialsJwtCustomizerGuard.required({
- script: true,
- tokenSample: true,
- }),
+ ...clientCredentialsJwtCustomizerGuard
+ .required({
+ script: true,
+ })
+ .pick({ envVars: true, script: true }).shape,
+ token: clientCredentialsJwtCustomizerGuard.required().shape.tokenSample,
}),
]);