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:
commit
005bb660cd
28 changed files with 752 additions and 38 deletions
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
142
packages/core/src/queries/logto-config.test.ts
Normal file
142
packages/core/src/queries/logto-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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[]>();
|
||||
|
|
|
@ -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) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue