diff --git a/app/routes/webauthn.py b/app/routes/webauthn.py index 699985a..6d877b9 100644 --- a/app/routes/webauthn.py +++ b/app/routes/webauthn.py @@ -78,11 +78,24 @@ def register_begin(): """ Generate PublicKeyCredentialCreationOptions for passkey registration. Requires an active JWT session (user must be logged in). + + Optional JSON body: + attachment: "platform" (default) | "cross-platform" + "platform" → device biometrics (Touch ID, Face ID, Windows Hello) + "cross-platform" → roaming authenticators (YubiKey, phone-as-key via QR) """ user = db.session.get(User, g.current_user_id) if not user: return jsonify({'error': 'User not found'}), 404 + data = request.get_json(silent=True) or {} + attachment_str = (data.get('attachment') or 'platform').strip().lower() + + if attachment_str == 'cross-platform': + authenticator_attachment = AuthenticatorAttachment.CROSS_PLATFORM + else: + authenticator_attachment = AuthenticatorAttachment.PLATFORM + # Collect existing credential IDs to exclude (prevent re-registering same key). existing = WebAuthnCredential.query.filter_by(user_id=user.id).all() exclude_credentials = [ @@ -102,7 +115,7 @@ def register_begin(): authenticator_selection=AuthenticatorSelectionCriteria( resident_key=ResidentKeyRequirement.PREFERRED, user_verification=UserVerificationRequirement.PREFERRED, - authenticator_attachment=AuthenticatorAttachment.PLATFORM, + authenticator_attachment=authenticator_attachment, ), exclude_credentials=exclude_credentials, ) @@ -389,4 +402,4 @@ def delete_credential(cred_id): ip_address=client_ip(), ) db.session.commit() - return jsonify({'message': 'Passkey removed'}), 200 \ No newline at end of file + return jsonify({'message': 'Passkey removed'}), 200 diff --git a/app/static/css/app.css b/app/static/css/app.css index 8d8c83a..460378d 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2549,4 +2549,22 @@ html.sidebar-open { .health-banner-dismiss:hover { opacity: 1; -} \ No newline at end of file +} + +/* Passkey attachment type selector */ +.passkey-attachment-select { + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + color: var(--text); + background: var(--surface); + cursor: pointer; + flex-shrink: 0; +} + +.passkey-attachment-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.15); +} diff --git a/app/static/js/auth.js b/app/static/js/auth.js index dde6fb7..a17fcbc 100644 --- a/app/static/js/auth.js +++ b/app/static/js/auth.js @@ -523,9 +523,10 @@ const PasskeyAuth = (() => { /** * Register a new passkey for the currently logged-in user. * Requires an active access_token in sessionStorage (set by vault.js on login). - * @param {string} name User-friendly name for the passkey (e.g. "iPhone 15"). + * @param {string} name User-friendly name (e.g. "iPhone 15"). + * @param {string} attachment "platform" (default) | "cross-platform" */ - async function registerPasskey(name) { + async function registerPasskey(name, attachment = "platform") { if (!window.PublicKeyCredential) { return { error: 'Passkeys are not supported in this browser.' }; } @@ -540,6 +541,7 @@ const PasskeyAuth = (() => { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, + body: JSON.stringify({ attachment }), }); if (!beginRes.ok) { const d = await beginRes.json().catch(() => ({})); diff --git a/app/static/js/vault.js b/app/static/js/vault.js index acbb513..0005fdc 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -2442,10 +2442,16 @@ const Vault = (() => { const lastUsed = c.last_used_at ? `Last used ${new Date(c.last_used_at).toLocaleDateString()}` : "Never used"; + const transports = c.transports || []; + const typeLabel = transports.includes("internal") + ? "📱 Device" + : transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t)) + ? "🔑 Security key" + : "🔑 Passkey"; return `
${escHtml(c.name)} - ${lastUsed} · Added ${escHtml(created)} + ${typeLabel} · ${lastUsed} · Added ${escHtml(created)}
@@ -2498,13 +2504,15 @@ const Vault = (() => { registerBtn.dataset.bound = "1"; registerBtn.addEventListener("click", async () => { const nameInput = document.getElementById("passkey-name-input"); + const attachSel = document.getElementById("passkey-attachment-select"); const name = (nameInput?.value || "").trim() || "Passkey"; + const attachment = attachSel?.value || "platform"; const errEl = document.getElementById("passkeys-error"); errEl?.classList.add("hidden"); registerBtn.disabled = true; registerBtn.textContent = "Waiting…"; - const result = await PasskeyAuth.registerPasskey(name); + const result = await PasskeyAuth.registerPasskey(name, attachment); registerBtn.disabled = false; registerBtn.textContent = "+ Add passkey"; @@ -4339,4 +4347,4 @@ const Vault = (() => { } })(); -document.addEventListener("DOMContentLoaded", Vault.init); \ No newline at end of file +document.addEventListener("DOMContentLoaded", Vault.init); diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index 510b808..f91f625 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -977,6 +977,10 @@ maxlength="128" class="passkey-name-input" /> + @@ -1242,4 +1246,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %}