diff --git a/docs/dashboard.md b/docs/dashboard.md index 162513b..6f4d567 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -2,7 +2,7 @@ Optional web panel served by BeaconMCP on the same origin as the MCP endpoint (`https:///app/...`). Three pages: -- **`/app/login`** — exchanges a client id + client secret + TOTP code for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. +- **`/app/login`** — exchanges a client id + client secret + a second factor (TOTP code or passkey) for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. - **`/app/chat`** — multi-conversation chat with Gemini 2.5 Flash/Pro (stable) or Gemini 3 Flash / 3.1 Pro (preview, Google allowlist required). **Requires `GEMINI_API_KEY`.** - **`/app/tokens`** — generates named bearers so external MCP clients (Gemini web, ChatGPT, Assistant Desktop) can be wired up without the OAuth dance. **Works without `GEMINI_API_KEY`.** @@ -36,9 +36,32 @@ or `(chat: disabled, tokens only)` when the Gemini key is missing, or `disabled` ## Flow 1. Navigate to `https:///` on any device — the root path redirects to `/app/login`. -2. Enter client id + client secret + current TOTP code (once per 90-day window). -3. Land on `/app/chat` (or `/app/tokens` in tokens-only mode) with the conversation history restored. -4. Every 24 hours the underlying MCP bearer expires. The dashboard prompts only for a fresh TOTP code on `/app/refresh`; the client id and secret remain encrypted server-side. +2. Enter client id + client secret, then the current TOTP code — or use a passkey instead, from the + link under the code boxes (see [Passkeys](#passkeys)). Enter validates as soon as the + sixth digit lands. +3. A confirmation screen shows when the MCP access and the signed-in session expire, offers to + register a passkey on this device, and waits for **Finish signing in**. +4. Land on `/app/chat` (or `/app/tokens` in tokens-only mode) with the conversation history restored. +5. Every 24 hours the underlying MCP bearer expires. The dashboard prompts only for a fresh TOTP code on `/app/refresh`; the client id and secret remain encrypted server-side. + +With JavaScript disabled the page falls back to the previous behaviour: a plain form POST that +redirects straight to the landing page, no confirmation screen and no passkeys. + +## Passkeys + +A passkey replaces the 6-digit code, not the client secret — signing in still needs both factors. +Enrol one from the confirmation screen right after a normal sign-in, then on the next visit use +**Use a passkey instead** under the code boxes. + +The same option is on the OAuth consent page (`/oauth/authorize`), which now also confirms before +redirecting: it shows when the granted access expires, offers passkey enrolment, and only mints the +authorization code when you press **Finish signing in**. + +Registered passkeys are listed at the bottom of `/app/tokens` with their label, when they were added +and when they were last used, each with a **Remove** button. + +Caveats — hostname binding, the HTTPS/loopback requirement, and why you should keep your TOTP seed — +are covered in [security.md](security.md#passkeys). ## Tokens page diff --git a/docs/security.md b/docs/security.md index 7730f6b..d26862a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -56,6 +56,51 @@ Unattended services (scheduled jobs, CI pipelines) sometimes genuinely need mach case, with its required precautions, is covered separately in [totp-automation.md](totp-automation.md). Read it end to end before deciding. +## Passkeys + +A passkey (WebAuthn) replaces the **TOTP factor**, never the client secret. Both login pages keep the +same two-factor shape: + +1. `client_id` + `client_secret` +2. a 6-digit code **or** a passkey assertion + +That ordering is what makes a stolen passkey worthless on its own, and it is also a practical +constraint: the dashboard session encrypts the client secret so it can re-mint MCP bearers later, so +a fully usernameless login could not build a working session anyway. + +Where they work: + +| Page | Sign in with a passkey | Enrol a passkey | +|------|------------------------|-----------------| +| `/app/login` | Link under the 2FA step | On the post-2FA screen | +| `/oauth/authorize` | Link under the 2FA step | On the approval screen | + +Credentials are stored in the dashboard database (`passkeys` table): a credential id, a **public** +key and a signature counter. Nothing secret leaves the authenticator. + +Things worth knowing before you enrol: + +- **Passkeys are bound to the hostname.** The relying-party ID is derived from the request host, so + a credential registered on `beacon.example` will not work on `beacon.internal` or on a raw IP that + differs from the one used at registration. Settle on your public hostname first. +- **A secure context is required.** Browsers only expose the WebAuthn API over HTTPS or on loopback. + On a plain-HTTP LAN deployment the passkey buttons are hidden and TOTP stays the only path in. +- **Keep TOTP working.** Passkeys are an alternative, not a replacement: losing every enrolled device + must not lock you out. The authenticator seed remains the recovery path. +- **Dynamically-registered clients delegate**, exactly like TOTP: a client created through the DCR + bootstrap is authorized by its *owner's* passkeys, so the second factor never leaves the owner. +- Registrations, revocations and passkey sign-ins are recorded in the audit log + (`dashboard.passkey.*`, `auth.passkey.*`, and `login`/`authorize` events tagged `via=passkey`). + +Passkey ceremonies are rate-limited per IP by the same limiter that guards `/app/login`, and a TOTP +lockout also blocks the passkey path for that client. + +Manage enrolled credentials from `/app/tokens`, or drop them all for a client with: + +```sql +DELETE FROM passkeys WHERE client_id = 'beaconmcp_...'; +``` + ## Audit trail `server.audit_log` records tool calls, dashboard logins, OAuth authorizations and client revocations diff --git a/pyproject.toml b/pyproject.toml index a18ee64..2421871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ dependencies = [ "python-dotenv", "pyyaml", "pyotp", + # Passkeys (WebAuthn) on the login / authorize pages. Imported lazily: + # the auth pages fall back to TOTP-only when it is missing. + "webauthn>=2,<4", "qrcode", "cryptography>=42", "jinja2>=3", diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 5232f7a..443f366 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -320,6 +320,18 @@ async def check_bmc() -> None: else: print(" - No BMC devices configured.") + # Only the library half can be checked here: credential storage depends + # on the running server's database, which doctor does not open. + print("\nPasskeys (WebAuthn):") + from .dashboard.passkeys import webauthn_installed + + if webauthn_installed(): + print(" ✓ 'webauthn' package importable.") + print(" Browsers additionally require HTTPS or a loopback host.") + else: + print(" ✗ 'webauthn' package is missing - login pages offer TOTP only.") + print(" Fix with: pip install 'webauthn>=2,<4' (or reinstall BeaconMCP)") + print("\nPreflight check complete.") @@ -431,9 +443,630 @@ def _cmd_auth(args): sys.exit(1) +# --------------------------------------------------------------------------- +# /oauth/authorize page assets +# +# Kept as plain module-level strings rather than inlined in the f-string that +# builds the page: CSS and JS are brace-dense, and doubling every one of them +# to survive `str.format` made the block unreadable and easy to break. +# --------------------------------------------------------------------------- + +_AUTHORIZE_CSS = """ +:root { + --accent: oklch(0.68 0.17 48); + --accent-soft: oklch(0.68 0.17 48 / 0.12); + --accent-softer: oklch(0.68 0.17 48 / 0.06); + --accent-border: oklch(0.68 0.17 48 / 0.35); + --accent-hover: oklch(0.62 0.18 48); + --accent-fg: #fff; + --bg: oklch(0.99 0.004 70); + --bg-soft: oklch(0.975 0.005 70); + --bg-elev: #fff; + --fg: oklch(0.22 0.01 70); + --fg-mid: oklch(0.42 0.008 70); + --fg-muted: oklch(0.55 0.008 70); + --fg-faint: oklch(0.7 0.006 70); + --border: oklch(0.92 0.006 70); + --border-strong: oklch(0.86 0.008 70); + --border-subtle: oklch(0.95 0.005 70); + --danger: oklch(0.58 0.19 25); + --danger-soft: oklch(0.58 0.19 25 / 0.1); + --success: oklch(0.62 0.12 155); + --success-soft: oklch(0.62 0.12 155 / 0.12); + --shadow: 0 1px 2px rgba(20,14,8,0.04), 0 4px 20px rgba(20,14,8,0.06); + --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); +} +[data-theme="dark"] { + --bg: oklch(0.16 0.008 60); + --bg-soft: oklch(0.19 0.008 60); + --bg-elev: oklch(0.21 0.009 60); + --fg: oklch(0.95 0.006 70); + --fg-mid: oklch(0.78 0.008 70); + --fg-muted: oklch(0.62 0.01 70); + --fg-faint: oklch(0.45 0.008 70); + --border: oklch(0.28 0.009 60); + --border-strong: oklch(0.36 0.01 60); + --border-subtle: oklch(0.24 0.008 60); + --accent: oklch(0.75 0.17 50); + --accent-soft: oklch(0.75 0.17 50 / 0.16); + --accent-softer: oklch(0.75 0.17 50 / 0.08); + --accent-border: oklch(0.75 0.17 50 / 0.4); + --accent-hover: oklch(0.82 0.17 50); + --accent-fg: oklch(0.12 0.008 60); + --danger: oklch(0.68 0.19 25); + --success: oklch(0.72 0.14 155); + --shadow: 0 1px 2px rgba(0,0,0,0.3), 0 4px 20px rgba(0,0,0,0.4); +} +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; + font-family: var(--font); + font-size: 15px; + color: var(--fg); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} +body { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} +.auth-card { + width: 100%; max-width: 380px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow); + animation: rise 400ms var(--ease-out) both; +} +@keyframes rise { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.auth-brand { display: flex; align-items: center; margin-bottom: 26px; } +.auth-brand .name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; } +h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; letter-spacing: -0.015em; } +.sub { margin: 0 0 18px; font-size: 13.5px; color: var(--fg-muted); } +.sub strong { color: var(--fg); font-weight: 600; } +.banner { + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + margin: 0 0 14px; + background: var(--danger-soft); + color: var(--danger); + border: 1px solid color-mix(in oklab, var(--danger) 35%, var(--border)); +} +.banner-success { + background: var(--success-soft); + color: var(--success); + border-color: color-mix(in oklab, var(--success) 35%, var(--border)); +} +.toast-banner { + background: var(--accent-softer); + border: 1px solid var(--accent-border); + color: var(--fg); + border-radius: 10px; + padding: 9px 12px; + font-size: 12.5px; + margin-bottom: 16px; + display: flex; align-items: center; gap: 8px; +} +.toast-banner .dot { + width: 6px; height: 6px; + border-radius: 50%; background: var(--accent); + flex-shrink: 0; +} +.toast-banner b { font-family: var(--font-mono); margin-left: 2px; } +.totp-inputs { + display: flex; gap: 8px; justify-content: space-between; + margin: 8px 0 18px; +} +.totp-inputs input { + width: 100%; aspect-ratio: 1 / 1.15; + text-align: center; + font-size: 24px; font-weight: 600; + font-family: var(--font-mono); + background: var(--bg-soft); + border: 1px solid var(--border-strong); + border-radius: 10px; + color: var(--fg); + outline: none; + transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out); +} +.totp-inputs input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-soft); +} +.totp-inputs input.filled { + background: var(--accent-softer); + border-color: var(--accent-border); +} +.btn-primary { + width: 100%; + padding: 12px 16px; + border: 0; border-radius: 10px; + background: var(--accent); color: var(--accent-fg); + font-family: var(--font); font-weight: 600; font-size: 14.5px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: background 180ms var(--ease-out), transform 100ms var(--ease-out); + box-shadow: 0 4px 14px oklch(0.68 0.17 48 / 0.28), inset 0 1px 0 rgba(255,255,255,0.2); +} +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } +.btn-primary:active:not(:disabled) { transform: translateY(1px); } +.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } +.btn-ghost { + width: 100%; + padding: 11px 16px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--bg-soft); + color: var(--fg); + font-family: var(--font); font-weight: 550; font-size: 14px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out); +} +.btn-ghost:hover:not(:disabled) { + border-color: var(--accent-border); + background: var(--accent-softer); +} +.btn-ghost:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-ghost svg { flex-shrink: 0; } +.btn-primary.is-loading, .btn-ghost.is-loading { + position: relative; overflow: hidden; cursor: progress; opacity: 1; +} +.btn-primary.is-loading::after, .btn-ghost.is-loading::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.38) 50%, transparent 80%); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; + pointer-events: none; +} +.btn-ghost.is-loading::after { + background: linear-gradient(100deg, transparent 20%, var(--accent-soft) 50%, transparent 80%); +} +@keyframes shimmer-sweep { to { transform: translateX(100%); } } +.btn-primary.is-loading .btn-icon { opacity: 0; } +@media (prefers-reduced-motion: reduce) { + .btn-primary.is-loading::after, .btn-ghost.is-loading::after { + animation: none; opacity: 0.25; + } +} +.alt-divider { + display: flex; align-items: center; gap: 12px; + margin: 18px 0 14px; + color: var(--fg-faint); + font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; +} +.alt-divider::before, .alt-divider::after { + content: ""; flex: 1; height: 1px; background: var(--border); +} +.hint { + margin: 10px 0 0; + font-size: 12.5px; line-height: 1.5; + color: var(--fg-muted); text-align: center; +} +.success-mark { + width: 42px; height: 42px; + border-radius: 50%; + display: grid; place-items: center; + margin-bottom: 16px; + color: var(--success); + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 35%, var(--border)); + animation: pop-in 320ms var(--ease-out) both; +} +@keyframes pop-in { + from { opacity: 0; transform: scale(0.82); } + to { opacity: 1; transform: scale(1); } +} +.expiry-card { + margin: 4px 0 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.expiry-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; padding: 5px 0; +} +.expiry-row + .expiry-row { border-top: 1px solid var(--border-subtle); } +.expiry-row dt { font-size: 12.5px; color: var(--fg-muted); } +.expiry-row dd { + margin: 0; font-size: 13px; font-weight: 550; + color: var(--fg); text-align: right; +} +#passkey-add-block { margin-top: 18px; } +#finalize-form { margin-top: 20px; } +""" + + +_AUTHORIZE_THEME_JS = """ +(function() { + try { + var raw = localStorage.getItem("beaconmcp-ui-state"); + var s = raw ? JSON.parse(raw) : {}; + var t = s.theme || "auto"; + var dark = t === "dark" || (t === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches); + document.documentElement.setAttribute("data-theme", dark ? "dark" : "light"); + } catch (e) {} +})(); +""" + + +# Self-contained: /oauth/authorize is reachable even when the dashboard (and +# its /app/static bundle) is disabled, so the WebAuthn plumbing is duplicated +# here rather than imported from static/webauthn.js. +_AUTHORIZE_JS = """ +(function() { + "use strict"; + + var card = document.querySelector(".auth-card"); + var stepVerify = document.getElementById("step-verify"); + var stepDone = document.getElementById("step-done"); + var form = document.getElementById("authorize-form"); + var totpHidden = document.getElementById("totp"); + var verifyBtn = document.getElementById("verify-btn"); + var container = document.getElementById("totp-inputs"); + var inputs = container ? container.querySelectorAll("input") : []; + var verifyError = document.getElementById("verify-error"); + var doneError = document.getElementById("done-error"); + var doneOk = document.getElementById("done-ok"); + var passkeyBlock = document.getElementById("passkey-block"); + var passkeyBtn = document.getElementById("passkey-btn"); + var passkeyAddBlock = document.getElementById("passkey-add-block"); + var addPasskeyBtn = document.getElementById("add-passkey-btn"); + var ticketInput = document.getElementById("ticket"); + var finalizeForm = document.getElementById("finalize-form"); + var finishBtn = document.getElementById("finish-btn"); + + var passkeysEnabled = card && card.dataset.passkeys === "true"; + var clientId = card ? card.dataset.clientId : ""; + var busy = false; + + function b64urlToBuf(value) { + var s = String(value).replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + var bin = window.atob(s); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + function bufToB64url(buf) { + var bytes = new Uint8Array(buf), bin = ""; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return window.btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, ""); + } + function descriptors(list) { + return (list || []).map(function(d) { + var out = { type: d.type || "public-key", id: b64urlToBuf(d.id) }; + if (d.transports && d.transports.length) out.transports = d.transports; + return out; + }); + } + function webauthnSupported() { + return !!(window.PublicKeyCredential && navigator.credentials && + navigator.credentials.create && navigator.credentials.get); + } + function describeError(err) { + if (!err) return "Passkey request failed."; + if (err.name === "NotAllowedError") return "Passkey prompt cancelled or timed out."; + if (err.name === "InvalidStateError") return "This device already has a passkey for this client."; + if (err.name === "SecurityError") return "Passkeys need a secure origin (HTTPS or localhost)."; + return err.message || String(err); + } + function postJson(url, body) { + return fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-BeaconMCP-Mode": "json" }, + body: JSON.stringify(body || {}) + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }); + } + function show(el, message) { + if (!el) return; + el.textContent = message || ""; + el.hidden = !message; + } + function setLoading(btn, loading, label) { + if (!btn) return; + var labelEl = btn.querySelector(".btn-label"); + if (loading) { + if (labelEl && label) { + if (!btn.dataset.idleLabel) btn.dataset.idleLabel = labelEl.textContent; + labelEl.textContent = label; + } + btn.classList.add("is-loading"); + btn.disabled = true; + } else { + if (labelEl && btn.dataset.idleLabel) { + labelEl.textContent = btn.dataset.idleLabel; + delete btn.dataset.idleLabel; + } + btn.classList.remove("is-loading"); + } + } + function formatDateTime(epochSeconds) { + if (!epochSeconds || !isFinite(epochSeconds)) return "\\u2014"; + var d = new Date(epochSeconds * 1000); + var sameDay = d.toDateString() === new Date().toDateString(); + var time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + if (sameDay) return "today at " + time; + return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) + " at " + time; + } + function formatRelative(epochSeconds) { + var secs = epochSeconds - (Date.now() / 1000); + if (secs <= 0) return "expired"; + var hours = secs / 3600; + if (hours < 1) return "in " + Math.max(1, Math.round(secs / 60)) + " min"; + if (hours < 48) return "in " + Math.round(hours) + " h"; + return "in " + Math.round(hours / 24) + " days"; + } + + // --- TOTP boxes --- + function collectTotp() { + var s = ""; + inputs.forEach(function(i) { s += (i.value || "").replace(/\\D/g, ""); }); + return s; + } + function refresh() { + var v = collectTotp(); + if (totpHidden) totpHidden.value = v; + if (verifyBtn) verifyBtn.disabled = busy || v.length !== 6; + } + function clearTotp() { + inputs.forEach(function(i) { i.value = ""; i.classList.remove("filled"); }); + refresh(); + if (inputs[0]) inputs[0].focus(); + } + + inputs.forEach(function(inp, i) { + inp.addEventListener("input", function(e) { + var v = (e.target.value || "").replace(/\\D/g, ""); + e.target.value = v.slice(0, 1); + if (v) { + e.target.classList.add("filled"); + if (inputs[i + 1]) inputs[i + 1].focus(); + } else { + e.target.classList.remove("filled"); + } + refresh(); + }); + inp.addEventListener("keydown", function(e) { + if (e.key === "Enter") { + e.preventDefault(); + refresh(); + if (!busy && collectTotp().length === 6) submitTotp(); + return; + } + if (e.key === "Backspace" && !e.target.value && inputs[i - 1]) { + inputs[i - 1].focus(); + inputs[i - 1].value = ""; + inputs[i - 1].classList.remove("filled"); + refresh(); + } + }); + inp.addEventListener("paste", function(e) { + e.preventDefault(); + var src = e.clipboardData || window.clipboardData; + var pasted = ((src && src.getData("text")) || "").replace(/\\D/g, "").slice(0, 6); + pasted.split("").forEach(function(ch, k) { + if (inputs[k]) { inputs[k].value = ch; inputs[k].classList.add("filled"); } + }); + if (inputs[Math.min(pasted.length, 5)]) inputs[Math.min(pasted.length, 5)].focus(); + refresh(); + }); + }); + + // --- approved screen --- + function showDone(payload) { + if (ticketInput) ticketInput.value = payload.ticket || ""; + var accessEl = document.getElementById("access-expiry"); + if (accessEl) { + accessEl.textContent = formatDateTime(payload.access_expires_at) + + " (" + formatRelative(payload.access_expires_at) + ")"; + } + var ticketEl = document.getElementById("ticket-expiry"); + if (ticketEl) ticketEl.textContent = formatRelative(payload.ticket_expires_at); + if (passkeyAddBlock) { + var canAdd = payload.passkeys_enabled && webauthnSupported(); + passkeyAddBlock.hidden = !canAdd; + // Server says passkeys are on but the browser won't expose the API: + // that is an insecure origin, and silently hiding the button just + // makes people wonder where it went. + var addNote = document.getElementById("passkey-add-unsupported"); + if (addNote) addNote.hidden = canAdd || !payload.passkeys_enabled; + } + if (stepVerify) stepVerify.hidden = true; + if (stepDone) stepDone.hidden = false; + if (finishBtn) finishBtn.focus(); + } + + function submitTotp() { + if (busy || !form) return; + var code = collectTotp(); + if (code.length !== 6) return; + busy = true; + show(verifyError, ""); + setLoading(verifyBtn, true, "Verifying\\u2026"); + + var body = new URLSearchParams(new FormData(form)); + body.set("totp", code); + fetch("/oauth/authorize", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-BeaconMCP-Mode": "json" + }, + body: body.toString() + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }).then(function(res) { + busy = false; + setLoading(verifyBtn, false); + if (res.ok && res.data && res.data.ok) { showDone(res.data); return; } + show(verifyError, (res.data && res.data.error) || "Authorization failed."); + clearTotp(); + }).catch(function() { + busy = false; + setLoading(verifyBtn, false); + show(verifyError, "Network error. Try again."); + refresh(); + }); + } + + if (form) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + submitTotp(); + }); + } + + // --- passkey instead of a code --- + if (passkeyBlock) { + var canUse = passkeysEnabled && webauthnSupported(); + passkeyBlock.hidden = !canUse; + var note = document.getElementById("passkey-unsupported"); + if (note) note.hidden = canUse || !passkeysEnabled; + } + + if (passkeyBtn) { + passkeyBtn.addEventListener("click", function() { + if (busy || !form) return; + busy = true; + show(verifyError, ""); + setLoading(passkeyBtn, true, "Waiting for your passkey\\u2026"); + if (verifyBtn) verifyBtn.disabled = true; + + var params = {}; + new FormData(form).forEach(function(v, k) { params[k] = v; }); + postJson("/oauth/passkey/options", { client_id: clientId }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Passkey sign-in unavailable."); + } + var options = Object.assign({}, res.data.options); + options.challenge = b64urlToBuf(res.data.options.challenge); + options.allowCredentials = descriptors(res.data.options.allowCredentials); + return navigator.credentials.get({ publicKey: options }).then(function(cred) { + if (!cred) throw new Error("No passkey was selected."); + return postJson("/oauth/passkey/verify", { + state: res.data.state, + params: params, + credential: { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(cred.response.clientDataJSON), + authenticatorData: bufToB64url(cred.response.authenticatorData), + signature: bufToB64url(cred.response.signature), + userHandle: cred.response.userHandle ? bufToB64url(cred.response.userHandle) : null + } + } + }); + }); + }).then(function(res) { + busy = false; + setLoading(passkeyBtn, false); + refresh(); + if (res.ok && res.data && res.data.ok) { showDone(res.data); return; } + show(verifyError, (res.data && res.data.error) || "Passkey rejected."); + }).catch(function(err) { + busy = false; + setLoading(passkeyBtn, false); + refresh(); + show(verifyError, describeError(err)); + }); + }); + } + + // --- enrol a passkey right after approving --- + if (addPasskeyBtn) { + addPasskeyBtn.addEventListener("click", function() { + show(doneError, ""); + show(doneOk, ""); + setLoading(addPasskeyBtn, true, "Follow your device prompt\\u2026"); + var ticket = ticketInput ? ticketInput.value : ""; + + postJson("/oauth/passkey/register/options", { ticket: ticket }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Could not start registration."); + } + var options = Object.assign({}, res.data.options); + options.challenge = b64urlToBuf(res.data.options.challenge); + options.user = Object.assign({}, res.data.options.user, { + id: b64urlToBuf(res.data.options.user.id) + }); + options.excludeCredentials = descriptors(res.data.options.excludeCredentials); + return navigator.credentials.create({ publicKey: options }).then(function(cred) { + if (!cred) throw new Error("No credential was created."); + var payload = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(cred.response.clientDataJSON), + attestationObject: bufToB64url(cred.response.attestationObject) + } + }; + if (cred.response.getTransports) { + try { payload.response.transports = cred.response.getTransports(); } catch (e) {} + } + return postJson("/oauth/passkey/register/verify", { + ticket: ticket, state: res.data.state, credential: payload + }); + }); + }).then(function(res) { + setLoading(addPasskeyBtn, false); + if (res.ok && res.data && res.data.ok) { + show(doneOk, "Passkey \\u201c" + res.data.passkey.label + + "\\u201d registered. Next time you can skip the 2FA code."); + addPasskeyBtn.disabled = true; + var hint = document.getElementById("passkey-add-hint"); + if (hint) hint.hidden = true; + return; + } + show(doneError, (res.data && res.data.error) || "Registration failed."); + }).catch(function(err) { + setLoading(addPasskeyBtn, false); + show(doneError, describeError(err)); + }); + }); + } + + if (finalizeForm) { + finalizeForm.addEventListener("submit", function() { + setLoading(finishBtn, true, "Redirecting\\u2026"); + }); + } + + if (inputs[0] && !inputs[0].disabled && stepDone && stepDone.hidden) inputs[0].focus(); +})(); +""" + + def _run_http(mcp, host: str, port: int): """Run the MCP server over Streamable HTTP with OAuth client credentials.""" + import datetime as _dt import html + import secrets import uvicorn from contextlib import asynccontextmanager from starlette.applications import Starlette @@ -512,6 +1145,35 @@ async def dispatch(self, request: Request, call_next): ) token_store = TokenStore(db_path=tokens_db, named_token_ttl=named_token_ttl) code_store = CodeStore() + + # Shared dashboard SQLite handle. Three features live in it -- sessions, + # DCR bootstrap slugs and passkeys -- and /oauth/authorize wants the + # passkey table even on deployments that keep the dashboard off, so the + # handle is opened here rather than inside the dashboard builder. + from . import dashboard as _dashboard_mod + from .dashboard import passkeys as passkeys_mod + from .dashboard.db import Database as _Database + + shared_database = None + _database_required = ( + _dashboard_mod.is_enabled() or config.server.allow_dynamic_registration + ) + try: + shared_database = _Database() + except Exception as exc: # noqa: BLE001 + if _database_required: + raise + # Dashboard off and no writable database: passkeys are simply not + # offered. Never a reason to refuse to boot the MCP server. + logging.getLogger("beaconmcp").warning( + "passkey storage unavailable (%s); passkeys disabled", exc, + ) + + passkey_service = passkeys_mod.PasskeyService( + passkeys_mod.PasskeyStore(shared_database) + if shared_database is not None + else None + ) # Share the TokenStore with MCP tools so security_end_session can revoke # the caller's bearer without an import cycle. auth.register_token_store(token_store) @@ -637,10 +1299,84 @@ def _validate_authorize_params( None, ) - def _render_authorize_form( - normalized: dict[str, str], error: str | None = None, locked: bool = False + # Approvals that cleared 2FA but have not been turned into an + # authorization code yet. The operator sits on the confirmation screen + # (session lifetime, optional passkey enrolment) for as long as they + # like; the *code* is only minted when they press "Finish", so its + # 60 s OAuth 2.1 lifetime is never burned by a human reading a page. + # + # In-memory and single-use on purpose: a ticket that survives a restart + # would be a standing authorization nobody asked for. + pending_authorizations: dict[str, tuple[dict[str, str], float]] = {} + PENDING_AUTHORIZATION_TTL = 600.0 + + def _prune_pending() -> None: + now = time.time() + for key in [k for k, (_, exp) in pending_authorizations.items() if now > exp]: + del pending_authorizations[key] + + def _issue_pending(normalized: dict[str, str]) -> tuple[str, float]: + _prune_pending() + ticket = secrets.token_urlsafe(32) + expires_at = time.time() + PENDING_AUTHORIZATION_TTL + pending_authorizations[ticket] = (dict(normalized), expires_at) + return ticket, expires_at + + def _consume_pending(ticket: str) -> dict[str, str] | None: + _prune_pending() + entry = pending_authorizations.pop(ticket, None) + if entry is None: + return None + params, expires_at = entry + return params if time.time() <= expires_at else None + + def _peek_pending(ticket: str) -> dict[str, str] | None: + """Read a ticket without spending it (passkey enrolment mid-screen).""" + _prune_pending() + entry = pending_authorizations.get(ticket) + if entry is None: + return None + params, expires_at = entry + return params if time.time() <= expires_at else None + + def _passkey_owner(client_id: str) -> str: + """Client whose passkeys guard ``client_id``. + + Mirrors the TOTP delegation in :meth:`ClientStore.check_totp`: a + dynamically-registered client (ChatGPT and friends) has no second + factor of its own, so the human owner's passkeys authorize it. + """ + client = client_store.get(client_id) + if client is not None and client.owner_client_id: + return client.owner_client_id + return client_id + + def _authorize_passkeys_enabled(request: Request, client_id: str) -> bool: + if passkey_service is None or not passkey_service.available: + return False + return passkeys_mod.is_secure_context(request) + + def _wants_json_authorize(request: Request) -> bool: + return request.headers.get("x-beaconmcp-mode", "") == "json" + + def _authorize_page( + normalized: dict[str, str], + *, + request: Request, + error: str | None = None, + locked: bool = False, + ticket: str | None = None, + access_expires_at: float | None = None, + ticket_expires_at: float | None = None, ) -> HTMLResponse: - client_name = client_store.get_name(normalized["client_id"]) or normalized["client_id"] + """Render the two-panel authorize page. + + ``ticket`` switches it to the post-2FA panel. That panel is rendered + server-side too, so the flow still completes with JavaScript off: + the confirmation screen is a plain form posting the ticket back. + """ + client_id = normalized["client_id"] + client_name = client_store.get_name(client_id) or client_id hidden = "\n".join( f'' for k, v in normalized.items() @@ -649,273 +1385,198 @@ def _render_authorize_form( banner = "" if locked: banner = ( - '" + '' ) elif error: - banner = f'' + banner = f'' disabled = "disabled" if locked else "" - # Self-contained page: /oauth/authorize is reachable even when the - # dashboard (and its /app/static bundle) is disabled. Styles/JS live - # inline; same design tokens as the dashboard auth pages. + approved = ticket is not None + passkeys_on = _authorize_passkeys_enabled(request, client_id) + + def _fmt(ts: float | None) -> str: + if not ts: + return "—" + return html.escape( + _dt.datetime.fromtimestamp(ts).strftime("%d %b %Y at %H:%M") + ) + page = f""" -BeaconMCP · Two-factor +BeaconMCP · Authorize - - + + -
+
BeaconMCP
-

Authorize access

-

Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

- {banner} -
- - Client: {html.escape(normalized["client_id"])} -
-
+ +
+

Authorize access

+

Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

+ {banner} +
+ + Client: {html.escape(client_id)} +
+ + {hidden} -
- - - - - - +
+ + + + + + +
+ + + + + +
+ +
+ +

Two-factor confirmed

+

You're about to grant {html.escape(client_name)} access to this BeaconMCP server.

+ + + +
+
+
Access expires
+
{_fmt(access_expires_at)}
+
+
+
This approval expires
+
{_fmt(ticket_expires_at)}
+
+
+

The client will have to go through two-factor again once its access expires.

+ + - - - + + +
+ + +
+
- + """ return HTMLResponse(page) + def _authorize_redirect(normalized: dict[str, str]) -> Response: + """Mint the authorization code and bounce back to the OAuth client.""" + redirect_uri = normalized["redirect_uri"] + code = code_store.issue( + normalized["client_id"], + redirect_uri, + normalized["code_challenge"], + normalized["code_challenge_method"], + ) + query = {"code": code} + if normalized["state"]: + query["state"] = normalized["state"] + parsed = urlparse(redirect_uri) + sep = "&" if parsed.query else "?" + location = f"{redirect_uri}{sep}{urlencode(query)}" + return Response(status_code=302, headers={"Location": location}) + + def _authorize_approved( + request: Request, normalized: dict[str, str], *, as_json: bool = False, + ) -> Response: + """Second factor cleared: hand the operator the confirmation screen. + + ``as_json`` forces the JSON shape for callers that are JSON-only by + construction (the passkey endpoints), independently of whether the + client remembered to send the mode header. + """ + ticket, ticket_expires_at = _issue_pending(normalized) + access_expires_at = time.time() + TokenStore.TOKEN_TTL + if as_json or _wants_json_authorize(request): + return JSONResponse({ + "ok": True, + "ticket": ticket, + "ticket_expires_at": ticket_expires_at, + "access_expires_at": access_expires_at, + "passkeys_enabled": _authorize_passkeys_enabled( + request, normalized["client_id"], + ), + }) + return _authorize_page( + normalized, + request=request, + ticket=ticket, + ticket_expires_at=ticket_expires_at, + access_expires_at=access_expires_at, + ) + + def _authorize_failed( + request: Request, + normalized: dict[str, str], + message: str, + *, + status: int = 401, + locked: bool = False, + ) -> Response: + if _wants_json_authorize(request): + return JSONResponse( + {"ok": False, "error": message, "locked": locked}, + status_code=status, + ) + return _authorize_page( + normalized, request=request, error=message, locked=locked, + ) + async def oauth_authorize_get(request: Request) -> Response: normalized, err = _validate_authorize_params(dict(request.query_params)) if err is not None: return err - return _render_authorize_form( - normalized, locked=totp_locked(normalized["client_id"]) + return _authorize_page( + normalized, + request=request, + locked=totp_locked(normalized["client_id"]), ) async def oauth_authorize_post(request: Request) -> Response: @@ -927,7 +1588,11 @@ async def oauth_authorize_post(request: Request) -> Response: client_id = normalized["client_id"] if totp_locked(client_id): - return _render_authorize_form(normalized, locked=True) + return _authorize_failed( + request, normalized, + "Too many attempts. Try again in 5 minutes.", + status=429, locked=True, + ) code_totp = body.get("totp", "") totp_result = client_store.check_totp(client_id, code_totp) @@ -941,9 +1606,9 @@ async def oauth_authorize_post(request: Request) -> Response: reason=totp_result.value, ip=client_ip(request, tuple(config.server.trusted_proxies)), ) - return _render_authorize_form( - normalized, - error=( + return _authorize_failed( + request, normalized, + ( TOTP_REPLAY_MESSAGE if totp_result is TotpResult.REPLAY else "Incorrect code. Check that your device clock is in sync." @@ -952,24 +1617,195 @@ async def oauth_authorize_post(request: Request) -> Response: ) totp_record_success(client_id) audit.emit( - "auth.authorize.ok", client_id=client_id, + "auth.authorize.2fa", client_id=client_id, ip=client_ip(request, tuple(config.server.trusted_proxies)), ) + return _authorize_approved(request, normalized) - redirect_uri = normalized["redirect_uri"] - code = code_store.issue( - client_id, - redirect_uri, - normalized["code_challenge"], - normalized["code_challenge_method"], + async def oauth_authorize_finalize(request: Request) -> Response: + """Turn an approved ticket into an authorization code and redirect.""" + form = await request.form() + ticket_raw = form.get("ticket", "") + ticket = ticket_raw.strip() if isinstance(ticket_raw, str) else "" + normalized = _consume_pending(ticket) if ticket else None + if normalized is None: + return JSONResponse( + { + "error": "invalid_request", + "error_description": ( + "This approval expired or was already used. " + "Start the authorization again." + ), + }, + status_code=400, + ) + audit.emit( + "auth.authorize.ok", client_id=normalized["client_id"], + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return _authorize_redirect(normalized) + + # --- passkeys on the authorize page ---------------------------------- + + def _authorize_passkey_guard(request: Request) -> Response | None: + """Rate-limit + availability gate shared by the passkey endpoints.""" + if passkey_service is None or not passkey_service.available: + return JSONResponse( + {"ok": False, "error": "Passkeys are not available here."}, + status_code=503, + ) + ip = client_ip(request, tuple(config.server.trusted_proxies)) + if not _login_limiter.check(ip): + retry = _login_limiter.retry_after(ip) + return JSONResponse( + { + "ok": False, + "error": f"Too many attempts. Retry in {retry}s.", + }, + status_code=429, + ) + return None + + async def _authorize_json_body(request: Request) -> dict: + try: + data = await request.json() + except Exception: # noqa: BLE001 + return {} + return data if isinstance(data, dict) else {} + + async def oauth_passkey_options(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + client_id = str(body.get("client_id") or "").strip() + if not client_id or not client_store.exists(client_id): + return JSONResponse( + {"ok": False, "error": "Unknown client."}, status_code=400, + ) + if totp_locked(client_id): + return JSONResponse( + {"ok": False, "error": "Too many attempts. Try again in 5 minutes."}, + status_code=429, + ) + try: + options, state = passkey_service.authentication_options( + request, client_id=_passkey_owner(client_id), + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse( + {"ok": False, "error": str(exc)}, status_code=400, + ) + return JSONResponse({"ok": True, "options": options, "state": state}) + + async def oauth_passkey_verify(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + raw_params = body.get("params") + if not isinstance(raw_params, dict): + return JSONResponse( + {"ok": False, "error": "invalid_request"}, status_code=400, + ) + params = {k: v for k, v in raw_params.items() if isinstance(v, str)} + normalized, err = _validate_authorize_params(params) + if err is not None: + return JSONResponse( + {"ok": False, "error": "Invalid authorization request."}, + status_code=400, + ) + client_id = normalized["client_id"] + if totp_locked(client_id): + return JSONResponse( + {"ok": False, "error": "Too many attempts. Try again in 5 minutes."}, + status_code=429, + ) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return JSONResponse( + {"ok": False, "error": "Malformed passkey response."}, + status_code=400, + ) + try: + record = passkey_service.verify_authentication( + request, state=state, credential=credential, + ) + except passkeys_mod.PasskeyError as exc: + audit.emit( + "auth.authorize.fail", client_id=client_id, + reason=f"passkey:{exc}", + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return JSONResponse({"ok": False, "error": str(exc)}, status_code=401) + # The challenge was minted for the seed owner; make sure the + # credential that answered it really guards *this* client. + if record.client_id != _passkey_owner(client_id): + return JSONResponse( + {"ok": False, "error": "This passkey belongs to another client."}, + status_code=401, + ) + totp_record_success(client_id) + audit.emit( + "auth.authorize.2fa", client_id=client_id, via="passkey", + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return _authorize_approved(request, normalized, as_json=True) + + async def oauth_passkey_register_options(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + normalized = _peek_pending(str(body.get("ticket") or "")) + if normalized is None: + return JSONResponse( + {"ok": False, "error": "This approval expired. Start again."}, + status_code=400, + ) + owner = _passkey_owner(normalized["client_id"]) + try: + options, state = passkey_service.registration_options( + request, + client_id=owner, + client_name=client_store.get_name(owner) or owner, + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + return JSONResponse({"ok": True, "options": options, "state": state}) + + async def oauth_passkey_register_verify(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + normalized = _peek_pending(str(body.get("ticket") or "")) + if normalized is None: + return JSONResponse( + {"ok": False, "error": "This approval expired. Start again."}, + status_code=400, + ) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return JSONResponse( + {"ok": False, "error": "Malformed passkey response."}, + status_code=400, + ) + try: + record = passkey_service.verify_registration( + request, state=state, credential=credential, + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + audit.emit( + "auth.passkey.register.ok", + client_id=record.client_id, label=record.label, + ) + return JSONResponse( + {"ok": True, "passkey": record.to_json()}, status_code=201, ) - query = {"code": code} - if normalized["state"]: - query["state"] = normalized["state"] - parsed = urlparse(redirect_uri) - sep = "&" if parsed.query else "?" - location = f"{redirect_uri}{sep}{urlencode(query)}" - return Response(status_code=302, headers={"Location": location}) async def oauth_token(request: Request) -> Response: ip = client_ip(request, tuple(config.server.trusted_proxies)) @@ -1085,6 +1921,7 @@ async def auth_middleware(request: Request, call_next): "/metrics", "/oauth/token", "/oauth/authorize", + "/oauth/authorize/finalize", "/oauth/register", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", @@ -1101,6 +1938,14 @@ async def auth_middleware(request: Request, call_next): ): return await call_next(request) + # Passkey ceremonies for /oauth/authorize. They are part of signing + # in, so they cannot require a bearer -- there isn't one yet. Their + # own guards are the WebAuthn signature, the TOTP lockout and the + # per-IP login limiter; enrolment additionally demands an approval + # ticket, which is only handed out after a second factor passed. + if path.startswith("/oauth/passkey/"): + return await call_next(request) + # Dashboard routes have their own session-based auth. if path.startswith("/app/"): return await call_next(request) @@ -1149,10 +1994,8 @@ async def auth_middleware(request: Request, call_next): # OAuth Dynamic Client Registration plumbing. Only engaged when both # the feature flag is set AND the dashboard is enabled (the slug store - # lives in the dashboard's SQLite db). - from . import dashboard as _dashboard_mod + # lives in the dashboard's SQLite db, opened above). dyn_reg_store = None - shared_database = None if config.server.allow_dynamic_registration: if not _dashboard_mod.is_enabled(): print( @@ -1162,9 +2005,8 @@ async def auth_middleware(request: Request, call_next): file=sys.stderr, ) sys.exit(1) - from .dashboard.db import Database as _Database from .dashboard.dyn_reg import DynamicSlugStore as _DynamicSlugStore - shared_database = _Database() + assert shared_database is not None # required path: creation raised otherwise dyn_reg_store = _DynamicSlugStore(shared_database) async def dcr_protected_resource_metadata(request: Request) -> Response: @@ -1337,6 +2179,7 @@ async def lifespan(_app): dyn_reg=dyn_reg_store, shared_database=shared_database, login_limiter=_login_limiter, trusted_proxies=tuple(config.server.trusted_proxies), + passkey_service=passkey_service, ) app = Starlette( @@ -1348,6 +2191,20 @@ async def lifespan(_app): Route("/.well-known/oauth-protected-resource/mcp", protected_resource_metadata), Route("/oauth/authorize", oauth_authorize_get, methods=["GET"]), Route("/oauth/authorize", oauth_authorize_post, methods=["POST"]), + Route( + "/oauth/authorize/finalize", + oauth_authorize_finalize, methods=["POST"], + ), + Route("/oauth/passkey/options", oauth_passkey_options, methods=["POST"]), + Route("/oauth/passkey/verify", oauth_passkey_verify, methods=["POST"]), + Route( + "/oauth/passkey/register/options", + oauth_passkey_register_options, methods=["POST"], + ), + Route( + "/oauth/passkey/register/verify", + oauth_passkey_register_verify, methods=["POST"], + ), Route("/oauth/token", oauth_token, methods=["POST"]), Route("/oauth/register", oauth_register, methods=["POST"]), *dcr_routes, @@ -1373,6 +2230,13 @@ async def lifespan(_app): print(f"Dashboard: http://{host}:{port}/app/login (chat: {chat_status})") else: print("Dashboard: disabled (BEACONMCP_DASHBOARD_ENABLED=false)") + # The login pages hide their passkey buttons when this is off, with no + # visible explanation -- so say it here, where the operator can see it. + passkey_reason = passkey_service.unavailable_reason + if passkey_reason is None: + print("Passkeys: enabled (also needs HTTPS, or a loopback host)") + else: + print(f"Passkeys: disabled - {passkey_reason}") if n_clients == 0: print("\nNo clients registered. Create one with: beaconmcp auth create --name 'My Client'") uvicorn.run(app, host=host, port=port, log_level="info") @@ -1381,7 +2245,8 @@ async def lifespan(_app): def _build_dashboard_routes(client_store, token_store, totp_locked, totp_record_failure, totp_record_success, *, dyn_reg=None, shared_database=None, - login_limiter=None, trusted_proxies=()): + login_limiter=None, trusted_proxies=(), + passkey_service=None): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -1454,6 +2319,7 @@ def _float_env(name: str, default: float) -> float: dyn_reg=dyn_reg, login_limiter=login_limiter, trusted_proxies=trusted_proxies, + passkeys=passkey_service, ) return build_dashboard_routes(deps) diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 97ffec2..34b8f54 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -51,6 +51,7 @@ ) from .db import Database from .dyn_reg import DynamicSlugStore, SLUG_TTL_SECONDS +from .passkeys import PasskeyError, PasskeyService, is_secure_context from .session import SESSION_TTL_SECONDS, Session, SessionStore from .usage import UsageMeter, UsageStore @@ -98,6 +99,10 @@ class DashboardDeps: # tests/embedding paths can skip the limiter entirely. login_limiter: object | None = None trusted_proxies: tuple[str, ...] = () + # WebAuthn ceremonies for the passkey login/registration flow. When + # unset (or reporting ``available is False``), the login page hides + # every passkey affordance and the TOTP path is the only way in. + passkeys: PasskeyService | None = None # --------------------------------------------------------------------------- @@ -121,8 +126,17 @@ def _set_session_cookie(response: Response, session_id: str, secure: bool) -> No ) -def _set_csrf_cookie(response: Response, secure: bool) -> str: - token = csrf.issue_token() +def _set_csrf_cookie( + response: Response, secure: bool, token: str | None = None, +) -> str: + """Issue (or re-issue) the double-submit CSRF cookie. + + ``token`` lets a caller mint the value first so it can also hand it to + the client out-of-band -- the JSON login does that, because rotating + the cookie without telling the page would leave its in-DOM token stale + and every follow-up fetch would 403. + """ + token = token or csrf.issue_token() response.set_cookie( csrf.CSRF_COOKIE, token, @@ -243,6 +257,21 @@ def _totp_error(result: TotpResult, invalid_message: str) -> str: return TOTP_REPLAY_MESSAGE if result is TotpResult.REPLAY else invalid_message +def _wants_json(request: Request) -> bool: + """True when the caller is the login page's fetch() rather than a form POST. + + The login form still works with JavaScript disabled -- it posts and gets + a redirect, exactly as before. The enhanced flow (shimmer, then the + post-2FA screen offering to enrol a passkey) needs to stay on the page, + so it opts in explicitly with this header. + """ + return request.headers.get("x-beaconmcp-mode", "") == "json" + + +def _passkeys_ready(deps: DashboardDeps) -> bool: + return deps.passkeys is not None and deps.passkeys.available + + # --------------------------------------------------------------------------- # Route factories # --------------------------------------------------------------------------- @@ -278,8 +307,72 @@ async def login_get(request: Request) -> Response: next=request.query_params.get("next", ""), banner=None, locked=False, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) + def _login_success_payload( + request: Request, session: Session, *, next_url: str, bearer_ttl: int, + ) -> dict[str, Any]: + """Data the post-2FA screen renders: expiry + passkey affordances.""" + return { + "ok": True, + "next": next_url, + "client_id": session.client_id, + "client_name": ( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + # Two different clocks, and users care about both: the MCP bearer + # is what actually stops working (a 2FA re-prompt at /app/refresh), + # while the signed-in cookie is what saves them the full login. + "bearer_expires_at": time.time() + bearer_ttl, + "session_expires_at": session.expires_at, + "passkeys_enabled": _passkeys_ready(deps), + "secure_context": is_secure_context(request), + "passkey_count": ( + deps.passkeys.store.count_for_client(session.client_id) + if _passkeys_ready(deps) else 0 + ), + } + + def _start_session( + request: Request, + *, + client_id: str, + client_secret: str, + next_url: str, + as_json: bool | None = None, + ) -> tuple[Session, int, Response]: + """Mint the MCP bearer + session row and attach the cookies. + + Shared by the TOTP login and the passkey login so the two paths + cannot drift on cookie flags or bearer lifetime. + """ + bearer, ttl = deps.token_store.issue(client_id) # type: ignore[attr-defined] + ua = request.headers.get("user-agent", "")[:200] + session = deps.session_store.create( + client_id=client_id, + client_secret=client_secret, + mcp_bearer=bearer, + bearer_ttl_seconds=ttl, + user_agent=ua, + ) + secure = _is_secure(request) + new_csrf = csrf.issue_token() + if as_json if as_json is not None else _wants_json(request): + payload = _login_success_payload( + request, session, next_url=next_url, bearer_ttl=ttl, + ) + payload["csrf_token"] = new_csrf + response: Response = JSONResponse(payload) + else: + response = RedirectResponse(next_url, status_code=303) + _set_session_cookie(response, session.session_id, secure) + _set_csrf_cookie(response, secure, new_csrf) + _apply_security_headers(response) + return session, ttl, response + async def login_post(request: Request) -> Response: if not await csrf.verify(request): return JSONResponse({"error": "csrf"}, status_code=403) @@ -292,14 +385,23 @@ async def login_post(request: Request) -> Response: ip = _client_ip(request, deps.trusted_proxies) if not limiter.check(ip): # type: ignore[attr-defined] retry = limiter.retry_after(ip) # type: ignore[attr-defined] + message = ( + f"Too many attempts from this address. Retry in {retry}s." + ) + if _wants_json(request): + return _json( + {"ok": False, "error": message, "locked": True}, status=429, + ) return _render( "login.html", request, client_id="", next="", - banner=f"Too many attempts from this address. Retry in {retry}s.", + banner=message, locked=True, status_code=429, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) form = await request.form() @@ -316,6 +418,10 @@ def _v(name: str) -> str: next_url = _default_landing() def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: + if _wants_json(request): + return _json( + {"ok": False, "error": message, "locked": locked}, status=status, + ) return _render( "login.html", request, @@ -324,6 +430,8 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: banner=message, locked=locked, status_code=status, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) if not client_id or not client_secret or not totp: @@ -356,21 +464,12 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: deps.totp_record_success(client_id) audit.emit("dashboard.login.ok", client_id=client_id) - bearer, ttl = deps.token_store.issue(client_id) # type: ignore[attr-defined] - ua = request.headers.get("user-agent", "")[:200] - session = deps.session_store.create( + _, _, response = _start_session( + request, client_id=client_id, client_secret=client_secret, - mcp_bearer=bearer, - bearer_ttl_seconds=ttl, - user_agent=ua, + next_url=next_url, ) - - secure = _is_secure(request) - response = RedirectResponse(next_url, status_code=303) - _set_session_cookie(response, session.session_id, secure) - _set_csrf_cookie(response, secure) - _apply_security_headers(response) return response async def refresh_get(request: Request) -> Response: @@ -772,6 +871,231 @@ async def tokens_revoke(request: Request) -> Response: ) return RedirectResponse("/app/tokens", status_code=303) + # --- Passkeys (WebAuthn) --------------------------------------------- + + def _passkey_service() -> PasskeyService | Response: + if not _passkeys_ready(deps): + return _json({"error": "passkeys_unavailable"}, status=503) + assert deps.passkeys is not None + return deps.passkeys + + async def api_passkeys_list(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + service = _passkey_service() + if isinstance(service, Response): + return service + return _json({ + "passkeys": [ + p.to_json() for p in service.store.list_for_client(session.client_id) + ], + }) + + async def api_passkeys_register_options(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + try: + options, state = service.registration_options( + request, + client_id=session.client_id, + client_name=( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + session_id=session.session_id, + ) + except PasskeyError as exc: + return _json({"error": str(exc)}, status=400) + return _json({"options": options, "state": state}) + + async def api_passkeys_register_verify(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return _json({"error": "invalid_request"}, status=400) + label_raw = body.get("label") + try: + record = service.verify_registration( + request, + state=state, + credential=credential, + label=label_raw if isinstance(label_raw, str) else None, + session_id=session.session_id, + ) + except PasskeyError as exc: + audit.emit( + "dashboard.passkey.register.fail", + client_id=session.client_id, reason=str(exc), + ) + return _json({"error": str(exc)}, status=400) + audit.emit( + "dashboard.passkey.register.ok", + client_id=session.client_id, label=record.label, + ) + return _json({"ok": True, "passkey": record.to_json()}, status=201) + + async def api_passkeys_delete(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + credential_id = str(body.get("credential_id") or "").strip() + if not credential_id: + return _json({"error": "invalid_request"}, status=400) + if not service.store.delete(credential_id, session.client_id): + return _json({"error": "not_found"}, status=404) + audit.emit("dashboard.passkey.revoke", client_id=session.client_id) + return _json({"ok": True}) + + async def passkeys_remove(request: Request) -> Response: + """Form-POST twin of the JSON delete, for the tokens page. + + The tokens page is deliberately JS-free for destructive actions, so + removing a passkey posts a plain form and lands back on the page. + """ + session = _load_session(request, deps) + if not session: + return RedirectResponse("/app/login", status_code=302) + if not _bearer_live(deps, session): + return RedirectResponse("/app/refresh?next=/app/tokens", status_code=302) + if not await csrf.verify(request): + return JSONResponse({"error": "csrf"}, status_code=403) + if _passkeys_ready(deps): + form = await request.form() + raw = form.get("credential_id", "") + credential_id = (raw if isinstance(raw, str) else "").strip() + assert deps.passkeys is not None + if credential_id and deps.passkeys.store.delete( + credential_id, session.client_id, + ): + audit.emit("dashboard.passkey.revoke", client_id=session.client_id) + return RedirectResponse("/app/tokens", status_code=303) + + def _passkey_login_rate_limited(request: Request) -> Response | None: + """Apply the login limiter to the unauthenticated passkey endpoints. + + These take a client_id/client_secret pair, so they are just as much + a credential-stuffing surface as /app/login itself. + """ + limiter = deps.login_limiter + if limiter is None: + return None + from ..ratelimit import client_ip as _client_ip # local: avoid import cycle + ip = _client_ip(request, deps.trusted_proxies) + if limiter.check(ip): # type: ignore[attr-defined] + return None + retry = limiter.retry_after(ip) # type: ignore[attr-defined] + return _json( + { + "ok": False, + "error": f"Too many attempts from this address. Retry in {retry}s.", + }, + status=429, + ) + + async def api_passkeys_auth_options(request: Request) -> Response: + """Start a passkey sign-in. Requires the client credentials first. + + The passkey replaces the TOTP factor only: without a valid + client_id/client_secret we will not even disclose whether a client + has credentials registered. + """ + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + limited = _passkey_login_rate_limited(request) + if limited is not None: + return limited + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + client_id = str(body.get("client_id") or "").strip() + client_secret = str(body.get("client_secret") or "") + if not client_id or not client_secret: + return _json({"ok": False, "error": "Missing credentials."}, status=400) + if not deps.client_store.verify(client_id, client_secret): # type: ignore[attr-defined] + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + try: + options, state = service.authentication_options( + request, client_id=client_id, + ) + except PasskeyError as exc: + return _json({"ok": False, "error": str(exc)}, status=400) + return _json({"ok": True, "options": options, "state": state}) + + async def api_passkeys_auth_verify(request: Request) -> Response: + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + limited = _passkey_login_rate_limited(request) + if limited is not None: + return limited + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + client_id = str(body.get("client_id") or "").strip() + client_secret = str(body.get("client_secret") or "") + state = str(body.get("state") or "") + credential = body.get("credential") + if not client_id or not client_secret or not state: + return _json({"ok": False, "error": "Missing credentials."}, status=400) + if not isinstance(credential, dict): + return _json({"ok": False, "error": "Malformed passkey response."}, status=400) + # Re-verify the secret: the options call proved it once, but the + # state token alone must never be enough to mint a session. + if not deps.client_store.verify(client_id, client_secret): # type: ignore[attr-defined] + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + + try: + record = service.verify_authentication( + request, state=state, credential=credential, + ) + except PasskeyError as exc: + audit.emit( + "dashboard.login.fail", client_id=client_id, + reason=f"passkey:{exc}", + ) + return _json({"ok": False, "error": str(exc)}, status=401) + if record.client_id != client_id: + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + + next_url = str(body.get("next") or "").strip() + if not next_url.startswith("/app/"): + next_url = _default_landing() + # A passkey sign-in is a clean second factor: clear any TOTP + # lockout the same way a correct code would. + deps.totp_record_success(client_id) + audit.emit("dashboard.login.ok", client_id=client_id, via="passkey") + _, _, response = _start_session( + request, + client_id=client_id, + client_secret=client_secret, + next_url=next_url, + as_json=True, + ) + return response + async def chat_get(request: Request) -> Response: session = _load_session(request, deps) if not session: @@ -1132,6 +1456,25 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/app/api/chat/stream", api_chat_stream, methods=["POST"]), Route("/app/api/chat/confirm", api_chat_confirm, methods=["POST"]), Route("/app/api/usage", api_usage, methods=["GET"]), + Route("/app/api/passkeys", api_passkeys_list, methods=["GET"]), + Route( + "/app/api/passkeys/register/options", + api_passkeys_register_options, methods=["POST"], + ), + Route( + "/app/api/passkeys/register/verify", + api_passkeys_register_verify, methods=["POST"], + ), + Route("/app/api/passkeys/delete", api_passkeys_delete, methods=["POST"]), + Route("/app/passkeys/remove", passkeys_remove, methods=["POST"]), + Route( + "/app/api/passkeys/auth/options", + api_passkeys_auth_options, methods=["POST"], + ), + Route( + "/app/api/passkeys/auth/verify", + api_passkeys_auth_verify, methods=["POST"], + ), Route("/", index, methods=["GET"]), Mount( "/app/static", @@ -1251,6 +1594,21 @@ def _expires_label(expires_at: float) -> str: locked=deps.totp_locked(session.client_id), chat_enabled=deps.engine is not None, dcr_enabled=deps.dyn_reg is not None, + passkeys_enabled=_passkeys_ready(deps), + passkeys=( + [ + { + "credential_id": p.credential_id, + "label": p.label, + "created_human": _human_time(p.created_at), + "last_used_human": ( + _human_time(p.last_used_at) if p.last_used_at else None + ), + } + for p in deps.passkeys.store.list_for_client(session.client_id) + ] + if _passkeys_ready(deps) else [] + ), ) diff --git a/src/beaconmcp/dashboard/db.py b/src/beaconmcp/dashboard/db.py index 7607758..dd80fd7 100644 --- a/src/beaconmcp/dashboard/db.py +++ b/src/beaconmcp/dashboard/db.py @@ -19,7 +19,7 @@ def db_path() -> Path: return Path(override) if override else DEFAULT_DB_PATH -_LATEST_VERSION = 4 +_LATEST_VERSION = 5 def _migrate(conn: sqlite3.Connection) -> None: @@ -147,6 +147,30 @@ def _migrate(conn: sqlite3.Connection) -> None: """ ) + if version < 5: + # WebAuthn credentials, one row per registered passkey. The public + # key and signature counter are all we need to verify assertions; + # nothing here is secret (a public key is public), but the table + # still lives in the 0600 dashboard database because knowing which + # credentials a client owns is useful to an attacker. + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS passkeys ( + credential_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT, + label TEXT NOT NULL, + created_at REAL NOT NULL, + last_used_at REAL, + backed_up INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_passkeys_client + ON passkeys(client_id, created_at DESC); + """ + ) + conn.execute(f"PRAGMA user_version = {_LATEST_VERSION}") conn.commit() diff --git a/src/beaconmcp/dashboard/passkeys.py b/src/beaconmcp/dashboard/passkeys.py new file mode 100644 index 0000000..d80370f --- /dev/null +++ b/src/beaconmcp/dashboard/passkeys.py @@ -0,0 +1,598 @@ +"""Passkey (WebAuthn) support for the BeaconMCP login pages. + +A passkey stands in for the TOTP factor, never for the client secret. The +flow on both login pages stays two-factor: + +1. ``client_id`` + ``client_secret`` -- something you know / have stored. +2. Either a 6-digit TOTP code **or** a WebAuthn assertion -- something you + have on your device. + +That ordering is deliberate. The dashboard session record encrypts the +client secret so it can re-mint MCP bearers later, so a purely +"usernameless" passkey login could not build a usable session anyway. +Keeping the secret as the first factor also means a stolen passkey alone +is worthless. + +Credentials live in the dashboard SQLite database (``passkeys`` table); +challenges live in memory with a short TTL, since a challenge that +survives a restart is a replay window, not a feature. + +The ``webauthn`` package (py_webauthn) is an optional dependency: when it +is missing, :class:`PasskeyService` reports ``available is False`` and +every login page simply hides its passkey affordances instead of erroring. +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import threading +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - typing only + from starlette.requests import Request + + from .db import Database + +try: + import webauthn as _webauthn + from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, + ) +except ImportError: # pragma: no cover - exercised only on slim installs + _webauthn = None # type: ignore[assignment] + AuthenticatorSelectionCriteria = None # type: ignore[assignment,misc] + AuthenticatorTransport = None # type: ignore[assignment,misc] + PublicKeyCredentialDescriptor = None # type: ignore[assignment,misc] + ResidentKeyRequirement = None # type: ignore[assignment,misc] + UserVerificationRequirement = None # type: ignore[assignment,misc] + + +#: How long a registration/authentication challenge stays usable. The spec +#: suggests a couple of minutes; the browser prompt itself times out at 60 s. +CHALLENGE_TTL_SECONDS = 180 + +#: Relying-party display name shown in the OS passkey prompt. +RP_NAME = "BeaconMCP" + +#: Cap on credentials per client. High enough for phone + laptop + a +#: hardware key or two, low enough that the allowCredentials list stays sane. +MAX_PASSKEYS_PER_CLIENT = 10 + + +class PasskeyError(Exception): + """User-facing passkey failure. The message is safe to render.""" + + +def webauthn_installed() -> bool: + """True when the optional ``webauthn`` dependency is importable.""" + return _webauthn is not None + + +def b64url_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def b64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + +# --------------------------------------------------------------------------- +# Relying-party identity, derived from the live request +# --------------------------------------------------------------------------- + +def _forwarded_host(request: "Request") -> str: + host = request.headers.get("x-forwarded-host") or request.headers.get("host") + if not host: + host = request.url.netloc or "localhost" + # A proxy chain may append entries; the first one is the client-facing host. + return host.split(",")[0].strip() + + +def rp_id_for(request: "Request") -> str: + """Return the WebAuthn RP ID (effective domain, no scheme, no port). + + Derived from the request rather than configured, so a deployment that + moves behind a new hostname keeps working without a config edit. The + trade-off is that credentials are scoped to the hostname they were + registered under -- which is exactly the WebAuthn security model. + """ + host = _forwarded_host(request) + if host.startswith("["): # IPv6 literal: [::1]:8420 + closing = host.find("]") + if closing != -1: + return host[1:closing] + return host.rsplit(":", 1)[0] if ":" in host else host + + +def origin_for(request: "Request") -> str: + """Return the origin the browser will put in ``clientDataJSON``.""" + scheme = request.headers.get("x-forwarded-proto") or request.url.scheme + scheme = scheme.split(",")[0].strip() + return f"{scheme}://{_forwarded_host(request)}" + + +def is_secure_context(request: "Request") -> bool: + """True when the browser will expose ``navigator.credentials``. + + WebAuthn is gated on a secure context: HTTPS, or a loopback host. A + plain-HTTP LAN deployment silently has no passkey API at all, so the + UI needs to know before it offers the button. + """ + scheme = ( + request.headers.get("x-forwarded-proto") or request.url.scheme + ).split(",")[0].strip() + if scheme == "https": + return True + return rp_id_for(request) in ("localhost", "127.0.0.1", "::1") + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +@dataclass +class PasskeyRecord: + credential_id: str # base64url + client_id: str + public_key: bytes + sign_count: int + transports: list[str] + label: str + created_at: float + last_used_at: float | None + backed_up: bool + + def to_json(self) -> dict[str, Any]: + return { + "credential_id": self.credential_id, + "label": self.label, + "created_at": self.created_at, + "last_used_at": self.last_used_at, + "backed_up": self.backed_up, + "transports": self.transports, + } + + +class PasskeyStore: + """Thin wrapper around the ``passkeys`` table.""" + + def __init__(self, database: "Database") -> None: + self._db = database + + @staticmethod + def _row_to_record(row: Any) -> PasskeyRecord: + try: + transports = json.loads(row["transports"] or "[]") + except (TypeError, ValueError): + transports = [] + return PasskeyRecord( + credential_id=row["credential_id"], + client_id=row["client_id"], + public_key=row["public_key"], + sign_count=row["sign_count"], + transports=[t for t in transports if isinstance(t, str)], + label=row["label"], + created_at=row["created_at"], + last_used_at=row["last_used_at"], + backed_up=bool(row["backed_up"]), + ) + + def add( + self, + *, + credential_id: str, + client_id: str, + public_key: bytes, + sign_count: int, + transports: list[str], + label: str, + backed_up: bool, + ) -> PasskeyRecord: + now = time.time() + self._db.conn().execute( + """ + INSERT INTO passkeys ( + credential_id, client_id, public_key, sign_count, + transports, label, created_at, last_used_at, backed_up + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?) + """, + ( + credential_id, + client_id, + public_key, + sign_count, + json.dumps(transports), + label, + now, + 1 if backed_up else 0, + ), + ) + return PasskeyRecord( + credential_id=credential_id, + client_id=client_id, + public_key=public_key, + sign_count=sign_count, + transports=transports, + label=label, + created_at=now, + last_used_at=None, + backed_up=backed_up, + ) + + def list_for_client(self, client_id: str) -> list[PasskeyRecord]: + rows = self._db.conn().execute( + """ + SELECT credential_id, client_id, public_key, sign_count, transports, + label, created_at, last_used_at, backed_up + FROM passkeys WHERE client_id = ? + ORDER BY created_at DESC + """, + (client_id,), + ).fetchall() + return [self._row_to_record(r) for r in rows] + + def get(self, credential_id: str) -> PasskeyRecord | None: + row = self._db.conn().execute( + """ + SELECT credential_id, client_id, public_key, sign_count, transports, + label, created_at, last_used_at, backed_up + FROM passkeys WHERE credential_id = ? + """, + (credential_id,), + ).fetchone() + return self._row_to_record(row) if row is not None else None + + def count_for_client(self, client_id: str) -> int: + row = self._db.conn().execute( + "SELECT COUNT(*) AS n FROM passkeys WHERE client_id = ?", + (client_id,), + ).fetchone() + return int(row["n"]) if row is not None else 0 + + def touch(self, credential_id: str, sign_count: int) -> None: + self._db.conn().execute( + "UPDATE passkeys SET sign_count = ?, last_used_at = ? " + "WHERE credential_id = ?", + (sign_count, time.time(), credential_id), + ) + + def delete(self, credential_id: str, client_id: str) -> bool: + cur = self._db.conn().execute( + "DELETE FROM passkeys WHERE credential_id = ? AND client_id = ?", + (credential_id, client_id), + ) + return bool(cur.rowcount) + + def delete_all_for_client(self, client_id: str) -> int: + cur = self._db.conn().execute( + "DELETE FROM passkeys WHERE client_id = ?", (client_id,) + ) + return cur.rowcount or 0 + + +# --------------------------------------------------------------------------- +# Challenges +# --------------------------------------------------------------------------- + +@dataclass +class _Challenge: + purpose: str # "register" | "authenticate" + challenge: bytes + client_id: str + session_id: str | None + expires_at: float + + +class ChallengeStore: + """In-memory, single-use challenge store. + + Deliberately not persisted: a challenge that outlives the process is a + replay window. A restart mid-ceremony just makes the user click again. + """ + + def __init__(self, ttl_seconds: int = CHALLENGE_TTL_SECONDS) -> None: + self._ttl = ttl_seconds + self._items: dict[str, _Challenge] = {} + self._lock = threading.Lock() + + def issue( + self, + *, + purpose: str, + challenge: bytes, + client_id: str, + session_id: str | None = None, + ) -> str: + state = secrets.token_urlsafe(24) + with self._lock: + self._prune_locked() + self._items[state] = _Challenge( + purpose=purpose, + challenge=challenge, + client_id=client_id, + session_id=session_id, + expires_at=time.time() + self._ttl, + ) + return state + + def consume(self, state: str, purpose: str) -> _Challenge | None: + with self._lock: + self._prune_locked() + item = self._items.pop(state, None) + if item is None or item.purpose != purpose: + return None + if time.time() > item.expires_at: + return None + return item + + def _prune_locked(self) -> None: + now = time.time() + for key in [k for k, v in self._items.items() if now > v.expires_at]: + del self._items[key] + + +# --------------------------------------------------------------------------- +# Ceremony orchestration +# --------------------------------------------------------------------------- + +def default_label(user_agent: str) -> str: + """Best-effort human label for a freshly registered credential. + + Users never get asked to name their passkey during login -- one more + field in the middle of a sign-in is friction for no security gain -- so + we guess from the User-Agent and let them recognise it later. + """ + ua = (user_agent or "").lower() + if "iphone" in ua or "ipad" in ua or "ios" in ua: + return "iPhone / iPad" + if "android" in ua: + return "Android" + if "mac os" in ua or "macintosh" in ua: + return "Mac" + if "windows" in ua: + return "Windows" + if "linux" in ua: + return "Linux" + return "Passkey" + + +def _transport_descriptors(transports: list[str]) -> list[Any] | None: + """Map stored transport strings onto the library enum, dropping unknowns.""" + if not transports or AuthenticatorTransport is None: + return None + out = [] + for t in transports: + try: + out.append(AuthenticatorTransport(t)) + except ValueError: + continue + return out or None + + +class PasskeyService: + """Generates and verifies WebAuthn ceremonies against a :class:`PasskeyStore`.""" + + def __init__(self, store: PasskeyStore | None) -> None: + self._store = store + self._challenges = ChallengeStore() + + @property + def available(self) -> bool: + """True when passkeys can actually be used (library + storage present).""" + return _webauthn is not None and self._store is not None + + @property + def unavailable_reason(self) -> str | None: + """Why passkeys are off, or ``None`` when they work. + + The login pages hide their passkey affordances silently -- there is + nothing an anonymous visitor could do about it anyway -- so the + operator needs to be able to ask the question somewhere. This backs + the startup banner and ``beaconmcp doctor``. + """ + if _webauthn is None: + return ( + "the 'webauthn' Python package is missing " + "(pip install 'webauthn>=2,<4', or reinstall BeaconMCP)" + ) + if self._store is None: + return "no writable dashboard database to store credentials in" + return None + + @property + def store(self) -> PasskeyStore: + if self._store is None: + raise PasskeyError("Passkeys are not available on this deployment.") + return self._store + + def _require(self) -> None: + if _webauthn is None: + raise PasskeyError( + "Passkey support requires the 'webauthn' package. " + "Reinstall BeaconMCP to pull it in." + ) + if self._store is None: + raise PasskeyError("Passkeys are not available on this deployment.") + + # --- registration ---------------------------------------------------- + + def registration_options( + self, + request: "Request", + *, + client_id: str, + client_name: str, + session_id: str | None = None, + ) -> tuple[dict[str, Any], str]: + """Return ``(options_dict, state)`` for ``navigator.credentials.create``.""" + self._require() + existing = self.store.list_for_client(client_id) + if len(existing) >= MAX_PASSKEYS_PER_CLIENT: + raise PasskeyError( + f"This client already has {MAX_PASSKEYS_PER_CLIENT} passkeys. " + "Remove one before adding another." + ) + options = _webauthn.generate_registration_options( + rp_id=rp_id_for(request), + rp_name=RP_NAME, + user_id=client_id.encode("utf-8"), + user_name=client_id, + user_display_name=client_name or client_id, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + # Stops the authenticator offering to overwrite a passkey the + # client already registered on this device. + exclude_credentials=[ + PublicKeyCredentialDescriptor( + id=b64url_decode(p.credential_id), + transports=_transport_descriptors(p.transports), + ) + for p in existing + ], + ) + state = self._challenges.issue( + purpose="register", + challenge=options.challenge, + client_id=client_id, + session_id=session_id, + ) + return json.loads(_webauthn.options_to_json(options)), state + + def verify_registration( + self, + request: "Request", + *, + state: str, + credential: dict[str, Any], + label: str | None = None, + session_id: str | None = None, + ) -> PasskeyRecord: + """Verify a ``navigator.credentials.create`` result and persist it.""" + self._require() + pending = self._challenges.consume(state, "register") + if pending is None: + raise PasskeyError( + "This passkey request expired. Start the registration again." + ) + if pending.session_id is not None and pending.session_id != session_id: + raise PasskeyError("This passkey request belongs to another session.") + + try: + verified = _webauthn.verify_registration_response( + credential=credential, + expected_challenge=pending.challenge, + expected_rp_id=rp_id_for(request), + expected_origin=origin_for(request), + ) + except Exception as exc: # noqa: BLE001 - library raises many subtypes + raise PasskeyError(f"Passkey registration rejected: {exc}") from exc + + credential_id = b64url_encode(verified.credential_id) + if self.store.get(credential_id) is not None: + raise PasskeyError("This passkey is already registered.") + + transports = [] + raw_transports = (credential.get("response") or {}).get("transports") + if isinstance(raw_transports, list): + transports = [t for t in raw_transports if isinstance(t, str)] + + return self.store.add( + credential_id=credential_id, + client_id=pending.client_id, + public_key=verified.credential_public_key, + sign_count=verified.sign_count, + transports=transports, + label=(label or "").strip()[:60] + or default_label(request.headers.get("user-agent", "")), + backed_up=bool(getattr(verified, "credential_backed_up", False)), + ) + + # --- authentication -------------------------------------------------- + + def authentication_options( + self, request: "Request", *, client_id: str, + ) -> tuple[dict[str, Any], str]: + """Return ``(options_dict, state)`` for ``navigator.credentials.get``.""" + self._require() + credentials = self.store.list_for_client(client_id) + if not credentials: + raise PasskeyError( + "No passkey is registered for this client. Sign in with your " + "2FA code once, then add one." + ) + options = _webauthn.generate_authentication_options( + rp_id=rp_id_for(request), + allow_credentials=[ + PublicKeyCredentialDescriptor( + id=b64url_decode(p.credential_id), + transports=_transport_descriptors(p.transports), + ) + for p in credentials + ], + user_verification=UserVerificationRequirement.PREFERRED, + ) + state = self._challenges.issue( + purpose="authenticate", + challenge=options.challenge, + client_id=client_id, + ) + return json.loads(_webauthn.options_to_json(options)), state + + def verify_authentication( + self, request: "Request", *, state: str, credential: dict[str, Any], + ) -> PasskeyRecord: + """Verify a ``navigator.credentials.get`` result. + + Returns the credential that signed the challenge. The caller is + responsible for turning that into a session -- this module never + touches bearers or cookies. + """ + self._require() + pending = self._challenges.consume(state, "authenticate") + if pending is None: + raise PasskeyError( + "This passkey request expired. Try signing in again." + ) + + raw_id = credential.get("id") or credential.get("rawId") + if not isinstance(raw_id, str) or not raw_id: + raise PasskeyError("Malformed passkey response.") + record = self.store.get(raw_id) + if record is None: + raise PasskeyError("Unknown passkey.") + # The challenge was issued for one client; a credential belonging to + # another must never satisfy it, even if both are valid on their own. + if record.client_id != pending.client_id: + raise PasskeyError("This passkey belongs to a different client.") + + try: + verified = _webauthn.verify_authentication_response( + credential=credential, + expected_challenge=pending.challenge, + expected_rp_id=rp_id_for(request), + expected_origin=origin_for(request), + credential_public_key=record.public_key, + credential_current_sign_count=record.sign_count, + ) + except Exception as exc: # noqa: BLE001 - library raises many subtypes + # This also covers the clone signal: py_webauthn refuses any + # assertion whose signature counter did not move forward, as + # long as either side maintains one (platform authenticators + # commonly pin it at 0, and that stays legal). + raise PasskeyError(f"Passkey rejected: {exc}") from exc + + self.store.touch(record.credential_id, verified.new_sign_count) + record.sign_count = verified.new_sign_count + record.last_used_at = time.time() + return record diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index a9020c4..7488464 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -2168,3 +2168,150 @@ a.cta:hover { background: var(--accent-hover); } /* Shared icon sprite placement */ .icon-sprite { position: absolute; width: 0; height: 0; } + +/* =============================================================== + PASSKEYS + POST-2FA SCREEN (login.html) + =============================================================== */ + +/* Shimmer: a highlight sweeping across the button while a request is in + flight. Prefers a real animation over a spinner so the button keeps its + size and the label can carry the status text. */ +.btn-primary.is-loading, +.btn-ghost.is-loading { + position: relative; + overflow: hidden; + cursor: progress; + opacity: 1; +} +.btn-primary.is-loading::after, +.btn-ghost.is-loading::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + transparent 20%, + rgba(255, 255, 255, 0.38) 50%, + transparent 80% + ); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; + pointer-events: none; +} +.btn-ghost.is-loading::after { + background: linear-gradient( + 100deg, + transparent 20%, + var(--accent-soft) 50%, + transparent 80% + ); +} +@keyframes shimmer-sweep { + to { transform: translateX(100%); } +} +/* The trailing chevron/check is noise once the button says "Verifying…". */ +.btn-primary.is-loading .btn-icon { opacity: 0; } +@media (prefers-reduced-motion: reduce) { + .btn-primary.is-loading::after, + .btn-ghost.is-loading::after { animation: none; opacity: 0.25; } +} + +.btn-ghost { + width: 100%; + padding: 11px 16px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--bg-soft); + color: var(--fg); + font-family: var(--font); font-weight: 550; font-size: 14px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out), + color 160ms var(--ease-out); +} +.btn-ghost:hover:not(:disabled) { + border-color: var(--accent-border); + background: var(--accent-softer); + color: var(--fg); +} +.btn-ghost:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-ghost svg { flex-shrink: 0; } + +.alt-divider { + display: flex; align-items: center; gap: 12px; + margin: 18px 0 14px; + color: var(--fg-faint); + font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; +} +.alt-divider::before, +.alt-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +.auth-card .hint { + margin: 10px 0 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--fg-muted); + text-align: center; +} + +.success-mark { + width: 42px; height: 42px; + border-radius: 50%; + display: grid; place-items: center; + margin-bottom: 16px; + color: var(--success); + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 35%, var(--border)); + animation: pop-in 320ms var(--ease-out) both; +} +@keyframes pop-in { + from { opacity: 0; transform: scale(0.82); } + to { opacity: 1; transform: scale(1); } +} + +.expiry-card { + margin: 4px 0 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.expiry-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; + padding: 5px 0; +} +.expiry-row + .expiry-row { border-top: 1px solid var(--border-subtle); } +.expiry-row dt { + font-size: 12.5px; + color: var(--fg-muted); +} +.expiry-row dd { + margin: 0; + font-size: 13px; font-weight: 550; + color: var(--fg); + text-align: right; +} + +#passkey-add-block { margin-top: 18px; } +#step-3 .btn-primary { margin-top: 20px; } +#step-3 .banner { margin-bottom: 14px; } + +/* Registered passkeys list (dashboard "API tokens" page) */ +.passkey-row { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; + padding: 10px 0; +} +.passkey-row + .passkey-row { border-top: 1px solid var(--border-subtle); } +.passkey-row .passkey-meta { min-width: 0; } +.passkey-row .passkey-name { + font-size: 13.5px; font-weight: 550; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.passkey-row .passkey-sub { font-size: 12px; color: var(--fg-muted); } diff --git a/src/beaconmcp/dashboard/static/login.js b/src/beaconmcp/dashboard/static/login.js index b1f2fce..5292230 100644 --- a/src/beaconmcp/dashboard/static/login.js +++ b/src/beaconmcp/dashboard/static/login.js @@ -1,10 +1,22 @@ -// Two-step login: Client ID / Secret → TOTP. -// Submits all three fields together once the 6-digit code is entered. +// Three-step login. +// +// 1. Client ID / Secret +// 2. TOTP code, or a passkey instead +// 3. Signed in: session lifetime, optional passkey enrolment, finish +// +// Step 3 only exists when JavaScript is on: the form still posts normally +// and gets a 303 to the landing page when it isn't, so the whole passkey +// layer is progressive enhancement over a flow that already worked. (function() { + "use strict"; + var form = document.getElementById("login-form"); if (!form) return; + + var card = document.querySelector(".auth-card"); var s1 = document.getElementById("step-1"); var s2 = document.getElementById("step-2"); + var s3 = document.getElementById("step-3"); var toggle = document.getElementById("toggle-pw"); var pw = document.getElementById("client_secret"); var cid = document.getElementById("client_id"); @@ -14,6 +26,112 @@ var verifyBtn = document.getElementById("verify-btn"); var inputs = document.querySelectorAll("#totp-inputs input"); var verifiedLabel = document.getElementById("client-verified"); + var csrfInput = document.getElementById("csrf-token"); + var step2Error = document.getElementById("step-2-error"); + var step3Error = document.getElementById("step-3-error"); + var step3Ok = document.getElementById("step-3-ok"); + var passkeyLoginBlock = document.getElementById("passkey-login-block"); + var passkeyLoginBtn = document.getElementById("passkey-login-btn"); + var passkeyAddBlock = document.getElementById("passkey-add-block"); + var addPasskeyBtn = document.getElementById("add-passkey-btn"); + var finishBtn = document.getElementById("finish-btn"); + + var passkeysEnabled = card && card.dataset.passkeysEnabled === "true"; + var secureContext = card && card.dataset.secureContext === "true"; + var passkeysUsable = passkeysEnabled && secureContext && + window.BeaconPasskeys && window.BeaconPasskeys.supported(); + + var session = null; // payload returned by the successful login + var busy = false; + + if (passkeyLoginBlock && !passkeysUsable) passkeyLoginBlock.hidden = true; + // Server offers passkeys but the browser won't expose the API (insecure + // origin): say so, rather than silently dropping the button. + var passkeyUnsupported = document.getElementById("passkey-unsupported"); + if (passkeyUnsupported) { + passkeyUnsupported.hidden = passkeysUsable || !passkeysEnabled; + } + + // --- small helpers ---------------------------------------------------- + + function csrf() { + return csrfInput ? csrfInput.value : ""; + } + + function postJson(url, body) { + return fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrf(), + "X-BeaconMCP-Mode": "json", + }, + body: JSON.stringify(body || {}), + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }); + } + + function showError(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = !message; + } + + function showOk(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = !message; + } + + // Shimmer: the button keeps its width, gains a sweeping highlight and + // swaps its label. Kept as a class so the CSS owns the animation. + function setLoading(btn, loading, label) { + if (!btn) return; + var labelEl = btn.querySelector(".btn-label"); + if (loading) { + if (labelEl && label) { + if (!btn.dataset.idleLabel) btn.dataset.idleLabel = labelEl.textContent; + labelEl.textContent = label; + } + btn.classList.add("is-loading"); + btn.disabled = true; + } else { + if (labelEl && btn.dataset.idleLabel) { + labelEl.textContent = btn.dataset.idleLabel; + delete btn.dataset.idleLabel; + } + btn.classList.remove("is-loading"); + } + } + + function formatDateTime(epochSeconds) { + if (!epochSeconds || !isFinite(epochSeconds)) return "—"; + var d = new Date(epochSeconds * 1000); + var today = new Date(); + var sameDay = d.toDateString() === today.toDateString(); + var time = d.toLocaleTimeString(undefined, { + hour: "2-digit", minute: "2-digit", + }); + if (sameDay) return "today at " + time; + return d.toLocaleDateString(undefined, { + day: "numeric", month: "short", year: "numeric", + }) + " at " + time; + } + + function formatRelative(epochSeconds) { + var secs = epochSeconds - (Date.now() / 1000); + if (secs <= 0) return "expired"; + var hours = secs / 3600; + if (hours < 1) return "in " + Math.max(1, Math.round(secs / 60)) + " min"; + if (hours < 48) return "in " + Math.round(hours) + " h"; + return "in " + Math.round(hours / 24) + " days"; + } + + // --- step 1 -> step 2 ------------------------------------------------- if (toggle) { toggle.addEventListener("click", function() { @@ -24,13 +142,14 @@ function goStep2() { if (!cid.value.trim() || !pw.value) return; if (verifiedLabel) verifiedLabel.textContent = cid.value.trim(); + showError(step2Error, ""); s1.hidden = true; s2.hidden = false; setTimeout(function() { if (inputs[0]) inputs[0].focus(); }, 40); } if (toStep2) toStep2.addEventListener("click", goStep2); - // Allow Enter in step-1 inputs to advance rather than submit. + // Enter in step-1 advances rather than submitting a half-filled form. [cid, pw].forEach(function(el) { if (!el) return; el.addEventListener("keydown", function(e) { @@ -46,15 +165,18 @@ s1.hidden = false; }); + // --- TOTP boxes ------------------------------------------------------- + function collectTotp() { var s = ""; inputs.forEach(function(i) { s += (i.value || "").replace(/\D/g, ""); }); return s; } + function refresh() { var v = collectTotp(); totpHidden.value = v; - verifyBtn.disabled = v.length !== 6; + verifyBtn.disabled = busy || v.length !== 6; } inputs.forEach(function(inp, i) { @@ -70,6 +192,14 @@ refresh(); }); inp.addEventListener("keydown", function(e) { + // Enter validates as soon as the six digits are in — no reaching + // for the mouse after typing the last one. + if (e.key === "Enter") { + e.preventDefault(); + refresh(); + if (!busy && collectTotp().length === 6) submitTotp(); + return; + } if (e.key === "Backspace" && !e.target.value && inputs[i - 1]) { inputs[i - 1].focus(); inputs[i - 1].value = ""; @@ -89,10 +219,201 @@ }); }); + function clearTotp() { + inputs.forEach(function(i) { i.value = ""; i.classList.remove("filled"); }); + totpHidden.value = ""; + refresh(); + if (inputs[0]) inputs[0].focus(); + } + + // --- step 3 ----------------------------------------------------------- + + function showStep3(payload) { + session = payload; + // Signing in rotates the CSRF cookie; without picking the new value up + // every follow-up fetch (passkey enrolment) would 403. + if (payload.csrf_token && csrfInput) csrfInput.value = payload.csrf_token; + s1.hidden = true; + s2.hidden = true; + s3.hidden = false; + + var who = document.getElementById("success-client"); + if (who) who.textContent = payload.client_name || payload.client_id || "—"; + + var bearerEl = document.getElementById("bearer-expiry"); + if (bearerEl) { + bearerEl.textContent = formatDateTime(payload.bearer_expires_at) + + " (" + formatRelative(payload.bearer_expires_at) + ")"; + } + var sessionEl = document.getElementById("session-expiry"); + if (sessionEl) sessionEl.textContent = formatDateTime(payload.session_expires_at); + + if (passkeyAddBlock) { + var canAdd = !!(payload.passkeys_enabled && payload.secure_context && + window.BeaconPasskeys && window.BeaconPasskeys.supported()); + passkeyAddBlock.hidden = !canAdd; + var addNote = document.getElementById("passkey-add-unsupported"); + if (addNote) addNote.hidden = canAdd || !payload.passkeys_enabled; + var hint = document.getElementById("passkey-add-hint"); + if (canAdd && hint && payload.passkey_count > 0) { + hint.textContent = payload.passkey_count === 1 + ? "1 passkey already registered. Add another for a second device." + : payload.passkey_count + " passkeys already registered. " + + "Add another for a second device."; + } + } + if (finishBtn) finishBtn.focus(); + } + + function finish() { + var next = (session && session.next) || "/app/tokens"; + setLoading(finishBtn, true, "Opening…"); + window.location.href = next; + } + + if (finishBtn) finishBtn.addEventListener("click", finish); + + // --- TOTP submit ------------------------------------------------------ + + function submitTotp() { + if (busy) return; + var code = collectTotp(); + if (code.length !== 6) return; + busy = true; + showError(step2Error, ""); + setLoading(verifyBtn, true, "Verifying…"); + + var body = new URLSearchParams(); + body.set("csrf_token", csrf()); + body.set("client_id", cid.value.trim()); + body.set("client_secret", pw.value); + body.set("totp", code); + var nextField = form.querySelector('input[name="next"]'); + if (nextField) body.set("next", nextField.value); + + fetch("/app/login", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": csrf(), + "X-BeaconMCP-Mode": "json", + }, + body: body.toString(), + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }).then(function(res) { + busy = false; + setLoading(verifyBtn, false); + if (res.ok && res.data && res.data.ok) { + showStep3(res.data); + return; + } + showError(step2Error, (res.data && res.data.error) || "Sign-in failed."); + clearTotp(); + }).catch(function() { + busy = false; + setLoading(verifyBtn, false); + showError(step2Error, "Network error. Try again."); + refresh(); + }); + } + form.addEventListener("submit", function(e) { + e.preventDefault(); if (totpHidden.value.length !== 6) { - e.preventDefault(); goStep2(); + return; } + submitTotp(); }); + + // --- passkey sign-in (step 2) ---------------------------------------- + + if (passkeyLoginBtn) { + passkeyLoginBtn.addEventListener("click", function() { + if (busy) return; + var clientId = cid.value.trim(); + var secret = pw.value; + if (!clientId || !secret) { + showError(step2Error, "Enter your client credentials first."); + return; + } + busy = true; + showError(step2Error, ""); + setLoading(passkeyLoginBtn, true, "Waiting for your passkey…"); + verifyBtn.disabled = true; + + var nextField = form.querySelector('input[name="next"]'); + postJson("/app/api/passkeys/auth/options", { + client_id: clientId, + client_secret: secret, + }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Passkey sign-in unavailable."); + } + return window.BeaconPasskeys.authenticate(res.data.options) + .then(function(assertion) { + return postJson("/app/api/passkeys/auth/verify", { + client_id: clientId, + client_secret: secret, + state: res.data.state, + credential: assertion, + next: nextField ? nextField.value : "", + }); + }); + }).then(function(res) { + busy = false; + setLoading(passkeyLoginBtn, false); + refresh(); + if (res.ok && res.data && res.data.ok) { + showStep3(res.data); + return; + } + showError(step2Error, (res.data && res.data.error) || "Passkey rejected."); + }).catch(function(err) { + busy = false; + setLoading(passkeyLoginBtn, false); + refresh(); + showError(step2Error, window.BeaconPasskeys.describeError(err)); + }); + }); + } + + // --- passkey enrolment (step 3) -------------------------------------- + + if (addPasskeyBtn) { + addPasskeyBtn.addEventListener("click", function() { + showError(step3Error, ""); + showOk(step3Ok, ""); + setLoading(addPasskeyBtn, true, "Follow your device prompt…"); + + postJson("/app/api/passkeys/register/options", {}).then(function(res) { + if (!res.ok) throw new Error(res.data.error || "Could not start registration."); + return window.BeaconPasskeys.register(res.data.options) + .then(function(attestation) { + return postJson("/app/api/passkeys/register/verify", { + state: res.data.state, + credential: attestation, + }); + }); + }).then(function(res) { + setLoading(addPasskeyBtn, false); + if (res.ok && res.data && res.data.ok) { + showOk(step3Ok, "Passkey “" + res.data.passkey.label + + "” registered. Next time you can skip the 2FA code."); + addPasskeyBtn.disabled = true; + var hint = document.getElementById("passkey-add-hint"); + if (hint) hint.hidden = true; + return; + } + showError(step3Error, (res.data && res.data.error) || "Registration failed."); + }).catch(function(err) { + setLoading(addPasskeyBtn, false); + showError(step3Error, window.BeaconPasskeys.describeError(err)); + }); + }); + } })(); diff --git a/src/beaconmcp/dashboard/static/webauthn.js b/src/beaconmcp/dashboard/static/webauthn.js new file mode 100644 index 0000000..1a079ba --- /dev/null +++ b/src/beaconmcp/dashboard/static/webauthn.js @@ -0,0 +1,119 @@ +// Minimal WebAuthn plumbing shared by the dashboard auth pages. +// +// The server speaks base64url (that's what py_webauthn's options_to_json +// emits and what its verifiers parse); the browser API speaks ArrayBuffer. +// Everything here is that translation, plus the two ceremony wrappers. +window.BeaconPasskeys = (function() { + "use strict"; + + function supported() { + return !!(window.PublicKeyCredential && navigator.credentials && + navigator.credentials.create && navigator.credentials.get); + } + + function b64urlToBuf(value) { + var s = String(value).replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + var bin = window.atob(s); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + + function bufToB64url(buf) { + var bytes = new Uint8Array(buf); + var bin = ""; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return window.btoa(bin) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function decodeDescriptors(list) { + return (list || []).map(function(d) { + var out = { type: d.type || "public-key", id: b64urlToBuf(d.id) }; + if (d.transports && d.transports.length) out.transports = d.transports; + return out; + }); + } + + // navigator.credentials.create() — returns a JSON-safe attestation. + function register(options) { + var publicKey = Object.assign({}, options); + publicKey.challenge = b64urlToBuf(options.challenge); + publicKey.user = Object.assign({}, options.user, { + id: b64urlToBuf(options.user.id), + }); + publicKey.excludeCredentials = decodeDescriptors(options.excludeCredentials); + return navigator.credentials.create({ publicKey: publicKey }).then(function(cred) { + if (!cred) throw new Error("No credential was created."); + var response = cred.response; + var out = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + attestationObject: bufToB64url(response.attestationObject), + }, + }; + if (cred.authenticatorAttachment) { + out.authenticatorAttachment = cred.authenticatorAttachment; + } + if (response.getTransports) { + try { out.response.transports = response.getTransports(); } catch (e) {} + } + return out; + }); + } + + // navigator.credentials.get() — returns a JSON-safe assertion. + function authenticate(options) { + var publicKey = Object.assign({}, options); + publicKey.challenge = b64urlToBuf(options.challenge); + publicKey.allowCredentials = decodeDescriptors(options.allowCredentials); + return navigator.credentials.get({ publicKey: publicKey }).then(function(cred) { + if (!cred) throw new Error("No passkey was selected."); + var response = cred.response; + return { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + authenticatorData: bufToB64url(response.authenticatorData), + signature: bufToB64url(response.signature), + userHandle: response.userHandle ? bufToB64url(response.userHandle) : null, + }, + }; + }); + } + + // The browser throws opaque DOMExceptions; turn the ones users actually + // hit into something actionable and let the rest through as-is. + function describeError(err) { + if (!err) return "Passkey request failed."; + if (err.name === "NotAllowedError") { + return "Passkey prompt cancelled or timed out."; + } + if (err.name === "InvalidStateError") { + return "This device already has a passkey registered for this client."; + } + if (err.name === "SecurityError") { + return "Passkeys need a secure origin (HTTPS or localhost)."; + } + return err.message || String(err); + } + + return { + supported: supported, + register: register, + authenticate: authenticate, + describeError: describeError, + b64urlToBuf: b64urlToBuf, + bufToB64url: bufToB64url, + }; +})(); diff --git a/src/beaconmcp/dashboard/templates/login.html b/src/beaconmcp/dashboard/templates/login.html index 0aa7c94..41684f2 100644 --- a/src/beaconmcp/dashboard/templates/login.html +++ b/src/beaconmcp/dashboard/templates/login.html @@ -2,13 +2,15 @@ {% block title %}Sign in · BeaconMCP{% endblock %} {% block body_class %}auth-page{% endblock %} {% block body %} -
+
BeaconMCP
- + {% if next %}{% endif %} {# Step 1 — Client ID + Client Secret #} @@ -58,7 +60,7 @@

Sign in

- {# Step 2 — TOTP #} + {# Step 2 — TOTP, or a passkey instead #}
+ +
@@ -84,12 +88,70 @@

Two-factor

+ + {% if passkeys_enabled %} +
+
or
+ +

Skip the 6-digit code with a passkey registered on this device.

+
+ + {% endif %} + + {# Step 3 — signed in: session lifetime, passkey enrolment, finish #} +
+ {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 121fe70..032a97a 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -259,6 +259,39 @@

Static bearer tokens

+ {% if passkeys_enabled %} +
+
Passkeys
+ {% if passkeys %} +
+ {% for p in passkeys %} +
+
+
{{ p.label }}
+
+ Added {{ p.created_human }}{% if p.last_used_human %} · last used {{ p.last_used_human }}{% else %} · never used{% endif %} +
+
+
+ + + +
+
+ {% endfor %} +
+ {% else %} +

+ No passkey registered. Add one right after your next sign-in to skip + the 2FA code on this device. +

+ {% endif %} +
+ {% endif %} +

Concrete setup per client is in the By platform tab.

diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 65091af..5acd498 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -704,8 +704,10 @@ def test_migration_v1_to_v2_renames_gemini_models(tmp_path): assert msg["model"] == "gemini-3-flash-preview" # user_version reflects the migration (latest schema version). + from beaconmcp.dashboard.db import _LATEST_VERSION + ver = db.conn().execute("PRAGMA user_version").fetchone()[0] - assert ver == 4 + assert ver == _LATEST_VERSION def test_short_ciphertext_decryption_returns_none(store): diff --git a/tests/test_passkeys.py b/tests/test_passkeys.py new file mode 100644 index 0000000..06279ac --- /dev/null +++ b/tests/test_passkeys.py @@ -0,0 +1,808 @@ +"""Passkey (WebAuthn) tests. + +Drives the real ceremonies end to end against a software authenticator +built here from ``cryptography`` primitives: a fake that only returned +canned dicts would prove nothing, since every interesting failure mode +(wrong origin, replayed challenge, foreign credential) lives inside the +signature verification. + +Run with:: + + pytest tests/test_passkeys.py -v +""" + +from __future__ import annotations + +import hashlib +import json +import os +import struct +import sys +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +pytest.importorskip("webauthn", reason="passkey support is an optional extra") +cbor2 = pytest.importorskip("cbor2") + +from cryptography.hazmat.primitives import hashes # noqa: E402 +from cryptography.hazmat.primitives.asymmetric import ec # noqa: E402 +from cryptography.hazmat.primitives.asymmetric.utils import ( # noqa: E402 + decode_dss_signature, + encode_dss_signature, +) + +from beaconmcp.auth import TotpResult # noqa: E402 +from beaconmcp.dashboard.app import ( # noqa: E402 + DashboardDeps, + SESSION_COOKIE, + build_dashboard_routes, +) +from beaconmcp.dashboard.csrf import CSRF_COOKIE # noqa: E402 +from beaconmcp.dashboard.db import Database # noqa: E402 +from beaconmcp.dashboard.passkeys import ( # noqa: E402 + ChallengeStore, + PasskeyError, + PasskeyService, + PasskeyStore, + b64url_decode, + b64url_encode, + default_label, +) +from beaconmcp.dashboard.session import SessionStore # noqa: E402 + + +ORIGIN = "https://beacon.example" +RP_ID = "beacon.example" + + +# --------------------------------------------------------------------------- +# Software authenticator +# --------------------------------------------------------------------------- + +class SoftwareAuthenticator: + """A minimal ES256 authenticator: enough to produce valid ceremonies.""" + + AAGUID = b"\x00" * 16 + + def __init__(self) -> None: + self.key = ec.generate_private_key(ec.SECP256R1()) + self.credential_id = os.urandom(32) + self.sign_count = 0 + + # --- helpers --------------------------------------------------------- + + def _cose_key(self) -> bytes: + numbers = self.key.public_key().public_numbers() + return cbor2.dumps({ + 1: 2, # kty: EC2 + 3: -7, # alg: ES256 + -1: 1, # crv: P-256 + -2: numbers.x.to_bytes(32, "big"), + -3: numbers.y.to_bytes(32, "big"), + }) + + @staticmethod + def _client_data(kind: str, challenge: str, origin: str) -> bytes: + return json.dumps( + { + "type": kind, + "challenge": challenge, + "origin": origin, + "crossOrigin": False, + }, + separators=(",", ":"), + ).encode("utf-8") + + def _auth_data(self, rp_id: str, flags: int, attested: bool) -> bytes: + data = hashlib.sha256(rp_id.encode("utf-8")).digest() + data += bytes([flags]) + data += struct.pack(">I", self.sign_count) + if attested: + key = self._cose_key() + data += self.AAGUID + data += struct.pack(">H", len(self.credential_id)) + data += self.credential_id + data += key + return data + + # --- ceremonies ------------------------------------------------------ + + def create(self, options: dict, *, origin: str = ORIGIN, rp_id: str = RP_ID) -> dict: + client_data = self._client_data( + "webauthn.create", options["challenge"], origin, + ) + # UP | UV | AT + auth_data = self._auth_data(rp_id, 0x01 | 0x04 | 0x40, attested=True) + attestation = cbor2.dumps({ + "fmt": "none", "attStmt": {}, "authData": auth_data, + }) + return { + "id": b64url_encode(self.credential_id), + "rawId": b64url_encode(self.credential_id), + "type": "public-key", + "clientExtensionResults": {}, + "response": { + "clientDataJSON": b64url_encode(client_data), + "attestationObject": b64url_encode(attestation), + "transports": ["internal"], + }, + } + + def get(self, options: dict, *, origin: str = ORIGIN, rp_id: str = RP_ID) -> dict: + self.sign_count += 1 + client_data = self._client_data("webauthn.get", options["challenge"], origin) + auth_data = self._auth_data(rp_id, 0x01 | 0x04, attested=False) + payload = auth_data + hashlib.sha256(client_data).digest() + raw = self.key.sign(payload, ec.ECDSA(hashes.SHA256())) + # Re-encode so the DER is canonical whatever the backend produced. + r, s = decode_dss_signature(raw) + signature = encode_dss_signature(r, s) + return { + "id": b64url_encode(self.credential_id), + "rawId": b64url_encode(self.credential_id), + "type": "public-key", + "clientExtensionResults": {}, + "response": { + "clientDataJSON": b64url_encode(client_data), + "authenticatorData": b64url_encode(auth_data), + "signature": b64url_encode(signature), + "userHandle": None, + }, + } + + +class FakeRequest: + """Just the surface :mod:`beaconmcp.dashboard.passkeys` reads.""" + + def __init__(self, host: str = RP_ID, scheme: str = "https") -> None: + self.headers = {"host": host, "x-forwarded-proto": scheme} + + class _URL: + netloc = host + + self.url = _URL() + self.url.scheme = scheme # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def store(tmp_path) -> PasskeyStore: + return PasskeyStore(Database(tmp_path / "dashboard.db")) + + +@pytest.fixture() +def service(store) -> PasskeyService: + return PasskeyService(store) + + +@pytest.fixture() +def enrolled(service): + """A client with one registered passkey, plus its authenticator.""" + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + record = service.verify_registration( + request, state=state, credential=auth.create(options), + ) + return auth, record + + +# --------------------------------------------------------------------------- +# RP identity +# --------------------------------------------------------------------------- + +def test_rp_id_strips_port_and_scheme(): + from beaconmcp.dashboard.passkeys import origin_for, rp_id_for + + req = FakeRequest(host="beacon.example:8420") + assert rp_id_for(req) == "beacon.example" + assert origin_for(req) == "https://beacon.example:8420" + + +def test_rp_id_handles_ipv6_literal(): + from beaconmcp.dashboard.passkeys import rp_id_for + + assert rp_id_for(FakeRequest(host="[::1]:8420")) == "::1" + + +def test_secure_context_requires_https_or_loopback(): + from beaconmcp.dashboard.passkeys import is_secure_context + + assert is_secure_context(FakeRequest(host="beacon.example", scheme="https")) + assert is_secure_context(FakeRequest(host="localhost:8420", scheme="http")) + assert not is_secure_context(FakeRequest(host="192.168.1.5:8420", scheme="http")) + + +def test_forwarded_host_wins_over_host(): + from beaconmcp.dashboard.passkeys import rp_id_for + + req = FakeRequest(host="internal:8420") + req.headers["x-forwarded-host"] = "public.example" + assert rp_id_for(req) == "public.example" + + +def test_default_label_from_user_agent(): + assert default_label("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)") == "iPhone / iPad" + assert default_label("Mozilla/5.0 (Windows NT 10.0)") == "Windows" + assert default_label("") == "Passkey" + + +# --------------------------------------------------------------------------- +# Challenge store +# --------------------------------------------------------------------------- + +def test_challenge_is_single_use(): + challenges = ChallengeStore() + state = challenges.issue( + purpose="authenticate", challenge=b"abc", client_id="c1", + ) + assert challenges.consume(state, "authenticate") is not None + assert challenges.consume(state, "authenticate") is None + + +def test_challenge_purpose_must_match(): + challenges = ChallengeStore() + state = challenges.issue(purpose="register", challenge=b"abc", client_id="c1") + assert challenges.consume(state, "authenticate") is None + + +def test_challenge_expires(): + challenges = ChallengeStore(ttl_seconds=-1) + state = challenges.issue(purpose="register", challenge=b"abc", client_id="c1") + assert challenges.consume(state, "register") is None + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +def test_registration_round_trip(service, store): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + assert options["rp"]["id"] == RP_ID + assert b64url_decode(options["user"]["id"]) == b"beaconmcp_test" + + record = service.verify_registration( + request, state=state, credential=auth.create(options), label="My laptop", + ) + assert record.client_id == "beaconmcp_test" + assert record.label == "My laptop" + assert record.transports == ["internal"] + assert store.count_for_client("beaconmcp_test") == 1 + + +def test_registration_rejects_wrong_origin(service): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + with pytest.raises(PasskeyError): + service.verify_registration( + request, + state=state, + credential=auth.create(options, origin="https://evil.example"), + ) + + +def test_registration_state_bound_to_session(service): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test", + session_id="session-a", + ) + with pytest.raises(PasskeyError, match="another session"): + service.verify_registration( + request, state=state, credential=auth.create(options), + session_id="session-b", + ) + + +def test_registration_excludes_known_credentials(service, enrolled): + _, record = enrolled + options, _ = service.registration_options( + FakeRequest(), client_id="beaconmcp_test", client_name="Test", + ) + assert [c["id"] for c in options["excludeCredentials"]] == [record.credential_id] + + +def test_registration_label_defaults_from_user_agent(service): + request = FakeRequest() + request.headers["user-agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)" + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test", + ) + record = service.verify_registration( + request, state=state, credential=auth.create(options), + ) + assert record.label == "Mac" + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + +def test_authentication_round_trip(service, enrolled, store): + auth, record = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + assert [c["id"] for c in options["allowCredentials"]] == [record.credential_id] + + verified = service.verify_authentication( + request, state=state, credential=auth.get(options), + ) + assert verified.client_id == "beaconmcp_test" + assert store.get(record.credential_id).last_used_at is not None + + +def test_authentication_without_credentials_is_refused(service): + with pytest.raises(PasskeyError, match="No passkey"): + service.authentication_options(FakeRequest(), client_id="nobody") + + +def test_authentication_rejects_replayed_challenge(service, enrolled): + auth, _ = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + assertion = auth.get(options) + service.verify_authentication(request, state=state, credential=assertion) + with pytest.raises(PasskeyError, match="expired"): + service.verify_authentication(request, state=state, credential=assertion) + + +def test_authentication_rejects_foreign_credential(service, enrolled): + """A challenge minted for client A must not accept client B's passkey.""" + auth_a, _ = enrolled + request = FakeRequest() + + other = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_other", client_name="Other", + ) + service.verify_registration( + request, state=state, credential=other.create(options), + ) + + options, state = service.authentication_options( + request, client_id="beaconmcp_other", + ) + # Sign the *other* client's challenge with the first client's key. + with pytest.raises(PasskeyError, match="different client"): + service.verify_authentication( + request, state=state, credential=auth_a.get(options), + ) + + +def test_authentication_rejects_wrong_rp_id(service, enrolled): + auth, _ = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + with pytest.raises(PasskeyError): + service.verify_authentication( + request, state=state, + credential=auth.get(options, rp_id="evil.example"), + ) + + +def test_sign_counter_regression_is_rejected(service, enrolled, store): + auth, record = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + service.verify_authentication(request, state=state, credential=auth.get(options)) + + # Roll the authenticator's counter back, as a cloned key would. + auth.sign_count = 0 + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + with pytest.raises(PasskeyError, match="sign count"): + service.verify_authentication( + request, state=state, credential=auth.get(options), + ) + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +def test_delete_is_scoped_to_owner(store, service, enrolled): + _, record = enrolled + assert store.delete(record.credential_id, "someone_else") is False + assert store.delete(record.credential_id, "beaconmcp_test") is True + assert store.count_for_client("beaconmcp_test") == 0 + + +def test_service_without_store_reports_unavailable(): + service = PasskeyService(None) + assert service.available is False + assert "database" in service.unavailable_reason + with pytest.raises(PasskeyError): + service.authentication_options(FakeRequest(), client_id="x") + + +def test_working_service_has_no_unavailable_reason(service): + assert service.available is True + assert service.unavailable_reason is None + + +def test_missing_library_is_reported_with_a_fix(monkeypatch, store): + """The banner/doctor message must name the package and the command.""" + import beaconmcp.dashboard.passkeys as mod + + monkeypatch.setattr(mod, "_webauthn", None) + assert mod.webauthn_installed() is False + reason = PasskeyService(store).unavailable_reason + assert "webauthn" in reason and "pip install" in reason + + +# --------------------------------------------------------------------------- +# Dashboard routes +# --------------------------------------------------------------------------- + +class FakeClientStore: + def __init__(self): + self.clients = { + "beaconmcp_test": { + "secret": "sk_test", "name": "Test Client", "totp": "123456", + } + } + + def verify(self, client_id, secret): + c = self.clients.get(client_id) + return bool(c and c["secret"] == secret) + + def check_totp(self, client_id, code): + c = self.clients.get(client_id) + return TotpResult.OK if (c and c["totp"] == code) else TotpResult.INVALID + + def get_name(self, client_id): + c = self.clients.get(client_id) + return c["name"] if c else None + + +class FakeTokenStore: + def __init__(self): + self._tokens: dict[str, str] = {} + self._n = 0 + + def issue(self, client_id, name=None): + self._n += 1 + token = f"bearer_{self._n}" + self._tokens[token] = client_id + return token, 86400 + + def validate(self, token): + return self._tokens.get(token) + + def revoke(self, token): + self._tokens.pop(token, None) + return True + + def list_named(self, client_id): + return [] + + +@pytest.fixture() +def web(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "dashboard.db")) + db = Database(tmp_path / "dashboard.db") + passkey_store = PasskeyStore(db) + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=PasskeyService(passkey_store), + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient( + app, follow_redirects=False, base_url=ORIGIN, + headers={"x-forwarded-proto": "https"}, + ) + return client, deps, passkey_store + + +def _csrf(client) -> str: + client.get("/app/login") + return client.cookies.get(CSRF_COOKIE) + + +def _json_headers(token: str) -> dict[str, str]: + return {"X-CSRF-Token": token, "X-BeaconMCP-Mode": "json"} + + +def _login(client) -> str: + """Sign in with TOTP and return the *rotated* CSRF token.""" + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers=_json_headers(token), + ) + assert res.status_code == 200, res.text + return res.json()["csrf_token"] + + +def test_login_json_mode_returns_expiry_and_passkey_state(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers=_json_headers(token), + ) + assert res.status_code == 200 + body = res.json() + assert body["ok"] is True + assert body["client_name"] == "Test Client" + assert body["bearer_expires_at"] > time.time() + assert body["session_expires_at"] > body["bearer_expires_at"] + assert body["passkeys_enabled"] is True + assert body["passkey_count"] == 0 + assert client.cookies.get(SESSION_COOKIE) + + +def test_login_form_mode_still_redirects(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + ) + assert res.status_code == 303 + assert res.headers["location"] == "/app/tokens" + + +def test_login_json_mode_reports_errors_as_json(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "000000", + }, + headers=_json_headers(token), + ) + assert res.status_code == 401 + assert res.json()["ok"] is False + assert "2FA" in res.json()["error"] + + +def _register_passkey(client, token) -> SoftwareAuthenticator: + auth = SoftwareAuthenticator() + res = client.post( + "/app/api/passkeys/register/options", json={}, headers=_json_headers(token), + ) + assert res.status_code == 200, res.text + body = res.json() + res = client.post( + "/app/api/passkeys/register/verify", + json={"state": body["state"], "credential": auth.create(body["options"])}, + headers=_json_headers(token), + ) + assert res.status_code == 201, res.text + return auth + + +def test_register_then_sign_in_with_passkey(web): + client, _, passkey_store = web + token = _login(client) + auth = _register_passkey(client, token) + assert passkey_store.count_for_client("beaconmcp_test") == 1 + + listed = client.get("/app/api/passkeys", headers=_json_headers(token)).json() + assert len(listed["passkeys"]) == 1 + + # Fresh browser: credentials + passkey, no TOTP anywhere. + fresh = TestClient( + client.app, follow_redirects=False, base_url=ORIGIN, + headers={"x-forwarded-proto": "https"}, + ) + token2 = _csrf(fresh) + res = fresh.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token2), + ) + assert res.status_code == 200, res.text + body = res.json() + res = fresh.post( + "/app/api/passkeys/auth/verify", + json={ + "client_id": "beaconmcp_test", + "client_secret": "sk_test", + "state": body["state"], + "credential": auth.get(body["options"]), + }, + headers=_json_headers(token2), + ) + assert res.status_code == 200, res.text + assert res.json()["ok"] is True + assert res.json()["passkey_count"] == 1 + assert fresh.cookies.get(SESSION_COOKIE) + + +def test_passkey_auth_requires_valid_client_secret(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "wrong"}, + headers=_json_headers(token), + ) + assert res.status_code == 401 + assert res.json()["error"] == "Invalid credentials." + + +def test_passkey_auth_verify_rechecks_the_secret(web): + """The state token alone must never be enough to mint a session.""" + client, _, _ = web + token = _login(client) + auth = _register_passkey(client, token) + body = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token), + ).json() + res = client.post( + "/app/api/passkeys/auth/verify", + json={ + "client_id": "beaconmcp_test", + "client_secret": "wrong", + "state": body["state"], + "credential": auth.get(body["options"]), + }, + headers=_json_headers(token), + ) + assert res.status_code == 401 + + +def test_passkey_endpoints_require_csrf(web): + client, _, _ = web + _csrf(client) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + ) + assert res.status_code == 403 + + +def test_passkey_registration_requires_a_session(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/api/passkeys/register/options", json={}, headers=_json_headers(token), + ) + assert res.status_code == 401 + + +def test_passkey_delete_scoped_to_session_client(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + res = client.post( + "/app/api/passkeys/delete", + json={"credential_id": credential_id}, + headers=_json_headers(token), + ) + assert res.status_code == 200 + assert passkey_store.count_for_client("beaconmcp_test") == 0 + + +def test_tokens_page_lists_and_removes_passkeys(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + + page = client.get("/app/tokens").text + assert "Passkeys" in page + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + assert credential_id in page + assert "never used" in page + + res = client.post( + "/app/passkeys/remove", + data={"csrf_token": token, "credential_id": credential_id}, + ) + assert res.status_code == 303 + assert res.headers["location"] == "/app/tokens" + assert passkey_store.count_for_client("beaconmcp_test") == 0 + + +def test_passkeys_remove_requires_csrf(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + res = client.post( + "/app/passkeys/remove", + data={"csrf_token": "wrong", "credential_id": credential_id}, + ) + assert res.status_code == 403 + assert passkey_store.count_for_client("beaconmcp_test") == 1 + + +def test_routes_report_unavailable_without_a_service(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d.db")) + db = Database(tmp_path / "d.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=None, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), + follow_redirects=False, base_url=ORIGIN, + ) + client.get("/app/login") + token = client.cookies.get(CSRF_COOKIE) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token), + ) + assert res.status_code == 503 + + +def test_login_page_hides_passkeys_when_unavailable(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d2.db")) + db = Database(tmp_path / "d2.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=None, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), base_url=ORIGIN, + ) + body = client.get("/app/login").text + assert 'data-passkeys-enabled="false"' in body + assert "passkey-login-btn" not in body