/** OIDC settings for local dev (Authentik blueprints in ulti-backend). */ function trimSlash(url: string) { return url.endsWith("/") ? url : `${url}/` } /** Public app origin for OAuth redirects and post-login navigation (never 0.0.0.0). */ export function getAppOrigin(): string { const raw = ( process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3004" ).replace(/\/$/, "") try { const url = new URL(raw) if (url.hostname === "0.0.0.0") { url.hostname = "localhost" } return url.origin } catch { return "http://localhost:3004" } } /** Base URL Authentik (sans segment application OIDC). */ export function getAuthentikBase(): string { const issuer = trimSlash( process.env.NEXT_PUBLIC_OIDC_ISSUER ?? "http://localhost/auth/application/o/ulti/" ) return issuer.replace(/application\/o\/[^/]+\/?$/, "") } export function getAuthentikEnrollmentUrl(): string { return `${getAuthentikBase()}if/flow/ulti-enrollment/` } type OidcDiscovery = { authorization_endpoint: string token_endpoint: string end_session_endpoint?: string } type OidcConfig = { issuer: string clientId: string appUrl: string redirectUri: string authorizationEndpoint: string tokenEndpoint: string endSessionEndpoint: string } let discoveryCache: { discoveryIssuer: string doc: OidcDiscovery at: number } | null = null const DISCOVERY_TTL_MS = 5 * 60 * 1000 /** Internal origin for server-side OIDC calls (Docker: http://nginx, dev host: http://127.0.0.1). */ function getOidcInternalOrigin(): string | null { const raw = process.env.OIDC_DISCOVERY_ORIGIN?.trim() || process.env.ULTI_PROXY_ORIGIN?.trim() if (!raw) return null try { return new URL(raw.endsWith("/") ? raw : `${raw}/`).origin } catch { return null } } function getOidcPublicOrigin(): string { try { return new URL(getPublicOidcConfig().issuer).origin } catch { return "http://localhost" } } function issuerWithOrigin(issuer: string, origin: string): string { try { const parsed = new URL(issuer) return `${origin}${parsed.pathname}` } catch { return issuer } } function rewriteOrigin(url: string, origin: string): string { try { const parsed = new URL(url) const target = new URL(origin.endsWith("/") ? origin : `${origin}/`) parsed.protocol = target.protocol parsed.hostname = target.hostname parsed.port = target.port return parsed.toString() } catch { return url } } /** Browser redirects must use the public Authentik URL, not Docker-internal hostnames. */ function toPublicEndpoint( endpoint: string, internalOrigin: string | null, publicOrigin: string ): string { if (!internalOrigin || internalOrigin === publicOrigin) return endpoint return rewriteOrigin(endpoint, publicOrigin) } /** * When token exchange hits Docker-internal nginx, Authentik must still emit the public * issuer (localhost) or ultid's ID token verifier rejects the JWT. */ export function oidcServerFetchHeaders(): Record { const internalOrigin = getOidcInternalOrigin() const publicOrigin = getOidcPublicOrigin() if (!internalOrigin || internalOrigin === publicOrigin) return {} try { const publicUrl = new URL(publicOrigin) const host = publicUrl.host return { Host: host, "X-Forwarded-Host": host, "X-Forwarded-Proto": publicUrl.protocol.replace(":", ""), } } catch { return {} } } /** Server-side token/logout calls must not use browser-facing localhost from inside Docker. */ function toServerEndpoint( endpoint: string, internalOrigin: string | null, publicOrigin: string ): string { if (!internalOrigin || internalOrigin === publicOrigin) return endpoint return rewriteOrigin(endpoint, internalOrigin) } export function getPublicOidcConfig(): OidcConfig { const issuer = trimSlash( process.env.NEXT_PUBLIC_OIDC_ISSUER ?? "http://localhost/auth/application/o/ulti/" ) const clientId = process.env.NEXT_PUBLIC_OIDC_CLIENT_ID ?? "ulti-backend" const appUrl = getAppOrigin() return { issuer, clientId, appUrl, redirectUri: `${appUrl}/api/auth/callback`, authorizationEndpoint: "", tokenEndpoint: "", endSessionEndpoint: `${issuer}end-session/`, } } /** Resolve authorize/token URLs from issuer discovery (Authentik uses shared /o/ endpoints). */ export async function resolveOidcConfig(): Promise { const base = getPublicOidcConfig() const internalOrigin = getOidcInternalOrigin() const discoveryIssuer = internalOrigin ? issuerWithOrigin(base.issuer, internalOrigin) : base.issuer const now = Date.now() if ( discoveryCache && discoveryCache.discoveryIssuer === discoveryIssuer && now - discoveryCache.at < DISCOVERY_TTL_MS ) { return applyDiscovery(base, discoveryCache.doc, internalOrigin) } const res = await fetch( `${discoveryIssuer}.well-known/openid-configuration`, { next: { revalidate: 300 } } ) if (!res.ok) { throw new Error( `OIDC discovery failed (${res.status}) for ${discoveryIssuer}` ) } const doc = (await res.json()) as OidcDiscovery discoveryCache = { discoveryIssuer, doc, at: now } return applyDiscovery(base, doc, internalOrigin) } function applyDiscovery( base: OidcConfig, doc: OidcDiscovery, internalOrigin: string | null ): OidcConfig { const publicOrigin = getOidcPublicOrigin() return { ...base, authorizationEndpoint: toPublicEndpoint( doc.authorization_endpoint, internalOrigin, publicOrigin ), tokenEndpoint: toServerEndpoint( doc.token_endpoint, internalOrigin, publicOrigin ), endSessionEndpoint: toPublicEndpoint( doc.end_session_endpoint ?? base.endSessionEndpoint, internalOrigin, publicOrigin ), } } export async function resolveServerOidcConfig(): Promise< OidcConfig & { clientSecret: string } > { const pub = await resolveOidcConfig() const clientSecret = process.env.OIDC_CLIENT_SECRET ?? "changeme" return { ...pub, clientSecret } } /** OIDC login enabled unless explicitly disabled (default: on for local Authentik stack). */ export function isOidcConfigured() { return process.env.NEXT_PUBLIC_OIDC_DISABLED !== "true" }