diff --git a/packages/cli/src/commands/database/config.ts b/packages/cli/src/commands/database/config.ts index 1ac8f55c5..e503701f6 100644 --- a/packages/cli/src/commands/database/config.ts +++ b/packages/cli/src/commands/database/config.ts @@ -1,5 +1,5 @@ import type { LogtoConfigKey } from '@logto/schemas'; -import { logtoConfigGuards, logtoConfigKeys } from '@logto/schemas'; +import { LogtoOidcConfigKey, logtoConfigGuards, logtoConfigKeys } from '@logto/schemas'; import { deduplicate, noop } from '@silverhand/essentials'; import chalk from 'chalk'; import type { CommandModule } from 'yargs'; @@ -7,6 +7,7 @@ import type { CommandModule } from 'yargs'; import { createPoolFromConfig } from '../../database'; import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config'; import { log } from '../../utilities'; +import { generateOidcCookieKey, generateOidcPrivateKey } from './utilities'; const validKeysDisplay = chalk.green(logtoConfigKeys.join(', ')); @@ -29,6 +30,21 @@ const validateKeys: ValidateKeysFunction = (keys) => { } }; +const validRotateKeys = Object.freeze([ + LogtoOidcConfigKey.PrivateKeys, + LogtoOidcConfigKey.CookieKeys, +] as const); + +type ValidateRotateKeyFunction = (key: string) => asserts key is typeof validRotateKeys[number]; + +const validateRotateKey: ValidateRotateKeyFunction = (key) => { + // Using `.includes()` will result a type error + // eslint-disable-next-line unicorn/prefer-includes + if (!validRotateKeys.some((element) => element === key)) { + log.error(`Invalid config key ${chalk.red(key)} found, expected one of ${validKeysDisplay}`); + } +}; + const getConfig: CommandModule = { command: 'get [keys...]', describe: 'Get config value(s) of the given key(s) in Logto database', @@ -97,10 +113,54 @@ const setConfig: CommandModule = { }, }; +const rotateConfig: CommandModule = { + command: 'rotate ', + describe: + 'Generate a new private or secret key for the given config key and prepend to the key array', + builder: (yargs) => + yargs.positional('key', { + describe: `The key to rotate, one of ${chalk.green(validRotateKeys.join(', '))}`, + type: 'string', + demandOption: true, + }), + handler: async ({ key }) => { + validateRotateKey(key); + + const pool = await createPoolFromConfig(); + const { rows } = await getRowsByKeys(pool, [key]); + + if (!rows[0]) { + log.warn('No key found, create a new one'); + } + + const getValue = async () => { + const parsed = logtoConfigGuards[key].safeParse(rows[0]?.value); + const original = parsed.success ? parsed.data : []; + + switch (key) { + case LogtoOidcConfigKey.PrivateKeys: + return [await generateOidcPrivateKey(), ...original]; + case LogtoOidcConfigKey.CookieKeys: + return [generateOidcCookieKey(), ...original]; + default: + log.warn('No proper handler found, use empty array'); + + return []; + } + }; + const rotated = await getValue(); + await updateValueByKey(pool, key, rotated); + await pool.end(); + + log.info(`Rotate ${chalk.green(key)} succeeded, now it has ${rotated.length} keys`); + }, +}; + const config: CommandModule = { command: ['config', 'configs'], describe: 'Commands for Logto database config', - builder: (yargs) => yargs.command(getConfig).command(setConfig).demandCommand(1), + builder: (yargs) => + yargs.command(getConfig).command(setConfig).command(rotateConfig).demandCommand(1), handler: noop, }; diff --git a/packages/cli/src/commands/database/seed/oidc-config.ts b/packages/cli/src/commands/database/seed/oidc-config.ts index 4e4678263..a65b19c29 100644 --- a/packages/cli/src/commands/database/seed/oidc-config.ts +++ b/packages/cli/src/commands/database/seed/oidc-config.ts @@ -1,11 +1,10 @@ -import { generateKeyPair } from 'crypto'; import { readFile } from 'fs/promises'; -import { promisify } from 'util'; import type { LogtoOidcConfigType } from '@logto/schemas'; import { LogtoOidcConfigKey } from '@logto/schemas'; import { getEnv, getEnvAsStringArray } from '@silverhand/essentials'; -import { nanoid } from 'nanoid'; + +import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities'; const isBase64FormatPrivateKey = (key: string) => !key.includes('-'); @@ -57,21 +56,8 @@ export const oidcConfigReaders: { }; } - // Generate a new key - const { privateKey } = await promisify(generateKeyPair)('rsa', { - modulusLength: 4096, - publicKeyEncoding: { - type: 'spki', - format: 'pem', - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - }, - }); - return { - value: [privateKey], + value: [await generateOidcPrivateKey()], fromEnv: false, }; }, @@ -79,7 +65,7 @@ export const oidcConfigReaders: { const envKey = 'OIDC_COOKIE_KEYS'; const keys = getEnvAsStringArray(envKey); - return { value: keys.length > 0 ? keys : [nanoid()], fromEnv: keys.length > 0 }; + return { value: keys.length > 0 ? keys : [generateOidcCookieKey()], fromEnv: keys.length > 0 }; }, [LogtoOidcConfigKey.RefreshTokenReuseInterval]: async () => { const envKey = 'OIDC_REFRESH_TOKEN_REUSE_INTERVAL'; diff --git a/packages/cli/src/commands/database/utilities.ts b/packages/cli/src/commands/database/utilities.ts new file mode 100644 index 000000000..84c4f2ac8 --- /dev/null +++ b/packages/cli/src/commands/database/utilities.ts @@ -0,0 +1,22 @@ +import { generateKeyPair } from 'crypto'; +import { promisify } from 'util'; + +import { nanoid } from 'nanoid'; + +export const generateOidcPrivateKey = async () => { + const { privateKey } = await promisify(generateKeyPair)('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + return privateKey; +}; + +export const generateOidcCookieKey = () => nanoid();