From 76da3d9bffcebc0e4a610a23c26997863e4565ef Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Sun, 15 Feb 2026 12:27:56 +0000 Subject: [PATCH] fix: require discoverable credentials for passkey registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webauthn-rs `start_passkey_registration` sets residentKey to "discouraged", which iOS Safari respects strictly — creating non-discoverable credentials that never appear in autofill. Desktop password managers ignore this flag. Patch the creation challenge to require resident keys so discoverable passkeys work on all platforms. --- src/application/routes/api/auth/webauthn.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/application/routes/api/auth/webauthn.rs b/src/application/routes/api/auth/webauthn.rs index c9828a0..6329831 100644 --- a/src/application/routes/api/auth/webauthn.rs +++ b/src/application/routes/api/auth/webauthn.rs @@ -7,6 +7,7 @@ use tower_cookies::{Cookie, Cookies}; use tracing::{error, info, warn}; use uuid::Uuid; use webauthn_rs::prelude::*; +use webauthn_rs_proto::ResidentKeyRequirement; use crate::application::auth::{AuthenticatedUser, SESSION_COOKIE_NAME}; use crate::application::state::AppState; @@ -112,7 +113,7 @@ pub(crate) async fn register_start( })?; let exclude_credentials = Vec::new(); - let (ccr, reg_state) = state + let (mut ccr, reg_state) = state .webauthn .start_passkey_registration( webauthn_uuid, @@ -124,6 +125,7 @@ pub(crate) async fn register_start( error!(error = %err, "failed to start passkey registration"); StatusCode::INTERNAL_SERVER_ERROR })?; + require_discoverable_credential(&mut ccr); // Store ceremony state let challenge_id = generate_session_token(); @@ -409,7 +411,7 @@ pub(crate) async fn passkey_add_start( .map(|p| p.cred_id().clone()) .collect::>(); - let (ccr, reg_state) = state + let (mut ccr, reg_state) = state .webauthn .start_passkey_registration( webauthn_uuid, @@ -421,6 +423,7 @@ pub(crate) async fn passkey_add_start( error!(error = %err, "failed to start passkey registration for existing user"); StatusCode::INTERNAL_SERVER_ERROR })?; + require_discoverable_credential(&mut ccr); let challenge_id = generate_session_token(); state @@ -614,6 +617,20 @@ pub(crate) async fn discoverable_auth_finish( // --- Helpers --- +/// Patch the creation challenge to require a discoverable (resident) credential. +/// +/// The webauthn-rs `start_passkey_registration` sets `residentKey: "discouraged"`, +/// which prevents iOS Safari from creating discoverable passkeys. Desktop password +/// managers ignore this and create discoverable credentials anyway, but iOS respects +/// it strictly. The server-side `RegistrationState` discards `require_resident_key` +/// during `finish_passkey_registration`, so this client-only patch is safe. +fn require_discoverable_credential(ccr: &mut CreationChallengeResponse) { + if let Some(ref mut auth_sel) = ccr.public_key.authenticator_selection { + auth_sel.resident_key = Some(ResidentKeyRequirement::Required); + auth_sel.require_resident_key = true; + } +} + /// Reject CLI callback URLs that don't point to localhost. /// This prevents an attacker from redirecting the bearer token to an external server. fn validate_cli_callback_url(url_str: &str) -> Result<(), ()> {