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 // `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/// The host arrives in one of two places depending on the protocol version.
60/// HTTP/1.1 sends a `Host` header; HTTP/2 sends `:authority`, which hyper
61/// puts in the URI and does *not* mirror into a header. Reading only one of
62/// them would break passkeys behind an h2 reverse proxy.
63fn origin(state: &AppState, uri: &Uri, headers: &HeaderMap) -> Option<Url> {
64 if let Some(public) = &state.public_url {
65 return Url::parse(public).ok();
66 }
67 let host = match uri.authority() {
68 Some(a) => a.as_str().to_string(),
69 None => headers.get(header::HOST)?.to_str().ok()?.to_string(),
70 };
71 let scheme = if state.https { "https" } else { "http" };
72 Url::parse(&format!("{scheme}://{host}")).ok()
73}
74
75/// The client is told nothing but "not available here".
76///
77/// Some of the routes that reach this need no session, so the hint belongs in
78/// the log. It names the deployment's configuration, which is the operator's
79/// business and not a visitor's.
80fn misconfigured() -> ApiError {
81 tracing::error!("passkeys need a domain name for the relying party; set --public-url");
82 ApiError::localized(
83 StatusCode::INTERNAL_SERVER_ERROR,
84 "passkeys are not available here",
85 "err_passkey_unavailable",
86 )
87}
88
89// ---------------------------------------------------------------------------
90// In-flight ceremonies
91// ---------------------------------------------------------------------------
92
93/// What the second leg of a ceremony needs to know.
94pub enum Pending {
95 /// Registering a passkey for a signed-in user.
96 Register {
97 user_id: i64,
98 state: Box<PasskeyRegistration>,
99 },
100 /// Signing in with a known account: the challenge names that account's
101 /// credentials, so the answer can only come from one of them.
102 Authenticate {
103 user_id: i64,
104 state: Box<PasskeyAuthentication>,
105 /// True when the password already passed and this is the second
106 /// factor. Only then does finishing create a session directly.
107 second_factor: bool,
108 },
109 /// Signing in without a name. The account is only known once the browser
110 /// answers, because the answer carries the user handle.
111 Discoverable {
112 state: Box<DiscoverableAuthentication>,
113 /// True when this stands in for a name the server does not know.
114 ///
115 /// The ceremony is real so that it cannot be told apart from one for
116 /// an account that exists. Finishing it must still fail, or answering
117 /// with any passkey would sign that passkey's owner in and turn the
118 /// answer into the name oracle the padding exists to prevent.
119 decoy: bool,
120 },
121 /// A passkey passed, but the account also requires its password. Holds
122 /// the identified user until `POST /api/auth/login` supplies it.
123 NeedsPassword { user_id: i64 },
124}
125
126impl Pending {
127 /// Whether a stranger could have made this one.
128 ///
129 /// Only these count against [`MAX_ANONYMOUS`]. The rest cost a session, a
130 /// correct password or a real authenticator signature to produce, and that
131 /// limits them better than a number here could. It also keeps a flood of
132 /// the cheap kind from evicting a sign-in that is halfway done.
133 fn anonymous(&self) -> bool {
134 matches!(
135 self,
136 Pending::Discoverable { .. }
137 | Pending::Authenticate {
138 second_factor: false,
139 ..
140 }
141 )
142 }
143}
144
145/// How long a client has to answer a challenge.
146///
147/// The browser's own timeout is shorter, but a conditional-UI challenge sits
148/// in an autofill dropdown until the user touches the field.
149const TTL: Duration = Duration::from_secs(300);
150
151/// Upper bound on outstanding ceremonies nobody had to authenticate for.
152///
153/// Conditional UI creates one on every load of the login page, most of which
154/// are never answered. Without a cap an unauthenticated visitor could grow
155/// this map without limit.
156///
157/// ponytail: the authenticated kinds are uncapped. Registering needs a
158/// session, so an account could loop it; the ceiling is one TTL of requests,
159/// tens of megabytes at a realistic rate. Cap them too if that ever bites.
160const MAX_ANONYMOUS: usize = 4096;
161
162/// ponytail: process-wide map, like `auth::LOGIN_FAILURES` and
163/// `auth::VERIFIED`. Move it to the DB if the server is ever scaled out —
164/// today a challenge issued by one node could not be answered on another.
165static PENDING: LazyLock<std::sync::Mutex<HashMap<String, (Pending, Instant)>>> =
166 LazyLock::new(Default::default);
167
168/// Store a ceremony and return the handle the client sends back.
169pub fn put(pending: Pending) -> String {
170 let id = crate::auth::random_token();
171 let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner());
172 map.retain(|_, (_, at)| at.elapsed() < TTL);
173 // Still full of live anonymous entries: drop the oldest of those to make
174 // room. A visitor whose challenge is evicted here just retries, and a
175 // half-finished sign-in is never the thing that gets dropped.
176 while pending.anonymous()
177 && map.values().filter(|(p, _)| p.anonymous()).count() >= MAX_ANONYMOUS
178 {
179 let Some(oldest) = map
180 .iter()
181 .filter(|(_, (p, _))| p.anonymous())
182 .min_by_key(|(_, (_, at))| *at)
183 .map(|(k, _)| k.clone())
184 else {
185 break;
186 };
187 map.remove(&oldest);
188 }
189 map.insert(id.clone(), (pending, Instant::now()));
190 id
191}
192
193/// Take a ceremony out of the map. One handle answers one challenge: a replay
194/// of the same handle finds nothing.
195pub fn take(id: &str) -> Option<Pending> {
196 let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner());
197 map.retain(|_, (_, at)| at.elapsed() < TTL);
198 map.remove(id).map(|(p, _)| p)
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn a_handle_answers_once() {
207 let id = put(Pending::NeedsPassword { user_id: 7 });
208 assert!(matches!(
209 take(&id),
210 Some(Pending::NeedsPassword { user_id: 7 })
211 ));
212 assert!(take(&id).is_none(), "a handle must not be reusable");
213 assert!(take("never-issued").is_none());
214 }
215
216 /// An anonymous ceremony, the kind `login_begin` hands out to a stranger.
217 fn anonymous_ceremony() -> Pending {
218 let url = Url::parse("https://example.com").unwrap();
219 let rp = WebauthnBuilder::new("example.com", &url)
220 .unwrap()
221 .build()
222 .unwrap();
223 let (_, disc) = rp.start_discoverable_authentication().unwrap();
224 Pending::Discoverable {
225 state: Box::new(disc),
226 decoy: false,
227 }
228 }
229
230 #[test]
231 fn a_flood_of_strangers_stays_bounded_and_spares_a_sign_in() {
232 // A sign-in that already passed one factor, parked mid-flight.
233 let halfway = put(Pending::NeedsPassword { user_id: 1 });
234
235 for _ in 0..MAX_ANONYMOUS + 50 {
236 put(anonymous_ceremony());
237 }
238
239 let anon = PENDING
240 .lock()
241 .unwrap()
242 .values()
243 .filter(|(p, _)| p.anonymous())
244 .count();
245 assert!(anon <= MAX_ANONYMOUS, "{anon} entries outgrew the cap");
246 assert!(
247 take(&halfway).is_some(),
248 "a flood must not evict a half-finished sign-in"
249 );
250 }
251}
252