0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

Merge pull request #4594 from logto-io/charles-log-6861-api-to-rotate-private-keys

feat(core,phrases): add apis to fetch, delete and rotate oidc private keys
This commit is contained in:
Charles Zhao 2023-10-11 02:52:46 -05:00 committed by GitHub
commit 005bb660cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 752 additions and 38 deletions

View file

@ -4,6 +4,7 @@ import {
LogtoOidcConfigKey, LogtoOidcConfigKey,
logtoConfigGuards, logtoConfigGuards,
logtoConfigKeys, logtoConfigKeys,
SupportedSigningKeyAlgorithm,
} from '@logto/schemas'; } from '@logto/schemas';
import { deduplicate, noop } from '@silverhand/essentials'; import { deduplicate, noop } from '@silverhand/essentials';
import chalk from 'chalk'; import chalk from 'chalk';
@ -13,7 +14,7 @@ import { createPoolFromConfig } from '../../database.js';
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config.js'; import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config.js';
import { consoleLog } from '../../utils.js'; import { consoleLog } from '../../utils.js';
import { PrivateKeyType, generateOidcCookieKey, generateOidcPrivateKey } from './utils.js'; import { generateOidcCookieKey, generateOidcPrivateKey } from './utils.js';
const validKeysDisplay = chalk.green(logtoConfigKeys.join(', ')); const validKeysDisplay = chalk.green(logtoConfigKeys.join(', '));
@ -41,7 +42,10 @@ const validRotateKeys = Object.freeze([
LogtoOidcConfigKey.CookieKeys, LogtoOidcConfigKey.CookieKeys,
] as const); ] as const);
const validPrivateKeyTypes = Object.freeze([PrivateKeyType.RSA, PrivateKeyType.EC] as const); const validPrivateKeyTypes = Object.freeze([
SupportedSigningKeyAlgorithm.RSA,
SupportedSigningKeyAlgorithm.EC,
] as const);
type ValidateRotateKeyFunction = (key: string) => asserts key is (typeof validRotateKeys)[number]; type ValidateRotateKeyFunction = (key: string) => asserts key is (typeof validRotateKeys)[number];
@ -62,7 +66,7 @@ const validateRotateKey: ValidateRotateKeyFunction = (key) => {
const validatePrivateKeyType: ValidatePrivateKeyTypeFunction = (key) => { const validatePrivateKeyType: ValidatePrivateKeyTypeFunction = (key) => {
// Using `.includes()` will result a type error // Using `.includes()` will result a type error
// eslint-disable-next-line unicorn/prefer-includes // eslint-disable-next-line unicorn/prefer-includes
if (!validPrivateKeyTypes.some((element) => element === key)) { if (!validPrivateKeyTypes.some((element) => element === key.toUpperCase())) {
consoleLog.fatal( consoleLog.fatal(
`Invalid private key type ${chalk.red( `Invalid private key type ${chalk.red(
key key

View file

@ -1,18 +1,13 @@
import { generateKeyPair } from 'node:crypto'; import { generateKeyPair } from 'node:crypto';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { type PrivateKey } from '@logto/schemas'; import { type OidcConfigKey, SupportedSigningKeyAlgorithm } from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared'; import { generateStandardId, generateStandardSecret } from '@logto/shared';
export enum PrivateKeyType {
RSA = 'rsa',
EC = 'ec',
}
export const generateOidcPrivateKey = async ( export const generateOidcPrivateKey = async (
type: PrivateKeyType = PrivateKeyType.EC type: SupportedSigningKeyAlgorithm = SupportedSigningKeyAlgorithm.EC
): Promise<PrivateKey> => { ): Promise<OidcConfigKey> => {
if (type === PrivateKeyType.RSA) { if (type === SupportedSigningKeyAlgorithm.RSA) {
const { privateKey } = await promisify(generateKeyPair)('rsa', { const { privateKey } = await promisify(generateKeyPair)('rsa', {
modulusLength: 4096, modulusLength: 4096,
publicKeyEncoding: { publicKeyEncoding: {
@ -29,7 +24,7 @@ export const generateOidcPrivateKey = async (
} }
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (type === PrivateKeyType.EC) { if (type === SupportedSigningKeyAlgorithm.EC) {
const { privateKey } = await promisify(generateKeyPair)('ec', { const { privateKey } = await promisify(generateKeyPair)('ec', {
// https://security.stackexchange.com/questions/78621/which-elliptic-curve-should-i-use // https://security.stackexchange.com/questions/78621/which-elliptic-curve-should-i-use
namedCurve: 'secp384r1', namedCurve: 'secp384r1',

View file

@ -3,13 +3,15 @@ import type {
AdminConsoleData, AdminConsoleData,
Application, Application,
ApplicationsRole, ApplicationsRole,
LogtoConfig,
Passcode, Passcode,
OidcConfigKey,
Resource, Resource,
Role, Role,
Scope, Scope,
UsersRole, UsersRole,
} from '@logto/schemas'; } from '@logto/schemas';
import { RoleType, ApplicationType } from '@logto/schemas'; import { RoleType, ApplicationType, LogtoOidcConfigKey } from '@logto/schemas';
import { mockId } from '#src/test-utils/nanoid.js'; import { mockId } from '#src/test-utils/nanoid.js';
@ -97,6 +99,31 @@ export const mockAdminConsoleData: AdminConsoleData = {
signInExperienceCustomized: false, signInExperienceCustomized: false,
}; };
export const mockPrivateKeys: OidcConfigKey[] = [
{
id: 'private',
value: '-----BEGIN PRIVATE KEY-----\nxxxxx\nyyyyy\nzzzzz\n-----END PRIVATE KEY-----\n',
createdAt: 123_456_789,
},
];
export const mockCookieKeys: OidcConfigKey[] = [
{ id: 'cookie', value: 'bar', createdAt: 987_654_321 },
];
export const mockLogtoConfigs: LogtoConfig[] = [
{
tenantId: 'fake_tenant',
key: LogtoOidcConfigKey.PrivateKeys,
value: mockPrivateKeys,
},
{
tenantId: 'fake_tenant',
key: LogtoOidcConfigKey.CookieKeys,
value: mockCookieKeys,
},
];
export const mockPasscode: Passcode = { export const mockPasscode: Passcode = {
tenantId: 'fake_tenant', tenantId: 'fake_tenant',
id: 'foo', id: 'foo',

View file

@ -0,0 +1,142 @@
import {
type LogtoConfigKey,
LogtoConfigs,
LogtoOidcConfigKey,
LogtoTenantConfigKey,
} from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import { createMockPool, createMockQueryResult, sql } from 'slonik';
import { expectSqlAssert, type QueryType } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
const pool = createMockPool({
query: async (sql, values) => {
return mockQuery(sql, values);
},
});
const { createLogtoConfigQueries } = await import('./logto-config.js');
const {
getAdminConsoleConfig,
getCloudConnectionData,
getRowsByKeys,
updateAdminConsoleConfig,
updateOidcConfigsByKey,
} = createLogtoConfigQueries(pool);
describe('connector queries', () => {
const { table, fields } = convertToIdentifiers(LogtoConfigs);
test('getAdminConsoleConfig', async () => {
const rowData = { key: 'adminConsole', value: `{"signInExperienceCustomized": false}` };
const expectSql = sql`
select ${fields.value} from ${table}
where ${fields.key} = ${LogtoTenantConfigKey.AdminConsole}
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([LogtoTenantConfigKey.AdminConsole]);
return createMockQueryResult([rowData]);
});
const result = await getAdminConsoleConfig();
expect(result).toEqual(rowData);
});
test('updateAdminConsoleConfig', async () => {
const targetValue = { signInExperienceCustomized: true };
const targetRowData = { key: 'adminConsole', value: JSON.stringify(targetValue) };
const expectSql = sql`
update ${table}
set ${fields.value} = coalesce(${fields.value},'{}'::jsonb) || ${sql.jsonb(targetValue)}
where ${fields.key} = ${LogtoTenantConfigKey.AdminConsole}
returning ${fields.value}
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toMatchObject([
JSON.stringify(targetValue),
LogtoTenantConfigKey.AdminConsole,
]);
return createMockQueryResult([targetRowData]);
});
const result = await updateAdminConsoleConfig(targetValue);
expect(result).toEqual(targetRowData);
});
test('getCloudConnectionData', async () => {
const rowData = {
key: 'cloudConnection',
value: `"appId": "abc", "resource": "https://foo.io/api"`,
};
const expectSql = sql`
select ${fields.value} from ${table}
where ${fields.key} = ${LogtoTenantConfigKey.CloudConnection}
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([LogtoTenantConfigKey.CloudConnection]);
return createMockQueryResult([rowData]);
});
const result = await getCloudConnectionData();
expect(result).toEqual(rowData);
});
test('getRowsByKeys', async () => {
const rowData = [
{ key: 'adminConsole', value: `{"signInExperienceCustomized": false}` },
{ key: 'oidc.privateKeys', value: `[{ "id": "foo", value: "bar", "createdAt": 123456789 }]` },
];
const keys = rowData.map((row) => row.key) as LogtoConfigKey[];
const expectSql = sql`
select ${sql.join([fields.key, fields.value], sql`,`)} from ${table}
where ${fields.key} in (${sql.join(keys, sql`,`)})
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual(keys);
return createMockQueryResult(rowData);
});
const result = await getRowsByKeys(keys);
expect(result.rows).toEqual(rowData);
});
test('updateOidcConfigsByKey', async () => {
const targetValue = [{ id: 'foo', value: 'bar', createdAt: 123_456_789 }];
const targetRowData = [
{ key: LogtoOidcConfigKey.PrivateKeys, value: JSON.stringify(targetValue) },
];
const expectSql = sql`
update ${table}
set ${fields.value} = ${sql.jsonb(targetValue)}
where ${fields.key} = ${LogtoOidcConfigKey.PrivateKeys}
returning *
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toMatchObject([JSON.stringify(targetValue), LogtoOidcConfigKey.PrivateKeys]);
return createMockQueryResult(targetRowData);
});
void updateOidcConfigsByKey(LogtoOidcConfigKey.PrivateKeys, targetValue);
});
});

View file

@ -1,4 +1,10 @@
import type { AdminConsoleData, LogtoConfig, LogtoConfigKey } from '@logto/schemas'; import type {
AdminConsoleData,
LogtoConfig,
LogtoConfigKey,
LogtoOidcConfigKey,
OidcConfigKey,
} from '@logto/schemas';
import { LogtoTenantConfigKey, LogtoConfigs } from '@logto/schemas'; import { LogtoTenantConfigKey, LogtoConfigs } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared'; import { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik'; import type { CommonQueryMethods } from 'slonik';
@ -33,5 +39,19 @@ export const createLogtoConfigQueries = (pool: CommonQueryMethods) => {
where ${fields.key} in (${sql.join(keys, sql`,`)}) where ${fields.key} in (${sql.join(keys, sql`,`)})
`); `);
return { getAdminConsoleConfig, updateAdminConsoleConfig, getCloudConnectionData, getRowsByKeys }; const updateOidcConfigsByKey = async (key: LogtoOidcConfigKey, value: OidcConfigKey[]) =>
pool.query(sql`
update ${table}
set ${fields.value} = ${sql.jsonb(value)}
where ${fields.key} = ${key}
returning *
`);
return {
getAdminConsoleConfig,
updateAdminConsoleConfig,
getCloudConnectionData,
getRowsByKeys,
updateOidcConfigsByKey,
};
}; };

View file

@ -1,11 +1,49 @@
import type { AdminConsoleData } from '@logto/schemas'; import { LogtoOidcConfigKey, type AdminConsoleData } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm'; import { generateStandardId } from '@logto/shared';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import Sinon from 'sinon';
import { mockAdminConsoleData } from '#src/__mocks__/index.js'; import {
mockAdminConsoleData,
mockCookieKeys,
mockLogtoConfigs,
mockPrivateKeys,
} 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';
const logtoConfigs = { const { jest } = import.meta;
const { mockEsmWithActual, mockEsmDefault } = createMockUtils(jest);
const newPrivateKey = {
id: generateStandardId(),
value: '-----BEGIN PRIVATE KEY-----\naaaaa\nbbbbb\nccccc\n-----END PRIVATE KEY-----\n',
createdAt: Math.floor(Date.now() / 1000),
};
const newCookieKey = {
id: generateStandardId(),
value: 'abcdefg',
createdAt: Math.floor(Date.now() / 1000),
};
const { exportJWK } = await mockEsmWithActual('#src/utils/jwks.js', () => ({
exportJWK: jest.fn(async () => ({ kty: 'EC' })),
}));
const { generateOidcPrivateKey } = await mockEsmWithActual(
'@logto/cli/lib/commands/database/utils.js',
() => ({
generateOidcCookieKey: jest.fn(() => newCookieKey),
generateOidcPrivateKey: jest.fn(async () => newPrivateKey),
})
);
mockEsmDefault('node:crypto', () => ({
createPrivateKey: jest.fn((value) => value),
}));
const logtoConfigQueries = {
getAdminConsoleConfig: async () => ({ value: mockAdminConsoleData }), getAdminConsoleConfig: async () => ({ value: mockAdminConsoleData }),
updateAdminConsoleConfig: async (data: Partial<AdminConsoleData>) => ({ updateAdminConsoleConfig: async (data: Partial<AdminConsoleData>) => ({
value: { value: {
@ -13,14 +51,36 @@ const logtoConfigs = {
...data, ...data,
}, },
}), }),
updateOidcConfigsByKey: jest.fn(),
getRowsByKeys: jest.fn(async () => ({
rows: mockLogtoConfigs,
rowCount: mockLogtoConfigs.length,
command: 'SELECT' as const,
fields: [],
notices: [],
})),
};
const logtoConfigLibraries = {
getOidcConfigs: jest.fn(async () => ({
[LogtoOidcConfigKey.PrivateKeys]: mockPrivateKeys,
[LogtoOidcConfigKey.CookieKeys]: mockCookieKeys,
})),
}; };
const settingRoutes = await pickDefault(import('./logto-config.js')); const settingRoutes = await pickDefault(import('./logto-config.js'));
describe('configs routes', () => { describe('configs routes', () => {
const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries });
Sinon.stub(tenantContext, 'logtoConfigs').value(logtoConfigLibraries);
const routeRequester = createRequester({ const routeRequester = createRequester({
authedRoutes: settingRoutes, authedRoutes: settingRoutes,
tenantContext: new MockTenant(undefined, { logtoConfigs }), tenantContext,
});
afterEach(() => {
jest.clearAllMocks();
}); });
it('GET /configs/admin-console', async () => { it('GET /configs/admin-console', async () => {
@ -41,4 +101,132 @@ describe('configs routes', () => {
signInExperienceCustomized, signInExperienceCustomized,
}); });
}); });
it('GET /configs/oidc/:keyType', async () => {
const response = await routeRequester.get('/configs/oidc/private-keys');
expect(response.status).toEqual(200);
expect(response.body).toEqual(
mockPrivateKeys.map(({ id, createdAt }) => ({
id,
createdAt,
signingKeyAlgorithm: 'EC',
}))
);
const response2 = await routeRequester.get('/configs/oidc/cookie-keys');
expect(response2.status).toEqual(200);
expect(response2.body).toEqual(
mockCookieKeys.map(({ id, createdAt }) => ({
id,
createdAt,
}))
);
});
it('DELETE /configs/oidc/:keyType/:keyId will fail if there is only one key', async () => {
await expect(
routeRequester.delete(`/configs/oidc/private-keys/${mockPrivateKeys[0]!.id}`)
).resolves.toHaveProperty('status', 422);
expect(logtoConfigQueries.updateOidcConfigsByKey).not.toBeCalled();
});
it('DELETE /configs/oidc/:keyType/:keyId', async () => {
logtoConfigLibraries.getOidcConfigs.mockResolvedValue({
[LogtoOidcConfigKey.PrivateKeys]: [newPrivateKey, ...mockPrivateKeys],
[LogtoOidcConfigKey.CookieKeys]: [newCookieKey, ...mockCookieKeys],
});
await expect(
routeRequester.delete(`/configs/oidc/private-keys/${mockPrivateKeys[0]!.id}`)
).resolves.toHaveProperty('status', 204);
expect(logtoConfigQueries.updateOidcConfigsByKey).toBeCalledWith(
LogtoOidcConfigKey.PrivateKeys,
[newPrivateKey]
);
await expect(
routeRequester.delete(`/configs/oidc/cookie-keys/${mockCookieKeys[0]!.id}`)
).resolves.toHaveProperty('status', 204);
expect(logtoConfigQueries.updateOidcConfigsByKey).toBeCalledWith(
LogtoOidcConfigKey.CookieKeys,
[newCookieKey]
);
logtoConfigLibraries.getOidcConfigs.mockRestore();
});
it('DELETE /configs/oidc/:keyType/:keyId will fail if key is not found', async () => {
logtoConfigLibraries.getOidcConfigs.mockResolvedValue({
[LogtoOidcConfigKey.PrivateKeys]: [newPrivateKey, ...mockPrivateKeys],
[LogtoOidcConfigKey.CookieKeys]: [newCookieKey, ...mockCookieKeys],
});
await expect(
routeRequester.delete(`/configs/oidc/private-keys/fake_key_id`)
).resolves.toHaveProperty('status', 404);
await expect(
routeRequester.delete(`/configs/oidc/private-keys/fake_key_id`)
).resolves.toHaveProperty('status', 404);
expect(logtoConfigQueries.updateOidcConfigsByKey).not.toBeCalled();
logtoConfigLibraries.getOidcConfigs.mockRestore();
});
it('POST /configs/oidc/:keyType/rotate', async () => {
logtoConfigLibraries.getOidcConfigs.mockResolvedValue({
[LogtoOidcConfigKey.PrivateKeys]: mockPrivateKeys,
[LogtoOidcConfigKey.CookieKeys]: mockCookieKeys,
});
exportJWK.mockResolvedValueOnce({ kty: 'RSA' });
const response = await routeRequester.post('/configs/oidc/private-keys/rotate');
expect(response.status).toEqual(200);
expect(logtoConfigQueries.updateOidcConfigsByKey).toHaveBeenCalledWith(
LogtoOidcConfigKey.PrivateKeys,
[newPrivateKey, ...mockPrivateKeys]
);
expect(response.body[0]).toEqual({
id: newPrivateKey.id,
createdAt: newPrivateKey.createdAt,
signingKeyAlgorithm: 'RSA',
});
const response2 = await routeRequester.post('/configs/oidc/cookie-keys/rotate');
expect(response2.status).toEqual(200);
expect(logtoConfigQueries.updateOidcConfigsByKey).toHaveBeenCalledWith(
LogtoOidcConfigKey.CookieKeys,
[newCookieKey, ...mockCookieKeys]
);
expect(response2.body[0]).toEqual({
id: newCookieKey.id,
createdAt: newCookieKey.createdAt,
});
logtoConfigLibraries.getOidcConfigs.mockRestore();
});
it('keeps only the last 2 recent private keys when rotating', async () => {
logtoConfigLibraries.getOidcConfigs.mockResolvedValueOnce({
[LogtoOidcConfigKey.PrivateKeys]: [newPrivateKey, ...mockPrivateKeys],
[LogtoOidcConfigKey.CookieKeys]: [newCookieKey, ...mockCookieKeys],
});
const newPrivateKey2 = {
id: generateStandardId(),
value: '-----BEGIN PRIVATE KEY-----\nnew\nprivate\nkey\n-----END PRIVATE KEY-----\n',
createdAt: Math.floor(Date.now() / 1000),
};
generateOidcPrivateKey.mockResolvedValueOnce(newPrivateKey2);
await routeRequester.post('/configs/oidc/private-keys/rotate');
// Only has two keys and the original mocked private keys are clamped off
expect(logtoConfigQueries.updateOidcConfigsByKey).toHaveBeenCalledWith(
LogtoOidcConfigKey.PrivateKeys,
[newPrivateKey2, newPrivateKey]
);
});
}); });

View file

@ -1,13 +1,75 @@
import { adminConsoleDataGuard } from '@logto/schemas'; 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,
} from '@logto/schemas';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard 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';
/*
* Logto OIDC private key type used in API routes
*/
enum LogtoOidcPrivateKeyType {
PrivateKeys = 'private-keys',
CookieKeys = 'cookie-keys',
}
/**
* Provide a simple API router key type and DB column mapping
*/
const getOidcConfigKeyDatabaseColumnName = (key: LogtoOidcPrivateKeyType): LogtoOidcConfigKey =>
key === LogtoOidcPrivateKeyType.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>( export default function logtoConfigRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T> ...[router, { queries, logtoConfigs, envSet }]: RouterInitArgs<T>
) { ) {
const { getAdminConsoleConfig, updateAdminConsoleConfig } = queries.logtoConfigs; const { getAdminConsoleConfig, updateAdminConsoleConfig, updateOidcConfigsByKey } =
queries.logtoConfigs;
const { getOidcConfigs } = logtoConfigs;
router.get( router.get(
'/configs/admin-console', '/configs/admin-console',
@ -34,4 +96,116 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
return next(); return next();
} }
); );
/**
* Get Logto OIDC private keys from database. The actual key will be redacted from the result.
* @param keyType Logto OIDC private key type. Values are either `private-keys` or `cookie-keys`.
*/
router.get(
'/configs/oidc/:keyType',
koaGuard({
params: z.object({
keyType: z.nativeEnum(LogtoOidcPrivateKeyType),
}),
response: z.array(oidcConfigKeysResponseGuard),
status: [200, 404],
}),
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();
}
);
/**
* Delete a Logto OIDC private key from database.
* @param keyType Logto OIDC key type. Values are either `oidc.privateKeys` or `oidc.cookieKeys`.
* @param keyId The ID of the private key to be deleted.
*/
router.delete(
'/configs/oidc/:keyType/:keyId',
koaGuard({
params: z.object({
keyType: z.nativeEnum(LogtoOidcPrivateKeyType),
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);
// Reload OIDC configs in envSet in order to apply the changes immediately
await envSet.load();
ctx.status = 204;
return next();
}
);
/**
* Rotate Logto OIDC private keys. A new key will be generated and added to the list of private keys.
* Only keep the last 2 recent keys. The oldest key will be automatically removed if the list exceeds 2 keys.
* @param configKey Logto OIDC key type. Values are either `oidc.privateKeys` or `oidc.cookieKeys`.
* @param signingKeyAlgorithm The signing key algorithm the new generated private key is using. Values are either `EC` or `RSA`. Only applicable to `oidc.privateKeys`. Defaults to `EC`.
*/
router.post(
'/configs/oidc/:keyType/rotate',
koaGuard({
params: z.object({
keyType: z.nativeEnum(LogtoOidcPrivateKeyType),
}),
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);
// Reload OIDC configs in envSet in order to apply the changes immediately
await envSet.load();
// Remove actual values of the private keys from response
ctx.body = await getRedactedOidcKeyResponse(configKey, updatedKeys);
return next();
}
);
} }

View file

@ -69,14 +69,16 @@ export class MockTenant implements TenantContext {
public libraries: Libraries; public libraries: Libraries;
public sentinel: Sentinel; public sentinel: Sentinel;
// eslint-disable-next-line max-params
constructor( constructor(
public provider = createMockProvider(), public provider = createMockProvider(),
queriesOverride?: Partial2<Queries>, queriesOverride?: Partial2<Queries>,
connectorsOverride?: Partial<ConnectorLibrary>, connectorsOverride?: Partial<ConnectorLibrary>,
librariesOverride?: Partial2<Libraries> librariesOverride?: Partial2<Libraries>,
logtoConfigsOverride?: Partial<LogtoConfigLibrary>
) { ) {
this.queries = new MockQueries(queriesOverride); this.queries = new MockQueries(queriesOverride);
this.logtoConfigs = createLogtoConfigLibrary(this.queries); this.logtoConfigs = { ...createLogtoConfigLibrary(this.queries), ...logtoConfigsOverride };
this.cloudConnection = createCloudConnectionLibrary(this.logtoConfigs); this.cloudConnection = createCloudConnectionLibrary(this.logtoConfigs);
this.connectors = { this.connectors = {
...createConnectorLibrary(this.queries, this.cloudConnection), ...createConnectorLibrary(this.queries, this.cloudConnection),

View file

@ -5,8 +5,7 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { JWK, KeyLike } from 'jose'; import { type JWK, type KeyLike, exportJWK as joseExportJWK } from 'jose';
import { exportJWK as joseExportJWK } from 'jose';
const getCalculateKidComponents = (jwk: JWK) => { const getCalculateKidComponents = (jwk: JWK) => {
switch (jwk.kty) { switch (jwk.kty) {

View file

@ -1,4 +1,8 @@
import type { AdminConsoleData } from '@logto/schemas'; import {
SupportedSigningKeyAlgorithm,
type AdminConsoleData,
type OidcConfigKeysResponse,
} from '@logto/schemas';
import { authedAdminApi } from './api.js'; import { authedAdminApi } from './api.js';
@ -11,3 +15,17 @@ export const updateAdminConsoleConfig = async (payload: Partial<AdminConsoleData
json: payload, json: payload,
}) })
.json<AdminConsoleData>(); .json<AdminConsoleData>();
export const getOidcKeys = async (keyType: 'private-keys' | 'cookie-keys') =>
authedAdminApi.get(`configs/oidc/${keyType}`).json<OidcConfigKeysResponse[]>();
export const deleteOidcKey = async (keyType: 'private-keys' | 'cookie-keys', id: string) =>
authedAdminApi.delete(`configs/oidc/${keyType}/${id}`);
export const rotateOidcKeys = async (
keyType: 'private-keys' | 'cookie-keys',
signingKeyAlgorithm: SupportedSigningKeyAlgorithm = SupportedSigningKeyAlgorithm.EC
) =>
authedAdminApi
.post(`configs/oidc/${keyType}/rotate`, { json: { signingKeyAlgorithm } })
.json<OidcConfigKeysResponse[]>();

View file

@ -1,6 +1,13 @@
import { type AdminConsoleData } from '@logto/schemas'; import { SupportedSigningKeyAlgorithm, type AdminConsoleData } from '@logto/schemas';
import { getAdminConsoleConfig, updateAdminConsoleConfig } from '#src/api/index.js'; import {
deleteOidcKey,
getAdminConsoleConfig,
getOidcKeys,
rotateOidcKeys,
updateAdminConsoleConfig,
} from '#src/api/index.js';
import { expectRejects } from '#src/helpers/index.js';
const defaultAdminConsoleConfig: AdminConsoleData = { const defaultAdminConsoleConfig: AdminConsoleData = {
signInExperienceCustomized: false, signInExperienceCustomized: false,
@ -24,4 +31,72 @@ describe('admin console sign-in experience', () => {
...newAdminConsoleConfig, ...newAdminConsoleConfig,
}); });
}); });
it('should get OIDC keys successfully', async () => {
const privateKeys = await getOidcKeys('private-keys');
const cookieKeys = await getOidcKeys('cookie-keys');
expect(privateKeys).toHaveLength(1);
expect(privateKeys[0]).toMatchObject(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ id: expect.any(String), signingKeyAlgorithm: 'EC', createdAt: expect.any(Number) }
);
expect(cookieKeys).toHaveLength(1);
expect(cookieKeys[0]).toMatchObject(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ id: expect.any(String), createdAt: expect.any(Number) }
);
});
it('should not be able to delete the only private key', async () => {
const privateKeys = await getOidcKeys('private-keys');
expect(privateKeys).toHaveLength(1);
await expectRejects(deleteOidcKey('private-keys', privateKeys[0]!.id), {
code: 'oidc.key_required',
statusCode: 422,
});
const cookieKeys = await getOidcKeys('cookie-keys');
expect(cookieKeys).toHaveLength(1);
await expectRejects(deleteOidcKey('cookie-keys', cookieKeys[0]!.id), {
code: 'oidc.key_required',
statusCode: 422,
});
});
it('should rotate OIDC keys successfully', async () => {
const privateKeys = await rotateOidcKeys('private-keys', SupportedSigningKeyAlgorithm.RSA);
expect(privateKeys).toHaveLength(2);
expect(privateKeys).toMatchObject([
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ id: expect.any(String), signingKeyAlgorithm: 'RSA', createdAt: expect.any(Number) },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ id: expect.any(String), signingKeyAlgorithm: 'EC', createdAt: expect.any(Number) },
]);
const cookieKeys = await rotateOidcKeys('cookie-keys');
expect(cookieKeys).toHaveLength(2);
expect(cookieKeys).toMatchObject([
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ id: expect.any(String), createdAt: expect.any(Number) },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ id: expect.any(String), createdAt: expect.any(Number) },
]);
});
it('should only keep 2 recent OIDC keys', async () => {
await rotateOidcKeys('private-keys', SupportedSigningKeyAlgorithm.RSA);
await rotateOidcKeys('private-keys', SupportedSigningKeyAlgorithm.RSA);
const privateKeys = await rotateOidcKeys('private-keys'); // Defaults to 'EC' algorithm
expect(privateKeys).toHaveLength(2);
expect(privateKeys).toMatchObject([
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ id: expect.any(String), signingKeyAlgorithm: 'EC', createdAt: expect.any(Number) },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ id: expect.any(String), signingKeyAlgorithm: 'RSA', createdAt: expect.any(Number) },
]);
});
}); });

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -18,6 +18,8 @@ const oidc = {
provider_error: 'OIDC Internal Error: {{message}}.', provider_error: 'OIDC Internal Error: {{message}}.',
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
key_required: 'At least one key is required.',
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.', server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */ /** UNTRANSLATED */
provider_error_fallback: 'An OIDC error occurred: {{code}}.', provider_error_fallback: 'An OIDC error occurred: {{code}}.',
/** UNTRANSLATED */
key_required: 'At least one key is required.',
/** UNTRANSLATED */
key_not_found: 'Key with ID {{id}} is not found.',
}; };
export default Object.freeze(oidc); export default Object.freeze(oidc);

View file

@ -7,24 +7,30 @@ export enum LogtoOidcConfigKey {
CookieKeys = 'oidc.cookieKeys', CookieKeys = 'oidc.cookieKeys',
} }
const oidcPrivateKeyGuard = z.object({ /* --- Logto supported JWK signing key types --- */
export enum SupportedSigningKeyAlgorithm {
RSA = 'RSA',
EC = 'EC',
}
export const oidcConfigKeyGuard = z.object({
id: z.string(), id: z.string(),
value: z.string(), value: z.string(),
createdAt: z.number(), createdAt: z.number(),
}); });
export type PrivateKey = z.infer<typeof oidcPrivateKeyGuard>; export type OidcConfigKey = z.infer<typeof oidcConfigKeyGuard>;
export type LogtoOidcConfigType = { export type LogtoOidcConfigType = {
[LogtoOidcConfigKey.PrivateKeys]: PrivateKey[]; [LogtoOidcConfigKey.PrivateKeys]: OidcConfigKey[];
[LogtoOidcConfigKey.CookieKeys]: PrivateKey[]; [LogtoOidcConfigKey.CookieKeys]: OidcConfigKey[];
}; };
export const logtoOidcConfigGuard: Readonly<{ export const logtoOidcConfigGuard: Readonly<{
[key in LogtoOidcConfigKey]: ZodType<LogtoOidcConfigType[key]>; [key in LogtoOidcConfigKey]: ZodType<LogtoOidcConfigType[key]>;
}> = Object.freeze({ }> = Object.freeze({
[LogtoOidcConfigKey.PrivateKeys]: oidcPrivateKeyGuard.array(), [LogtoOidcConfigKey.PrivateKeys]: oidcConfigKeyGuard.array(),
[LogtoOidcConfigKey.CookieKeys]: oidcPrivateKeyGuard.array(), [LogtoOidcConfigKey.CookieKeys]: oidcConfigKeyGuard.array(),
}); });
/* --- Logto tenant configs --- */ /* --- Logto tenant configs --- */
@ -77,3 +83,9 @@ export const logtoConfigGuards: LogtoConfigGuard = Object.freeze({
...logtoOidcConfigGuard, ...logtoOidcConfigGuard,
...logtoTenantConfigGuard, ...logtoTenantConfigGuard,
}); });
export const oidcConfigKeysResponseGuard = oidcConfigKeyGuard
.omit({ value: true })
.merge(z.object({ signingKeyAlgorithm: z.nativeEnum(SupportedSigningKeyAlgorithm).optional() }));
export type OidcConfigKeysResponse = z.infer<typeof oidcConfigKeysResponseGuard>;

View file

@ -2,6 +2,6 @@ create table logto_configs (
tenant_id varchar(21) not null tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade, references tenants (id) on update cascade on delete cascade,
key varchar(256) not null, key varchar(256) not null,
value jsonb /* @use JsonObject */ not null default '{}'::jsonb, value jsonb /* @use Json */ not null default '{}'::jsonb,
primary key (tenant_id, key) primary key (tenant_id, key)
); );