mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #2342 from logto-io/gao-log-4341-cli-provide-a-command-to-rotate-keys
feat(cli): rotate keys
This commit is contained in:
commit
23315de00f
4 changed files with 162 additions and 20 deletions
23
.changeset/unlucky-lizards-agree.md
Normal file
23
.changeset/unlucky-lizards-agree.md
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
"@logto/cli": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
### Rotate your private or secret key
|
||||||
|
|
||||||
|
We add a new command `db config rotate <key>` to support key rotation via CLI.
|
||||||
|
|
||||||
|
When rotating, the CLI will generate a new key and prepend to the corresponding key array. Thus the old key is still valid and the service will use the new key for signing.
|
||||||
|
|
||||||
|
Run `logto db config rotate help` for detailed usage.
|
||||||
|
|
||||||
|
### Trim the private or secret key you don't need
|
||||||
|
|
||||||
|
If you want to trim one or more out-dated private or secret key(s) from the config, use the command `db config trim <key>`. It will remove the last item (private or secret key) in the array.
|
||||||
|
|
||||||
|
You may remove the old key after a certain period (such as half a year) to allow most of your users have time to touch the new key.
|
||||||
|
|
||||||
|
If you want to remove multiple keys at once, just append a number to the command. E.g. `logto db config trim oidc.cookieKeys 3`.
|
||||||
|
|
||||||
|
Run `logto db config trim help` for detailed usage.
|
|
@ -1,5 +1,5 @@
|
||||||
import type { LogtoConfigKey } from '@logto/schemas';
|
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 { deduplicate, noop } from '@silverhand/essentials';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
|
@ -7,6 +7,7 @@ import type { CommandModule } from 'yargs';
|
||||||
import { createPoolFromConfig } from '../../database';
|
import { createPoolFromConfig } from '../../database';
|
||||||
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config';
|
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config';
|
||||||
import { log } from '../../utilities';
|
import { log } from '../../utilities';
|
||||||
|
import { generateOidcCookieKey, generateOidcPrivateKey } from './utilities';
|
||||||
|
|
||||||
const validKeysDisplay = chalk.green(logtoConfigKeys.join(', '));
|
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<unknown, { key: string; keys: string[] }> = {
|
const getConfig: CommandModule<unknown, { key: string; keys: string[] }> = {
|
||||||
command: 'get <key> [keys...]',
|
command: 'get <key> [keys...]',
|
||||||
describe: 'Get config value(s) of the given key(s) in Logto database',
|
describe: 'Get config value(s) of the given key(s) in Logto database',
|
||||||
|
@ -97,10 +113,105 @@ const setConfig: CommandModule<unknown, { key: string; value: string }> = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rotateConfig: CommandModule<unknown, { key: string }> = {
|
||||||
|
command: 'rotate <key>',
|
||||||
|
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 : [];
|
||||||
|
|
||||||
|
// No need for default. It's already exhaustive
|
||||||
|
// eslint-disable-next-line default-case
|
||||||
|
switch (key) {
|
||||||
|
case LogtoOidcConfigKey.PrivateKeys:
|
||||||
|
return [await generateOidcPrivateKey(), ...original];
|
||||||
|
case LogtoOidcConfigKey.CookieKeys:
|
||||||
|
return [generateOidcCookieKey(), ...original];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 trimConfig: CommandModule<unknown, { key: string; length: number }> = {
|
||||||
|
command: 'trim <key> [length]',
|
||||||
|
describe: 'Remove the last [length] number of private or secret keys for the given config key',
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.positional('key', {
|
||||||
|
describe: `The config key to trim, one of ${chalk.green(validRotateKeys.join(', '))}`,
|
||||||
|
type: 'string',
|
||||||
|
demandOption: true,
|
||||||
|
})
|
||||||
|
.positional('length', {
|
||||||
|
describe: 'Number of private or secret keys to trim',
|
||||||
|
type: 'number',
|
||||||
|
default: 1,
|
||||||
|
demandOption: true,
|
||||||
|
}),
|
||||||
|
handler: async ({ key, length }) => {
|
||||||
|
validateRotateKey(key);
|
||||||
|
|
||||||
|
if (length < 1) {
|
||||||
|
log.error('Invalid length provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 value = logtoConfigGuards[key].parse(rows[0]?.value);
|
||||||
|
|
||||||
|
if (value.length - length < 1) {
|
||||||
|
await pool.end();
|
||||||
|
log.error(`You should keep at least one key in the array, current length=${value.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.slice(0, -length);
|
||||||
|
};
|
||||||
|
const trimmed = await getValue();
|
||||||
|
await updateValueByKey(pool, key, trimmed);
|
||||||
|
await pool.end();
|
||||||
|
|
||||||
|
log.info(`Trim ${chalk.green(key)} succeeded, now it has ${trimmed.length} keys`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const config: CommandModule = {
|
const config: CommandModule = {
|
||||||
command: ['config', 'configs'],
|
command: ['config', 'configs'],
|
||||||
describe: 'Commands for Logto database config',
|
describe: 'Commands for Logto database config',
|
||||||
builder: (yargs) => yargs.command(getConfig).command(setConfig).demandCommand(1),
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.command(getConfig)
|
||||||
|
.command(setConfig)
|
||||||
|
.command(rotateConfig)
|
||||||
|
.command(trimConfig)
|
||||||
|
.demandCommand(1),
|
||||||
handler: noop,
|
handler: noop,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import { generateKeyPair } from 'crypto';
|
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
import type { LogtoOidcConfigType } from '@logto/schemas';
|
import type { LogtoOidcConfigType } from '@logto/schemas';
|
||||||
import { LogtoOidcConfigKey } from '@logto/schemas';
|
import { LogtoOidcConfigKey } from '@logto/schemas';
|
||||||
import { getEnv, getEnvAsStringArray } from '@silverhand/essentials';
|
import { getEnv, getEnvAsStringArray } from '@silverhand/essentials';
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
|
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities';
|
||||||
|
|
||||||
const isBase64FormatPrivateKey = (key: string) => !key.includes('-');
|
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 {
|
return {
|
||||||
value: [privateKey],
|
value: [await generateOidcPrivateKey()],
|
||||||
fromEnv: false,
|
fromEnv: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -79,7 +65,7 @@ export const oidcConfigReaders: {
|
||||||
const envKey = 'OIDC_COOKIE_KEYS';
|
const envKey = 'OIDC_COOKIE_KEYS';
|
||||||
const keys = getEnvAsStringArray(envKey);
|
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 () => {
|
[LogtoOidcConfigKey.RefreshTokenReuseInterval]: async () => {
|
||||||
const envKey = 'OIDC_REFRESH_TOKEN_REUSE_INTERVAL';
|
const envKey = 'OIDC_REFRESH_TOKEN_REUSE_INTERVAL';
|
||||||
|
|
22
packages/cli/src/commands/database/utilities.ts
Normal file
22
packages/cli/src/commands/database/utilities.ts
Normal file
|
@ -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();
|
Loading…
Reference in a new issue