0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(core): reorg and separate logto-config APIs into files

This commit is contained in:
Darcy Ye 2024-03-28 12:54:13 +08:00
parent e34cfd812a
commit c1722c8793
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
14 changed files with 474 additions and 291 deletions

View file

@ -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: '<api-key>',
},
},
};

View file

@ -36,6 +36,7 @@ const logtoConfigs: LogtoConfigLibrary = {
getOidcConfigs: jest.fn(),
upsertJwtCustomizer: jest.fn(),
getJwtCustomizer: jest.fn(),
getJwtCustomizers: jest.fn(),
};
describe('getAccessToken()', () => {

View file

@ -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<Partial<JwtCustomizerType>> => {
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,
};
};

View file

@ -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');

View file

@ -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';

View file

@ -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);
});
});

View file

@ -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<OidcConfigKeysResponse[]> =>
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<T extends AuthedRouter>(
...[router, tenant]: RouterInitArgs<T>
) {
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);
}

View file

@ -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);
});
});

View file

@ -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<OidcConfigKeysResponse[]> =>
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<T extends AuthedRouter>(
...[
router,
{ id: tenantId, queries, logtoConfigs, invalidateCache, cloudConnection },
]: RouterInitArgs<T>
export default function logtoConfigJwtCustomizerRoutes<T extends AuthedRouter>(
...[router, { id: tenantId, queries, logtoConfigs, cloudConnection }]: RouterInitArgs<T>
) {
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<T extends AuthedRouter>(
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<T extends AuthedRouter>(
}
);
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({

View file

@ -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",

View file

@ -0,0 +1,15 @@
export const clientCredentialsJwtCustomizerPayload = {
script: '',
envVars: {},
contextSample: {},
};
export const accessTokenJwtCustomizerPayload = {
...clientCredentialsJwtCustomizerPayload,
contextSample: {
user: {
username: 'test',
id: 'fake-id',
},
},
};

View file

@ -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<AccessTokenJwtCustomizer | ClientCredentialsJwtCustomizer>();
export const getJwtCustomizers = async () =>
authedAdminApi.get(`configs/jwt-customizer`).json<JwtCustomizerConfigs[]>();
export const deleteJwtCustomizer = async (keyTypePath: 'access-token' | 'client-credentials') =>
authedAdminApi.delete(`configs/jwt-customizer/${keyTypePath}`);

View file

@ -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([]);
});
});

View file

@ -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<typeof jwtCustomizerConfigsGuard>;
/* --- Logto tenant configs --- */
export const adminConsoleDataGuard = z.object({
signInExperienceCustomized: z.boolean(),