257 lines
7.0 KiB
TypeScript
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"
|
|
}
|