0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -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,
logtoConfigGuards,
logtoConfigKeys,
SupportedSigningKeyAlgorithm,
} from '@logto/schemas';
import { deduplicate, noop } from '@silverhand/essentials';
import chalk from 'chalk';
@ -13,7 +14,7 @@ import { createPoolFromConfig } from '../../database.js';
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config.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(', '));
@ -41,7 +42,10 @@ const validRotateKeys = Object.freeze([
LogtoOidcConfigKey.CookieKeys,
] 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];
@ -62,7 +66,7 @@ const validateRotateKey: ValidateRotateKeyFunction = (key) => {
const validatePrivateKeyType: ValidatePrivateKeyTypeFunction = (key) => {
// Using `.includes()` will result a type error
// eslint-disable-next-line unicorn/prefer-includes
if (!validPrivateKeyTypes.some((element) => element === key)) {
if (!validPrivateKeyTypes.some((element) => element === key.toUpperCase())) {
consoleLog.fatal(
`Invalid private key type ${chalk.red(
key

View file

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

View file

@ -3,13 +3,15 @@ import type {
AdminConsoleData,
Application,
ApplicationsRole,
LogtoConfig,
Passcode,
OidcConfigKey,
Resource,
Role,
Scope,
UsersRole,
} from '@logto/schemas';
import { RoleType, ApplicationType } from '@logto/schemas';
import { RoleType, ApplicationType, LogtoOidcConfigKey } from '@logto/schemas';
import { mockId } from '#src/test-utils/nanoid.js';
@ -97,6 +99,31 @@ export const mockAdminConsoleData: AdminConsoleData = {
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 = {
tenantId: 'fake_tenant',
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 { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
@ -33,5 +39,19 @@ export const createLogtoConfigQueries = (pool: CommonQueryMethods) => {
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 { pickDefault } from '@logto/shared/esm';
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 } from '#src/__mocks__/index.js';
import {
mockAdminConsoleData,
mockCookieKeys,
mockLogtoConfigs,
mockPrivateKeys,
} from '#src/__mocks__/index.js';
import { MockTenant } from '#src/test-utils/tenant.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 }),
updateAdminConsoleConfig: async (data: Partial<AdminConsoleData>) => ({
value: {
@ -13,14 +51,36 @@ const logtoConfigs = {
...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'));
describe('configs routes', () => {
const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries });
Sinon.stub(tenantContext, 'logtoConfigs').value(logtoConfigLibraries);
const routeRequester = createRequester({
authedRoutes: settingRoutes,
tenantContext: new MockTenant(undefined, { logtoConfigs }),
tenantContext,
});
afterEach(() => {
jest.clearAllMocks();
});
it('GET /configs/admin-console', async () => {
@ -41,4 +101,132 @@ describe('configs routes', () => {
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 { exportJWK } from '#src/utils/jwks.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>(
...[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(
'/configs/admin-console',
@ -34,4 +96,116 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
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 sentinel: Sentinel;
// eslint-disable-next-line max-params
constructor(
public provider = createMockProvider(),
queriesOverride?: Partial2<Queries>,
connectorsOverride?: Partial<ConnectorLibrary>,
librariesOverride?: Partial2<Libraries>
librariesOverride?: Partial2<Libraries>,
logtoConfigsOverride?: Partial<LogtoConfigLibrary>
) {
this.queries = new MockQueries(queriesOverride);
this.logtoConfigs = createLogtoConfigLibrary(this.queries);
this.logtoConfigs = { ...createLogtoConfigLibrary(this.queries), ...logtoConfigsOverride };
this.cloudConnection = createCloudConnectionLibrary(this.logtoConfigs);
this.connectors = {
...createConnectorLibrary(this.queries, this.cloudConnection),

View file

@ -5,8 +5,7 @@
import { createHash } from 'node:crypto';
import type { JWK, KeyLike } from 'jose';
import { exportJWK as joseExportJWK } from 'jose';
import { type JWK, type KeyLike, exportJWK as joseExportJWK } from 'jose';
const getCalculateKidComponents = (jwk: JWK) => {
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';
@ -11,3 +15,17 @@ export const updateAdminConsoleConfig = async (payload: Partial<AdminConsoleData
json: payload,
})
.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 = {
signInExperienceCustomized: false,
@ -24,4 +31,72 @@ describe('admin console sign-in experience', () => {
...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.',
/** UNTRANSLATED */
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);

View file

@ -18,6 +18,8 @@ const oidc = {
provider_error: 'OIDC Internal Error: {{message}}.',
server_error: 'An unknown OIDC error occurred. Please try again later.',
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);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -19,6 +19,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -18,6 +18,10 @@ const oidc = {
server_error: 'An unknown OIDC error occurred. Please try again later.',
/** UNTRANSLATED */
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);

View file

@ -7,24 +7,30 @@ export enum LogtoOidcConfigKey {
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(),
value: z.string(),
createdAt: z.number(),
});
export type PrivateKey = z.infer<typeof oidcPrivateKeyGuard>;
export type OidcConfigKey = z.infer<typeof oidcConfigKeyGuard>;
export type LogtoOidcConfigType = {
[LogtoOidcConfigKey.PrivateKeys]: PrivateKey[];
[LogtoOidcConfigKey.CookieKeys]: PrivateKey[];
[LogtoOidcConfigKey.PrivateKeys]: OidcConfigKey[];
[LogtoOidcConfigKey.CookieKeys]: OidcConfigKey[];
};
export const logtoOidcConfigGuard: Readonly<{
[key in LogtoOidcConfigKey]: ZodType<LogtoOidcConfigType[key]>;
}> = Object.freeze({
[LogtoOidcConfigKey.PrivateKeys]: oidcPrivateKeyGuard.array(),
[LogtoOidcConfigKey.CookieKeys]: oidcPrivateKeyGuard.array(),
[LogtoOidcConfigKey.PrivateKeys]: oidcConfigKeyGuard.array(),
[LogtoOidcConfigKey.CookieKeys]: oidcConfigKeyGuard.array(),
});
/* --- Logto tenant configs --- */
@ -77,3 +83,9 @@ export const logtoConfigGuards: LogtoConfigGuard = Object.freeze({
...logtoOidcConfigGuard,
...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
references tenants (id) on update cascade on delete cascade,
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)
);