/** 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:3000" ).replace(/\/$/, "") try { const url = new URL(raw) if (url.hostname === "0.0.0.0") { url.hostname = "localhost" } return url.origin } catch { return "http://localhost:3000" } } /** 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: { issuer: string; doc: OidcDiscovery; at: number } | null = null const DISCOVERY_TTL_MS = 5 * 60 * 1000 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 now = Date.now() if ( discoveryCache && discoveryCache.issuer === base.issuer && now - discoveryCache.at < DISCOVERY_TTL_MS ) { return applyDiscovery(base, discoveryCache.doc) } const res = await fetch( `${base.issuer}.well-known/openid-configuration`, { next: { revalidate: 300 } } ) if (!res.ok) { throw new Error(`OIDC discovery failed (${res.status}) for ${base.issuer}`) } const doc = (await res.json()) as OidcDiscovery discoveryCache = { issuer: base.issuer, doc, at: now } return applyDiscovery(base, doc) } function applyDiscovery(base: OidcConfig, doc: OidcDiscovery): OidcConfig { return { ...base, authorizationEndpoint: doc.authorization_endpoint, tokenEndpoint: doc.token_endpoint, endSessionEndpoint: doc.end_session_endpoint ?? base.endSessionEndpoint, } } 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" }