From 322929aebe6ebd20371905ff5d59718db21bf5f6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 20:26:47 -0400 Subject: [PATCH] fix(19): WR-02+WR-04 centralize OIDC config (env-or-app_config) and discover auth endpoint --- apps/api/src/auth/oidcConfig.ts | 91 +++++++++++++++++++++++++++++++++ apps/api/src/routes/me.ts | 34 ++++++------ 2 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/auth/oidcConfig.ts diff --git a/apps/api/src/auth/oidcConfig.ts b/apps/api/src/auth/oidcConfig.ts new file mode 100644 index 0000000..eaafdf3 --- /dev/null +++ b/apps/api/src/auth/oidcConfig.ts @@ -0,0 +1,91 @@ +/** + * 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; + } +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 1fff649..dd18473 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -41,6 +41,7 @@ import { CredentialValidationError, } from '../broker/credentialSync.js'; import { hashPassword, verifyPassword } from '../auth/localCredentials.js'; +import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js'; // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; @@ -324,22 +325,25 @@ meRouter.post('/link-oidc', async (c) => { 'HS256', ); - // Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button) - const issuer = process.env.OIDC_ISSUER ?? null; - const clientId = process.env.OIDC_CLIENT_ID ?? null; - const redirectUri = process.env.OIDC_REDIRECT_URI ?? null; - + // Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button). + // WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth, + // consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured + // instance does not report oidcEnabled:true while returning authorizationUrl:null here. + // WR-02: discover the authorization_endpoint from the provider's discovery document instead + // of hardcoding Authelia's /api/oidc/authorization path. let authorizationUrl: string | null = null; - if (issuer && clientId && redirectUri) { - // Construct the authorization URL. plan 19-03 will handle the full PKCE flow; - // for now encode the signed state so the callback can read linkUserId. - const url = new URL(`${issuer.replace(/\/$/, '')}/api/oidc/authorization`); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('client_id', clientId); - url.searchParams.set('redirect_uri', redirectUri); - url.searchParams.set('scope', 'openid profile email'); - url.searchParams.set('state', signedState); - authorizationUrl = url.toString(); + const oidc = await resolveOidcConfig(); + if (oidc) { + const authEndpoint = await discoverAuthorizationEndpoint(oidc.issuer); + if (authEndpoint) { + const url = new URL(authEndpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', oidc.clientId); + url.searchParams.set('redirect_uri', oidc.redirectUri); + url.searchParams.set('scope', 'openid profile email'); + url.searchParams.set('state', signedState); + authorizationUrl = url.toString(); + } } return c.json({ signedState, authorizationUrl }, 200);