//! WebAuthn (passkey) support: the relying-party instance and the short-lived //! state of an in-flight ceremony. //! //! Both WebAuthn ceremonies take two round trips. The server issues a //! challenge, the browser answers it, and the server must still hold the //! challenge it issued to check the answer. That state lives in [`PENDING`] //! here, keyed by an opaque handle the client echoes back. use std::collections::HashMap; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::http::{HeaderMap, StatusCode, Uri, header}; use webauthn_rs::prelude::*; use crate::error::{ApiError, AppState}; /// The relying party, as an extractor. /// /// Built per request rather than once at startup, because the RP ID is the /// domain the browser is on, and nothing tells us that at startup unless /// `--public-url` is set. A reverse proxy that rewrites the host would break /// this; set `--public-url` there. pub struct Rp(pub Webauthn); impl FromRequestParts> for Rp { type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &Arc, ) -> Result { relying_party(state, &parts.uri, &parts.headers).map(Rp) } } pub fn relying_party( state: &AppState, uri: &Uri, headers: &HeaderMap, ) -> Result { let origin = origin(state, uri, headers).ok_or_else(misconfigured)?; check_origin(&origin, headers)?; // `domain()` is None for a bare IP address, and WebAuthn does not work on // one at all — the RP ID has to be a registrable domain. let rp_id = origin.domain().ok_or_else(misconfigured)?; WebauthnBuilder::new(rp_id, &origin) .and_then(|b| b.rp_name("dovenest").build()) .map_err(|e| { tracing::error!(error = ?e, %origin, "cannot build the WebAuthn relying party"); misconfigured() }) } /// The origin the browser will report, as far as the server can tell. /// /// Falls back to the request's own host, which is plain HTTP because that is /// all this process ever speaks. Behind a TLS proxy that guess is wrong and /// `--public-url` is the only way to correct it. /// /// The host arrives in one of two places depending on the protocol version. /// HTTP/1.1 sends a `Host` header; HTTP/2 sends `:authority`, which hyper /// puts in the URI and does *not* mirror into a header. Reading only one of /// them would break passkeys behind an h2 reverse proxy. fn origin(state: &AppState, uri: &Uri, headers: &HeaderMap) -> Option { if let Some(public) = &state.public_url { return Some(public.clone()); } let host = match uri.authority() { Some(a) => a.as_str().to_string(), None => headers.get(header::HOST)?.to_str().ok()?.to_string(), }; Url::parse(&format!("http://{host}")).ok() } /// Refuse a ceremony the browser could not complete anyway. /// /// A challenge built for the wrong origin fails in the browser with a bare /// `SecurityError`, or at the last step with a mismatch nobody can see. The /// `Origin` header is what the browser will sign, so comparing it here turns /// both into one message that names the two addresses. /// /// Absent on requests that are not a browser fetch, and then unenforced. fn check_origin(configured: &Url, headers: &HeaderMap) -> Result<(), ApiError> { let Some(browser) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { return Ok(()); }; let expected = configured.origin().ascii_serialization(); if browser == expected { return Ok(()); } tracing::error!( %browser, %expected, "passkeys are configured for a different address than the browser is on; \ set --public-url to the address the browser uses" ); Err(ApiError::localized( StatusCode::INTERNAL_SERVER_ERROR, "passkeys are set up for a different address", "err_passkey_origin", )) } /// The client is told nothing but "not available here". /// /// Some of the routes that reach this need no session, so the hint belongs in /// the log. It names the deployment's configuration, which is the operator's /// business and not a visitor's. fn misconfigured() -> ApiError { tracing::error!("passkeys need a domain name for the relying party; set --public-url"); ApiError::localized( StatusCode::INTERNAL_SERVER_ERROR, "passkeys are not available here", "err_passkey_unavailable", ) } // --------------------------------------------------------------------------- // In-flight ceremonies // --------------------------------------------------------------------------- /// What the second leg of a ceremony needs to know. pub enum Pending { /// Registering a passkey for a signed-in user. Register { user_id: i64, state: Box, }, /// Signing in with a known account: the challenge names that account's /// credentials, so the answer can only come from one of them. Authenticate { user_id: i64, state: Box, /// True when the password already passed and this is the second /// factor. Only then does finishing create a session directly. second_factor: bool, }, /// Signing in without a name. The account is only known once the browser /// answers, because the answer carries the user handle. Discoverable { state: Box, /// True when this stands in for a name the server does not know. /// /// The ceremony is real so that it cannot be told apart from one for /// an account that exists. Finishing it must still fail, or answering /// with any passkey would sign that passkey's owner in and turn the /// answer into the name oracle the padding exists to prevent. decoy: bool, }, /// A passkey passed, but the account also requires its password. Holds /// the identified user until `POST /api/auth/login` supplies it. NeedsPassword { user_id: i64 }, } impl Pending { /// Whether a stranger could have made this one. /// /// Only these count against [`MAX_ANONYMOUS`]. The rest cost a session, a /// correct password or a real authenticator signature to produce, and that /// limits them better than a number here could. It also keeps a flood of /// the cheap kind from evicting a sign-in that is halfway done. fn anonymous(&self) -> bool { matches!( self, Pending::Discoverable { .. } | Pending::Authenticate { second_factor: false, .. } ) } } /// How long a client has to answer a challenge. /// /// The browser's own timeout is shorter, but a conditional-UI challenge sits /// in an autofill dropdown until the user touches the field. const TTL: Duration = Duration::from_secs(300); /// Upper bound on outstanding ceremonies nobody had to authenticate for. /// /// Conditional UI creates one on every load of the login page, most of which /// are never answered. Without a cap an unauthenticated visitor could grow /// this map without limit. /// /// ponytail: the authenticated kinds are uncapped. Registering needs a /// session, so an account could loop it; the ceiling is one TTL of requests, /// tens of megabytes at a realistic rate. Cap them too if that ever bites. const MAX_ANONYMOUS: usize = 4096; /// ponytail: process-wide map, like `auth::LOGIN_FAILURES` and /// `auth::VERIFIED`. Move it to the DB if the server is ever scaled out — /// today a challenge issued by one node could not be answered on another. static PENDING: LazyLock>> = LazyLock::new(Default::default); /// Store a ceremony and return the handle the client sends back. pub fn put(pending: Pending) -> String { let id = crate::auth::random_token(); let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner()); map.retain(|_, (_, at)| at.elapsed() < TTL); // Still full of live anonymous entries: drop the oldest of those to make // room. A visitor whose challenge is evicted here just retries, and a // half-finished sign-in is never the thing that gets dropped. if pending.anonymous() && map.values().filter(|(p, _)| p.anonymous()).count() >= MAX_ANONYMOUS && let Some(oldest) = map .iter() .filter(|(_, (p, _))| p.anonymous()) .min_by_key(|(_, (_, at))| *at) .map(|(k, _)| k.clone()) { map.remove(&oldest); } map.insert(id.clone(), (pending, Instant::now())); id } /// Take a ceremony out of the map. One handle answers one challenge: a replay /// of the same handle finds nothing. pub fn take(id: &str) -> Option { let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner()); map.retain(|_, (_, at)| at.elapsed() < TTL); map.remove(id).map(|(p, _)| p) } #[cfg(test)] mod tests { use super::*; #[test] fn a_browser_on_another_origin_is_refused() { let configured = Url::parse("https://files.example.com/").unwrap(); let header = |v: &str| { let mut h = HeaderMap::new(); h.insert(header::ORIGIN, v.parse().unwrap()); h }; assert!(check_origin(&configured, &HeaderMap::new()).is_ok()); assert!(check_origin(&configured, &header("https://files.example.com")).is_ok()); // The three ways --public-url goes wrong. assert!(check_origin(&configured, &header("http://files.example.com")).is_err()); assert!(check_origin(&configured, &header("https://other.example.com")).is_err()); assert!(check_origin(&configured, &header("https://files.example.com:8443")).is_err()); } #[test] fn a_handle_answers_once() { let id = put(Pending::NeedsPassword { user_id: 7 }); assert!(matches!( take(&id), Some(Pending::NeedsPassword { user_id: 7 }) )); assert!(take(&id).is_none(), "a handle must not be reusable"); assert!(take("never-issued").is_none()); } /// An anonymous ceremony, the kind `login_begin` hands out to a stranger. fn anonymous_ceremony() -> Pending { let url = Url::parse("https://example.com").unwrap(); let rp = WebauthnBuilder::new("example.com", &url) .unwrap() .build() .unwrap(); let (_, disc) = rp.start_discoverable_authentication().unwrap(); Pending::Discoverable { state: Box::new(disc), decoy: false, } } #[test] fn a_flood_of_strangers_stays_bounded_and_spares_a_sign_in() { // A sign-in that already passed one factor, parked mid-flight. let halfway = put(Pending::NeedsPassword { user_id: 1 }); for _ in 0..MAX_ANONYMOUS + 50 { put(anonymous_ceremony()); } let anon = PENDING .lock() .unwrap() .values() .filter(|(p, _)| p.anonymous()) .count(); assert!(anon <= MAX_ANONYMOUS, "{anon} entries outgrew the cap"); assert!( take(&halfway).is_some(), "a flood must not evict a half-finished sign-in" ); } }