ultisuite-client/lib/auth/oidc-config.ts
R3D347HR4Y d6d18f911b
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Lots of stuff and mobile app
2026-06-17 00:13:28 +02:00

257 lines
7.0 KiB
TypeScript

/** OIDC settings for local dev (Authentik blueprints in ulti-backend). */
import { useNativeRuntime } from "@/lib/platform"
import { getRuntimeConfig } from "@/lib/runtime-config"
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\/[^/]+\/?$/, "")
}
/** Authentik enrollment flow (same origin as the suite — nginx /auth/). */
const AUTHENTIK_ENROLLMENT_PATH = "/auth/if/flow/ulti-enrollment/"
export function getAuthentikEnrollmentUrl(): string {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
if (cfg?.oidc.enrollmentUrl) return cfg.oidc.enrollmentUrl
}
// Relative URL: identical SSR/client on localhost, tunnel, prod (no NEXT_PUBLIC mismatch).
return AUTHENTIK_ENROLLMENT_PATH
}
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<string, string> {
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 {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
if (cfg) {
return {
issuer: cfg.oidc.issuer,
clientId: cfg.oidc.clientId,
appUrl: cfg.instanceOrigin,
redirectUri: cfg.oidc.redirectUri,
authorizationEndpoint: cfg.oidc.authorizationEndpoint,
tokenEndpoint: cfg.oidc.tokenEndpoint,
endSessionEndpoint: cfg.oidc.endSessionEndpoint,
}
}
}
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<OidcConfig> {
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"
}