feat(auth): MFA stages and embedded recovery reset flow
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
de5b5a60ef
commit
ee05c804f9
@ -3,12 +3,13 @@
|
|||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { ResetPasswordPageContent } from "@/components/auth/reset-password-page-content"
|
import { ResetPasswordPageContent } from "@/components/auth/reset-password-page-content"
|
||||||
|
import { buildRecoveryFlowQuery } from "@/lib/auth/recovery-flow-query"
|
||||||
|
|
||||||
function ResetPasswordContent() {
|
function ResetPasswordContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const flowQuery = searchParams.toString()
|
const flowQuery = buildRecoveryFlowQuery(searchParams)
|
||||||
|
|
||||||
return <ResetPasswordPageContent flowQuery={flowQuery || undefined} />
|
return <ResetPasswordPageContent flowQuery={flowQuery} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
|
|||||||
@ -26,13 +26,19 @@ import {
|
|||||||
AccessDeniedStage,
|
AccessDeniedStage,
|
||||||
FlowRedirectStage,
|
FlowRedirectStage,
|
||||||
} from "@/components/auth/flow-stages/access-denied-stage"
|
} 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-password"] = PasswordStage
|
||||||
FLOW_STAGE_REGISTRY["ak-stage-user-login"] = UserLoginStage
|
FLOW_STAGE_REGISTRY["ak-stage-user-login"] = UserLoginStage
|
||||||
FLOW_STAGE_REGISTRY["ak-stage-authenticator-webauthn"] = WebAuthnStage
|
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-source"] = SourceOAuthStage
|
||||||
FLOW_STAGE_REGISTRY["ak-stage-access-denied"] = AccessDeniedStage
|
FLOW_STAGE_REGISTRY["ak-stage-access-denied"] = AccessDeniedStage
|
||||||
FLOW_STAGE_REGISTRY["xak-flow-redirect"] = FlowRedirectStage
|
FLOW_STAGE_REGISTRY["xak-flow-redirect"] = FlowRedirectStage
|
||||||
|
FLOW_STAGE_REGISTRY["xak-flow-frame"] = FlowFrameStage
|
||||||
|
|
||||||
type PromptField = {
|
type PromptField = {
|
||||||
field_key: string
|
field_key: string
|
||||||
@ -133,8 +139,6 @@ export function FlowChallengeForm({
|
|||||||
}
|
}
|
||||||
} else if (component === "ak-stage-email") {
|
} else if (component === "ak-stage-email") {
|
||||||
payload.email = values.email ?? ""
|
payload.email = values.email ?? ""
|
||||||
} else if (component === "ak-stage-authenticator-validate") {
|
|
||||||
payload.code = values.code ?? ""
|
|
||||||
} else if (component === "ak-stage-password") {
|
} else if (component === "ak-stage-password") {
|
||||||
payload.password = values.password ?? ""
|
payload.password = values.password ?? ""
|
||||||
} else if (!RegistryStage) {
|
} else if (!RegistryStage) {
|
||||||
@ -152,6 +156,7 @@ export function FlowChallengeForm({
|
|||||||
component === "ak-stage-user-login" ||
|
component === "ak-stage-user-login" ||
|
||||||
component === "ak-stage-authenticator-webauthn" ||
|
component === "ak-stage-authenticator-webauthn" ||
|
||||||
component === "xak-flow-redirect" ||
|
component === "xak-flow-redirect" ||
|
||||||
|
component === "xak-flow-frame" ||
|
||||||
(component === "ak-stage-source" && Boolean(flowRedirectUrl(challenge)))
|
(component === "ak-stage-source" && Boolean(flowRedirectUrl(challenge)))
|
||||||
|
|
||||||
if (component === "ak-stage-access-denied") {
|
if (component === "ak-stage-access-denied") {
|
||||||
@ -244,19 +249,6 @@ export function FlowChallengeForm({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : 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) ? (
|
{!isKnownFlowComponent(component) ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Étape non prise en charge ({component}). Utilisez le portail Authentik.
|
Étape non prise en charge ({component}). Utilisez le portail Authentik.
|
||||||
|
|||||||
@ -31,7 +31,9 @@ const LEGACY_KNOWN_COMPONENTS = new Set([
|
|||||||
"ak-stage-user-login",
|
"ak-stage-user-login",
|
||||||
"ak-stage-authenticator-webauthn",
|
"ak-stage-authenticator-webauthn",
|
||||||
"ak-stage-source",
|
"ak-stage-source",
|
||||||
|
"ak-stage-captcha",
|
||||||
"xak-flow-redirect",
|
"xak-flow-redirect",
|
||||||
|
"xak-flow-frame",
|
||||||
"ak-stage-access-denied",
|
"ak-stage-access-denied",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
222
components/auth/flow-stages/authenticator-validate-stage.tsx
Normal file
222
components/auth/flow-stages/authenticator-validate-stage.tsx
Normal 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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
components/auth/flow-stages/captcha-stage.tsx
Normal file
64
components/auth/flow-stages/captcha-stage.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
components/auth/flow-stages/flow-frame-stage.tsx
Normal file
56
components/auth/flow-stages/flow-frame-stage.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,70 +3,18 @@
|
|||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||||
|
import {
|
||||||
function readRequestOptions(
|
normalizeRequestOptions,
|
||||||
challenge: StageRendererProps["challenge"]
|
readWebAuthnRequestOptions,
|
||||||
): PublicKeyCredentialRequestOptions | null {
|
serializeWebAuthnCredential,
|
||||||
const raw = challenge?.request_options ?? challenge?.requestOptions
|
} from "@/lib/auth/webauthn-utils"
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebAuthnStage({ challenge, submitting, onSubmit }: StageRendererProps) {
|
export function WebAuthnStage({ challenge, submitting, onSubmit }: StageRendererProps) {
|
||||||
const startedRef = useRef(false)
|
const startedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedRef.current || submitting) return
|
if (startedRef.current || submitting) return
|
||||||
const options = readRequestOptions(challenge)
|
const options = readWebAuthnRequestOptions(challenge)
|
||||||
if (!options || typeof window === "undefined" || !window.PublicKeyCredential) {
|
if (!options || typeof window === "undefined" || !window.PublicKeyCredential) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -83,7 +31,7 @@ export function WebAuthnStage({ challenge, submitting, onSubmit }: StageRenderer
|
|||||||
}
|
}
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
component: "ak-stage-authenticator-webauthn",
|
component: "ak-stage-authenticator-webauthn",
|
||||||
response: serializeCredential(credential),
|
response: serializeWebAuthnCredential(credential),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
startedRef.current = false
|
startedRef.current = false
|
||||||
|
|||||||
@ -52,6 +52,10 @@ export function getForgotPasswordUrl(): string {
|
|||||||
return FORGOT_PASSWORD_PATH
|
return FORGOT_PASSWORD_PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getResetPasswordUrl(): string {
|
||||||
|
return RESET_PASSWORD_PATH
|
||||||
|
}
|
||||||
|
|
||||||
export function getAuthentikEnrollmentUrl(): string {
|
export function getAuthentikEnrollmentUrl(): string {
|
||||||
if (useNativeRuntime()) {
|
if (useNativeRuntime()) {
|
||||||
const cfg = getRuntimeConfig()
|
const cfg = getRuntimeConfig()
|
||||||
|
|||||||
17
lib/auth/recovery-flow-query.ts
Normal file
17
lib/auth/recovery-flow-query.ts
Normal 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
|
||||||
|
}
|
||||||
86
lib/auth/webauthn-utils.ts
Normal file
86
lib/auth/webauthn-utils.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user