diff --git a/Cargo.toml b/Cargo.toml index 99b0654..ed273b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ tower-http = { version = "0.6", features = ["compression-gzip", "limit", "set-he slug = "0.1.6" url = "2" uuid = { version = "1", features = ["v4"] } -webauthn-rs = "0.5" +webauthn-rs = { version = "0.5", features = ["conditional-ui"] } webauthn-rs-proto = "0.5" kamadak-exif = "0.6.1" diff --git a/src/application/routes/api/auth/webauthn.rs b/src/application/routes/api/auth/webauthn.rs index f202ae0..c9828a0 100644 --- a/src/application/routes/api/auth/webauthn.rs +++ b/src/application/routes/api/auth/webauthn.rs @@ -483,6 +483,135 @@ pub(crate) async fn passkey_add_finish( Ok(StatusCode::OK) } +// --- Discoverable authentication (Conditional UI) --- + +#[tracing::instrument(skip(state))] +pub(crate) async fn discoverable_auth_start( + State(state): State, +) -> Result, StatusCode> { + let (rcr, auth_state) = state + .webauthn + .start_discoverable_authentication() + .map_err(|err| { + error!(error = %err, "failed to start discoverable authentication"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let challenge_id = generate_session_token(); + state + .challenge_store + .store_discoverable_authentication(challenge_id.clone(), auth_state) + .await; + + Ok(Json(AuthStartResponse { + challenge_id, + options: rcr, + })) +} + +#[tracing::instrument(skip(state, cookies, payload))] +pub(crate) async fn discoverable_auth_finish( + State(state): State, + cookies: Cookies, + Json(payload): Json, +) -> Result, StatusCode> { + let auth_state = state + .challenge_store + .take_discoverable_authentication(&payload.challenge_id) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + + // Extract user UUID and credential ID from the credential + let (user_uuid, _credential_id) = state + .webauthn + .identify_discoverable_authentication(&payload.credential) + .map_err(|err| { + warn!(error = %err, "failed to identify discoverable credential"); + StatusCode::UNAUTHORIZED + })?; + + // Look up user by UUID + let user = state + .user_repo + .get_by_uuid(&user_uuid.to_string()) + .await + .map_err(|err| { + warn!(error = %err, uuid = %user_uuid, "user not found for discoverable auth"); + StatusCode::UNAUTHORIZED + })?; + + // Load user's passkeys and convert to DiscoverableKey + let credentials = state + .passkey_repo + .list_by_user(user.id) + .await + .map_err(|err| { + error!(error = %err, user_id = %user.id, "failed to list passkeys for discoverable auth"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let mut discoverable_keys: Vec = Vec::new(); + for cred in &credentials { + let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| { + error!(error = %err, credential_id = %cred.id, "failed to deserialize passkey for discoverable auth"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + discoverable_keys.push(DiscoverableKey::from(&passkey)); + } + + // Complete discoverable authentication + let auth_result = state + .webauthn + .finish_discoverable_authentication(&payload.credential, auth_state, &discoverable_keys) + .map_err(|err| { + warn!(error = %err, "discoverable authentication failed"); + StatusCode::UNAUTHORIZED + })?; + + // Update credential counter if needed + let credential_id = auth_result.cred_id(); + for cred in &credentials { + let passkey: Passkey = match serde_json::from_str(&cred.credential_json) { + Ok(p) => p, + Err(_) => continue, + }; + if passkey.cred_id() == credential_id && auth_result.needs_update() { + let mut updated_passkey = passkey; + updated_passkey.update_credential(&auth_result); + match serde_json::to_string(&updated_passkey) { + Ok(updated_json) => { + if let Err(err) = state + .passkey_repo + .update_credential_json(cred.id, &updated_json) + .await + { + warn!(error = %err, credential_id = %cred.id, "failed to update passkey credential counter"); + } + } + Err(err) => { + warn!(error = %err, "failed to serialize updated passkey credential"); + } + } + + // Update last used timestamp + let passkey_repo = state.passkey_repo.clone(); + let cred_id = cred.id; + tokio::spawn(async move { + if let Err(err) = passkey_repo.update_last_used(cred_id).await { + warn!(error = %err, credential_id = %cred_id, "failed to update passkey last_used"); + } + }); + break; + } + } + + create_session(&state, &cookies, user.id).await; + + info!(user_id = %user.id, "user authenticated via discoverable passkey"); + + Ok(Json(AuthFinishResponse { redirect: None })) +} + // --- Helpers --- /// Reject CLI callback URLs that don't point to localhost. diff --git a/src/application/routes/api/mod.rs b/src/application/routes/api/mod.rs index 298d014..8bc3e81 100644 --- a/src/application/routes/api/mod.rs +++ b/src/application/routes/api/mod.rs @@ -122,4 +122,12 @@ pub(super) fn webauthn_router() -> axum::Router { .route("/auth/finish", post(webauthn::auth_finish)) .route("/passkey/start", post(webauthn::passkey_add_start)) .route("/passkey/finish", post(webauthn::passkey_add_finish)) + .route( + "/auth/discoverable/start", + get(webauthn::discoverable_auth_start), + ) + .route( + "/auth/discoverable/finish", + post(webauthn::discoverable_auth_finish), + ) } diff --git a/src/infrastructure/webauthn.rs b/src/infrastructure/webauthn.rs index 38d3f77..0381069 100644 --- a/src/infrastructure/webauthn.rs +++ b/src/infrastructure/webauthn.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use chrono::{DateTime, Duration, Utc}; use tokio::sync::RwLock; -use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration}; +use webauthn_rs::prelude::{ + DiscoverableAuthentication, PasskeyAuthentication, PasskeyRegistration, +}; use crate::domain::ids::UserId; @@ -13,6 +15,7 @@ use crate::domain::ids::UserId; pub struct ChallengeStore { registrations: Arc>>, authentications: Arc>>, + discoverable_authentications: Arc>>, } struct RegistrationEntry { @@ -27,6 +30,11 @@ struct AuthenticationEntry { pub cli_callback: Option, } +struct DiscoverableAuthEntry { + pub state: DiscoverableAuthentication, + pub expires_at: DateTime, +} + #[derive(Clone)] pub struct CliCallbackInfo { pub callback_url: String, @@ -47,6 +55,7 @@ impl ChallengeStore { Self { registrations: Arc::new(RwLock::new(HashMap::new())), authentications: Arc::new(RwLock::new(HashMap::new())), + discoverable_authentications: Arc::new(RwLock::new(HashMap::new())), } } @@ -106,6 +115,32 @@ impl ChallengeStore { Some((entry.state, entry.cli_callback)) } + pub async fn store_discoverable_authentication( + &self, + challenge_id: String, + state: DiscoverableAuthentication, + ) { + let entry = DiscoverableAuthEntry { + state, + expires_at: Utc::now() + Duration::minutes(CHALLENGE_TTL_MINUTES), + }; + let mut map = self.discoverable_authentications.write().await; + Self::cleanup_expired_discoverable(&mut map); + map.insert(challenge_id, entry); + } + + pub async fn take_discoverable_authentication( + &self, + challenge_id: &str, + ) -> Option { + let mut map = self.discoverable_authentications.write().await; + let entry = map.remove(challenge_id)?; + if Utc::now() > entry.expires_at { + return None; + } + Some(entry.state) + } + fn cleanup_expired_registrations(map: &mut HashMap) { let now = Utc::now(); map.retain(|_, entry| entry.expires_at > now); @@ -115,4 +150,9 @@ impl ChallengeStore { let now = Utc::now(); map.retain(|_, entry| entry.expires_at > now); } + + fn cleanup_expired_discoverable(map: &mut HashMap) { + let now = Utc::now(); + map.retain(|_, entry| entry.expires_at > now); + } } diff --git a/static/js/webauthn.js b/static/js/webauthn.js index b631691..fc957d7 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -163,6 +163,55 @@ const startPasskeyAuthentication = async (queryParams) => { return finishResponse.json(); }; +// Start conditional UI authentication (passkey autofill) +const startConditionalUIAuthentication = async (signal) => { + // 1. Get discoverable challenge from server + const startResponse = await fetch( + "/api/v1/webauthn/auth/discoverable/start", + { + credentials: "same-origin", + signal, + }, + ); + + if (!startResponse.ok) { + throw new Error( + `Failed to start discoverable auth (HTTP ${startResponse.status}).`, + ); + } + + const { challenge_id, options } = await startResponse.json(); + + // 2. Get assertion via browser WebAuthn API with conditional mediation + const requestOptions = prepareRequestOptions(options); + requestOptions.mediation = "conditional"; + requestOptions.signal = signal; + const credential = await navigator.credentials.get(requestOptions); + + // 3. Send assertion to server + const finishResponse = await fetch( + "/api/v1/webauthn/auth/discoverable/finish", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + challenge_id, + credential: serializeAuthenticationCredential(credential), + }), + signal, + }, + ); + + if (!finishResponse.ok) { + throw new Error( + `Discoverable authentication failed (HTTP ${finishResponse.status}).`, + ); + } + + return finishResponse.json(); +}; + // Add a passkey to an existing authenticated account const addPasskey = async (name) => { // 1. Get challenge from server diff --git a/templates/pages/login.html b/templates/pages/login.html index b7adaa8..20c55a0 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -29,6 +29,15 @@
+ +