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:
commit
8651c06f93
5 changed files with 89 additions and 60 deletions
|
@ -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');
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 }),
|
||||
]);
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue