Skip to content

Commit

Permalink
chore(core,console): update error handling of testing custom JWT
Browse files Browse the repository at this point in the history
  • Loading branch information
darcyYe committed Apr 1, 2024
1 parent add78b7 commit f025e55
Show file tree
Hide file tree
Showing 9 changed files with 99 additions and 101 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function ErrorContent({ testResult }: Props) {
)}
{testResult.payload && (
<pre>
{'JWT Payload: \n'}
{'Extra JWT claims: \n'}
{testResult.payload}
</pre>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +36,8 @@ const useTestHandler = () => {
if (error instanceof HTTPError) {
const { response } = error;
const metadata = await response.clone().json<RequestErrorBody>();

// Get error message from cloud connection client.
if (metadata.code === jwtCustomizerGeneralErrorCode) {
const result = z.object({ message: z.string() }).safeParse(metadata.data);
if (result.success) {
Expand All @@ -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({
Expand Down
14 changes: 7 additions & 7 deletions packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,20 +209,20 @@ export const defaultClientCredentialsPayload: ClientCredentialsPayload = {

const defaultUserContext: Partial<JwtCustomizerUserContext> = {
id: '123',
username: 'foo',
primaryEmail: '[email protected]',
primaryPhone: '+1234567890',
name: 'Foo Bar',
roles: [],
avatar: 'https://example.com/avatar.png',
profile: {},
username: 'foo',
customData: {},
identities: {},
primaryEmail: '[email protected]',
primaryPhone: '+1234567890',
profile: {},
applicationId: 'my-app',
organizations: [],
ssoIdentities: [],
organizationRoles: [],
mfaVerificationFactors: [],
roles: [],
organizations: [],
organizationRoles: [],
};

export const defaultUserTokenContextData = {
Expand Down
21 changes: 9 additions & 12 deletions packages/console/src/pages/CustomizeJwtDetails/utils/format.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
};
};
2 changes: 1 addition & 1 deletion packages/core/src/errors/RequestError/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}) `;

Expand Down
50 changes: 14 additions & 36 deletions packages/core/src/routes/logto-config/jwt-customizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -197,10 +164,14 @@ export default function logtoConfigJwtCustomizerRoutes<T extends AuthedRouter>(

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.
*/
Expand All @@ -209,6 +180,13 @@ export default function logtoConfigJwtCustomizerRoutes<T extends AuthedRouter>(
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;
}

Expand Down
1 change: 0 additions & 1 deletion packages/schemas/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
32 changes: 7 additions & 25 deletions packages/schemas/src/types/logto-config/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<typeof accessTokenJwtCustomizerGuard>;

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<typeof clientCredentialsJwtCustomizerGuard>;

export type JwtCustomizerType = {
[LogtoJwtTokenKey.AccessToken]: AccessTokenJwtCustomizer;
[LogtoJwtTokenKey.ClientCredentials]: ClientCredentialsJwtCustomizer;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -36,6 +32,29 @@ export const jwtCustomizerUserContextGuard = userInfoGuard.extend({

export type JwtCustomizerUserContext = z.infer<typeof jwtCustomizerUserContextGuard>;

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<typeof accessTokenJwtCustomizerGuard>;

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<typeof clientCredentialsJwtCustomizerGuard>;

export enum LogtoJwtTokenPath {
AccessToken = 'access-token',
ClientCredentials = 'client-credentials',
Expand All @@ -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,
}),
]);

Expand Down

0 comments on commit f025e55

Please sign in to comment.