ultisuite-client/lib/auth/flow-api.ts
R3D347HR4Y 9ea2d3325d
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(auth): enhance authentication flows with embedded support and UI improvements
- Updated login and signup components to utilize AuthCard for better user experience during redirection.
- Introduced AuthentikEmbedDialog for seamless integration of Authentik's identity portal within the application.
- Enhanced password recovery and signup flows with dynamic theme handling and improved loading states.
- Refactored existing components to streamline authentication processes and improve maintainability.
2026-06-21 00:12:45 +02:00

275 lines
8.6 KiB
TypeScript

import { AUTH_FLOW_SLUGS, type AuthFlowSlug } from "@/lib/auth/auth-flow-slugs"
export type FlowChallenge = Record<string, unknown>
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<FlowStepResponse> {
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<FlowStepResponse> {
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<string, unknown>,
query?: string
): Promise<FlowStepResponse> {
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<EmbeddedAuthContext> {
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<EmbeddedAuthContext> & {
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<string, string>): Record<string, string> {
const headers: Record<string, string> = { Accept: "application/json", ...extra }
const csrf = readCookie("authentik_csrf")
if (csrf) headers["X-authentik-CSRF"] = csrf
return headers
}
async function parseDirectChallenge(res: Response): Promise<FlowStepResponse> {
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<FlowStepResponse> {
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<string, unknown>,
query?: string
): Promise<FlowStepResponse> {
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<string, unknown>
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<string, unknown>).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<string, string> {
const out: Record<string, string> = {}
const errors = challenge?.response_errors
if (!errors || typeof errors !== "object") return out
for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
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<string, unknown>
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<string, unknown>).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 }