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, Scope,
UsersRole, UsersRole,
} from '@logto/schemas'; } 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 { protectedAppSignInCallbackUrl } from '#src/constants/index.js';
import { mockId } from '#src/test-utils/nanoid.js'; import { mockId } from '#src/test-utils/nanoid.js';
@ -209,7 +215,7 @@ export const mockApplicationRole: ApplicationsRole = {
export const mockJwtCustomizerConfigForAccessToken = { export const mockJwtCustomizerConfigForAccessToken = {
tenantId: 'fake_tenant', tenantId: 'fake_tenant',
key: 'jwt.accessToken', key: LogtoJwtTokenKey.AccessToken,
value: { value: {
script: 'console.log("hello world");', script: 'console.log("hello world");',
envVars: { 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(), getOidcConfigs: jest.fn(),
upsertJwtCustomizer: jest.fn(), upsertJwtCustomizer: jest.fn(),
getJwtCustomizer: jest.fn(), getJwtCustomizer: jest.fn(),
getJwtCustomizers: jest.fn(),
}; };
describe('getAccessToken()', () => { describe('getAccessToken()', () => {

View file

@ -5,8 +5,9 @@ import {
LogtoOidcConfigKey, LogtoOidcConfigKey,
jwtCustomizerConfigGuard, jwtCustomizerConfigGuard,
LogtoConfigs, LogtoConfigs,
LogtoJwtTokenKey,
} from '@logto/schemas'; } from '@logto/schemas';
import type { LogtoOidcConfigType, LogtoJwtTokenKey, CloudConnectionData } from '@logto/schemas'; import type { LogtoOidcConfigType, CloudConnectionData, JwtCustomizerType } from '@logto/schemas';
import chalk from 'chalk'; import chalk from 'chalk';
import { z, ZodError } from 'zod'; import { z, ZodError } from 'zod';
@ -95,5 +96,34 @@ export const createLogtoConfigLibrary = ({
return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value; 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(), getOidcConfigs: jest.fn(),
upsertJwtCustomizer: jest.fn(), upsertJwtCustomizer: jest.fn(),
getJwtCustomizer: jest.fn(), getJwtCustomizer: jest.fn(),
getJwtCustomizers: jest.fn(),
}); });
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors'); const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');

View file

@ -25,7 +25,7 @@ import domainRoutes from './domain.js';
import hookRoutes from './hook.js'; import hookRoutes from './hook.js';
import interactionRoutes from './interaction/index.js'; import interactionRoutes from './interaction/index.js';
import logRoutes from './log.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 organizationRoutes from './organization/index.js';
import resourceRoutes from './resource.js'; import resourceRoutes from './resource.js';
import resourceScopeRoutes from './resource.scope.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 { generateStandardId } from '@logto/shared';
import { createMockUtils, pickDefault } from '@logto/shared/esm'; import { createMockUtils, pickDefault } from '@logto/shared/esm';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { import { mockAdminConsoleData, mockCookieKeys, mockPrivateKeys } from '#src/__mocks__/index.js';
mockAdminConsoleData,
mockCookieKeys,
mockPrivateKeys,
mockLogtoConfigRows,
mockJwtCustomizerConfigForAccessToken,
} from '#src/__mocks__/index.js';
import { MockTenant } from '#src/test-utils/tenant.js'; import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js'; import { createRequester } from '#src/utils/test-utils.js';
@ -53,8 +47,6 @@ const logtoConfigQueries = {
}, },
}), }),
updateOidcConfigsByKey: jest.fn(), updateOidcConfigsByKey: jest.fn(),
getRowsByKeys: jest.fn(async () => mockLogtoConfigRows),
deleteJwtCustomizer: jest.fn(),
}; };
const logtoConfigLibraries = { const logtoConfigLibraries = {
@ -62,11 +54,9 @@ const logtoConfigLibraries = {
[LogtoOidcConfigKey.PrivateKeys]: mockPrivateKeys, [LogtoOidcConfigKey.PrivateKeys]: mockPrivateKeys,
[LogtoOidcConfigKey.CookieKeys]: mockCookieKeys, [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', () => { describe('configs routes', () => {
const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries }); const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries });
@ -227,61 +217,4 @@ describe('configs routes', () => {
[newPrivateKey2, newPrivateKey] [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 { import {
generateOidcCookieKey,
generateOidcPrivateKey,
} from '@logto/cli/lib/commands/database/utils.js';
import {
LogtoOidcConfigKey,
adminConsoleDataGuard,
oidcConfigKeysResponseGuard,
SupportedSigningKeyAlgorithm,
type OidcConfigKeysResponse,
type OidcConfigKey,
LogtoOidcConfigKeyType,
accessTokenJwtCustomizerGuard, accessTokenJwtCustomizerGuard,
clientCredentialsJwtCustomizerGuard, clientCredentialsJwtCustomizerGuard,
LogtoJwtTokenKey, LogtoJwtTokenKey,
LogtoJwtTokenPath, LogtoJwtTokenPath,
jsonObjectGuard, jsonObjectGuard,
type CustomJwtFetcher, adminTenantId,
jwtCustomizerConfigsGuard,
jwtCustomizerTestRequestBodyGuard, jwtCustomizerTestRequestBodyGuard,
type JwtCustomizerTestRequestBody, type JwtCustomizerTestRequestBody,
type CustomJwtFetcher,
} from '@logto/schemas'; } from '@logto/schemas';
import { adminTenantId } from '@logto/schemas';
import { ResponseError } from '@withtyped/client'; import { ResponseError } from '@withtyped/client';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard, { parse } from '#src/middleware/koa-guard.js'; import koaGuard, { parse } from '#src/middleware/koa-guard.js';
import { exportJWK } from '#src/utils/jwks.js';
import type { AuthedRouter, RouterInitArgs } from './types.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),
};
};
/** /**
* Transpile the request body of the JWT customizer test API to the request body of the Cloud JWT customizer test API. * 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 = (
}; };
}; };
/** const getJwtTokenKeyAndBody = (tokenPath: LogtoJwtTokenPath, body: unknown) => {
* Remove actual values of the private keys from response. if (tokenPath === LogtoJwtTokenPath.AccessToken) {
* @param type Logto config key DB column name. Values are either `oidc.privateKeys` or `oidc.cookieKeys`. return {
* @param keys Logto OIDC private keys. key: LogtoJwtTokenKey.AccessToken,
* @returns Redacted Logto OIDC private keys without actual private key value. body: parse('body', accessTokenJwtCustomizerGuard, body),
*/ };
const getRedactedOidcKeyResponse = async ( }
type: LogtoOidcConfigKey, return {
keys: OidcConfigKey[] key: LogtoJwtTokenKey.ClientCredentials,
): Promise<OidcConfigKeysResponse[]> => body: parse('body', clientCredentialsJwtCustomizerGuard, body),
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>( export default function logtoConfigJwtCustomizerRoutes<T extends AuthedRouter>(
...[ ...[router, { id: tenantId, queries, logtoConfigs, cloudConnection }]: RouterInitArgs<T>
router,
{ id: tenantId, queries, logtoConfigs, invalidateCache, cloudConnection },
]: RouterInitArgs<T>
) { ) {
const { const { getRowsByKeys, deleteJwtCustomizer } = queries.logtoConfigs;
getAdminConsoleConfig, const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers } = logtoConfigs;
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();
}
);
router.put( router.put(
'/configs/jwt-customizer/:tokenTypePath', '/configs/jwt-customizer/:tokenTypePath',
@ -264,8 +87,8 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
status: [200, 201, 400, 403], status: [200, 201, 400, 403],
}), }),
async (ctx, next) => { async (ctx, next) => {
const { isCloud, isUnitTest, isIntegrationTest } = EnvSet.values; const { isCloud, isIntegrationTest } = EnvSet.values;
if (tenantId === adminTenantId && isCloud && !(isUnitTest || isIntegrationTest)) { if (tenantId === adminTenantId && isCloud && !isIntegrationTest) {
throw new RequestError({ throw new RequestError({
code: 'jwt_customizer.can_not_create_for_admin_tenant', code: 'jwt_customizer.can_not_create_for_admin_tenant',
status: 422, 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( router.get(
'/configs/jwt-customizer/:tokenTypePath', '/configs/jwt-customizer/:tokenTypePath',
koaGuard({ 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}": { "/api/configs/jwt-customizer/{tokenTypePath}": {
"put": { "put": {
"summary": "Create or update JWT customizer", "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 LogtoOidcConfigKeyType,
type AccessTokenJwtCustomizer, type AccessTokenJwtCustomizer,
type ClientCredentialsJwtCustomizer, type ClientCredentialsJwtCustomizer,
type JwtCustomizerConfigs,
} from '@logto/schemas'; } from '@logto/schemas';
import { authedAdminApi } from './api.js'; import { authedAdminApi } from './api.js';
@ -46,5 +47,8 @@ export const getJwtCustomizer = async (keyTypePath: 'access-token' | 'client-cre
.get(`configs/jwt-customizer/${keyTypePath}`) .get(`configs/jwt-customizer/${keyTypePath}`)
.json<AccessTokenJwtCustomizer | ClientCredentialsJwtCustomizer>(); .json<AccessTokenJwtCustomizer | ClientCredentialsJwtCustomizer>();
export const getJwtCustomizers = async () =>
authedAdminApi.get(`configs/jwt-customizer`).json<JwtCustomizerConfigs[]>();
export const deleteJwtCustomizer = async (keyTypePath: 'access-token' | 'client-credentials') => export const deleteJwtCustomizer = async (keyTypePath: 'access-token' | 'client-credentials') =>
authedAdminApi.delete(`configs/jwt-customizer/${keyTypePath}`); authedAdminApi.delete(`configs/jwt-customizer/${keyTypePath}`);

View file

@ -2,8 +2,13 @@ import {
SupportedSigningKeyAlgorithm, SupportedSigningKeyAlgorithm,
type AdminConsoleData, type AdminConsoleData,
LogtoOidcConfigKeyType, LogtoOidcConfigKeyType,
LogtoJwtTokenKey,
} from '@logto/schemas'; } from '@logto/schemas';
import {
accessTokenJwtCustomizerPayload,
clientCredentialsJwtCustomizerPayload,
} from '#src/__mocks__/jwt-customizer.js';
import { import {
deleteOidcKey, deleteOidcKey,
getAdminConsoleConfig, getAdminConsoleConfig,
@ -12,6 +17,7 @@ import {
updateAdminConsoleConfig, updateAdminConsoleConfig,
upsertJwtCustomizer, upsertJwtCustomizer,
getJwtCustomizer, getJwtCustomizer,
getJwtCustomizers,
deleteJwtCustomizer, deleteJwtCustomizer,
} from '#src/api/index.js'; } from '#src/api/index.js';
import { expectRejects } from '#src/helpers/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 () => { 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'), { await expectRejects(getJwtCustomizer('access-token'), {
code: 'entity.not_exists_with_id', code: 'entity.not_exists_with_id',
status: 404, status: 404,
@ -169,12 +164,6 @@ describe('admin console sign-in experience', () => {
}); });
it('should successfully PUT/GET/DELETE a JWT customizer (client credentials)', async () => { it('should successfully PUT/GET/DELETE a JWT customizer (client credentials)', async () => {
const clientCredentialsJwtCustomizerPayload = {
script: '',
envVars: {},
contextSample: {},
};
await expectRejects(getJwtCustomizer('client-credentials'), { await expectRejects(getJwtCustomizer('client-credentials'), {
code: 'entity.not_exists_with_id', code: 'entity.not_exists_with_id',
status: 404, status: 404,
@ -206,4 +195,35 @@ describe('admin console sign-in experience', () => {
status: 404, 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, [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 --- */ /* --- Logto tenant configs --- */
export const adminConsoleDataGuard = z.object({ export const adminConsoleDataGuard = z.object({
signInExperienceCustomized: z.boolean(), signInExperienceCustomized: z.boolean(),