setValues((prev) => ({ ...prev, code: value }))}
- required
- />
- ) : null}
-
{!isKnownFlowComponent(component) ? (
Étape non prise en charge ({component}). Utilisez le portail Authentik.
diff --git a/components/auth/flow-stage-registry.ts b/components/auth/flow-stage-registry.ts
index ee8190f..365a3d7 100644
--- a/components/auth/flow-stage-registry.ts
+++ b/components/auth/flow-stage-registry.ts
@@ -31,7 +31,9 @@ const LEGACY_KNOWN_COMPONENTS = new Set([
"ak-stage-user-login",
"ak-stage-authenticator-webauthn",
"ak-stage-source",
+ "ak-stage-captcha",
"xak-flow-redirect",
+ "xak-flow-frame",
"ak-stage-access-denied",
])
diff --git a/components/auth/flow-stages/authenticator-validate-stage.tsx b/components/auth/flow-stages/authenticator-validate-stage.tsx
new file mode 100644
index 0000000..04ac8cd
--- /dev/null
+++ b/components/auth/flow-stages/authenticator-validate-stage.tsx
@@ -0,0 +1,222 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { Loader2 } from "lucide-react"
+import { AuthConnectButton } from "@/components/auth/auth-connect-button"
+import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ normalizeRequestOptions,
+ readWebAuthnRequestOptions,
+ serializeWebAuthnCredential,
+} from "@/lib/auth/webauthn-utils"
+
+type DeviceChallenge = {
+ device_class?: string
+ device_uid?: string
+ challenge?: Record
+ title?: string
+}
+
+type ConfigStage = {
+ pk?: string
+ name?: string
+ verbose_name?: string
+}
+
+function readDeviceChallenges(challenge: StageRendererProps["challenge"]): DeviceChallenge[] {
+ const raw = challenge?.device_challenges ?? challenge?.deviceChallenges
+ if (!Array.isArray(raw)) return []
+ return raw.filter((item): item is DeviceChallenge => typeof item === "object" && item !== null)
+}
+
+function readConfigurationStages(challenge: StageRendererProps["challenge"]): ConfigStage[] {
+ const raw = challenge?.configuration_stages ?? challenge?.configurationStages
+ if (!Array.isArray(raw)) return []
+ return raw.filter((item): item is ConfigStage => typeof item === "object" && item !== null)
+}
+
+function isWebAuthnDevice(device: DeviceChallenge): boolean {
+ return device.device_class === "webauthn" || device.device_class === "apple"
+}
+
+function deviceLabel(device: DeviceChallenge): string {
+ if (device.title?.trim()) return device.title
+ switch (device.device_class) {
+ case "webauthn":
+ return "Clé de sécurité"
+ case "totp":
+ return "Application TOTP"
+ case "static":
+ return "Codes de secours"
+ case "sms":
+ return "SMS"
+ case "email":
+ return "E-mail"
+ default:
+ return device.device_class ?? "Authentificateur"
+ }
+}
+
+export function AuthenticatorValidateStage({
+ challenge,
+ submitting,
+ onSubmit,
+}: StageRendererProps) {
+ const [selectedDevice, setSelectedDevice] = useState(null)
+ const [code, setCode] = useState("")
+ const webAuthnStartedRef = useRef(false)
+
+ const devices = readDeviceChallenges(challenge)
+ const configStages = readConfigurationStages(challenge)
+ const activeDevice = selectedDevice ?? (devices.length === 1 ? devices[0]! : null)
+
+ useEffect(() => {
+ setSelectedDevice(null)
+ setCode("")
+ webAuthnStartedRef.current = false
+ }, [challenge])
+
+ useEffect(() => {
+ if (!activeDevice || !isWebAuthnDevice(activeDevice) || submitting) return
+ if (webAuthnStartedRef.current) return
+ if (typeof window === "undefined" || !window.PublicKeyCredential) return
+
+ const options =
+ readWebAuthnRequestOptions(activeDevice.challenge) ??
+ readWebAuthnRequestOptions(challenge)
+ if (!options) return
+
+ webAuthnStartedRef.current = true
+ void (async () => {
+ try {
+ const credential = (await navigator.credentials.get({
+ publicKey: normalizeRequestOptions(options),
+ })) as PublicKeyCredential | null
+ if (!credential) {
+ webAuthnStartedRef.current = false
+ return
+ }
+ await onSubmit({
+ component: "ak-stage-authenticator-validate",
+ selected_challenge: activeDevice,
+ webauthn: serializeWebAuthnCredential(credential),
+ })
+ } catch {
+ webAuthnStartedRef.current = false
+ }
+ })()
+ }, [activeDevice, challenge, onSubmit, submitting])
+
+ if (configStages.length > 0 && devices.length === 0) {
+ return (
+
+
+ Configurez une méthode d'authentification forte pour continuer.
+
+ {configStages.map((stage) => (
+
+ void onSubmit({
+ component: "ak-stage-authenticator-validate",
+ selected_stage: stage.pk,
+ })
+ }
+ >
+ {stage.verbose_name ?? stage.name ?? "Configurer"}
+
+ ))}
+
+ )
+ }
+
+ if (devices.length > 1 && !selectedDevice) {
+ return (
+
+
Choisissez une méthode de vérification.
+ {devices.map((device, index) => (
+
setSelectedDevice(device)}
+ >
+ {deviceLabel(device)}
+
+ ))}
+
+ )
+ }
+
+ if (activeDevice && isWebAuthnDevice(activeDevice)) {
+ return (
+
+
+
Suivez l'invite de votre clé de sécurité…
+ {devices.length > 1 ? (
+
+ ) : null}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/components/auth/flow-stages/captcha-stage.tsx b/components/auth/flow-stages/captcha-stage.tsx
new file mode 100644
index 0000000..7a729d8
--- /dev/null
+++ b/components/auth/flow-stages/captcha-stage.tsx
@@ -0,0 +1,64 @@
+"use client"
+
+import { Loader2 } from "lucide-react"
+import { AuthConnectButton } from "@/components/auth/auth-connect-button"
+import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+
+function readSiteKey(challenge: StageRendererProps["challenge"]): string {
+ const key = challenge?.site_key ?? challenge?.siteKey
+ return typeof key === "string" ? key : ""
+}
+
+/** Captcha stage — token field fallback for embedded UI without provider widget. */
+export function CaptchaStage({
+ challenge,
+ submitting,
+ values,
+ onChange,
+ onSubmit,
+}: StageRendererProps) {
+ const siteKey = readSiteKey(challenge)
+ const token = values.captcha_token ?? values.token ?? ""
+
+ return (
+
+ )
+}
diff --git a/components/auth/flow-stages/flow-frame-stage.tsx b/components/auth/flow-stages/flow-frame-stage.tsx
new file mode 100644
index 0000000..c1e5e32
--- /dev/null
+++ b/components/auth/flow-stages/flow-frame-stage.tsx
@@ -0,0 +1,56 @@
+"use client"
+
+import { useEffect } from "react"
+import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
+import { flowRedirectUrl } from "@/lib/auth/flow-api"
+
+function readFrameUrl(challenge: StageRendererProps["challenge"]): string {
+ const direct = flowRedirectUrl(challenge)
+ if (direct) return direct
+ const url = challenge?.url
+ return typeof url === "string" ? url : ""
+}
+
+type AuthentikFrameMessage = {
+ source?: string
+ context?: string
+ message?: string
+}
+
+/** Embedded Authentik sub-flow (captcha iframe, etc.). */
+export function FlowFrameStage({ challenge, onSubmit }: StageRendererProps) {
+ const frameUrl = readFrameUrl(challenge)
+
+ useEffect(() => {
+ function handleMessage(event: MessageEvent) {
+ const data = event.data
+ if (
+ data?.source === "goauthentik.io" &&
+ data?.context === "flow-executor" &&
+ data?.message === "submit"
+ ) {
+ void onSubmit({ component: "xak-flow-frame" })
+ }
+ }
+
+ window.addEventListener("message", handleMessage)
+ return () => window.removeEventListener("message", handleMessage)
+ }, [onSubmit])
+
+ if (!frameUrl) {
+ return (
+
+ Étape de vérification en cours…
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/components/auth/flow-stages/webauthn-stage.tsx b/components/auth/flow-stages/webauthn-stage.tsx
index 4aff05a..8d9bc03 100644
--- a/components/auth/flow-stages/webauthn-stage.tsx
+++ b/components/auth/flow-stages/webauthn-stage.tsx
@@ -3,70 +3,18 @@
import { useEffect, useRef } from "react"
import { Loader2 } from "lucide-react"
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
-
-function readRequestOptions(
- challenge: StageRendererProps["challenge"]
-): PublicKeyCredentialRequestOptions | null {
- const raw = challenge?.request_options ?? challenge?.requestOptions
- if (!raw || typeof raw !== "object") return null
- const nested = raw as { publicKey?: PublicKeyCredentialRequestOptions }
- if (nested.publicKey) return nested.publicKey
- return raw as PublicKeyCredentialRequestOptions
-}
-
-function bufferFromBase64URL(value: string): ArrayBuffer {
- const padded = value.replace(/-/g, "+").replace(/_/g, "/")
- const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
- const binary = atob(padded + pad)
- const bytes = new Uint8Array(binary.length)
- for (let i = 0; i < binary.length; i += 1) {
- bytes[i] = binary.charCodeAt(i)
- }
- return bytes.buffer
-}
-
-function bufferToBase64URL(buffer: ArrayBuffer): string {
- const bytes = new Uint8Array(buffer)
- let binary = ""
- for (let i = 0; i < bytes.length; i += 1) {
- binary += String.fromCharCode(bytes[i]!)
- }
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
-}
-
-function normalizeRequestOptions(
- options: PublicKeyCredentialRequestOptions
-): PublicKeyCredentialRequestOptions {
- const challenge = options.challenge
- if (typeof challenge === "string") {
- return { ...options, challenge: bufferFromBase64URL(challenge) }
- }
- return options
-}
-
-function serializeCredential(credential: PublicKeyCredential): Record {
- const response = credential.response as AuthenticatorAssertionResponse
- return {
- id: credential.id,
- rawId: bufferToBase64URL(credential.rawId),
- type: credential.type,
- response: {
- clientDataJSON: bufferToBase64URL(response.clientDataJSON),
- authenticatorData: bufferToBase64URL(response.authenticatorData),
- signature: bufferToBase64URL(response.signature),
- userHandle: response.userHandle
- ? bufferToBase64URL(response.userHandle)
- : null,
- },
- }
-}
+import {
+ normalizeRequestOptions,
+ readWebAuthnRequestOptions,
+ serializeWebAuthnCredential,
+} from "@/lib/auth/webauthn-utils"
export function WebAuthnStage({ challenge, submitting, onSubmit }: StageRendererProps) {
const startedRef = useRef(false)
useEffect(() => {
if (startedRef.current || submitting) return
- const options = readRequestOptions(challenge)
+ const options = readWebAuthnRequestOptions(challenge)
if (!options || typeof window === "undefined" || !window.PublicKeyCredential) {
return
}
@@ -83,7 +31,7 @@ export function WebAuthnStage({ challenge, submitting, onSubmit }: StageRenderer
}
await onSubmit({
component: "ak-stage-authenticator-webauthn",
- response: serializeCredential(credential),
+ response: serializeWebAuthnCredential(credential),
})
} catch {
startedRef.current = false
diff --git a/lib/auth/oidc-config.ts b/lib/auth/oidc-config.ts
index 380b719..63a3d99 100644
--- a/lib/auth/oidc-config.ts
+++ b/lib/auth/oidc-config.ts
@@ -52,6 +52,10 @@ export function getForgotPasswordUrl(): string {
return FORGOT_PASSWORD_PATH
}
+export function getResetPasswordUrl(): string {
+ return RESET_PASSWORD_PATH
+}
+
export function getAuthentikEnrollmentUrl(): string {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
diff --git a/lib/auth/recovery-flow-query.ts b/lib/auth/recovery-flow-query.ts
new file mode 100644
index 0000000..11ce6bc
--- /dev/null
+++ b/lib/auth/recovery-flow-query.ts
@@ -0,0 +1,17 @@
+/** Build Authentik executor `query` param from a reset-password URL. */
+export function buildRecoveryFlowQuery(searchParams: URLSearchParams): string | undefined {
+ const nested = searchParams.get("query")?.trim()
+ if (nested) return nested
+
+ const token = searchParams.get("flow_token")?.trim()
+ if (token) {
+ return `flow_token=${encodeURIComponent(token)}`
+ }
+
+ const parts: string[] = []
+ searchParams.forEach((value, key) => {
+ if (key === "returnTo") return
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
+ })
+ return parts.length > 0 ? parts.join("&") : undefined
+}
diff --git a/lib/auth/webauthn-utils.ts b/lib/auth/webauthn-utils.ts
new file mode 100644
index 0000000..b4575c3
--- /dev/null
+++ b/lib/auth/webauthn-utils.ts
@@ -0,0 +1,86 @@
+export function bufferFromBase64URL(value: string): ArrayBuffer {
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/")
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
+ const binary = atob(padded + pad)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ return bytes.buffer
+}
+
+export function bufferToBase64URL(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer)
+ let binary = ""
+ for (let i = 0; i < bytes.length; i += 1) {
+ binary += String.fromCharCode(bytes[i]!)
+ }
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
+}
+
+export function normalizeRequestOptions(
+ options: PublicKeyCredentialRequestOptions
+): PublicKeyCredentialRequestOptions {
+ const challenge = options.challenge
+ if (typeof challenge === "string") {
+ return { ...options, challenge: bufferFromBase64URL(challenge) }
+ }
+ return options
+}
+
+function isPublicKeyRequestOptions(value: unknown): value is PublicKeyCredentialRequestOptions {
+ if (!value || typeof value !== "object") return false
+ const record = value as Record
+ return (
+ typeof record.challenge === "string" ||
+ typeof record.rp === "object" ||
+ Array.isArray(record.allowCredentials)
+ )
+}
+
+export function readWebAuthnRequestOptions(
+ source: Record | null | undefined
+): PublicKeyCredentialRequestOptions | null {
+ if (!source) return null
+
+ const wrapped = source.request_options ?? source.requestOptions
+ if (wrapped && typeof wrapped === "object") {
+ const nested = wrapped as { publicKey?: PublicKeyCredentialRequestOptions }
+ if (nested.publicKey) return nested.publicKey
+ if (isPublicKeyRequestOptions(wrapped)) return wrapped
+ }
+
+ if (isPublicKeyRequestOptions(source)) {
+ return source
+ }
+
+ const deviceChallenge = source.challenge
+ if (deviceChallenge && typeof deviceChallenge === "object") {
+ const nested = deviceChallenge as { publicKey?: PublicKeyCredentialRequestOptions }
+ if (nested.publicKey) return nested.publicKey
+ if (isPublicKeyRequestOptions(deviceChallenge)) {
+ return deviceChallenge as PublicKeyCredentialRequestOptions
+ }
+ }
+
+ return null
+}
+
+export function serializeWebAuthnCredential(
+ credential: PublicKeyCredential
+): Record {
+ const response = credential.response as AuthenticatorAssertionResponse
+ return {
+ id: credential.id,
+ rawId: bufferToBase64URL(credential.rawId),
+ type: credential.type,
+ response: {
+ clientDataJSON: bufferToBase64URL(response.clientDataJSON),
+ authenticatorData: bufferToBase64URL(response.authenticatorData),
+ signature: bufferToBase64URL(response.signature),
+ userHandle: response.userHandle
+ ? bufferToBase64URL(response.userHandle)
+ : null,
+ },
+ }
+}