import { AUTH_FLOW_SLUGS, type AuthFlowSlug } from "@/lib/auth/auth-flow-slugs" export type FlowChallenge = Record export type FlowStepResponse = { sessionId: string challenge: FlowChallenge done: boolean denied: boolean } export type FlowCompleteResponse = { redirectUrl: string } export type FlowApiError = { code?: string message?: string } function flowApiBase(slug: AuthFlowSlug): string { return `/api/v1/auth/flows/${encodeURIComponent(slug)}` } function buildQuerySuffix(query?: string): string { const trimmed = query?.trim() if (!trimmed) return "" return `?query=${encodeURIComponent(trimmed)}` } async function parseFlowResponse(res: Response): Promise { const body = (await res.json()) as FlowStepResponse & FlowApiError if (!res.ok) { throw new Error(body.message ?? `flow request failed (${res.status})`) } return body } export async function startAuthFlow( slug: AuthFlowSlug, query?: string ): Promise { const res = await fetch(`${flowApiBase(slug)}/start${buildQuerySuffix(query)}`, { method: "POST", credentials: "include", headers: { Accept: "application/json" }, }) return parseFlowResponse(res) } export async function respondAuthFlow( slug: AuthFlowSlug, payload: Record, query?: string ): Promise { const res = await fetch(`${flowApiBase(slug)}/respond${buildQuerySuffix(query)}`, { method: "POST", credentials: "include", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ payload }), }) return parseFlowResponse(res) } export function isOAuthAuthorizeRedirect(target: string): boolean { return target.includes("/application/o/authorize") } export type EmbeddedAuthContext = { authorizeUrl: string flowQuery: string executorBase: string } /** * Prepare the embedded authentication context: sets PKCE/state cookies (read later by the OIDC * callback) and returns the same-origin Authentik flow executor base + the `next` query that ties * the login to the pending OIDC authorize request. */ export async function fetchEmbeddedAuthContext( returnTo: string ): Promise { const params = new URLSearchParams({ embedded: "1", returnTo }) const res = await fetch(`/api/auth/login?${params.toString()}`, { credentials: "include", headers: { Accept: "application/json" }, }) const body = (await res.json()) as Partial & { error?: string } if (!res.ok || !body.authorizeUrl || !body.executorBase || !body.flowQuery) { throw new Error(body.error ?? `embedded auth context failed (${res.status})`) } return { authorizeUrl: body.authorizeUrl, flowQuery: body.flowQuery, executorBase: body.executorBase, } } function directExecutorUrl( executorBase: string, slug: AuthFlowSlug, query?: string ): string { let url = `${executorBase}/${encodeURIComponent(slug)}/` const trimmed = query?.trim() if (trimmed) { url += `?query=${encodeURIComponent(trimmed)}` } return url } /** Read a non-HttpOnly cookie value from the document (browser only). */ function readCookie(name: string): string | null { if (typeof document === "undefined") return null const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, "\\$1") const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`)) return match ? decodeURIComponent(match[1]!) : null } /** * Headers for direct Authentik executor calls. When the browser already holds an authenticated * `authentik_session`, Authentik (DRF SessionAuthentication) enforces CSRF on POST. Mirror * Authentik's own SPA by forwarding the `authentik_csrf` cookie as the `X-authentik-CSRF` header. */ function directExecutorHeaders(extra?: Record): Record { const headers: Record = { Accept: "application/json", ...extra } const csrf = readCookie("authentik_csrf") if (csrf) headers["X-authentik-CSRF"] = csrf return headers } async function parseDirectChallenge(res: Response): Promise { let data: FlowChallenge try { data = (await res.json()) as FlowChallenge } catch { throw new Error(`flow request failed (${res.status})`) } const component = typeof data.component === "string" ? data.component : "" if (!component && !res.ok) { const message = (data as FlowApiError)?.message throw new Error(message ?? `flow request failed (${res.status})`) } return { sessionId: "", challenge: data, done: component === "xak-flow-redirect", denied: component === "ak-stage-access-denied", } } /** Start the Authentik flow directly in the browser (same-origin), so the browser holds the session. */ export async function startDirectFlow( executorBase: string, slug: AuthFlowSlug, query?: string ): Promise { const res = await fetch(directExecutorUrl(executorBase, slug, query), { credentials: "include", headers: directExecutorHeaders(), }) return parseDirectChallenge(res) } /** Submit a stage response directly to the Authentik flow executor (payload includes `component`). */ export async function respondDirectFlow( executorBase: string, slug: AuthFlowSlug, payload: Record, query?: string ): Promise { const res = await fetch(directExecutorUrl(executorBase, slug, query), { method: "POST", credentials: "include", headers: directExecutorHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(payload), }) return parseDirectChallenge(res) } export function flowComponent(challenge: FlowChallenge | null | undefined): string { if (!challenge) return "" const value = challenge.component return typeof value === "string" ? value : "" } /** Recovery flow: email stage reached after identification means the reset link was sent. */ export function isRecoveryEmailSent( slug: AuthFlowSlug, challenge: FlowChallenge | null | undefined ): boolean { if (slug !== AUTH_FLOW_SLUGS.recovery || !challenge) return false if (flowComponent(challenge) !== "ak-stage-email") return false const errors = challenge.response_errors if (!errors || typeof errors !== "object") return true const record = errors as Record for (const [key, value] of Object.entries(record)) { if (key === "non_field_errors" && Array.isArray(value)) { if (value.length === 0) continue const onlyEmailSent = value.every( (item) => typeof item === "object" && item !== null && (item as Record).code === "email-sent" ) if (onlyEmailSent) return true return false } if (Array.isArray(value) && value.length > 0) return false if (typeof value === "string" && value.trim()) return false } return true } /** Extract Authentik field validation errors from a challenge. */ export function flowValidationErrors( challenge: FlowChallenge | null | undefined ): Record { const out: Record = {} const errors = challenge?.response_errors if (!errors || typeof errors !== "object") return out for (const [key, value] of Object.entries(errors as Record)) { if (key === "non_field_errors" && Array.isArray(value)) { const messages = value .map((item) => formatFlowErrorItem(item)) .filter(Boolean) if (messages.length > 0) { out._form = messages.join(" ") } continue } if (Array.isArray(value)) { const messages = value.map((item) => formatFlowErrorItem(item)).filter(Boolean) if (messages.length > 0) out[key] = messages.join(" ") } else if (typeof value === "string" && value.trim()) { out[key] = value } } return out } function formatFlowErrorItem(item: unknown): string { if (typeof item === "string") return item if (typeof item === "object" && item !== null) { const record = item as Record if (typeof record.string === "string" && record.string.trim()) return record.string if (typeof record.code === "string" && record.code.trim()) return record.code } return "" } export function flowTitle(challenge: FlowChallenge | null | undefined): string { const info = challenge?.flow_info if (info && typeof info === "object" && info !== null) { const title = (info as Record).title if (typeof title === "string" && title.trim()) return title } return "" } export function flowRedirectUrl(challenge: FlowChallenge | null | undefined): string { const to = challenge?.to return typeof to === "string" ? to : "" } export { AUTH_FLOW_SLUGS }