Files
auth-portal/apps/api/src/config.ts
T

109 lines
4.0 KiB
TypeScript

import { z } from 'zod'
const boolFromEnv = (v: string | undefined, fallback: boolean) => {
if (v === undefined || v === '') return fallback
return v === '1' || v.toLowerCase() === 'true'
}
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
jwtTtlHours: z.coerce.number().positive().default(1),
refreshTtlDays: z.coerce.number().positive().default(14),
issuer: z.string().url().default('https://auth.shnt.top'),
/** OIDC issuer URL; defaults to `issuer`. */
oidcIssuer: z.string().url().optional(),
/** Optional PEM PKCS8 RSA private key; otherwise auto-generated & persisted. */
oidcRsaPrivateKey: z.string().optional(),
adminEmail: z.string().email().default('[email protected]'),
adminPassword: z.string().default('admin'),
adminName: z.string().default('Admin'),
returnToAllowlist: z.string().default('.shnt.top,localhost,private'),
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
/** Сколько дней хранить истёкшие/отозванные сессии и коды перед удалением. */
sessionRetentionDays: z.coerce.number().positive().default(7),
/** Максимум на graceful shutdown, затем принудительный выход. */
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
isProd: z.boolean(),
auditIngestSecret: z.string().min(8).optional(),
webauthnRpID: z.string().min(1),
webauthnRpName: z.string().min(1).default('Auth Portal'),
webauthnOrigins: z.array(z.string().url()).min(1),
})
export type AppConfig = z.infer<typeof configSchema>
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
const auditIngestSecret =
env.AUDIT_INGEST_SECRET ??
(isProd ? undefined : 'dev-audit-ingest-secret')
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
return configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
issuer,
oidcIssuer: env.OIDC_ISSUER || issuer,
oidcRsaPrivateKey: env.OIDC_RSA_PRIVATE_KEY || undefined,
adminEmail: env.ADMIN_EMAIL ?? '[email protected]',
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
adminName: env.ADMIN_NAME ?? 'Admin',
returnToAllowlist:
env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost,private',
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
isProd:
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd,
auditIngestSecret,
webauthnRpID: rpID,
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins,
})
}
function webauthnFromIssuer(
issuer: string,
env: NodeJS.ProcessEnv,
isProd: boolean,
): { rpID: string; origins: string[] } {
let issuerUrl: URL
try {
issuerUrl = new URL(issuer)
} catch {
issuerUrl = new URL('https://auth.shnt.top')
}
const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim()
const origins = new Set<string>()
origins.add(issuerUrl.origin)
const extra = env.WEBAUTHN_ORIGINS ?? ''
for (const raw of extra.split(',')) {
const value = raw.trim().replace(/\/$/, '')
if (!value) continue
try {
origins.add(new URL(value).origin)
} catch {
/* skip invalid */
}
}
if (!isProd) {
origins.add('http://localhost:5173')
origins.add('http://localhost:8080')
}
return { rpID, origins: [...origins] }
}
export function oidcIssuerFromConfig(config: AppConfig): string {
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
}