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, }), ]);