diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 1854c57..f3d7a14 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -119,6 +119,8 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.PATCH("/me/passkeys/:id", renamePasskey) apiGroup.DELETE("/me/passkeys/:id", auth.RequireStepUp(), deletePasskey) apiGroup.POST("/me/step-up", RateLimitAuth(), stepUp) + apiGroup.POST("/me/step-up/webauthn/begin", RateLimitAuth(), auth.HandleStepUpWebAuthnBegin) + apiGroup.POST("/me/step-up/webauthn/finish", RateLimitAuth(), auth.HandleStepUpWebAuthnFinish) apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA) apiGroup.GET("/openapi.json", getOpenAPI) diff --git a/server/internal/auth/webauthn.go b/server/internal/auth/webauthn.go index 68e6977..f65783b 100644 --- a/server/internal/auth/webauthn.go +++ b/server/internal/auth/webauthn.go @@ -317,6 +317,83 @@ func HandleRegisterPasskeyFinish(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } +// HandleStepUpWebAuthnBegin offers an assertion challenge for step-up +// re-authentication: the signed-in user proving it is still them before a +// guarded action, rather than a pending ticket proving it during sign-in. +// +// @Summary Begin passkey step-up +// @Tags mfa +// @Produce json +// @Success 200 {object} object{publicKey=object,ceremony_id=string} +// @Failure 400 {object} object{error=string} +// @Router /me/step-up/webauthn/begin [post] +func HandleStepUpWebAuthnBegin(c *gin.Context) { + sess := GetSessionFromContext(c) + creds, err := services.ListPasskeys(sess.InstanceID, sess.UserID) + if err != nil || len(creds) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"}) + return + } + handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + lib := make([]webauthn.Credential, 0, len(creds)) + for _, cr := range creds { + lib = append(lib, toLibCredential(cr)) + } + w, err := webAuthnFor(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: sess.Email, credentials: lib}, + webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + id, err := saveCeremony(c.Request.Context(), sessionData) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id}) +} + +// HandleStepUpWebAuthnFinish verifies the assertion and records a fresh +// step-up for the current session. Unlike HandleMFAWebAuthnFinish this does +// not mint a session: the caller is already signed in, this only proves they +// still hold the passkey. finishAssertion is called with the session's own +// user ID, so a credential belonging to somebody else is refused rather than +// stepping up this session on their behalf. +// +// @Summary Complete passkey step-up +// @Tags mfa +// @Accept json +// @Produce json +// @Param body body object{ceremony_id=string,credential=object} true "Assertion" +// @Success 200 {object} object{ok=bool} +// @Failure 401 {object} object{error=string,code=string} +// @Router /me/step-up/webauthn/finish [post] +func HandleStepUpWebAuthnFinish(c *gin.Context) { + sess := GetSessionFromContext(c) + cred, err := finishAssertion(c, sess.InstanceID, sess.UserID, sess.Email) + if err != nil { + services.LogEvent(sess.InstanceID, "step_up.failed", sess.Email, "", "", "factor=webauthn") + c.JSON(http.StatusUnauthorized, gin.H{"error": "that passkey could not be verified", "code": "invalid_assertion"}) + return + } + _ = services.TouchPasskey(sess.InstanceID, cred.ID, cred.Authenticator.SignCount) + if err := TouchStepUpFromRequest(c); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"}) + return + } + services.LogEvent(sess.InstanceID, "step_up.ok", sess.Email, "", "", "factor=webauthn") + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + // finishAssertion is shared by second-factor sign-in, passwordless sign-in and // step-up, so the verification rules (user verification, clone detection, // instance scope) exist once. diff --git a/web/components/mfa/StepUpModal.tsx b/web/components/mfa/StepUpModal.tsx index d27c7e3..f47bf79 100644 --- a/web/components/mfa/StepUpModal.tsx +++ b/web/components/mfa/StepUpModal.tsx @@ -3,23 +3,23 @@ import { useEffect, useRef, useState } from "react"; import { me } from "@/lib/api"; import { registerStepUpPrompt } from "@/lib/stepup"; +import { isPasskeySupported, toRequestOptions, credentialToJSON } from "@/lib/webauthn"; import { Button, Modal, friendlyMessage } from "@/components/ui"; -// The server may also offer "webauthn" (a passkey), but /me/step-up/webauthn -// isn't wired up server-side yet, so this modal only ever offers the three -// methods it can actually complete. -const SUPPORTED = ["totp", "recovery", "password"] as const; +const SUPPORTED = ["totp", "recovery", "webauthn", "password"] as const; type SupportedMethod = (typeof SUPPORTED)[number]; const LABELS: Record = { totp: "Authenticator app", recovery: "Recovery code", + webauthn: "Passkey", password: "Password", }; const INPUT_LABELS: Record = { totp: "6-digit code", recovery: "Recovery code", + webauthn: "", password: "Password", }; @@ -40,7 +40,12 @@ export function StepUpModal() { useEffect(() => { registerStepUpPrompt((requested) => { - const supported = requested.filter((m): m is SupportedMethod => (SUPPORTED as readonly string[]).includes(m)); + // "webauthn" only counts as offered when this browser can actually + // complete it - otherwise it would sit there as a dead option. + const supported = requested.filter( + (m): m is SupportedMethod => + (SUPPORTED as readonly string[]).includes(m) && (m !== "webauthn" || isPasskeySupported()), + ); return new Promise((resolve, reject) => { setMethods(supported); setMethod(supported[0] ?? null); @@ -63,18 +68,37 @@ export function StepUpModal() { rejectRef.current = null; } + function succeed() { + setOpen(false); + resolveRef.current?.(); + resolveRef.current = null; + rejectRef.current = null; + } + async function submit(e: React.FormEvent) { e.preventDefault(); - if (!method) return; + if (!method || method === "webauthn") return; setBusy(true); setError(""); try { const factor = method === "totp" ? { totp: value } : method === "recovery" ? { recovery: value } : { password: value }; await me.stepUp(factor); - setOpen(false); - resolveRef.current?.(); - resolveRef.current = null; - rejectRef.current = null; + succeed(); + } catch (err) { + setError(friendlyMessage(err)); + } finally { + setBusy(false); + } + } + + async function usePasskey() { + setBusy(true); + setError(""); + try { + const { publicKey, ceremony_id } = await me.stepUpWebAuthnBegin(); + const cred = (await navigator.credentials.get({ publicKey: toRequestOptions(publicKey) })) as PublicKeyCredential; + await me.stepUpWebAuthnFinish(ceremony_id, credentialToJSON(cred)); + succeed(); } catch (err) { setError(friendlyMessage(err)); } finally { @@ -119,36 +143,53 @@ export function StepUpModal() { )} -
-
- - setValue(e.target.value)} - className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" - /> -
+ {method === "webauthn" ? ( +
+

Use your device's passkey to confirm it is you.

- {error &&
{error}
} + {error &&
{error}
} -
- - +
+ + +
- + ) : ( +
+
+ + setValue(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+ + {error &&
{error}
} + +
+ + +
+
+ )} )}
diff --git a/web/lib/api.ts b/web/lib/api.ts index f2bff68..3497fcf 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -886,6 +886,17 @@ export const me = { stepUp(factor: { totp: string } | { recovery: string } | { password: string }): Promise<{ ok: true }> { return request("/me/step-up", { method: "POST", body: JSON.stringify(factor) }); }, + + stepUpWebAuthnBegin(): Promise<{ publicKey: any; ceremony_id: string }> { + return request("/me/step-up/webauthn/begin", { method: "POST" }); + }, + + stepUpWebAuthnFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> { + return request("/me/step-up/webauthn/finish", { + method: "POST", + body: JSON.stringify({ ceremony_id: ceremonyId, credential }), + }); + }, }; export const api = {