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