feat(auth): MFA stages and embedded recovery reset flow
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

Add authenticator-validate device picker, captcha/frame stages, WebAuthn
utils fix, and recovery query parsing for /reset-password links.
This commit is contained in:
R3D347HR4Y 2026-06-20 01:21:29 +02:00
parent de5b5a60ef
commit ee05c804f9
10 changed files with 468 additions and 76 deletions

View File

@ -3,12 +3,13 @@
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
import { ResetPasswordPageContent } from "@/components/auth/reset-password-page-content"
import { buildRecoveryFlowQuery } from "@/lib/auth/recovery-flow-query"
function ResetPasswordContent() {
const searchParams = useSearchParams()
const flowQuery = searchParams.toString()
const flowQuery = buildRecoveryFlowQuery(searchParams)
return <ResetPasswordPageContent flowQuery={flowQuery || undefined} />
return <ResetPasswordPageContent flowQuery={flowQuery} />
}
export default function ResetPasswordPage() {

View File

@ -26,13 +26,19 @@ import {
AccessDeniedStage,
FlowRedirectStage,
} from "@/components/auth/flow-stages/access-denied-stage"
import { AuthenticatorValidateStage } from "@/components/auth/flow-stages/authenticator-validate-stage"
import { CaptchaStage } from "@/components/auth/flow-stages/captcha-stage"
import { FlowFrameStage } from "@/components/auth/flow-stages/flow-frame-stage"
FLOW_STAGE_REGISTRY["ak-stage-password"] = PasswordStage
FLOW_STAGE_REGISTRY["ak-stage-user-login"] = UserLoginStage
FLOW_STAGE_REGISTRY["ak-stage-authenticator-webauthn"] = WebAuthnStage
FLOW_STAGE_REGISTRY["ak-stage-authenticator-validate"] = AuthenticatorValidateStage
FLOW_STAGE_REGISTRY["ak-stage-captcha"] = CaptchaStage
FLOW_STAGE_REGISTRY["ak-stage-source"] = SourceOAuthStage
FLOW_STAGE_REGISTRY["ak-stage-access-denied"] = AccessDeniedStage
FLOW_STAGE_REGISTRY["xak-flow-redirect"] = FlowRedirectStage
FLOW_STAGE_REGISTRY["xak-flow-frame"] = FlowFrameStage
type PromptField = {
field_key: string
@ -133,8 +139,6 @@ export function FlowChallengeForm({
}
} else if (component === "ak-stage-email") {
payload.email = values.email ?? ""
} else if (component === "ak-stage-authenticator-validate") {
payload.code = values.code ?? ""
} else if (component === "ak-stage-password") {
payload.password = values.password ?? ""
} else if (!RegistryStage) {
@ -152,6 +156,7 @@ export function FlowChallengeForm({
component === "ak-stage-user-login" ||
component === "ak-stage-authenticator-webauthn" ||
component === "xak-flow-redirect" ||
component === "xak-flow-frame" ||
(component === "ak-stage-source" && Boolean(flowRedirectUrl(challenge)))
if (component === "ak-stage-access-denied") {
@ -244,19 +249,6 @@ export function FlowChallengeForm({
/>
) : null}
{component === "ak-stage-authenticator-validate" ? (
<FieldBlock
id="code"
label="Code de validation"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={values.code ?? ""}
onChange={(value) => setValues((prev) => ({ ...prev, code: value }))}
required
/>
) : null}
{!isKnownFlowComponent(component) ? (
<p className="text-sm text-muted-foreground">
Étape non prise en charge ({component}). Utilisez le portail Authentik.

View File

@ -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",
])

View File

@ -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<string, unknown>
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<DeviceChallenge | null>(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 (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Configurez une méthode d&apos;authentification forte pour continuer.
</p>
{configStages.map((stage) => (
<AuthConnectButton
key={stage.pk}
type="button"
disabled={submitting}
onClick={() =>
void onSubmit({
component: "ak-stage-authenticator-validate",
selected_stage: stage.pk,
})
}
>
{stage.verbose_name ?? stage.name ?? "Configurer"}
</AuthConnectButton>
))}
</div>
)
}
if (devices.length > 1 && !selectedDevice) {
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">Choisissez une méthode de vérification.</p>
{devices.map((device, index) => (
<AuthConnectButton
key={`${device.device_uid ?? device.device_class ?? "device"}-${index}`}
type="button"
disabled={submitting}
onClick={() => setSelectedDevice(device)}
>
{deviceLabel(device)}
</AuthConnectButton>
))}
</div>
)
}
if (activeDevice && isWebAuthnDevice(activeDevice)) {
return (
<div className="flex flex-col items-center gap-3 py-4 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin" aria-hidden />
<p>Suivez l&apos;invite de votre clé de sécurité</p>
{devices.length > 1 ? (
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
webAuthnStartedRef.current = false
setSelectedDevice(null)
}}
>
Choisir une autre méthode
</button>
) : null}
</div>
)
}
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
void onSubmit({
component: "ak-stage-authenticator-validate",
...(activeDevice ? { selected_challenge: activeDevice } : {}),
code,
})
}}
>
<div className="space-y-2">
<Label htmlFor="mfa-code">Code de validation</Label>
<Input
id="mfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={code}
required
onChange={(event) => setCode(event.target.value)}
className="h-10 rounded-lg"
/>
</div>
{devices.length > 1 ? (
<button
type="button"
className="text-xs text-primary underline"
onClick={() => setSelectedDevice(null)}
>
Choisir une autre méthode
</button>
) : null}
<AuthConnectButton type="submit" disabled={submitting || !code.trim()}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
<span>Patientez</span>
</>
) : (
"Valider"
)}
</AuthConnectButton>
</form>
)
}

View File

@ -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 (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
void onSubmit({
component: "ak-stage-captcha",
captcha_token: token,
token,
})
}}
>
{siteKey ? (
<p className="text-sm text-muted-foreground">
Captcha requis. Si le widget ne s&apos;affiche pas ici, utilisez le lien de connexion
redirect sur la page de connexion.
</p>
) : null}
<div className="space-y-2">
<Label htmlFor="captcha_token">Jeton captcha</Label>
<Input
id="captcha_token"
value={token}
onChange={(event) => onChange("captcha_token", event.target.value)}
className="h-10 rounded-lg"
/>
</div>
<AuthConnectButton type="submit" disabled={submitting || !token.trim()}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
<span>Patientez</span>
</>
) : (
"Continuer"
)}
</AuthConnectButton>
</form>
)
}

View File

@ -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<AuthentikFrameMessage>) {
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 (
<p className="text-sm text-muted-foreground">
Étape de vérification en cours
</p>
)
}
return (
<iframe
title="Vérification"
src={frameUrl}
className="h-24 w-full rounded-lg border bg-background"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
)
}

View File

@ -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<string, unknown> {
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

View File

@ -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()

View File

@ -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
}

View File

@ -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<string, unknown>
return (
typeof record.challenge === "string" ||
typeof record.rp === "object" ||
Array.isArray(record.allowCredentials)
)
}
export function readWebAuthnRequestOptions(
source: Record<string, unknown> | 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<string, unknown> {
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,
},
}
}