From 4134547ccaf423d5fa33b83fc91e128d1992e79d Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 9 Oct 2022 15:16:19 +0800 Subject: [PATCH] refactor(cli): flatten OIDC config keys --- .../cli/src/commands/database/seed/index.ts | 48 +++++++------ .../src/commands/database/seed/oidc-config.ts | 15 ++-- packages/cli/src/queries/logto-config.test.ts | 10 +-- packages/cli/src/queries/logto-config.ts | 8 +-- packages/schemas/src/types/logto-config.ts | 68 +++++++++++++------ 5 files changed, 89 insertions(+), 60 deletions(-) diff --git a/packages/cli/src/commands/database/seed/index.ts b/packages/cli/src/commands/database/seed/index.ts index 1c7e18f01..121ddaf10 100644 --- a/packages/cli/src/commands/database/seed/index.ts +++ b/packages/cli/src/commands/database/seed/index.ts @@ -1,11 +1,12 @@ import { readdir, readFile } from 'fs/promises'; import path from 'path'; -import { LogtoConfigKey, LogtoOidcConfig, logtoOidcConfigGuard, seeds } from '@logto/schemas'; +import { logtoConfigGuards, LogtoOidcConfigKey, seeds } from '@logto/schemas'; import chalk from 'chalk'; import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; import { raw } from 'slonik-sql-tag-raw'; import { CommandModule } from 'yargs'; +import { z } from 'zod'; import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database'; import { @@ -15,7 +16,7 @@ import { } from '../../../queries/logto-config'; import { buildApplicationSecret, getPathInModule, log, oraPromise } from '../../../utilities'; import { getLatestAlterationTimestamp } from '../alteration'; -import { OidcConfigKey, oidcConfigReaders } from './oidc-config'; +import { oidcConfigReaders } from './oidc-config'; const createTables = async (connection: DatabaseTransactionConnection) => { const tableDirectory = getPathInModule('@logto/schemas', 'tables'); @@ -57,15 +58,26 @@ const seedTables = async (connection: DatabaseTransactionConnection) => { }; const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => { - const { rows } = await getRowsByKeys(pool, [LogtoConfigKey.OidcConfig]); - const existingConfig = await logtoOidcConfigGuard - .parseAsync(rows[0]?.value) - // It's useful! - // eslint-disable-next-line unicorn/no-useless-undefined - .catch(() => undefined); - const existingKeys = existingConfig ? Object.keys(existingConfig) : []; - const validOptions = OidcConfigKey.options.filter((key) => { - const included = existingKeys.includes(key); + const configGuard = z.object({ + key: z.nativeEnum(LogtoOidcConfigKey), + value: z.unknown(), + }); + const { rows } = await getRowsByKeys(pool, Object.values(LogtoOidcConfigKey)); + // Filter out valid keys that hold a valid value + const result = await Promise.all( + rows.map>(async (row) => { + try { + const { key, value } = await configGuard.parseAsync(row); + await logtoConfigGuards[key].parseAsync(value); + + return key; + } catch {} + }) + ); + const existingKeys = new Set(result.filter(Boolean)); + + const validOptions = Object.values(LogtoOidcConfigKey).filter((key) => { + const included = existingKeys.has(key); if (included) { log.info(`Key ${chalk.green(key)} exists, skipping`); @@ -74,11 +86,9 @@ const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => { return !included; }); - const entries: Array<[keyof LogtoOidcConfig, LogtoOidcConfig[keyof LogtoOidcConfig]]> = []; - - // Both await in loop and `.push()` are intended since we'd like to log info in sequence + // The awaits in loop is intended since we'd like to log info in sequence + /* eslint-disable no-await-in-loop */ for (const key of validOptions) { - // eslint-disable-next-line no-await-in-loop const { value, fromEnv } = await oidcConfigReaders[key](); if (fromEnv) { @@ -87,14 +97,10 @@ const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => { log.info(`Generated config ${chalk.green(key)}`); } - // eslint-disable-next-line @silverhand/fp/no-mutating-methods - entries.push([key, value]); + await updateValueByKey(pool, key, value); } + /* eslint-enable no-await-in-loop */ - await updateValueByKey(pool, LogtoConfigKey.OidcConfig, { - ...existingConfig, - ...Object.fromEntries(entries), - }); log.succeed('Seed OIDC config'); }; diff --git a/packages/cli/src/commands/database/seed/oidc-config.ts b/packages/cli/src/commands/database/seed/oidc-config.ts index 2b8d1fb05..5beee6f32 100644 --- a/packages/cli/src/commands/database/seed/oidc-config.ts +++ b/packages/cli/src/commands/database/seed/oidc-config.ts @@ -2,23 +2,20 @@ import { generateKeyPair } from 'crypto'; import { readFile } from 'fs/promises'; import { promisify } from 'util'; -import { LogtoOidcConfig, logtoOidcConfigGuard } from '@logto/schemas'; +import { LogtoOidcConfigKey, LogtoOidcConfigType } from '@logto/schemas'; import { getEnv, getEnvAsStringArray } from '@silverhand/essentials'; import { nanoid } from 'nanoid'; -import { z } from 'zod'; const isBase64FormatPrivateKey = (key: string) => !key.includes('-'); -export const OidcConfigKey = logtoOidcConfigGuard.keyof(); - /** * Each config reader will do the following things in order: * 1. Try to read value from env (mimic the behavior from the original core) * 2. Generate value if #1 doesn't work */ export const oidcConfigReaders: { - [key in z.infer]: () => Promise<{ - value: NonNullable; + [key in LogtoOidcConfigKey]: () => Promise<{ + value: LogtoOidcConfigType[key]; fromEnv: boolean; }>; } = { @@ -32,7 +29,7 @@ export const oidcConfigReaders: { * @returns The private keys for OIDC provider. * @throws An error when failed to read a private key. */ - privateKeys: async () => { + [LogtoOidcConfigKey.PrivateKeys]: async () => { // Direct keys in env const privateKeys = getEnvAsStringArray('OIDC_PRIVATE_KEYS'); @@ -77,13 +74,13 @@ export const oidcConfigReaders: { fromEnv: false, }; }, - cookieKeys: async () => { + [LogtoOidcConfigKey.CookieKeys]: async () => { const envKey = 'OIDC_COOKIE_KEYS'; const keys = getEnvAsStringArray(envKey); return { value: keys.length > 0 ? keys : [nanoid()], fromEnv: keys.length > 0 }; }, - refreshTokenReuseInterval: async () => { + [LogtoOidcConfigKey.RefreshTokenReuseInterval]: async () => { const envKey = 'OIDC_REFRESH_TOKEN_REUSE_INTERVAL'; const raw = Number(getEnv(envKey)); const value = Math.max(3, raw || 0); diff --git a/packages/cli/src/queries/logto-config.test.ts b/packages/cli/src/queries/logto-config.test.ts index 41a6455ab..28f4f9dbe 100644 --- a/packages/cli/src/queries/logto-config.test.ts +++ b/packages/cli/src/queries/logto-config.test.ts @@ -1,4 +1,4 @@ -import { LogtoConfigKey, LogtoConfigs } from '@logto/schemas'; +import { AlterationStateKey, LogtoConfigs } from '@logto/schemas'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; import { convertToIdentifiers } from '../database'; @@ -29,7 +29,7 @@ describe('getCurrentDatabaseAlterationTimestamp()', () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([LogtoConfigKey.AlterationState]); + expect(values).toEqual([AlterationStateKey.AlterationState]); return createMockQueryResult([]); }); @@ -44,7 +44,7 @@ describe('getCurrentDatabaseAlterationTimestamp()', () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([LogtoConfigKey.AlterationState]); + expect(values).toEqual([AlterationStateKey.AlterationState]); return createMockQueryResult([{ value: 'some_value' }]); }); @@ -59,7 +59,7 @@ describe('getCurrentDatabaseAlterationTimestamp()', () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([LogtoConfigKey.AlterationState]); + expect(values).toEqual([AlterationStateKey.AlterationState]); // @ts-expect-error createMockQueryResult doesn't support jsonb return createMockQueryResult([{ value: { timestamp, updatedAt: 'now' } }]); @@ -90,7 +90,7 @@ describe('updateDatabaseTimestamp()', () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); expect(values).toEqual([ - LogtoConfigKey.AlterationState, + AlterationStateKey.AlterationState, JSON.stringify({ timestamp, updatedAt }), ]); diff --git a/packages/cli/src/queries/logto-config.ts b/packages/cli/src/queries/logto-config.ts index 51083e74a..b02cbbaa9 100644 --- a/packages/cli/src/queries/logto-config.ts +++ b/packages/cli/src/queries/logto-config.ts @@ -1,10 +1,10 @@ import { AlterationState, - alterationStateGuard, LogtoConfig, logtoConfigGuards, LogtoConfigKey, LogtoConfigs, + AlterationStateKey, } from '@logto/schemas'; import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; import { z } from 'zod'; @@ -38,9 +38,9 @@ export const updateValueByKey = async ( export const getCurrentDatabaseAlterationTimestamp = async (pool: DatabasePool) => { try { const result = await pool.maybeOne( - sql`select * from ${table} where ${fields.key}=${LogtoConfigKey.AlterationState}` + sql`select * from ${table} where ${fields.key}=${AlterationStateKey.AlterationState}` ); - const parsed = alterationStateGuard.safeParse(result?.value); + const parsed = logtoConfigGuards[AlterationStateKey.AlterationState].safeParse(result?.value); return (parsed.success && parsed.data.timestamp) || 0; } catch (error: unknown) { @@ -65,5 +65,5 @@ export const updateDatabaseTimestamp = async ( updatedAt: new Date().toISOString(), }; - return updateValueByKey(connection, LogtoConfigKey.AlterationState, value); + return updateValueByKey(connection, AlterationStateKey.AlterationState, value); }; diff --git a/packages/schemas/src/types/logto-config.ts b/packages/schemas/src/types/logto-config.ts index d4b2e57d6..f5ff1a089 100644 --- a/packages/schemas/src/types/logto-config.ts +++ b/packages/schemas/src/types/logto-config.ts @@ -1,36 +1,62 @@ -import { z } from 'zod'; +import { z, ZodType } from 'zod'; // Alteration state -export const alterationStateGuard = z.object({ - timestamp: z.number(), - updatedAt: z.string().optional(), +export enum AlterationStateKey { + AlterationState = 'alterationState', +} + +export type AlterationState = { timestamp: number; updatedAt?: string }; + +export type AlterationStateType = { + [AlterationStateKey.AlterationState]: AlterationState; +}; + +const alterationStateGuard: Readonly<{ + [key in AlterationStateKey]: ZodType; +}> = Object.freeze({ + [AlterationStateKey.AlterationState]: z.object({ + timestamp: z.number(), + updatedAt: z.string().optional(), + }), }); -export type AlterationState = z.infer; - // Logto OIDC config -export const logtoOidcConfigGuard = z.object({ - privateKeys: z.string().array().optional(), - cookieKeys: z.string().array().optional(), +export enum LogtoOidcConfigKey { + PrivateKeys = 'oidc.privateKeys', + CookieKeys = 'oidc.cookieKeys', + RefreshTokenReuseInterval = 'oidc.refreshTokenReuseInterval', +} + +export type LogtoOidcConfigType = { + [LogtoOidcConfigKey.PrivateKeys]: string[]; + [LogtoOidcConfigKey.CookieKeys]: string[]; + [LogtoOidcConfigKey.RefreshTokenReuseInterval]: number; +}; + +const logtoOidcConfigGuard: Readonly<{ + [key in LogtoOidcConfigKey]: ZodType; +}> = Object.freeze({ + [LogtoOidcConfigKey.PrivateKeys]: z.string().array(), + [LogtoOidcConfigKey.CookieKeys]: z.string().array(), /** * This interval helps to avoid concurrency issues when exchanging the rotating refresh token multiple times within a given timeframe. * During the leeway window (in seconds), the consumed refresh token will be considered as valid. * This is useful for distributed apps and serverless apps like Next.js, in which there is no shared memory. */ - refreshTokenReuseInterval: z.number().gte(3).optional(), + [LogtoOidcConfigKey.RefreshTokenReuseInterval]: z.number().gte(3), }); -export type LogtoOidcConfig = z.infer; - // Summary -export enum LogtoConfigKey { - AlterationState = 'alterationState', - OidcConfig = 'oidcConfig', -} +export type LogtoConfigKey = AlterationStateKey | LogtoOidcConfigKey; +export type LogtoConfigType = AlterationStateType | LogtoOidcConfigType; +export type LogtoConfigGuard = typeof alterationStateGuard & typeof logtoOidcConfigGuard; -export const logtoConfigKeys = Object.values(LogtoConfigKey); +export const logtoConfigKeys: readonly LogtoConfigKey[] = Object.freeze([ + ...Object.values(AlterationStateKey), + ...Object.values(LogtoOidcConfigKey), +]); -export const logtoConfigGuards = Object.freeze({ - [LogtoConfigKey.AlterationState]: alterationStateGuard, - [LogtoConfigKey.OidcConfig]: logtoOidcConfigGuard, -} as const); +export const logtoConfigGuards: LogtoConfigGuard = Object.freeze({ + ...alterationStateGuard, + ...logtoOidcConfigGuard, +});