webauthn.rs
⎇
Raw
1//! WebAuthn (passkey) support: the relying-party instance and the short-lived
2//! state of an in-flight ceremony.
3//!
4//! Both WebAuthn ceremonies take two round trips. The server issues a
5//! challenge, the browser answers it, and the server must still hold the
6//! challenge it issued to check the answer. That state lives in [`PENDING`]
7//! here, keyed by an opaque handle the client echoes back.
8
9use std::collections::HashMap;
10use std::sync::{Arc, LazyLock};
11use std::time::{Duration, Instant};
12
13use axum::extract::FromRequestParts;
14use axum::http::request::Parts;
15use axum::http::{HeaderMap, StatusCode, Uri, header};
16use webauthn_rs::prelude::*;
17
18use crate::error::{ApiError, AppState};
19
20/// The relying party, as an extractor.
21///
22/// Built per request rather than once at startup, because the RP ID is the
23/// domain the browser is on, and nothing tells us that at startup unless
24/// `--public-url` is set. A reverse proxy that rewrites the host would break
25/// this; set `--public-url` there.
26pub struct Rp(pub Webauthn);
27
28impl FromRequestParts<Arc<AppState>> for Rp {
29 type Rejection = ApiError;
30
31 async fn from_request_parts(
32 parts: &mut Parts,
33 state: &Arc<AppState>,
34 ) -> Result<Self, Self::Rejection> {
35 relying_party(state, &parts.uri, &parts.headers).map(Rp)
36 }
37}
38
39pub fn relying_party(
40 state: &AppState,
41 uri: &Uri,
42 headers: &HeaderMap,
43) -> Result<Webauthn, ApiError> {
44 let origin = origin(state, uri, headers).ok_or_else(misconfigured)?;
45 check_origin(&origin, headers)?;
46 // `domain()` is None for a bare IP address, and WebAuthn does not work on
47 // one at all — the RP ID has to be a registrable domain.
48 let rp_id = origin.domain().ok_or_else(misconfigured)?;
49 WebauthnBuilder::new(rp_id, &origin)
50 .and_then(|b| b.rp_name("dovenest").build())
51 .map_err(|e| {
52 tracing::error!(error = ?e, %origin, "cannot build the WebAuthn relying party");
53 misconfigured()
54 })
55}
56
57/// The origin the browser will report, as far as the server can tell.
58///
59/// Falls back to the request's own host, which is plain HTTP because that is
60/// all this process ever speaks. Behind a TLS proxy that guess is wrong and
61/// `--public-url` is the only way to correct it.
62///
63/// The host arrives in one of two places depending on the protocol version.
64/// HTTP/1.1 sends a `Host` header; HTTP/2 sends `:authority`, which hyper
65/// puts in the URI and does *not* mirror into a header. Reading only one of
66/// them would break passkeys behind an h2 reverse proxy.
67fn origin(state: &AppState, uri: &Uri, headers: &HeaderMap) -> Option<Url> {
68 if let Some(public) = &state.public_url {
69 return Some(public.clone());
70 }
71 let host = match uri.authority() {
72 Some(a) => a.as_str().to_string(),
73 None => headers.get(header::HOST)?.to_str().ok()?.to_string(),
74 };
75 Url::parse(&format!("http://{host}")).ok()
76}
77
78/// Refuse a ceremony the browser could not complete anyway.
79///
80/// A challenge built for the wrong origin fails in the browser with a bare
81/// `SecurityError`, or at the last step with a mismatch nobody can see. The
82/// `Origin` header is what the browser will sign, so comparing it here turns
83/// both into one message that names the two addresses.
84///
85/// Absent on requests that are not a browser fetch, and then unenforced.
86fn check_origin(configured: &Url, headers: &HeaderMap) -> Result<(), ApiError> {
87 let Some(browser) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
88 return Ok(());
89 };
90 let expected = configured.origin().ascii_serialization();
91 if browser == expected {
92 return Ok(());
93 }
94 tracing::error!(
95 %browser,
96 %expected,
97 "passkeys are configured for a different address than the browser is on; \
98 set --public-url to the address the browser uses"
99 );
100 Err(ApiError::localized(
101 StatusCode::INTERNAL_SERVER_ERROR,
102 "passkeys are set up for a different address",
103 "err_passkey_origin",
104 ))
105}
106
107/// The client is told nothing but "not available here".
108///
109/// Some of the routes that reach this need no session, so the hint belongs in
110/// the log. It names the deployment's configuration, which is the operator's
111/// business and not a visitor's.
112fn misconfigured() -> ApiError {
113 tracing::error!("passkeys need a domain name for the relying party; set --public-url");
114 ApiError::localized(
115 StatusCode::INTERNAL_SERVER_ERROR,
116 "passkeys are not available here",
117 "err_passkey_unavailable",
118 )
119}
120
121// ---------------------------------------------------------------------------
122// In-flight ceremonies
123// ---------------------------------------------------------------------------
124
125/// What the second leg of a ceremony needs to know.
126pub enum Pending {
127 /// Registering a passkey for a signed-in user.
128 Register {
129 user_id: i64,
130 state: Box<PasskeyRegistration>,
131 },
132 /// The passkey after a correct password: the challenge names that
133 /// account's credentials, so the answer can only come from one of them.
134 /// Finishing it creates a session, so only create it once the password
135 /// has passed.
136 SecondFactor {
137 user_id: i64,
138 state: Box<PasskeyAuthentication>,
139 },
140 /// Signing in with a passkey first. The account is only known once the
141 /// browser answers, because the answer carries the user handle.
142 Discoverable(Box<DiscoverableAuthentication>),
143 /// A passkey passed, but the account also requires its password. Holds
144 /// the identified user until `POST /api/auth/login` supplies it.
145 NeedsPassword { user_id: i64 },
146}
147
148impl Pending {
149 /// Whether a stranger could have made this one.
150 ///
151 /// Only these count against [`MAX_ANONYMOUS`]. The rest cost a session, a
152 /// correct password or a real authenticator signature to produce, and that
153 /// limits them better than a number here could. It also keeps a flood of
154 /// the cheap kind from evicting a sign-in that is halfway done.
155 fn anonymous(&self) -> bool {
156 matches!(self, Pending::Discoverable(_))
157 }
158}
159
160/// How long a client has to answer a challenge.
161///
162/// The browser's own timeout is shorter, but a conditional-UI challenge sits
163/// in an autofill dropdown until the user touches the field.
164const TTL: Duration = Duration::from_secs(300);
165
166/// Upper bound on outstanding ceremonies nobody had to authenticate for.
167///
168/// Conditional UI creates one on every load of the login page, most of which
169/// are never answered. Without a cap an unauthenticated visitor could grow
170/// this map without limit.
171///
172/// ponytail: the authenticated kinds are uncapped. Registering needs a
173/// session, so an account could loop it; the ceiling is one TTL of requests,
174/// tens of megabytes at a realistic rate. Cap them too if that ever bites.
175const MAX_ANONYMOUS: usize = 4096;
176
177/// ponytail: process-wide map, like `auth::LOGIN_FAILURES` and
178/// `auth::VERIFIED`. Move it to the DB if the server is ever scaled out —
179/// today a challenge issued by one node could not be answered on another.
180static PENDING: LazyLock<std::sync::Mutex<HashMap<String, (Pending, Instant)>>> =
181 LazyLock::new(Default::default);
182
183/// Store a ceremony and return the handle the client sends back.
184pub fn put(pending: Pending) -> String {
185 let id = crate::auth::random_token();
186 let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner());
187 map.retain(|_, (_, at)| at.elapsed() < TTL);
188 // Still full of live anonymous entries: drop the oldest of those to make
189 // room. A visitor whose challenge is evicted here just retries, and a
190 // half-finished sign-in is never the thing that gets dropped.
191 if pending.anonymous()
192 && map.values().filter(|(p, _)| p.anonymous()).count() >= MAX_ANONYMOUS
193 && let Some(oldest) = map
194 .iter()
195 .filter(|(_, (p, _))| p.anonymous())
196 .min_by_key(|(_, (_, at))| *at)
197 .map(|(k, _)| k.clone())
198 {
199 map.remove(&oldest);
200 }
201 map.insert(id.clone(), (pending, Instant::now()));
202 id
203}
204
205/// Take a ceremony out of the map. One handle answers one challenge: a replay
206/// of the same handle finds nothing.
207pub fn take(id: &str) -> Option<Pending> {
208 let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner());
209 map.retain(|_, (_, at)| at.elapsed() < TTL);
210 map.remove(id).map(|(p, _)| p)
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn a_browser_on_another_origin_is_refused() {
219 let configured = Url::parse("https://files.example.com/").unwrap();
220 let header = |v: &str| {
221 let mut h = HeaderMap::new();
222 h.insert(header::ORIGIN, v.parse().unwrap());
223 h
224 };
225
226 assert!(check_origin(&configured, &HeaderMap::new()).is_ok());
227 assert!(check_origin(&configured, &header("https://files.example.com")).is_ok());
228 // The three ways --public-url goes wrong.
229 assert!(check_origin(&configured, &header("http://files.example.com")).is_err());
230 assert!(check_origin(&configured, &header("https://other.example.com")).is_err());
231 assert!(check_origin(&configured, &header("https://files.example.com:8443")).is_err());
232 }
233
234 #[test]
235 fn a_handle_answers_once() {
236 let id = put(Pending::NeedsPassword { user_id: 7 });
237 assert!(matches!(
238 take(&id),
239 Some(Pending::NeedsPassword { user_id: 7 })
240 ));
241 assert!(take(&id).is_none(), "a handle must not be reusable");
242 assert!(take("never-issued").is_none());
243 }
244
245 /// An anonymous ceremony, the kind `login_begin` hands out to a stranger.
246 fn anonymous_ceremony() -> Pending {
247 let url = Url::parse("https://example.com").unwrap();
248 let rp = WebauthnBuilder::new("example.com", &url)
249 .unwrap()
250 .build()
251 .unwrap();
252 let (_, disc) = rp.start_discoverable_authentication().unwrap();
253 Pending::Discoverable(Box::new(disc))
254 }
255
256 #[test]
257 fn a_flood_of_strangers_stays_bounded_and_spares_a_sign_in() {
258 // A sign-in that already passed one factor, parked mid-flight.
259 let halfway = put(Pending::NeedsPassword { user_id: 1 });
260
261 for _ in 0..MAX_ANONYMOUS + 50 {
262 put(anonymous_ceremony());
263 }
264
265 let anon = PENDING
266 .lock()
267 .unwrap()
268 .values()
269 .filter(|(p, _)| p.anonymous())
270 .count();
271 assert!(anon <= MAX_ANONYMOUS, "{anon} entries outgrew the cap");
272 assert!(
273 take(&halfway).is_some(),
274 "a flood must not evict a half-finished sign-in"
275 );
276 }
277}
278