diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts index 6ba66c493..71973e7ff 100644 --- a/packages/core/src/__mocks__/index.ts +++ b/packages/core/src/__mocks__/index.ts @@ -11,7 +11,13 @@ import type { Scope, UsersRole, } from '@logto/schemas'; -import { RoleType, ApplicationType, LogtoOidcConfigKey, DomainStatus } from '@logto/schemas'; +import { + RoleType, + ApplicationType, + LogtoOidcConfigKey, + DomainStatus, + LogtoJwtTokenKey, +} from '@logto/schemas'; import { protectedAppSignInCallbackUrl } from '#src/constants/index.js'; import { mockId } from '#src/test-utils/nanoid.js'; @@ -209,7 +215,7 @@ export const mockApplicationRole: ApplicationsRole = { export const mockJwtCustomizerConfigForAccessToken = { tenantId: 'fake_tenant', - key: 'jwt.accessToken', + key: LogtoJwtTokenKey.AccessToken, value: { script: 'console.log("hello world");', envVars: { @@ -222,3 +228,14 @@ export const mockJwtCustomizerConfigForAccessToken = { }, }, }; + +export const mockJwtCustomizerConfigForClientCredentials = { + tenantId: 'fake_tenant', + key: LogtoJwtTokenKey.ClientCredentials, + value: { + script: 'console.log("hello world");', + envVars: { + API_KEY: '', + }, + }, +}; diff --git a/packages/core/src/libraries/cloud-connection.test.ts b/packages/core/src/libraries/cloud-connection.test.ts index 3bb7b09a7..7fd644319 100644 --- a/packages/core/src/libraries/cloud-connection.test.ts +++ b/packages/core/src/libraries/cloud-connection.test.ts @@ -36,6 +36,7 @@ const logtoConfigs: LogtoConfigLibrary = { getOidcConfigs: jest.fn(), upsertJwtCustomizer: jest.fn(), getJwtCustomizer: jest.fn(), + getJwtCustomizers: jest.fn(), }; describe('getAccessToken()', () => { diff --git a/packages/core/src/libraries/logto-config.ts b/packages/core/src/libraries/logto-config.ts index 4ecd0e11a..517370cf6 100644 --- a/packages/core/src/libraries/logto-config.ts +++ b/packages/core/src/libraries/logto-config.ts @@ -5,8 +5,9 @@ import { LogtoOidcConfigKey, jwtCustomizerConfigGuard, LogtoConfigs, + LogtoJwtTokenKey, } from '@logto/schemas'; -import type { LogtoOidcConfigType, LogtoJwtTokenKey, CloudConnectionData } from '@logto/schemas'; +import type { LogtoOidcConfigType, CloudConnectionData, JwtCustomizerType } from '@logto/schemas'; import chalk from 'chalk'; import { z, ZodError } from 'zod'; @@ -95,5 +96,34 @@ export const createLogtoConfigLibrary = ({ return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value; }; - return { getOidcConfigs, getCloudConnectionData, upsertJwtCustomizer, getJwtCustomizer }; + const getJwtCustomizers = async (): Promise> => { + try { + const { rows } = await getRowsByKeys(Object.values(LogtoJwtTokenKey)); + + return z + .object(jwtCustomizerConfigGuard) + .partial() + .parse(Object.fromEntries(rows.map(({ key, value }) => [key, value]))); + } catch (error: unknown) { + if (error instanceof ZodError) { + consoleLog.error( + error.issues + .map(({ message, path }) => `${message} at ${chalk.green(path.join('.'))}`) + .join('\n') + ); + } else { + consoleLog.error(error); + } + + throw new Error('Failed to get JWT customizers'); + } + }; + + return { + getOidcConfigs, + getCloudConnectionData, + upsertJwtCustomizer, + getJwtCustomizer, + getJwtCustomizers, + }; }; 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 2efdd1c00..d85bb9bd3 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -59,6 +59,7 @@ const cloudConnection = createCloudConnectionLibrary({ getOidcConfigs: jest.fn(), upsertJwtCustomizer: jest.fn(), getJwtCustomizer: jest.fn(), + getJwtCustomizers: jest.fn(), }); const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors'); diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 48decc417..a33c48315 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -25,7 +25,7 @@ import domainRoutes from './domain.js'; import hookRoutes from './hook.js'; import interactionRoutes from './interaction/index.js'; import logRoutes from './log.js'; -import logtoConfigRoutes from './logto-config.js'; +import logtoConfigRoutes from './logto-config/index.js'; import organizationRoutes from './organization/index.js'; import resourceRoutes from './resource.js'; import resourceScopeRoutes from './resource.scope.js'; diff --git a/packages/core/src/routes/logto-config.test.ts b/packages/core/src/routes/logto-config/index.test.ts similarity index 71% rename from packages/core/src/routes/logto-config.test.ts rename to packages/core/src/routes/logto-config/index.test.ts index 35b84f091..f1fea3943 100644 --- a/packages/core/src/routes/logto-config.test.ts +++ b/packages/core/src/routes/logto-config/index.test.ts @@ -1,15 +1,9 @@ -import { LogtoOidcConfigKey, type AdminConsoleData, LogtoJwtTokenKey } from '@logto/schemas'; +import { LogtoOidcConfigKey, type AdminConsoleData } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; import Sinon from 'sinon'; -import { - mockAdminConsoleData, - mockCookieKeys, - mockPrivateKeys, - mockLogtoConfigRows, - mockJwtCustomizerConfigForAccessToken, -} from '#src/__mocks__/index.js'; +import { mockAdminConsoleData, mockCookieKeys, mockPrivateKeys } from '#src/__mocks__/index.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createRequester } from '#src/utils/test-utils.js'; @@ -53,8 +47,6 @@ const logtoConfigQueries = { }, }), updateOidcConfigsByKey: jest.fn(), - getRowsByKeys: jest.fn(async () => mockLogtoConfigRows), - deleteJwtCustomizer: jest.fn(), }; const logtoConfigLibraries = { @@ -62,11 +54,9 @@ const logtoConfigLibraries = { [LogtoOidcConfigKey.PrivateKeys]: mockPrivateKeys, [LogtoOidcConfigKey.CookieKeys]: mockCookieKeys, })), - upsertJwtCustomizer: jest.fn(), - getJwtCustomizer: jest.fn(), }; -const settingRoutes = await pickDefault(import('./logto-config.js')); +const settingRoutes = await pickDefault(import('./index.js')); describe('configs routes', () => { const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries }); @@ -227,61 +217,4 @@ describe('configs routes', () => { [newPrivateKey2, newPrivateKey] ); }); - - it('PUT /configs/jwt-customizer/:tokenType should add a record successfully', async () => { - logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({ - ...mockLogtoConfigRows, - rows: [], - rowCount: 0, - }); - logtoConfigLibraries.upsertJwtCustomizer.mockResolvedValueOnce( - mockJwtCustomizerConfigForAccessToken - ); - const response = await routeRequester - .put(`/configs/jwt-customizer/access-token`) - .send(mockJwtCustomizerConfigForAccessToken.value); - expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith( - LogtoJwtTokenKey.AccessToken, - mockJwtCustomizerConfigForAccessToken.value - ); - expect(response.status).toEqual(201); - expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value); - }); - - it('PUT /configs/jwt-customizer/:tokenType should update a record successfully', async () => { - logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({ - ...mockLogtoConfigRows, - rows: [mockJwtCustomizerConfigForAccessToken], - rowCount: 1, - }); - logtoConfigLibraries.upsertJwtCustomizer.mockResolvedValueOnce( - mockJwtCustomizerConfigForAccessToken - ); - const response = await routeRequester - .put('/configs/jwt-customizer/access-token') - .send(mockJwtCustomizerConfigForAccessToken.value); - expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith( - LogtoJwtTokenKey.AccessToken, - mockJwtCustomizerConfigForAccessToken.value - ); - expect(response.status).toEqual(200); - expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value); - }); - - it('GET /configs/jwt-customizer/:tokenType should return the record', async () => { - logtoConfigLibraries.getJwtCustomizer.mockResolvedValueOnce( - mockJwtCustomizerConfigForAccessToken.value - ); - const response = await routeRequester.get('/configs/jwt-customizer/access-token'); - expect(response.status).toEqual(200); - expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value); - }); - - it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => { - const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials'); - expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith( - LogtoJwtTokenKey.ClientCredentials - ); - expect(response.status).toEqual(204); - }); }); diff --git a/packages/core/src/routes/logto-config/index.ts b/packages/core/src/routes/logto-config/index.ts new file mode 100644 index 000000000..0b9e424d2 --- /dev/null +++ b/packages/core/src/routes/logto-config/index.ts @@ -0,0 +1,189 @@ +import crypto from 'node:crypto'; + +import { + generateOidcCookieKey, + generateOidcPrivateKey, +} from '@logto/cli/lib/commands/database/utils.js'; +import { + LogtoOidcConfigKey, + adminConsoleDataGuard, + oidcConfigKeysResponseGuard, + SupportedSigningKeyAlgorithm, + type OidcConfigKeysResponse, + type OidcConfigKey, + LogtoOidcConfigKeyType, +} from '@logto/schemas'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import { exportJWK } from '#src/utils/jwks.js'; + +import type { AuthedRouter, RouterInitArgs } from '../types.js'; + +import logtoConfigJwtCustomizerRoutes from './jwt-customizer.js'; + +/** + * Provide a simple API router key type and DB config key mapping + */ +const getOidcConfigKeyDatabaseColumnName = (key: LogtoOidcConfigKeyType): LogtoOidcConfigKey => + key === LogtoOidcConfigKeyType.PrivateKeys + ? LogtoOidcConfigKey.PrivateKeys + : LogtoOidcConfigKey.CookieKeys; + +/** + * Remove actual values of the private keys from response. + * @param type Logto config key DB column name. Values are either `oidc.privateKeys` or `oidc.cookieKeys`. + * @param keys Logto OIDC private keys. + * @returns Redacted Logto OIDC private keys without actual private key value. + */ +const getRedactedOidcKeyResponse = async ( + type: LogtoOidcConfigKey, + keys: OidcConfigKey[] +): Promise => + Promise.all( + keys.map(async ({ id, value, createdAt }) => { + if (type === LogtoOidcConfigKey.PrivateKeys) { + const jwk = await exportJWK(crypto.createPrivateKey(value)); + const parseResult = oidcConfigKeysResponseGuard.safeParse({ + id, + createdAt, + signingKeyAlgorithm: jwk.kty, + }); + if (!parseResult.success) { + throw new RequestError({ code: 'request.general', status: 422 }); + } + return parseResult.data; + } + return { id, createdAt }; + }) + ); + +export default function logtoConfigRoutes( + ...[router, tenant]: RouterInitArgs +) { + const { getAdminConsoleConfig, updateAdminConsoleConfig, updateOidcConfigsByKey } = + tenant.queries.logtoConfigs; + const { getOidcConfigs } = tenant.logtoConfigs; + + router.get( + '/configs/admin-console', + koaGuard({ response: adminConsoleDataGuard, status: [200, 404] }), + async (ctx, next) => { + const { value } = await getAdminConsoleConfig(); + ctx.body = value; + + return next(); + } + ); + + router.patch( + '/configs/admin-console', + koaGuard({ + body: adminConsoleDataGuard.partial(), + response: adminConsoleDataGuard, + status: [200, 404], + }), + async (ctx, next) => { + const { value } = await updateAdminConsoleConfig(ctx.guard.body); + ctx.body = value; + + return next(); + } + ); + + router.get( + '/configs/oidc/:keyType', + koaGuard({ + params: z.object({ + keyType: z.nativeEnum(LogtoOidcConfigKeyType), + }), + response: z.array(oidcConfigKeysResponseGuard), + status: [200], + }), + async (ctx, next) => { + const { keyType } = ctx.guard.params; + const configKey = getOidcConfigKeyDatabaseColumnName(keyType); + const configs = await getOidcConfigs(); + + // Remove actual values of the private keys from response + ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]); + + return next(); + } + ); + + router.delete( + '/configs/oidc/:keyType/:keyId', + koaGuard({ + params: z.object({ + keyType: z.nativeEnum(LogtoOidcConfigKeyType), + keyId: z.string(), + }), + status: [204, 404, 422], + }), + async (ctx, next) => { + const { keyType, keyId } = ctx.guard.params; + const configKey = getOidcConfigKeyDatabaseColumnName(keyType); + const configs = await getOidcConfigs(); + const existingKeys = configs[configKey]; + + if (existingKeys.length <= 1) { + throw new RequestError({ code: 'oidc.key_required', status: 422 }); + } + + if (!existingKeys.some(({ id }) => id === keyId)) { + throw new RequestError({ code: 'oidc.key_not_found', id: keyId, status: 404 }); + } + + const updatedKeys = existingKeys.filter(({ id }) => id !== keyId); + + await updateOidcConfigsByKey(configKey, updatedKeys); + void tenant.invalidateCache(); + + ctx.status = 204; + + return next(); + } + ); + + router.post( + '/configs/oidc/:keyType/rotate', + koaGuard({ + params: z.object({ + keyType: z.nativeEnum(LogtoOidcConfigKeyType), + }), + body: z.object({ + signingKeyAlgorithm: z.nativeEnum(SupportedSigningKeyAlgorithm).optional(), + }), + response: z.array(oidcConfigKeysResponseGuard), + status: [200, 422], + }), + async (ctx, next) => { + const { keyType } = ctx.guard.params; + const { signingKeyAlgorithm } = ctx.guard.body; + const configKey = getOidcConfigKeyDatabaseColumnName(keyType); + const configs = await getOidcConfigs(); + const existingKeys = configs[configKey]; + + const newPrivateKey = + configKey === LogtoOidcConfigKey.PrivateKeys + ? await generateOidcPrivateKey(signingKeyAlgorithm) + : generateOidcCookieKey(); + + // Clamp and only keep the 2 most recent private keys. + // Also make sure the new key is always on top of the list. + const updatedKeys = [newPrivateKey, ...existingKeys].slice(0, 2); + + await updateOidcConfigsByKey(configKey, updatedKeys); + void tenant.invalidateCache(); + + // Remove actual values of the private keys from response + ctx.body = await getRedactedOidcKeyResponse(configKey, updatedKeys); + + return next(); + } + ); + + logtoConfigJwtCustomizerRoutes(router, tenant); +} diff --git a/packages/core/src/routes/logto-config/jwt-customizer.test.ts b/packages/core/src/routes/logto-config/jwt-customizer.test.ts new file mode 100644 index 000000000..53018b2ba --- /dev/null +++ b/packages/core/src/routes/logto-config/jwt-customizer.test.ts @@ -0,0 +1,111 @@ +import { LogtoJwtTokenKey } from '@logto/schemas'; +import { pickDefault } from '@logto/shared/esm'; +import { pick } from '@silverhand/essentials'; +import Sinon from 'sinon'; + +import { + mockLogtoConfigRows, + mockJwtCustomizerConfigForAccessToken, + mockJwtCustomizerConfigForClientCredentials, +} from '#src/__mocks__/index.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +const logtoConfigQueries = { + getRowsByKeys: jest.fn(async () => mockLogtoConfigRows), + deleteJwtCustomizer: jest.fn(), +}; + +const logtoConfigLibraries = { + upsertJwtCustomizer: jest.fn(), + getJwtCustomizer: jest.fn(), + getJwtCustomizers: jest.fn(), +}; + +const settingRoutes = await pickDefault(import('./index.js')); + +describe('configs JWT customizer routes', () => { + const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries }); + Sinon.stub(tenantContext, 'logtoConfigs').value(logtoConfigLibraries); + + const routeRequester = createRequester({ + authedRoutes: settingRoutes, + tenantContext, + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('PUT /configs/jwt-customizer/:tokenType should add a record successfully', async () => { + logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({ + ...mockLogtoConfigRows, + rows: [], + rowCount: 0, + }); + logtoConfigLibraries.upsertJwtCustomizer.mockResolvedValueOnce( + mockJwtCustomizerConfigForAccessToken + ); + const response = await routeRequester + .put(`/configs/jwt-customizer/access-token`) + .send(mockJwtCustomizerConfigForAccessToken.value); + expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith( + LogtoJwtTokenKey.AccessToken, + mockJwtCustomizerConfigForAccessToken.value + ); + expect(response.status).toEqual(201); + expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value); + }); + + it('PUT /configs/jwt-customizer/:tokenType should update a record successfully', async () => { + logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({ + ...mockLogtoConfigRows, + rows: [mockJwtCustomizerConfigForAccessToken], + rowCount: 1, + }); + logtoConfigLibraries.upsertJwtCustomizer.mockResolvedValueOnce( + mockJwtCustomizerConfigForAccessToken + ); + const response = await routeRequester + .put('/configs/jwt-customizer/access-token') + .send(mockJwtCustomizerConfigForAccessToken.value); + expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith( + LogtoJwtTokenKey.AccessToken, + mockJwtCustomizerConfigForAccessToken.value + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value); + }); + + it('GET /configs/jwt-customizer should return all records', async () => { + logtoConfigLibraries.getJwtCustomizers.mockResolvedValueOnce({ + [LogtoJwtTokenKey.AccessToken]: mockJwtCustomizerConfigForAccessToken.value, + [LogtoJwtTokenKey.ClientCredentials]: mockJwtCustomizerConfigForClientCredentials.value, + }); + const response = await routeRequester.get('/configs/jwt-customizer'); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + pick(mockJwtCustomizerConfigForAccessToken, 'key', 'value'), + pick(mockJwtCustomizerConfigForClientCredentials, 'key', 'value'), + ]); + }); + + it('GET /configs/jwt-customizer/:tokenType should return the record', async () => { + logtoConfigLibraries.getJwtCustomizer.mockResolvedValueOnce( + mockJwtCustomizerConfigForAccessToken.value + ); + const response = await routeRequester.get('/configs/jwt-customizer/access-token'); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value); + }); + + it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => { + const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials'); + expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith( + LogtoJwtTokenKey.ClientCredentials + ); + expect(response.status).toEqual(204); + }); +}); diff --git a/packages/core/src/routes/logto-config.ts b/packages/core/src/routes/logto-config/jwt-customizer.ts similarity index 50% rename from packages/core/src/routes/logto-config.ts rename to packages/core/src/routes/logto-config/jwt-customizer.ts index 80d159655..7cbf9d97d 100644 --- a/packages/core/src/routes/logto-config.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.ts @@ -1,57 +1,23 @@ -import crypto from 'node:crypto'; - import { - generateOidcCookieKey, - generateOidcPrivateKey, -} from '@logto/cli/lib/commands/database/utils.js'; -import { - LogtoOidcConfigKey, - adminConsoleDataGuard, - oidcConfigKeysResponseGuard, - SupportedSigningKeyAlgorithm, - type OidcConfigKeysResponse, - type OidcConfigKey, - LogtoOidcConfigKeyType, accessTokenJwtCustomizerGuard, clientCredentialsJwtCustomizerGuard, LogtoJwtTokenKey, LogtoJwtTokenPath, jsonObjectGuard, - type CustomJwtFetcher, + adminTenantId, + jwtCustomizerConfigsGuard, jwtCustomizerTestRequestBodyGuard, type JwtCustomizerTestRequestBody, + type CustomJwtFetcher, } from '@logto/schemas'; -import { adminTenantId } from '@logto/schemas'; import { ResponseError } from '@withtyped/client'; import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard, { parse } from '#src/middleware/koa-guard.js'; -import { exportJWK } from '#src/utils/jwks.js'; -import type { AuthedRouter, RouterInitArgs } from './types.js'; - -/** - * Provide a simple API router key type and DB config key mapping - */ -const getOidcConfigKeyDatabaseColumnName = (key: LogtoOidcConfigKeyType): LogtoOidcConfigKey => - key === LogtoOidcConfigKeyType.PrivateKeys - ? LogtoOidcConfigKey.PrivateKeys - : LogtoOidcConfigKey.CookieKeys; - -const getJwtTokenKeyAndBody = (tokenPath: LogtoJwtTokenPath, body: unknown) => { - if (tokenPath === LogtoJwtTokenPath.AccessToken) { - return { - key: LogtoJwtTokenKey.AccessToken, - body: parse('body', accessTokenJwtCustomizerGuard, body), - }; - } - return { - key: LogtoJwtTokenKey.ClientCredentials, - body: parse('body', clientCredentialsJwtCustomizerGuard, body), - }; -}; +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. @@ -84,167 +50,24 @@ const transpileJwtCustomizerTestRequestBody = ( }; }; -/** - * Remove actual values of the private keys from response. - * @param type Logto config key DB column name. Values are either `oidc.privateKeys` or `oidc.cookieKeys`. - * @param keys Logto OIDC private keys. - * @returns Redacted Logto OIDC private keys without actual private key value. - */ -const getRedactedOidcKeyResponse = async ( - type: LogtoOidcConfigKey, - keys: OidcConfigKey[] -): Promise => - Promise.all( - keys.map(async ({ id, value, createdAt }) => { - if (type === LogtoOidcConfigKey.PrivateKeys) { - const jwk = await exportJWK(crypto.createPrivateKey(value)); - const parseResult = oidcConfigKeysResponseGuard.safeParse({ - id, - createdAt, - signingKeyAlgorithm: jwk.kty, - }); - if (!parseResult.success) { - throw new RequestError({ code: 'request.general', status: 422 }); - } - return parseResult.data; - } - return { id, createdAt }; - }) - ); +const getJwtTokenKeyAndBody = (tokenPath: LogtoJwtTokenPath, body: unknown) => { + if (tokenPath === LogtoJwtTokenPath.AccessToken) { + return { + key: LogtoJwtTokenKey.AccessToken, + body: parse('body', accessTokenJwtCustomizerGuard, body), + }; + } + return { + key: LogtoJwtTokenKey.ClientCredentials, + body: parse('body', clientCredentialsJwtCustomizerGuard, body), + }; +}; -export default function logtoConfigRoutes( - ...[ - router, - { id: tenantId, queries, logtoConfigs, invalidateCache, cloudConnection }, - ]: RouterInitArgs +export default function logtoConfigJwtCustomizerRoutes( + ...[router, { id: tenantId, queries, logtoConfigs, cloudConnection }]: RouterInitArgs ) { - const { - getAdminConsoleConfig, - getRowsByKeys, - updateAdminConsoleConfig, - updateOidcConfigsByKey, - deleteJwtCustomizer, - } = queries.logtoConfigs; - const { getOidcConfigs, upsertJwtCustomizer, getJwtCustomizer } = logtoConfigs; - - router.get( - '/configs/admin-console', - koaGuard({ response: adminConsoleDataGuard, status: [200, 404] }), - async (ctx, next) => { - const { value } = await getAdminConsoleConfig(); - ctx.body = value; - - return next(); - } - ); - - router.patch( - '/configs/admin-console', - koaGuard({ - body: adminConsoleDataGuard.partial(), - response: adminConsoleDataGuard, - status: [200, 404], - }), - async (ctx, next) => { - const { value } = await updateAdminConsoleConfig(ctx.guard.body); - ctx.body = value; - - return next(); - } - ); - - router.get( - '/configs/oidc/:keyType', - koaGuard({ - params: z.object({ - keyType: z.nativeEnum(LogtoOidcConfigKeyType), - }), - response: z.array(oidcConfigKeysResponseGuard), - status: [200], - }), - async (ctx, next) => { - const { keyType } = ctx.guard.params; - const configKey = getOidcConfigKeyDatabaseColumnName(keyType); - const configs = await getOidcConfigs(); - - // Remove actual values of the private keys from response - ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]); - - return next(); - } - ); - - router.delete( - '/configs/oidc/:keyType/:keyId', - koaGuard({ - params: z.object({ - keyType: z.nativeEnum(LogtoOidcConfigKeyType), - keyId: z.string(), - }), - status: [204, 404, 422], - }), - async (ctx, next) => { - const { keyType, keyId } = ctx.guard.params; - const configKey = getOidcConfigKeyDatabaseColumnName(keyType); - const configs = await getOidcConfigs(); - const existingKeys = configs[configKey]; - - if (existingKeys.length <= 1) { - throw new RequestError({ code: 'oidc.key_required', status: 422 }); - } - - if (!existingKeys.some(({ id }) => id === keyId)) { - throw new RequestError({ code: 'oidc.key_not_found', id: keyId, status: 404 }); - } - - const updatedKeys = existingKeys.filter(({ id }) => id !== keyId); - - await updateOidcConfigsByKey(configKey, updatedKeys); - void invalidateCache(); - - ctx.status = 204; - - return next(); - } - ); - - router.post( - '/configs/oidc/:keyType/rotate', - koaGuard({ - params: z.object({ - keyType: z.nativeEnum(LogtoOidcConfigKeyType), - }), - body: z.object({ - signingKeyAlgorithm: z.nativeEnum(SupportedSigningKeyAlgorithm).optional(), - }), - response: z.array(oidcConfigKeysResponseGuard), - status: [200, 422], - }), - async (ctx, next) => { - const { keyType } = ctx.guard.params; - const { signingKeyAlgorithm } = ctx.guard.body; - const configKey = getOidcConfigKeyDatabaseColumnName(keyType); - const configs = await getOidcConfigs(); - const existingKeys = configs[configKey]; - - const newPrivateKey = - configKey === LogtoOidcConfigKey.PrivateKeys - ? await generateOidcPrivateKey(signingKeyAlgorithm) - : generateOidcCookieKey(); - - // Clamp and only keep the 2 most recent private keys. - // Also make sure the new key is always on top of the list. - const updatedKeys = [newPrivateKey, ...existingKeys].slice(0, 2); - - await updateOidcConfigsByKey(configKey, updatedKeys); - void invalidateCache(); - - // Remove actual values of the private keys from response - ctx.body = await getRedactedOidcKeyResponse(configKey, updatedKeys); - - return next(); - } - ); + const { getRowsByKeys, deleteJwtCustomizer } = queries.logtoConfigs; + const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers } = logtoConfigs; router.put( '/configs/jwt-customizer/:tokenTypePath', @@ -264,8 +87,8 @@ export default function logtoConfigRoutes( status: [200, 201, 400, 403], }), async (ctx, next) => { - const { isCloud, isUnitTest, isIntegrationTest } = EnvSet.values; - if (tenantId === adminTenantId && isCloud && !(isUnitTest || isIntegrationTest)) { + const { isCloud, isIntegrationTest } = EnvSet.values; + if (tenantId === adminTenantId && isCloud && !isIntegrationTest) { throw new RequestError({ code: 'jwt_customizer.can_not_create_for_admin_tenant', status: 422, @@ -291,6 +114,21 @@ export default function logtoConfigRoutes( } ); + router.get( + '/configs/jwt-customizer', + koaGuard({ + response: jwtCustomizerConfigsGuard.array(), + status: [200], + }), + async (ctx, next) => { + const jwtCustomizer = await getJwtCustomizers(); + ctx.body = Object.values(LogtoJwtTokenKey) + .filter((key) => jwtCustomizer[key]) + .map((key) => ({ key, value: jwtCustomizer[key] })); + return next(); + } + ); + router.get( '/configs/jwt-customizer/:tokenTypePath', koaGuard({ diff --git a/packages/core/src/routes/logto-config.openapi.json b/packages/core/src/routes/logto-config/logto-config.openapi.json similarity index 96% rename from packages/core/src/routes/logto-config.openapi.json rename to packages/core/src/routes/logto-config/logto-config.openapi.json index fea0b1cdf..d40a931c8 100644 --- a/packages/core/src/routes/logto-config.openapi.json +++ b/packages/core/src/routes/logto-config/logto-config.openapi.json @@ -105,6 +105,17 @@ } } }, + "/api/configs/jwt-customizer": { + "get": { + "summary": "Get all JWT customizers", + "description": "Get all JWT customizers for the tenant.", + "responses": { + "200": { + "description": "The JWT customizers." + } + } + } + }, "/api/configs/jwt-customizer/{tokenTypePath}": { "put": { "summary": "Create or update JWT customizer", diff --git a/packages/integration-tests/src/__mocks__/jwt-customizer.ts b/packages/integration-tests/src/__mocks__/jwt-customizer.ts new file mode 100644 index 000000000..1268f21ac --- /dev/null +++ b/packages/integration-tests/src/__mocks__/jwt-customizer.ts @@ -0,0 +1,15 @@ +export const clientCredentialsJwtCustomizerPayload = { + script: '', + envVars: {}, + contextSample: {}, +}; + +export const accessTokenJwtCustomizerPayload = { + ...clientCredentialsJwtCustomizerPayload, + contextSample: { + user: { + username: 'test', + id: 'fake-id', + }, + }, +}; diff --git a/packages/integration-tests/src/api/logto-config.ts b/packages/integration-tests/src/api/logto-config.ts index 569a5a342..acb3e5e89 100644 --- a/packages/integration-tests/src/api/logto-config.ts +++ b/packages/integration-tests/src/api/logto-config.ts @@ -5,6 +5,7 @@ import { type LogtoOidcConfigKeyType, type AccessTokenJwtCustomizer, type ClientCredentialsJwtCustomizer, + type JwtCustomizerConfigs, } from '@logto/schemas'; import { authedAdminApi } from './api.js'; @@ -46,5 +47,8 @@ export const getJwtCustomizer = async (keyTypePath: 'access-token' | 'client-cre .get(`configs/jwt-customizer/${keyTypePath}`) .json(); +export const getJwtCustomizers = async () => + authedAdminApi.get(`configs/jwt-customizer`).json(); + export const deleteJwtCustomizer = async (keyTypePath: 'access-token' | 'client-credentials') => authedAdminApi.delete(`configs/jwt-customizer/${keyTypePath}`); diff --git a/packages/integration-tests/src/tests/api/logto-config.test.ts b/packages/integration-tests/src/tests/api/logto-config.test.ts index 3934484fa..f7ee0649b 100644 --- a/packages/integration-tests/src/tests/api/logto-config.test.ts +++ b/packages/integration-tests/src/tests/api/logto-config.test.ts @@ -2,8 +2,13 @@ import { SupportedSigningKeyAlgorithm, type AdminConsoleData, LogtoOidcConfigKeyType, + LogtoJwtTokenKey, } from '@logto/schemas'; +import { + accessTokenJwtCustomizerPayload, + clientCredentialsJwtCustomizerPayload, +} from '#src/__mocks__/jwt-customizer.js'; import { deleteOidcKey, getAdminConsoleConfig, @@ -12,6 +17,7 @@ import { updateAdminConsoleConfig, upsertJwtCustomizer, getJwtCustomizer, + getJwtCustomizers, deleteJwtCustomizer, } from '#src/api/index.js'; import { expectRejects } from '#src/helpers/index.js'; @@ -128,17 +134,6 @@ describe('admin console sign-in experience', () => { }); it('should successfully PUT/GET/DELETE a JWT customizer (access token)', async () => { - const accessTokenJwtCustomizerPayload = { - script: '', - envVars: {}, - contextSample: { - user: { - username: 'test', - id: 'fake-id', - }, - }, - }; - await expectRejects(getJwtCustomizer('access-token'), { code: 'entity.not_exists_with_id', status: 404, @@ -169,12 +164,6 @@ describe('admin console sign-in experience', () => { }); it('should successfully PUT/GET/DELETE a JWT customizer (client credentials)', async () => { - const clientCredentialsJwtCustomizerPayload = { - script: '', - envVars: {}, - contextSample: {}, - }; - await expectRejects(getJwtCustomizer('client-credentials'), { code: 'entity.not_exists_with_id', status: 404, @@ -206,4 +195,35 @@ describe('admin console sign-in experience', () => { status: 404, }); }); + + it('should successfully GET all JWT customizers', async () => { + await expect(getJwtCustomizers()).resolves.toEqual([]); + await upsertJwtCustomizer('access-token', accessTokenJwtCustomizerPayload); + await expect(getJwtCustomizers()).resolves.toEqual([ + { + key: LogtoJwtTokenKey.AccessToken, + value: accessTokenJwtCustomizerPayload, + }, + ]); + await upsertJwtCustomizer('client-credentials', clientCredentialsJwtCustomizerPayload); + const jwtCustomizers = await getJwtCustomizers(); + expect(jwtCustomizers).toHaveLength(2); + expect(jwtCustomizers).toContainEqual({ + key: LogtoJwtTokenKey.AccessToken, + value: accessTokenJwtCustomizerPayload, + }); + expect(jwtCustomizers).toContainEqual({ + key: LogtoJwtTokenKey.ClientCredentials, + value: clientCredentialsJwtCustomizerPayload, + }); + await deleteJwtCustomizer('access-token'); + await expect(getJwtCustomizers()).resolves.toEqual([ + { + key: LogtoJwtTokenKey.ClientCredentials, + value: clientCredentialsJwtCustomizerPayload, + }, + ]); + await deleteJwtCustomizer('client-credentials'); + await expect(getJwtCustomizers()).resolves.toEqual([]); + }); }); diff --git a/packages/schemas/src/types/logto-config/index.ts b/packages/schemas/src/types/logto-config/index.ts index b47ad0442..3edb2c753 100644 --- a/packages/schemas/src/types/logto-config/index.ts +++ b/packages/schemas/src/types/logto-config/index.ts @@ -90,6 +90,19 @@ export const jwtCustomizerConfigGuard: Readonly<{ [LogtoJwtTokenKey.ClientCredentials]: clientCredentialsJwtCustomizerGuard, }); +export const jwtCustomizerConfigsGuard = z.discriminatedUnion('key', [ + z.object({ + key: z.literal(LogtoJwtTokenKey.AccessToken), + value: accessTokenJwtCustomizerGuard, + }), + z.object({ + key: z.literal(LogtoJwtTokenKey.ClientCredentials), + value: clientCredentialsJwtCustomizerGuard, + }), +]); + +export type JwtCustomizerConfigs = z.infer; + /* --- Logto tenant configs --- */ export const adminConsoleDataGuard = z.object({ signInExperienceCustomized: z.boolean(),