Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
275 lines
8.6 KiB
TypeScript
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 }
|