diff --git a/packages/core/package.json b/packages/core/package.json index c1afb72db..5178fe48d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -92,7 +92,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@logto/cloud": "0.2.5-ab8a489", + "@logto/cloud": "0.2.5-749cae5", "@silverhand/eslint-config": "5.0.0", "@silverhand/ts-config": "5.0.0", "@types/debug": "^4.1.7", diff --git a/packages/core/src/libraries/cloud-connection.test.ts b/packages/core/src/libraries/cloud-connection.test.ts index 5b42d312a..d781c10c1 100644 --- a/packages/core/src/libraries/cloud-connection.test.ts +++ b/packages/core/src/libraries/cloud-connection.test.ts @@ -38,6 +38,7 @@ const logtoConfigs: LogtoConfigLibrary = { getJwtCustomizer: jest.fn(), getJwtCustomizers: jest.fn(), updateJwtCustomizer: jest.fn(), + deployJwtCustomizerScript: jest.fn(), }; describe('getAccessToken()', () => { diff --git a/packages/core/src/libraries/logto-config.ts b/packages/core/src/libraries/logto-config.ts index 01438e4b6..62ac055b6 100644 --- a/packages/core/src/libraries/logto-config.ts +++ b/packages/core/src/libraries/logto-config.ts @@ -1,19 +1,22 @@ +import type { CloudConnectionData, JwtCustomizerType, LogtoOidcConfigType } from '@logto/schemas'; import { - cloudApiIndicator, - cloudConnectionDataGuard, - logtoOidcConfigGuard, - LogtoOidcConfigKey, - jwtCustomizerConfigGuard, LogtoConfigs, LogtoJwtTokenKey, + LogtoOidcConfigKey, + cloudApiIndicator, + cloudConnectionDataGuard, + jwtCustomizerConfigGuard, + logtoOidcConfigGuard, } from '@logto/schemas'; -import type { LogtoOidcConfigType, CloudConnectionData, JwtCustomizerType } from '@logto/schemas'; import chalk from 'chalk'; -import { z, ZodError } from 'zod'; +import { ZodError, z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; import { consoleLog } from '#src/utils/console.js'; +import { getJwtCustomizerScripts } from '#src/utils/custom-jwt.js'; + +import { type CloudConnectionLibrary } from './cloud-connection.js'; export type LogtoConfigLibrary = ReturnType; @@ -129,6 +132,47 @@ export const createLogtoConfigLibrary = ({ return updatedRow.value; }; + /** + * This method is used to deploy the give JWT customizer scripts to the cloud worker service. + * + * @remarks Since cloud worker service deploy all the JWT customizer scripts at once, + * and the latest JWT customizer updates needs to be deployed ahead before saving it to the database, + * we need to merge the input payload with the existing JWT customizer scripts. + * + * @params payload - The latest JWT customizer payload needs to be deployed. + * @params payload.key - The tokenType of the JWT customizer. + * @params payload.value - JWT customizer value + * @params payload.isTest - Whether the JWT customizer is for test environment. + */ + const deployJwtCustomizerScript = async ( + cloudConnection: CloudConnectionLibrary, + payload: { + key: T; + value: JwtCustomizerType[T]; + isTest?: boolean; + } + ) => { + const [client, jwtCustomizers] = await Promise.all([ + cloudConnection.getClient(), + getJwtCustomizers(), + ]); + + const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers); + + const newCustomizerScripts: { [key in LogtoJwtTokenKey]?: string } = { + [payload.key]: payload.value.script, + }; + + await client.put(`/api/services/custom-jwt/worker`, { + body: { + production: payload.isTest + ? customizerScriptsFromDatabase + : { ...customizerScriptsFromDatabase, ...newCustomizerScripts }, + test: payload.isTest ? newCustomizerScripts : undefined, + }, + }); + }; + return { getOidcConfigs, getCloudConnectionData, @@ -136,5 +180,6 @@ export const createLogtoConfigLibrary = ({ getJwtCustomizer, getJwtCustomizers, updateJwtCustomizer, + deployJwtCustomizerScript, }; }; diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index ef519c7fc..0c6284df4 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -3,10 +3,10 @@ import { builtInLanguages } from '@logto/phrases-experience'; import type { CreateSignInExperience, SignInExperience } from '@logto/schemas'; import { - socialTarget01, - socialTarget02, mockSignInExperience, mockSocialConnectors, + socialTarget01, + socialTarget02, wellConfiguredSsoConnector, } from '#src/__mocks__/index.js'; import RequestError from '#src/errors/RequestError/index.js'; @@ -61,6 +61,7 @@ const cloudConnection = createCloudConnectionLibrary({ getJwtCustomizer: jest.fn(), getJwtCustomizers: jest.fn(), updateJwtCustomizer: jest.fn(), + deployJwtCustomizerScript: jest.fn(), }); const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors'); 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 6bf9de195..d6bb2aa3b 100644 --- a/packages/core/src/routes/logto-config/jwt-customizer.test.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.test.ts @@ -4,9 +4,9 @@ import { pick } from '@silverhand/essentials'; import Sinon from 'sinon'; import { - mockLogtoConfigRows, mockJwtCustomizerConfigForAccessToken, mockJwtCustomizerConfigForClientCredentials, + mockLogtoConfigRows, } from '#src/__mocks__/index.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createRequester } from '#src/utils/test-utils.js'; @@ -23,6 +23,7 @@ const logtoConfigLibraries = { getJwtCustomizer: jest.fn(), getJwtCustomizers: jest.fn(), updateJwtCustomizer: jest.fn(), + deployJwtCustomizerScript: jest.fn(), }; const settingRoutes = await pickDefault(import('./index.js')); @@ -52,6 +53,9 @@ describe('configs JWT customizer routes', () => { const response = await routeRequester .put(`/configs/jwt-customizer/access-token`) .send(mockJwtCustomizerConfigForAccessToken.value); + + expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalled(); + expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith( LogtoJwtTokenKey.AccessToken, mockJwtCustomizerConfigForAccessToken.value @@ -87,6 +91,9 @@ describe('configs JWT customizer routes', () => { const response = await routeRequester .patch('/configs/jwt-customizer/access-token') .send(mockJwtCustomizerConfigForAccessToken.value); + + expect(logtoConfigLibraries.deployJwtCustomizerScript).toHaveBeenCalled(); + expect(logtoConfigLibraries.updateJwtCustomizer).toHaveBeenCalledWith( LogtoJwtTokenKey.AccessToken, mockJwtCustomizerConfigForAccessToken.value diff --git a/packages/core/src/routes/logto-config/jwt-customizer.ts b/packages/core/src/routes/logto-config/jwt-customizer.ts index 0c312a2b8..14e4ace9a 100644 --- a/packages/core/src/routes/logto-config/jwt-customizer.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.ts @@ -1,10 +1,10 @@ import { - accessTokenJwtCustomizerGuard, - clientCredentialsJwtCustomizerGuard, LogtoJwtTokenKey, LogtoJwtTokenKeyType, - jsonObjectGuard, + accessTokenJwtCustomizerGuard, adminTenantId, + clientCredentialsJwtCustomizerGuard, + jsonObjectGuard, jwtCustomizerConfigsGuard, jwtCustomizerTestRequestBodyGuard, } from '@logto/schemas'; @@ -34,8 +34,13 @@ export default function logtoConfigJwtCustomizerRoutes( ...[router, { id: tenantId, queries, logtoConfigs, cloudConnection }]: RouterInitArgs ) { const { getRowsByKeys, deleteJwtCustomizer } = queries.logtoConfigs; - const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers, updateJwtCustomizer } = - logtoConfigs; + const { + upsertJwtCustomizer, + getJwtCustomizer, + getJwtCustomizers, + updateJwtCustomizer, + deployJwtCustomizerScript, + } = logtoConfigs; router.put( '/configs/jwt-customizer/:tokenTypePath', @@ -67,8 +72,17 @@ export default function logtoConfigJwtCustomizerRoutes( params: { tokenTypePath }, body: rawBody, } = ctx.guard; + const { key, body } = getJwtTokenKeyAndBody(tokenTypePath, rawBody); + // Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully. + if (!isIntegrationTest) { + await deployJwtCustomizerScript(cloudConnection, { + key, + value: body, + }); + } + const { rows } = await getRowsByKeys([key]); const jwtCustomizer = await upsertJwtCustomizer(key, body); @@ -76,6 +90,7 @@ export default function logtoConfigJwtCustomizerRoutes( if (rows.length === 0) { ctx.status = 201; } + ctx.body = jwtCustomizer.value; return next(); @@ -94,12 +109,22 @@ export default function logtoConfigJwtCustomizerRoutes( status: [200, 400, 404], }), async (ctx, next) => { + const { isIntegrationTest } = EnvSet.values; + const { params: { tokenTypePath }, body: rawBody, } = ctx.guard; const { key, body } = getJwtTokenKeyAndBody(tokenTypePath, rawBody); + // Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully. + if (!isIntegrationTest) { + await deployJwtCustomizerScript(cloudConnection, { + key, + value: body, + }); + } + ctx.body = await updateJwtCustomizer(key, body); return next(); diff --git a/packages/core/src/utils/custom-jwt.ts b/packages/core/src/utils/custom-jwt.ts new file mode 100644 index 000000000..7d8e1c0cf --- /dev/null +++ b/packages/core/src/utils/custom-jwt.ts @@ -0,0 +1,8 @@ +import { LogtoJwtTokenKey, type JwtCustomizerType } from '@logto/schemas'; + +export const getJwtCustomizerScripts = (jwtCustomizers: Partial) => { + // eslint-disable-next-line no-restricted-syntax -- enable to infer the type using `Object.fromEntries` + return Object.fromEntries( + Object.values(LogtoJwtTokenKey).map((key) => [key, jwtCustomizers[key]?.script]) + ) as { [key in LogtoJwtTokenKey]?: string }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48e0417bd..6a242a38d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3205,8 +3205,8 @@ importers: version: 3.22.4 devDependencies: '@logto/cloud': - specifier: 0.2.5-ab8a489 - version: 0.2.5-ab8a489(zod@3.22.4) + specifier: 0.2.5-749cae5 + version: 0.2.5-749cae5(zod@3.22.4) '@silverhand/eslint-config': specifier: 5.0.0 version: 5.0.0(eslint@8.44.0)(prettier@3.0.0)(typescript@5.3.3) @@ -7647,6 +7647,16 @@ packages: jose: 5.2.2 dev: true + /@logto/cloud@0.2.5-749cae5(zod@3.22.4): + resolution: {integrity: sha512-QzebHRSBShQwOsKAvYlVd7QF43RlrHOt/nmwrlRNW4F9U0DUEFaeLZujYS56oxjQ49GRdsOPKSQCE97wRB7NNQ==} + engines: {node: ^20.9.0} + dependencies: + '@silverhand/essentials': 2.9.0 + '@withtyped/server': 0.13.3(zod@3.22.4) + transitivePeerDependencies: + - zod + dev: true + /@logto/cloud@0.2.5-ab8a489(zod@3.22.4): resolution: {integrity: sha512-nUD1n2CDe/nu6x4cOhXfJ5VyKKDqkKv+a/u9zSfbIMxIF0nShybd2LiCYJDO0SPuMqLnmlYFg+79KrdPCNvjIQ==} engines: {node: ^20.9.0}