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