diff --git a/packages/core/src/routes/logto-config/jwt-customizer.test.ts b/packages/core/src/routes/logto-config/jwt-customizer.test.ts index 5f72e6fe3..90662f467 100644 --- a/packages/core/src/routes/logto-config/jwt-customizer.test.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.test.ts @@ -155,11 +155,10 @@ describe('configs JWT customizer routes', () => { jest.spyOn(mockCloudClient, 'post').mockResolvedValue(cloudConnectionResponse); const payload: JwtCustomizerTestRequestBody = { - tokenType: LogtoJwtTokenKeyType.AccessToken, + tokenType: LogtoJwtTokenKeyType.ClientCredentials, + script: mockJwtCustomizerConfigForClientCredentials.value.script, + environmentVariables: mockJwtCustomizerConfigForClientCredentials.value.environmentVariables, token: {}, - script: mockJwtCustomizerConfigForAccessToken.value.script, - environmentVariables: mockJwtCustomizerConfigForAccessToken.value.environmentVariables, - context: mockJwtCustomizerConfigForAccessToken.value.contextSample, }; const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload); @@ -167,7 +166,7 @@ describe('configs JWT customizer routes', () => { expect(mockLogtoConfigsLibrary.deployJwtCustomizerScript).toHaveBeenCalledWith( tenantContext.cloudConnection, { - key: LogtoJwtTokenKey.AccessToken, + key: LogtoJwtTokenKey.ClientCredentials, value: payload, isTest: true, } diff --git a/packages/core/src/routes/logto-config/jwt-customizer.ts b/packages/core/src/routes/logto-config/jwt-customizer.ts index 2d7599345..45478731c 100644 --- a/packages/core/src/routes/logto-config/jwt-customizer.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.ts @@ -207,11 +207,6 @@ export default function logtoConfigJwtCustomizerRoutes( router.post( '/configs/jwt-customizer/test', koaGuard({ - /** - * Early throws when: - * 1. no `script` provided. - * 2. no `tokenSample` provided. - */ body: jwtCustomizerTestRequestBodyGuard, response: jsonObjectGuard, status: [200, 400, 403, 422], diff --git a/packages/schemas/alterations/next-1712912361-delete-jwt-customier-with-empty-script.ts b/packages/schemas/alterations/next-1712912361-delete-jwt-customier-with-empty-script.ts new file mode 100644 index 000000000..2bf05494f --- /dev/null +++ b/packages/schemas/alterations/next-1712912361-delete-jwt-customier-with-empty-script.ts @@ -0,0 +1,23 @@ +import { sql } from '@silverhand/slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + // We are making the jwt-customizer script field mandatory + // Delete the records in logto_configs where key is jwt.accessToken or jwt.clientCredentials and value jsonb's script field is undefined + up: async (pool) => { + await pool.query( + sql` + delete from logto_configs + where key in ('jwt.accessToken', 'jwt.clientCredentials') + and value->>'script' is null + ` + ); + }, + down: async () => { + // No down script available, this is a non-reversible operation + // It is fine since we have not released this feature yet + }, +}; + +export default alteration; diff --git a/packages/schemas/src/types/logto-config/jwt-customizer.test.ts b/packages/schemas/src/types/logto-config/jwt-customizer.test.ts index f8ab70463..38af57c28 100644 --- a/packages/schemas/src/types/logto-config/jwt-customizer.test.ts +++ b/packages/schemas/src/types/logto-config/jwt-customizer.test.ts @@ -1,5 +1,5 @@ import { pick } from '@silverhand/essentials'; -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { accessTokenJwtCustomizerGuard, @@ -7,6 +7,8 @@ import { } from './jwt-customizer.js'; const allFields = ['script', 'environmentVariables', 'contextSample', 'tokenSample'] as const; +const requiredFields = ['script'] as const; +const optionalFields = ['environmentVariables', 'contextSample', 'tokenSample'] as const; const testClientCredentialsTokenPayload = { script: '', @@ -39,14 +41,35 @@ const testAccessTokenPayload = { }; describe('test token sample guard', () => { - it.each(allFields)('should pass guard with any of the field not specified', (droppedField) => { + it.each(optionalFields)( + 'should pass guard with any of the optionalFields not specified', + (droppedField) => { + const resultAccessToken = accessTokenJwtCustomizerGuard.safeParse( + pick(testAccessTokenPayload, ...allFields.filter((field) => field !== droppedField)) + ); + if (!resultAccessToken.success) { + console.log('resultAccessToken.error', resultAccessToken.error); + } + expect(resultAccessToken.success).toBe(true); + + const resultClientCredentials = clientCredentialsJwtCustomizerGuard.safeParse( + pick( + testClientCredentialsTokenPayload, + ...allFields.filter((field) => field !== droppedField) + ) + ); + if (!resultClientCredentials.success) { + console.log('resultClientCredentials.error', resultClientCredentials.error); + } + expect(resultClientCredentials.success).toBe(true); + } + ); + + it.each(requiredFields)('should throw when required field is not specified', (droppedField) => { const resultAccessToken = accessTokenJwtCustomizerGuard.safeParse( pick(testAccessTokenPayload, ...allFields.filter((field) => field !== droppedField)) ); - if (!resultAccessToken.success) { - console.log('resultAccessToken.error', resultAccessToken.error); - } - expect(resultAccessToken.success).toBe(true); + expect(resultAccessToken.success).toBe(false); const resultClientCredentials = clientCredentialsJwtCustomizerGuard.safeParse( pick( @@ -54,10 +77,7 @@ describe('test token sample guard', () => { ...allFields.filter((field) => field !== droppedField) ) ); - if (!resultClientCredentials.success) { - console.log('resultClientCredentials.error', resultClientCredentials.error); - } - expect(resultClientCredentials.success).toBe(true); + expect(resultClientCredentials.success).toBe(false); }); it.each(allFields)( diff --git a/packages/schemas/src/types/logto-config/jwt-customizer.ts b/packages/schemas/src/types/logto-config/jwt-customizer.ts index d6f252a37..6c64073d9 100644 --- a/packages/schemas/src/types/logto-config/jwt-customizer.ts +++ b/packages/schemas/src/types/logto-config/jwt-customizer.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { Roles, UserSsoIdentities, Organizations } from '../../db-entries/index.js'; +import { Organizations, Roles, UserSsoIdentities } from '../../db-entries/index.js'; import { jsonObjectGuard, mfaFactorsGuard } from '../../foundations/index.js'; import { scopeResponseGuard } from '../scope.js'; import { userInfoGuard } from '../user.js'; @@ -32,13 +32,11 @@ export const jwtCustomizerUserContextGuard = userInfoGuard.extend({ export type JwtCustomizerUserContext = z.infer; -export const jwtCustomizerGuard = z - .object({ - script: z.string(), - environmentVariables: z.record(z.string()), - contextSample: jsonObjectGuard, - }) - .partial(); +export const jwtCustomizerGuard = z.object({ + script: z.string(), + environmentVariables: z.record(z.string()).optional(), + contextSample: jsonObjectGuard.optional(), +}); export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard .extend({ @@ -66,25 +64,21 @@ export enum LogtoJwtTokenKeyType { /** * This guard is for the core JWT customizer testing API request body guard. + * Unlike the DB guard + * + * - rename the `tokenSample` to `token` and is required for testing. + * - rename the `contextSample` to `context` and is required for AccessToken testing. */ export const jwtCustomizerTestRequestBodyGuard = z.discriminatedUnion('tokenType', [ z.object({ tokenType: z.literal(LogtoJwtTokenKeyType.AccessToken), - ...accessTokenJwtCustomizerGuard - .required({ - script: true, - }) - .pick({ environmentVariables: true, script: true }).shape, + ...accessTokenJwtCustomizerGuard.pick({ environmentVariables: true, script: true }).shape, token: accessTokenJwtCustomizerGuard.required().shape.tokenSample, context: accessTokenJwtCustomizerGuard.required().shape.contextSample, }), z.object({ tokenType: z.literal(LogtoJwtTokenKeyType.ClientCredentials), - ...clientCredentialsJwtCustomizerGuard - .required({ - script: true, - }) - .pick({ environmentVariables: true, script: true }).shape, + ...clientCredentialsJwtCustomizerGuard.pick({ environmentVariables: true, script: true }).shape, token: clientCredentialsJwtCustomizerGuard.required().shape.tokenSample, }), ]);