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:
parent
e34cfd812a
commit
c1722c8793
14 changed files with 474 additions and 291 deletions
|
@ -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>',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -36,6 +36,7 @@ const logtoConfigs: LogtoConfigLibrary = {
|
|||
getOidcConfigs: jest.fn(),
|
||||
upsertJwtCustomizer: jest.fn(),
|
||||
getJwtCustomizer: jest.fn(),
|
||||
getJwtCustomizers: jest.fn(),
|
||||
};
|
||||
|
||||
describe('getAccessToken()', () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
189
packages/core/src/routes/logto-config/index.ts
Normal file
189
packages/core/src/routes/logto-config/index.ts
Normal 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);
|
||||
}
|
111
packages/core/src/routes/logto-config/jwt-customizer.test.ts
Normal file
111
packages/core/src/routes/logto-config/jwt-customizer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
const getJwtTokenKeyAndBody = (tokenPath: LogtoJwtTokenPath, body: unknown) => {
|
||||
if (tokenPath === LogtoJwtTokenPath.AccessToken) {
|
||||
return {
|
||||
key: LogtoJwtTokenKey.AccessToken,
|
||||
body: parse('body', accessTokenJwtCustomizerGuard, body),
|
||||
};
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
return { id, createdAt };
|
||||
})
|
||||
);
|
||||
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({
|
|
@ -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",
|
15
packages/integration-tests/src/__mocks__/jwt-customizer.ts
Normal file
15
packages/integration-tests/src/__mocks__/jwt-customizer.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export const clientCredentialsJwtCustomizerPayload = {
|
||||
script: '',
|
||||
envVars: {},
|
||||
contextSample: {},
|
||||
};
|
||||
|
||||
export const accessTokenJwtCustomizerPayload = {
|
||||
...clientCredentialsJwtCustomizerPayload,
|
||||
contextSample: {
|
||||
user: {
|
||||
username: 'test',
|
||||
id: 'fake-id',
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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}`);
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in a new issue