From 46d0d4c0b96beb12a76699b0c5059ad23638f962 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Sun, 8 Oct 2023 12:51:04 -0500 Subject: [PATCH] refactor(schemas,core,cli): alter signing key type to json object (#4582) --- .changeset/lucky-brooms-hide.md | 7 ++ .../src/commands/database/seed/oidc-config.ts | 30 +++-- packages/cli/src/commands/database/utils.ts | 19 ++- packages/core/src/env-set/oidc.ts | 6 +- .../core/src/middleware/koa-auth/utils.ts | 2 +- ...next-1695647183-update-private-key-type.ts | 108 ++++++++++++++++++ packages/schemas/src/types/logto-config.ts | 16 ++- 7 files changed, 165 insertions(+), 23 deletions(-) create mode 100644 .changeset/lucky-brooms-hide.md create mode 100644 packages/schemas/alterations/next-1695647183-update-private-key-type.ts diff --git a/.changeset/lucky-brooms-hide.md b/.changeset/lucky-brooms-hide.md new file mode 100644 index 000000000..d60ee529d --- /dev/null +++ b/.changeset/lucky-brooms-hide.md @@ -0,0 +1,7 @@ +--- +"@logto/schemas": patch +"@logto/core": patch +"@logto/cli": patch +--- + +convert private signing key type from string to JSON object, in order to provide additional information such as key ID and creation timestamp. diff --git a/packages/cli/src/commands/database/seed/oidc-config.ts b/packages/cli/src/commands/database/seed/oidc-config.ts index 275683915..403342228 100644 --- a/packages/cli/src/commands/database/seed/oidc-config.ts +++ b/packages/cli/src/commands/database/seed/oidc-config.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import type { LogtoOidcConfigType } from '@logto/schemas'; import { LogtoOidcConfigKey, logtoConfigGuards } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; import { getEnvAsStringArray } from '@silverhand/essentials'; import chalk from 'chalk'; import type { DatabaseTransactionConnection } from 'slonik'; @@ -9,7 +10,11 @@ import { z } from 'zod'; import { getRowsByKeys, updateValueByKey } from '../../../queries/logto-config.js'; import { consoleLog } from '../../../utils.js'; -import { generateOidcCookieKey, generateOidcPrivateKey } from '../utils.js'; +import { + buildOidcKeyFromRawString, + generateOidcCookieKey, + generateOidcPrivateKey, +} from '../utils.js'; const isBase64FormatPrivateKey = (key: string) => !key.includes('-'); @@ -88,13 +93,11 @@ export const oidcConfigReaders: { if (privateKeys.length > 0) { return { - value: privateKeys.map((key) => { - if (isBase64FormatPrivateKey(key)) { - return Buffer.from(key, 'base64').toString('utf8'); - } - - return key; - }), + value: privateKeys.map((key) => + buildOidcKeyFromRawString( + isBase64FormatPrivateKey(key) ? Buffer.from(key, 'base64').toString('utf8') : key + ) + ), fromEnv: true, }; } @@ -103,8 +106,11 @@ export const oidcConfigReaders: { const privateKeyPaths = getEnvAsStringArray('OIDC_PRIVATE_KEY_PATHS'); if (privateKeyPaths.length > 0) { + const privateKeys = await Promise.all( + privateKeyPaths.map(async (path) => readFile(path, 'utf8')) + ); return { - value: await Promise.all(privateKeyPaths.map(async (path) => readFile(path, 'utf8'))), + value: privateKeys.map((key) => buildOidcKeyFromRawString(key)), fromEnv: true, }; } @@ -116,7 +122,11 @@ export const oidcConfigReaders: { }, [LogtoOidcConfigKey.CookieKeys]: async () => { const envKey = 'OIDC_COOKIE_KEYS'; - const keys = getEnvAsStringArray(envKey); + const keys = getEnvAsStringArray(envKey).map((key) => ({ + id: generateStandardId(), + value: key, + createdAt: Math.floor(Date.now() / 1000), + })); return { value: keys.length > 0 ? keys : [generateOidcCookieKey()], fromEnv: keys.length > 0 }; }, diff --git a/packages/cli/src/commands/database/utils.ts b/packages/cli/src/commands/database/utils.ts index e962b0a79..1a5778d5f 100644 --- a/packages/cli/src/commands/database/utils.ts +++ b/packages/cli/src/commands/database/utils.ts @@ -1,14 +1,17 @@ import { generateKeyPair } from 'node:crypto'; import { promisify } from 'node:util'; -import { generateStandardSecret } from '@logto/shared'; +import { type PrivateKey } from '@logto/schemas'; +import { generateStandardId, generateStandardSecret } from '@logto/shared'; export enum PrivateKeyType { RSA = 'rsa', EC = 'ec', } -export const generateOidcPrivateKey = async (type: PrivateKeyType = PrivateKeyType.EC) => { +export const generateOidcPrivateKey = async ( + type: PrivateKeyType = PrivateKeyType.EC +): Promise => { if (type === PrivateKeyType.RSA) { const { privateKey } = await promisify(generateKeyPair)('rsa', { modulusLength: 4096, @@ -22,7 +25,7 @@ export const generateOidcPrivateKey = async (type: PrivateKeyType = PrivateKeyTy }, }); - return privateKey; + return buildOidcKeyFromRawString(privateKey); } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -40,10 +43,16 @@ export const generateOidcPrivateKey = async (type: PrivateKeyType = PrivateKeyTy }, }); - return privateKey; + return buildOidcKeyFromRawString(privateKey); } throw new Error(`Unsupported private key ${String(type)}`); }; -export const generateOidcCookieKey = () => generateStandardSecret(); +export const generateOidcCookieKey = () => buildOidcKeyFromRawString(generateStandardSecret()); + +export const buildOidcKeyFromRawString = (raw: string) => ({ + id: generateStandardId(), + value: raw, + createdAt: Math.floor(Date.now() / 1000), +}); diff --git a/packages/core/src/env-set/oidc.ts b/packages/core/src/env-set/oidc.ts index 539ee4e72..9fed0eb8d 100644 --- a/packages/core/src/env-set/oidc.ts +++ b/packages/core/src/env-set/oidc.ts @@ -8,9 +8,9 @@ import { createLocalJWKSet } from 'jose'; import { exportJWK } from '#src/utils/jwks.js'; const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => { - const cookieKeys = configs[LogtoOidcConfigKey.CookieKeys]; - const privateKeys = configs[LogtoOidcConfigKey.PrivateKeys].map((key) => - crypto.createPrivateKey(key) + const cookieKeys = configs[LogtoOidcConfigKey.CookieKeys].map(({ value }) => value); + const privateKeys = configs[LogtoOidcConfigKey.PrivateKeys].map(({ value }) => + crypto.createPrivateKey(value) ); const publicKeys = privateKeys.map((key) => crypto.createPublicKey(key)); const privateJwks = await Promise.all(privateKeys.map(async (key) => exportJWK(key))); diff --git a/packages/core/src/middleware/koa-auth/utils.ts b/packages/core/src/middleware/koa-auth/utils.ts index 203d8a1a0..c959bf680 100644 --- a/packages/core/src/middleware/koa-auth/utils.ts +++ b/packages/core/src/middleware/koa-auth/utils.ts @@ -41,7 +41,7 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{ `); const privateKeys = logtoOidcConfigGuard['oidc.privateKeys'] .parse(value) - .map((key) => crypto.createPrivateKey(key)); + .map(({ value }) => crypto.createPrivateKey(value)); const publicKeys = privateKeys.map((key) => crypto.createPublicKey(key)); return { diff --git a/packages/schemas/alterations/next-1695647183-update-private-key-type.ts b/packages/schemas/alterations/next-1695647183-update-private-key-type.ts new file mode 100644 index 000000000..50aa05331 --- /dev/null +++ b/packages/schemas/alterations/next-1695647183-update-private-key-type.ts @@ -0,0 +1,108 @@ +import { generateStandardId } from '@logto/shared'; +import type { DatabaseTransactionConnection } from 'slonik'; +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const targetConfigKeys = ['oidc.cookieKeys', 'oidc.privateKeys']; + +type OldPrivateKeyData = { + tenantId: string; + value: string[]; +}; + +type PrivateKey = { + id: string; + value: string; + createdAt: number; +}; + +type NewPrivateKeyData = { + tenantId: string; + value: PrivateKey[]; +}; + +/** + * Alternate string array private signing keys to JSON array + * "oidc.cookieKeys": string[] -> PrivateKey[] + * "oidc.privateKeys": string[] -> PrivateKey[] + * @param configKey oidc.cookieKeys | oidc.privateKeys + * @param logtoConfig existing private key data for a specific tenant + * @param pool postgres database connection pool + */ +const alterPrivateKeysInLogtoConfig = async ( + configKey: string, + logtoConfig: OldPrivateKeyData, + pool: DatabaseTransactionConnection +) => { + const { tenantId, value: oldPrivateKey } = logtoConfig; + + // Use tenant creation time as `createdAt` timestamp for new private keys + const tenantData = await pool.maybeOne<{ createdAt: number }>( + sql`select * from tenants where id = ${tenantId}` + ); + const newPrivateKeyData: PrivateKey[] = oldPrivateKey.map((key) => ({ + id: generateStandardId(), + value: key, + createdAt: Math.floor((tenantData?.createdAt ?? Date.now()) / 1000), + })); + + await pool.query( + sql`update logto_configs set value = ${JSON.stringify( + newPrivateKeyData + )} where tenant_id = ${tenantId} and key = ${configKey}` + ); +}; + +/** + * Rollback JSON array private signing keys to string array + * "oidc.cookieKeys": PrivateKey[] -> string[] + * "oidc.privateKeys": PrivateKey[] -> string[] + * @param configKey oidc.cookieKeys | oidc.privateKeys + * @param logtoConfig new private key data for a specific tenant + * @param pool postgres database connection pool + */ +const rollbackPrivateKeysInLogtoConfig = async ( + configKey: string, + logtoConfig: NewPrivateKeyData, + pool: DatabaseTransactionConnection +) => { + const { tenantId, value: newPrivateKeyData } = logtoConfig; + + const oldPrivateKeys = newPrivateKeyData.map(({ value }) => value); + + await pool.query( + sql`update logto_configs set value = ${JSON.stringify( + oldPrivateKeys + )} where tenant_id = ${tenantId} and key = ${configKey}` + ); +}; + +const alteration: AlterationScript = { + up: async (pool) => { + await Promise.all( + targetConfigKeys.map(async (configKey) => { + const rows = await pool.many( + sql`select * from logto_configs where key = ${configKey}` + ); + await Promise.all( + rows.map(async (row) => alterPrivateKeysInLogtoConfig(configKey, row, pool)) + ); + }) + ); + }, + down: async (pool) => { + await Promise.all( + targetConfigKeys.map(async (configKey) => { + const rows = await pool.many( + sql`select * from logto_configs where key = ${configKey}` + ); + await Promise.all( + rows.map(async (row) => rollbackPrivateKeysInLogtoConfig(configKey, row, pool)) + ); + }) + ); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/types/logto-config.ts b/packages/schemas/src/types/logto-config.ts index 458677b37..be54dcd06 100644 --- a/packages/schemas/src/types/logto-config.ts +++ b/packages/schemas/src/types/logto-config.ts @@ -7,16 +7,24 @@ export enum LogtoOidcConfigKey { CookieKeys = 'oidc.cookieKeys', } +const oidcPrivateKeyGuard = z.object({ + id: z.string(), + value: z.string(), + createdAt: z.number(), +}); + +export type PrivateKey = z.infer; + export type LogtoOidcConfigType = { - [LogtoOidcConfigKey.PrivateKeys]: string[]; - [LogtoOidcConfigKey.CookieKeys]: string[]; + [LogtoOidcConfigKey.PrivateKeys]: PrivateKey[]; + [LogtoOidcConfigKey.CookieKeys]: PrivateKey[]; }; export const logtoOidcConfigGuard: Readonly<{ [key in LogtoOidcConfigKey]: ZodType; }> = Object.freeze({ - [LogtoOidcConfigKey.PrivateKeys]: z.string().array(), - [LogtoOidcConfigKey.CookieKeys]: z.string().array(), + [LogtoOidcConfigKey.PrivateKeys]: oidcPrivateKeyGuard.array(), + [LogtoOidcConfigKey.CookieKeys]: oidcPrivateKeyGuard.array(), }); /* --- Logto tenant configs --- */