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 `