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("filebrowser-ng").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 /// Signing in with a known account: the challenge names that account's
133 /// credentials, so the answer can only come from one of them.
134 Authenticate {
135 user_id: i64,
136 state: Box<PasskeyAuthentication>,
137 /// True when the password already passed and this is the second
138 /// factor. Only then does finishing create a session directly.
139 second_factor: bool,
140 },
141 /// Signing in without a name. The account is only known once the browser
142 /// answers, because the answer carries the user handle.
143 Discoverable {
144 state: Box<DiscoverableAuthentication>,
145 /// True when this stands in for a name the server does not know.
146 ///
147 /// The ceremony is real so that it cannot be told apart from one for
148 /// an account that exists. Finishing it must still fail, or answering
149 /// with any passkey would sign that passkey's owner in and turn the
150 /// answer into the name oracle the padding exists to prevent.
151 decoy: bool,
152 },
153 /// A passkey passed, but the account also requires its password. Holds
154 /// the identified user until `POST /api/auth/login` supplies it.
155 NeedsPassword { user_id: i64 },
156}
157
158impl Pending {
159 /// Whether a stranger could have made this one.
160 ///
161 /// Only these count against [`MAX_ANONYMOUS`]. The rest cost a session, a
162 /// correct password or a real authenticator signature to produce, and that
163 /// limits them better than a number here could. It also keeps a flood of
164 /// the cheap kind from evicting a sign-in that is halfway done.
165 fn anonymous(&self) -> bool {
166 matches!(
167 self,
168 Pending::Discoverable { .. }
169 | Pending::Authenticate {
170 second_factor: false,
171 ..
172 }
173 )
174 }
175}
176
177/// How long a client has to answer a challenge.
178///
179/// The browser's own timeout is shorter, but a conditional-UI challenge sits
180/// in an autofill dropdown until the user touches the field.
181const TTL: Duration = Duration::from_secs(300);
182
183/// Upper bound on outstanding ceremonies nobody had to authenticate for.
184///
185/// Conditional UI creates one on every load of the login page, most of which
186/// are never answered. Without a cap an unauthenticated visitor could grow
187/// this map without limit.
188///
189/// ponytail: the authenticated kinds are uncapped. Registering needs a
190/// session, so an account could loop it; the ceiling is one TTL of requests,
191/// tens of megabytes at a realistic rate. Cap them too if that ever bites.
192const MAX_ANONYMOUS: usize = 4096;
193
194/// ponytail: process-wide map, like `auth::LOGIN_FAILURES` and
195/// `auth::VERIFIED`. Move it to the DB if the server is ever scaled out —
196/// today a challenge issued by one node could not be answered on another.
197static PENDING: LazyLock<std::sync::Mutex<HashMap<String, (Pending, Instant)>>> =
198 LazyLock::new(Default::default);
199
200/// Store a ceremony and return the handle the client sends back.
201pub fn put(pending: Pending) -> String {
202 let id = crate::auth::random_token();
203 let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner());
204 map.retain(|_, (_, at)| at.elapsed() < TTL);
205 // Still full of live anonymous entries: drop the oldest of those to make
206 // room. A visitor whose challenge is evicted here just retries, and a
207 // half-finished sign-in is never the thing that gets dropped.
208 while pending.anonymous()
209 && map.values().filter(|(p, _)| p.anonymous()).count() >= MAX_ANONYMOUS
210 {
211 let Some(oldest) = map
212 .iter()
213 .filter(|(_, (p, _))| p.anonymous())
214 .min_by_key(|(_, (_, at))| *at)
215 .map(|(k, _)| k.clone())
216 else {
217 break;
218 };
219 map.remove(&oldest);
220 }
221 map.insert(id.clone(), (pending, Instant::now()));
222 id
223}
224
225/// Take a ceremony out of the map. One handle answers one challenge: a replay
226/// of the same handle finds nothing.
227pub fn take(id: &str) -> Option<Pending> {
228 let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner());
229 map.retain(|_, (_, at)| at.elapsed() < TTL);
230 map.remove(id).map(|(p, _)| p)
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn a_browser_on_another_origin_is_refused() {
239 let configured = Url::parse("https://files.example.com/").unwrap();
240 let header = |v: &str| {
241 let mut h = HeaderMap::new();
242 h.insert(header::ORIGIN, v.parse().unwrap());
243 h
244 };
245
246 assert!(check_origin(&configured, &HeaderMap::new()).is_ok());
247 assert!(check_origin(&configured, &header("https://files.example.com")).is_ok());
248 // The three ways --public-url goes wrong.
249 assert!(check_origin(&configured, &header("http://files.example.com")).is_err());
250 assert!(check_origin(&configured, &header("https://other.example.com")).is_err());
251 assert!(check_origin(&configured, &header("https://files.example.com:8443")).is_err());
252 }
253
254 #[test]
255 fn a_handle_answers_once() {
256 let id = put(Pending::NeedsPassword { user_id: 7 });
257 assert!(matches!(
258 take(&id),
259 Some(Pending::NeedsPassword { user_id: 7 })
260 ));
261 assert!(take(&id).is_none(), "a handle must not be reusable");
262 assert!(take("never-issued").is_none());
263 }
264
265 /// An anonymous ceremony, the kind `login_begin` hands out to a stranger.
266 fn anonymous_ceremony() -> Pending {
267 let url = Url::parse("https://example.com").unwrap();
268 let rp = WebauthnBuilder::new("example.com", &url)
269 .unwrap()
270 .build()
271 .unwrap();
272 let (_, disc) = rp.start_discoverable_authentication().unwrap();
273 Pending::Discoverable {
274 state: Box::new(disc),
275 decoy: false,
276 }
277 }
278
279 #[test]
280 fn a_flood_of_strangers_stays_bounded_and_spares_a_sign_in() {
281 // A sign-in that already passed one factor, parked mid-flight.
282 let halfway = put(Pending::NeedsPassword { user_id: 1 });
283
284 for _ in 0..MAX_ANONYMOUS + 50 {
285 put(anonymous_ceremony());
286 }
287
288 let anon = PENDING
289 .lock()
290 .unwrap()
291 .values()
292 .filter(|(p, _)| p.anonymous())
293 .count();
294 assert!(anon <= MAX_ANONYMOUS, "{anon} entries outgrew the cap");
295 assert!(
296 take(&halfway).is_some(),
297 "a flood must not evict a half-finished sign-in"
298 );
299 }
300}
301