/** * oidcConfig.ts — centralized "is OIDC configured" resolution + endpoint discovery. * * WR-04: three sites previously had independent notions of whether OIDC is configured: * - routes/authMode.ts → OIDC_ISSUER env OR app_config.oidc_issuer * - auth/middleware.ts → injects issuer/client-id/external-url from app_config * - routes/me.ts (link-oidc) → ONLY env (OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI) * * The me.ts divergence meant a wizard-configured-but-not-restarted instance reported * oidcEnabled:true from /api/auth/mode (and showed the "Link OIDC" button) but link-oidc * returned authorizationUrl:null. This helper makes the env-OR-app_config resolution a * single source of truth. * * WR-02: me.ts also hardcoded Authelia's `/api/oidc/authorization` path. The project's * design is to discover endpoints from the issuer's /.well-known/openid-configuration * document (the whole reason @hono/oidc-auth is used). discoverAuthorizationEndpoint() * resolves the real authorization_endpoint so any RFC-compliant provider works. */ import { eq } from 'drizzle-orm'; import { db } from '../db/client.js'; import { appConfig } from '../db/schema.js'; export interface ResolvedOidcConfig { issuer: string; clientId: string; redirectUri: string; } /** * Read a single app_config value (or null when absent). */ async function readAppConfig(key: string): Promise { const [row] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, key)) .limit(1); return row?.value ?? null; } /** * Resolve issuer + clientId + redirectUri using env first, then app_config (WR-04). * * Returns null when any of the three cannot be resolved from EITHER source — i.e. OIDC is * not (fully) configured, so no authorization URL can be built. The redirect URI is taken * from OIDC_REDIRECT_URI when present, else derived from the external app URL * (OIDC_AUTH_EXTERNAL_URL env or app_config.app_external_url) as `${externalUrl}/callback`, * matching the middleware's redirect-uri construction. */ export async function resolveOidcConfig(): Promise { const issuer = process.env.OIDC_ISSUER ?? (await readAppConfig('oidc_issuer')); const clientId = process.env.OIDC_CLIENT_ID ?? (await readAppConfig('oidc_client_id')); let redirectUri = process.env.OIDC_REDIRECT_URI ?? null; if (!redirectUri) { const externalUrl = process.env.OIDC_AUTH_EXTERNAL_URL ?? (await readAppConfig('app_external_url')); if (externalUrl) { redirectUri = `${externalUrl.replace(/\/$/, '')}/callback`; } } if (!issuer || !clientId || !redirectUri) return null; return { issuer, clientId, redirectUri }; } /** * Discover the provider's authorization_endpoint from its OIDC discovery document (WR-02). * * Fetches `${issuer}/.well-known/openid-configuration` (5s timeout, matching the setup * wizard's validate/oidc step) and returns the `authorization_endpoint` URL. Returns null * on any network error, non-2xx, or missing field — callers treat null as "cannot build * the authorization URL" and degrade gracefully (the PWA disables the Link button). */ export async function discoverAuthorizationEndpoint(issuer: string): Promise { try { const res = await fetch(`${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000), }); if (!res.ok) return null; const doc = (await res.json()) as { authorization_endpoint?: unknown }; return typeof doc.authorization_endpoint === 'string' ? doc.authorization_endpoint : null; } catch (err) { console.error( '[oidcConfig/discoverAuthorizationEndpoint]', err instanceof Error ? err.message : String(err), ); return null; } }