0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

Merge pull request #2073 from logto-io/gao-log-4334-cli-separate-oidc-config-keys-in

refactor(cli): flatten OIDC config keys
This commit is contained in:
Gao Sun 2022-10-09 22:31:13 +08:00 committed by GitHub
commit 8651c06f93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 89 additions and 60 deletions

View file

@ -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<Promise<LogtoOidcConfigKey | undefined>>(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');
};

View file

@ -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<typeof OidcConfigKey>]: () => Promise<{
value: NonNullable<LogtoOidcConfig[key]>;
[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);

View file

@ -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 }),
]);

View file

@ -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 <T extends LogtoConfigKey>(
export const getCurrentDatabaseAlterationTimestamp = async (pool: DatabasePool) => {
try {
const result = await pool.maybeOne<LogtoConfig>(
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);
};

View file

@ -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<AlterationStateType[key]>;
}> = Object.freeze({
[AlterationStateKey.AlterationState]: z.object({
timestamp: z.number(),
updatedAt: z.string().optional(),
}),
});
export type AlterationState = z.infer<typeof alterationStateGuard>;
// 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<LogtoOidcConfigType[key]>;
}> = 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<typeof logtoOidcConfigGuard>;
// 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,
});