axum_token_auth/lib.rs
1//! This crate implements middleware to authenticate requests to [axum]. Overall
2//! the aim is to provide simple, passwordless authentication for secure network
3//! communication. A session key is stored in a cookie and signed with a secret
4//! (using crypto implementations in [the `tower-cookies`
5//! crate](https://crates.io/crates/tower-cookies)). Due to the signature, the
6//! session key cannot be modified. Aside from storing the secret, the system is
7//! stateless, requiring no storage on the server.
8//!
9//! In the normal case, a token is provided out-of-band to the user. For
10//! example, the user will start the server from an SSH session and copy the
11//! token to their browser. Alternatively, if the connection is defined as
12//! trusted (see ["Trusted connection flow", below](#trusted-connection-flow)),
13//! authentication occurs without any check.
14//!
15//! This is useful in cases where a user launches a server process and wants to
16//! achieve network-based control of the server without the server exposing this
17//! functionality to unauthenticated network connections. In this scenario, if
18//! the user provides the correct token in the URL upon initial connection, the
19//! server sets a cookie in the user's browser and subsequent requests are
20//! automatically validated with no further token in the URL.
21//!
22//! The user does not need an account, Passkey, OpenID Connect (OIDC), OAuth,
23//! OAuth2, FIDO U2F, FIDO2, WebAuthn, SAML, LDAP, Kerberos, RADIUS, or SSO
24//! credentials. The developer also does not need to configure these services.
25//! Rather, the user uses a URL with the correct token in the query parameters
26//! when initially connecting to the server.
27//!
28//! # Typical flow
29//!
30//! 1. A user starts or connects to a server and the user is given an initial
31//! authentication token, minted with [AuthConfig::generate_token]. (For
32//! example, the server prints or shows a QR code containing a URL. The URL
33//! includes the token.) The token is signed with the persistent secret and
34//! carries its own expiry, so the server validates it without storing any
35//! per-token state.
36//! 2. The user connects via a browser to the server. In the first HTTP request
37//! from the user, the token is included in the query parameter in the URL.
38//! 3. A new [SessionKey] is included as a new cookie in the HTTP response to
39//! the user. The cookie is stored by the user's browser. On the server, the
40//! request is further processed by the next service with session key
41//! information being made available.
42//! 4. Subsequent requests from the user browser include the newly set cookie
43//! (and no longer include the token in the URL) and the middleware makes the
44//! session key information available to the next service.
45//!
46//! # Trusted connection flow
47//!
48//! In case of a trusted connection, no token is required for initial
49//! authentication. The session key is still issued as above. A "trusted
50//! connection" is defined by setting [AuthConfig::token_config] to `None`. This
51//! is useful when the server is only accessible on a loopback interface.
52//!
53//! # Trusted networks (overlay VPNs)
54//!
55//! Where setting [AuthConfig::token_config] to `None` trusts *every* connection,
56//! [AuthConfig::trusted_networks] trusts *individual clients* by their network
57//! address: a request whose immediate peer address falls in one of the
58//! configured ranges is authenticated without a token, just like a trusted
59//! connection.
60//!
61//! This is intended for a server fronted by an authenticated, encrypted overlay
62//! network — for example [Tailscale] (whose addresses lie in `100.64.0.0/10`) or
63//! a WireGuard subnet — where the overlay has already authenticated the peer, so
64//! an application token would be redundant. The peer address is taken from the
65//! [`ConnectInfo<SocketAddr>`](axum::extract::ConnectInfo) request extension, so
66//! the server must be run with
67//! [`into_make_service_with_connect_info`](axum::routing::Router::into_make_service_with_connect_info);
68//! if that extension is absent the client is treated as untrusted.
69//!
70//! Because the address checked is the immediate TCP peer, the configured ranges
71//! must **not** be reachable through an intermediate reverse proxy, which would
72//! make every client appear to originate from the proxy.
73//!
74//! [Tailscale]: https://tailscale.com/
75//!
76//! # Session expiration and renewal
77//!
78//! Session lifetime is controlled by [AuthConfig::session_expires].
79//!
80//! If it is `None`, issued sessions never expire on their own: a cookie's
81//! signature is valid until the persistent secret is changed, and the cookie is
82//! a browser "session cookie" (no `Expires` attribute), saved only until the
83//! browser quits. To invalidate every session at once, change the persistent
84//! secret.
85//!
86//! If it is `Some(ttl)`, the issue time plus `ttl` is embedded in the (signed,
87//! tamper-proof) cookie and enforced by the server, so an expired cookie stops
88//! being accepted even if the client keeps presenting it. The same instant is
89//! written to the cookie's browser-side `Expires` attribute. The expiry slides
90//! forward whenever a request arrives past the halfway point of the session's
91//! lifetime, so a regularly-returning client keeps a valid session indefinitely
92//! without ever needing the token again — including past the ~400 day cap
93//! browsers place on any single cookie's lifetime. A client that stays away
94//! longer than `ttl` must re-authenticate with a token.
95//!
96//! # Cookie security attributes
97//!
98//! The session cookie's `Secure`, `HttpOnly`, and `SameSite` attributes are
99//! configurable via [AuthConfig::cookie_secure], [AuthConfig::cookie_http_only],
100//! and [AuthConfig::cookie_same_site]. The defaults (`HttpOnly` on,
101//! `SameSite=Strict`, `Secure` off) are safe for the common loopback/HTTP
102//! deployment; set `cookie_secure` to `true` when serving over HTTPS.
103//!
104//! # Removing the token from the URL after login
105//!
106//! A token left in the address bar can leak through browser history, bookmarks,
107//! or `Referer` headers. When [AuthConfig::strip_token_redirect] is enabled (the
108//! default), a top-level browser navigation (a `GET` whose `Accept` header
109//! includes `text/html`) that authenticates with a token in the query is
110//! answered with a redirect to the same location minus the token parameter. The
111//! session cookie is set on that redirect, so the follow-up request is already
112//! authenticated and never carries the token. Non-browser clients (which do not
113//! send `Accept: text/html`) are served normally, so callers that pass a token
114//! on every request are unaffected.
115//!
116//! # For more extensive needs
117//!
118//! If this crate does not meet your needs, check
119//! [`axum-login`](https://crates.io/crates/axum-login).
120#![forbid(unsafe_code)]
121#![deny(missing_docs)]
122#![deny(missing_debug_implementations)]
123#![deny(unreachable_pub)]
124#![deny(unused_qualifications)]
125#![deny(rust_2018_idioms)]
126#![warn(clippy::all)]
127
128use axum::{
129 BoxError,
130 extract::{ConnectInfo, FromRequestParts, Request},
131 http::{Method, StatusCode, header, request::Parts},
132 response::Response,
133};
134
135use base64::Engine as _;
136use cookie::time::{Duration, OffsetDateTime, PrimitiveDateTime};
137pub use cookie::{Key, SameSite};
138use futures_util::future::BoxFuture;
139use hmac::{Hmac, Mac};
140use sha2::Sha256;
141use std::net::{IpAddr, SocketAddr};
142use std::task::{Context, Poll};
143use tower_layer::Layer;
144use tower_service::Service;
145
146type HmacSha256 = Hmac<Sha256>;
147
148/// A CIDR network range — an IP address paired with a prefix length — used to
149/// populate [AuthConfig::trusted_networks].
150///
151/// Parse one from CIDR notation with [`str::parse`]:
152///
153/// ```
154/// use axum_token_auth::CidrBlock;
155/// let net: CidrBlock = "100.64.0.0/10".parse().unwrap();
156/// ```
157#[derive(Clone, Copy, Debug, PartialEq, Eq)]
158pub struct CidrBlock {
159 addr: IpAddr,
160 prefix_len: u8,
161}
162
163impl CidrBlock {
164 /// The network's base address, e.g. the `100.64.0.0` of `100.64.0.0/10`.
165 pub fn addr(&self) -> IpAddr {
166 self.addr
167 }
168
169 /// The prefix length in bits, e.g. the `10` of `100.64.0.0/10`.
170 pub fn prefix_len(&self) -> u8 {
171 self.prefix_len
172 }
173
174 /// Whether `ip` falls within this network. An IPv4 block never contains an
175 /// IPv6 address, and vice versa.
176 pub fn contains(&self, ip: &IpAddr) -> bool {
177 match (self.addr, ip) {
178 (IpAddr::V4(net), IpAddr::V4(ip)) => {
179 let mask = if self.prefix_len == 0 {
180 0
181 } else {
182 u32::MAX << (32 - self.prefix_len)
183 };
184 net.to_bits() & mask == ip.to_bits() & mask
185 }
186 (IpAddr::V6(net), IpAddr::V6(ip)) => {
187 let mask = if self.prefix_len == 0 {
188 0
189 } else {
190 u128::MAX << (128 - self.prefix_len)
191 };
192 net.to_bits() & mask == ip.to_bits() & mask
193 }
194 _ => false,
195 }
196 }
197}
198
199impl std::fmt::Display for CidrBlock {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 write!(f, "{}/{}", self.addr, self.prefix_len)
202 }
203}
204
205/// Error returned when a string cannot be parsed as a [`CidrBlock`].
206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207pub struct CidrParseError;
208
209impl std::fmt::Display for CidrParseError {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.write_str("invalid CIDR block (expected `address/prefix`)")
212 }
213}
214
215impl std::error::Error for CidrParseError {}
216
217impl std::str::FromStr for CidrBlock {
218 type Err = CidrParseError;
219
220 fn from_str(s: &str) -> Result<Self, Self::Err> {
221 let (addr_str, prefix_str) = s.split_once('/').ok_or(CidrParseError)?;
222 let addr: IpAddr = addr_str.parse().map_err(|_| CidrParseError)?;
223 let prefix_len: u8 = prefix_str.parse().map_err(|_| CidrParseError)?;
224 let max_prefix = if addr.is_ipv4() { 32 } else { 128 };
225 if prefix_len > max_prefix {
226 return Err(CidrParseError);
227 }
228 Ok(CidrBlock { addr, prefix_len })
229 }
230}
231
232/// Serialize a [CidrBlock] as its CIDR string (e.g. `"100.64.0.0/10"`), matching
233/// the [`FromStr`](std::str::FromStr) representation.
234#[cfg(feature = "serde")]
235impl serde::Serialize for CidrBlock {
236 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
237 serializer.collect_str(self)
238 }
239}
240
241/// Deserialize a [CidrBlock] from a CIDR string (e.g. `"100.64.0.0/10"`).
242#[cfg(feature = "serde")]
243impl<'de> serde::Deserialize<'de> for CidrBlock {
244 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
245 let s = std::borrow::Cow::<'de, str>::deserialize(deserializer)?;
246 s.parse().map_err(serde::de::Error::custom)
247 }
248}
249
250/// Label used to derive the dedicated token-MAC key from the master secret.
251/// This domain-separates the token MAC from the key `tower-cookies` uses to
252/// sign cookies at the key-derivation level.
253const TOKEN_KEY_INFO: &[u8] = b"axum-token-auth/token-mac/v1";
254
255/// Base64 engine used to encode tokens (URL-safe, no padding so the token can
256/// be dropped into a URL query parameter unescaped).
257const TOKEN_B64: base64::engine::general_purpose::GeneralPurpose =
258 base64::engine::general_purpose::URL_SAFE_NO_PAD;
259
260/// Current token wire-format version, carried as the first byte of every token
261/// and authenticated by the MAC. A token whose version this build does not
262/// recognise is rejected, so the format can evolve later (e.g. a new payload
263/// field) without older tokens being silently misinterpreted.
264const TOKEN_VERSION: u8 = 1;
265
266/// Derive a dedicated token-MAC key from the master secret via a single-step
267/// HMAC KDF: `HMAC-SHA256(master, info)`. The cookie-signing key and this key
268/// are derived independently from the master secret, so token MACs and cookie
269/// signatures share no key material.
270fn token_mac_key(key: &Key) -> [u8; 32] {
271 let mut kdf =
272 HmacSha256::new_from_slice(key.master()).expect("HMAC accepts keys of any length");
273 kdf.update(TOKEN_KEY_INFO);
274 let out = kdf.finalize().into_bytes();
275 let mut subkey = [0u8; 32];
276 subkey.copy_from_slice(&out);
277 subkey
278}
279
280/// Begin a token MAC over the version byte and expiry, keyed by the derived
281/// token-MAC key. The version is authenticated so it cannot be flipped to
282/// reinterpret a token under different format rules.
283fn token_mac(key: &Key, version: u8, expiry_unix: i64) -> HmacSha256 {
284 let mut mac =
285 HmacSha256::new_from_slice(&token_mac_key(key)).expect("HMAC accepts keys of any length");
286 mac.update(&[version]);
287 mac.update(&expiry_unix.to_le_bytes());
288 mac
289}
290
291/// Create a self-expiring, signed token valid until `expiry`.
292///
293/// The token is `base64url(version_u8 ‖ expiry_i64_le ‖ HMAC-SHA256(token_mac_key,
294/// version ‖ expiry))`, where `token_mac_key` is derived from `key` (see
295/// [token_mac_key]). Validation requires only `key` and the current time, so the
296/// server stores no per-token state.
297fn sign_token(key: &Key, expiry: OffsetDateTime) -> String {
298 let expiry_unix = expiry.unix_timestamp();
299 let mac = token_mac(key, TOKEN_VERSION, expiry_unix)
300 .finalize()
301 .into_bytes();
302 let mut buf = Vec::with_capacity(1 + 8 + mac.len());
303 buf.push(TOKEN_VERSION);
304 buf.extend_from_slice(&expiry_unix.to_le_bytes());
305 buf.extend_from_slice(&mac);
306 TOKEN_B64.encode(buf)
307}
308
309/// Verify a token produced by [sign_token]: check the version, the signature (in
310/// constant time), and that it has not yet expired relative to `now`.
311fn verify_token(key: &Key, token: &str, now: OffsetDateTime) -> bool {
312 let Ok(buf) = TOKEN_B64.decode(token) else {
313 return false;
314 };
315 // Layout: version (1 byte) ‖ expiry (8 bytes) ‖ MAC. Split it without any
316 // indexing that could panic on a short or truncated token.
317 let Some((&version, rest)) = buf.split_first() else {
318 return false;
319 };
320 if version != TOKEN_VERSION {
321 return false;
322 }
323 let Some((expiry_bytes, mac_bytes)) = rest.split_first_chunk::<8>() else {
324 return false;
325 };
326 let expiry_unix = i64::from_le_bytes(*expiry_bytes);
327
328 // Constant-time signature check.
329 if token_mac(key, version, expiry_unix)
330 .verify_slice(mac_bytes)
331 .is_err()
332 {
333 return false;
334 }
335
336 match OffsetDateTime::from_unix_timestamp(expiry_unix) {
337 Ok(expiry) => now < expiry,
338 Err(_) => false,
339 }
340}
341
342/// Compute `now + ttl`, saturating at the maximum representable timestamp
343/// instead of panicking if `ttl` is absurdly large (or otherwise unrepresentable
344/// as a [time::Duration][Duration] or [OffsetDateTime] offset).
345fn saturating_expiry(now: OffsetDateTime, ttl: std::time::Duration) -> OffsetDateTime {
346 Duration::try_from(ttl)
347 .ok()
348 .and_then(|ttl| now.checked_add(ttl))
349 .unwrap_or_else(|| PrimitiveDateTime::MAX.assume_utc())
350}
351
352/// Parse a session cookie value of the form `uuid` or `uuid.expiry_unix`.
353///
354/// Returns the [SessionKey] and, if present, the embedded server-side expiry.
355/// Returns `None` if the value cannot be parsed (e.g. a malformed or truncated
356/// cookie), in which case it is treated as if no cookie were present.
357fn parse_session_cookie(value: &str) -> Option<(SessionKey, Option<OffsetDateTime>)> {
358 let (uuid_str, expiry) = match value.split_once('.') {
359 Some((uuid_str, expiry_str)) => {
360 let secs: i64 = expiry_str.parse().ok()?;
361 (
362 uuid_str,
363 Some(OffsetDateTime::from_unix_timestamp(secs).ok()?),
364 )
365 }
366 None => (value, None),
367 };
368 let uuid = uuid::Uuid::parse_str(uuid_str).ok()?;
369 Some((SessionKey(uuid), expiry))
370}
371
372/// One or more validation errors
373#[derive(thiserror::Error, Debug)]
374#[error("one or more validation errors")]
375pub struct ValidationErrors(Vec<String>);
376
377impl ValidationErrors {
378 /// Return an iterator over the validation errors that ocurred
379 pub fn errors(&self) -> impl Iterator<Item = &str> {
380 self.0.iter().map(String::as_str)
381 }
382}
383
384/// Identifier for each session (one per client browser).
385#[derive(Debug, Clone, Eq, PartialEq, Hash)]
386pub struct SessionKey(pub uuid::Uuid);
387
388impl SessionKey {
389 /// Ensures at compile-time that a session key is present.
390 ///
391 /// A handler which called this method can only be called with a (valid)
392 /// session key and thus do not present a security hole. Furthermore, having
393 /// such a method call in the handler prevents accidental removal of the
394 /// `SessionKey` argument to the handler.
395 pub fn is_present(&self) {}
396}
397
398impl Default for SessionKey {
399 fn default() -> Self {
400 SessionKey(uuid::Uuid::new_v4())
401 }
402}
403
404/// Configuration for URI query parameters to implement token-based
405/// authentication.
406///
407/// The token *value* is not stored here. Instead, tokens are self-expiring,
408/// signed values minted with [AuthConfig::generate_token] and validated against
409/// [AuthConfig::persistent_secret], so the server keeps no per-token state. A
410/// token is accepted as long as its signature verifies and it has not yet
411/// expired.
412#[derive(Clone, Debug)]
413pub struct TokenConfig {
414 /// The key of the token in the URI query parameters.
415 pub name: String,
416}
417
418impl TokenConfig {
419 /// Create a [TokenConfig] for the given query parameter name.
420 pub fn new(name: &str) -> Self {
421 Self { name: name.into() }
422 }
423}
424
425/// Configuration for [AuthLayer] and [AuthMiddleware].
426///
427/// This struct is `#[non_exhaustive]`, so new fields can be added in future
428/// releases without breaking downstream code. Construct it with [AuthConfig::new]
429/// (or [Default::default]) and then set the public fields you need rather than
430/// with a struct literal.
431#[derive(Clone, Debug)]
432#[non_exhaustive]
433pub struct AuthConfig<'a> {
434 /// The cookie name
435 ///
436 /// This is the name of the cookie stored in the clients' browsers.
437 pub cookie_name: &'a str,
438 /// A long lived secret used to sign cookies set to the users.
439 ///
440 /// The secret is not shared with users.
441 ///
442 /// All issued session keys are valid as long as the persistent secret is
443 /// unchanged. There is no mechanism to invalidate individual sessions.
444 pub persistent_secret: Key,
445 /// The authentication token configuration.
446 ///
447 /// Set to `None` if the entire connection is trusted (e.g. it is on a
448 /// loopback interface). In this case, token checking is disabled but
449 /// [SessionKey] is still provided by [AuthMiddleware].
450 pub token_config: Option<TokenConfig>,
451 /// If set, issued sessions expire this long after they are issued, and the
452 /// session is renewed (its expiry slid forward) once it passes the halfway
453 /// point of its lifetime.
454 ///
455 /// The expiry is embedded in the (signed, tamper-proof) cookie and enforced
456 /// by the server, so an expired cookie stops being accepted even if the
457 /// client keeps presenting it. The cookie's browser-side `Expires`
458 /// attribute is set to the same instant on every (re)issue. Because the
459 /// expiry slides forward on use, a regularly-returning client keeps a valid
460 /// session indefinitely without ever needing the token again — including
461 /// past the ~400 day cap browsers place on a single cookie's lifetime.
462 ///
463 /// If `None`, issued sessions never expire (they remain valid as long as
464 /// [Self::persistent_secret] is unchanged) and the cookie is a "session
465 /// cookie" with no `Expires` attribute, saved only until the browser quits.
466 pub session_expires: Option<std::time::Duration>,
467 /// Whether the session cookie is marked `Secure` (sent only over HTTPS).
468 ///
469 /// Defaults to `false` so the cookie still works over plain HTTP on a
470 /// loopback interface, which is a common deployment for this crate. Set to
471 /// `true` whenever the server is reached over HTTPS.
472 pub cookie_secure: bool,
473 /// Whether the session cookie is marked `HttpOnly` (hidden from client-side
474 /// JavaScript, mitigating session theft via XSS).
475 ///
476 /// Defaults to `true`; this crate never needs to read the cookie from JS.
477 pub cookie_http_only: bool,
478 /// The `SameSite` attribute of the session cookie (CSRF defense).
479 ///
480 /// Defaults to `Some(SameSite::Strict)`. Use `Some(SameSite::Lax)` if
481 /// clients must stay authenticated when following cross-site links into the
482 /// app, or `None` to omit the attribute entirely. Note that
483 /// `Some(SameSite::None)` implies `Secure` per the cookie specification.
484 pub cookie_same_site: Option<SameSite>,
485 /// Client networks that are trusted to have already authenticated the peer,
486 /// so a request from one is accepted without a token (as if
487 /// [Self::token_config] were `None` for that client).
488 ///
489 /// This is for deployments fronted by a trusted overlay network — e.g.
490 /// Tailscale (`100.64.0.0/10`) or a WireGuard subnet — where the overlay
491 /// authenticates and encrypts the peer connection, making an application
492 /// token redundant. The client's address is taken from the
493 /// [`ConnectInfo<SocketAddr>`](axum::extract::ConnectInfo) request
494 /// extension, so the server must be run with
495 /// [`into_make_service_with_connect_info`] for this to take effect; if the
496 /// extension is absent the client is treated as untrusted.
497 ///
498 /// Defaults to empty (no overlay trust). Note that the address checked is
499 /// the immediate TCP peer, so this must not include ranges that could be
500 /// spoofed via an intermediate reverse proxy.
501 ///
502 /// [`into_make_service_with_connect_info`]: axum::routing::Router::into_make_service_with_connect_info
503 pub trusted_networks: Vec<CidrBlock>,
504 /// When a browser navigation authenticates with a token in the query
505 /// string, reply with a redirect to the same location minus the token
506 /// parameter, so the token does not linger in the address bar, browser
507 /// history, or `Referer` headers.
508 ///
509 /// Only top-level navigations (a `GET` whose `Accept` header includes
510 /// `text/html`) are redirected, so programmatic clients that authenticate
511 /// with a token on every request are unaffected. Defaults to `true`.
512 pub strip_token_redirect: bool,
513}
514
515impl Default for AuthConfig<'_> {
516 fn default() -> Self {
517 Self {
518 cookie_name: env!["CARGO_PKG_NAME"],
519 persistent_secret: Key::generate(),
520 token_config: None,
521 session_expires: None,
522 cookie_secure: false,
523 cookie_http_only: true,
524 cookie_same_site: Some(SameSite::Strict),
525 trusted_networks: Vec::new(),
526 strip_token_redirect: true,
527 }
528 }
529}
530
531impl AuthConfig<'_> {
532 /// Create a configuration with the given persistent secret and the default
533 /// value for every other field.
534 ///
535 /// Because [AuthConfig] is `#[non_exhaustive]`, downstream crates cannot
536 /// build it with a struct literal; start here (or from [Default::default])
537 /// and set the public fields you need:
538 ///
539 /// ```
540 /// use axum_token_auth::{AuthConfig, Key, TokenConfig};
541 /// let mut cfg = AuthConfig::new(Key::generate());
542 /// cfg.token_config = Some(TokenConfig::new("token"));
543 /// let layer = cfg.into_layer();
544 /// ```
545 pub fn new(persistent_secret: Key) -> Self {
546 Self {
547 persistent_secret,
548 ..Self::default()
549 }
550 }
551
552 /// Convert [Self] to an [AuthLayer].
553 pub fn into_layer(self) -> AuthLayer {
554 let access_info = AccessInfo::new(self);
555 AuthLayer { access_info }
556 }
557
558 /// Mint a self-expiring authentication token valid for `ttl` from now.
559 ///
560 /// The returned string is the value to place in the [TokenConfig::name]
561 /// query parameter of the initial URL handed to the user out-of-band. It is
562 /// signed with [Self::persistent_secret] and carries its own expiry, so the
563 /// server validates it without storing anything. Prefer a short `ttl`: a
564 /// token only needs to live long enough for the first request, after which
565 /// the client holds a session cookie. An absurdly large `ttl` saturates at
566 /// the maximum representable expiry rather than panicking.
567 pub fn generate_token(&self, ttl: std::time::Duration) -> String {
568 generate_token(&self.persistent_secret, ttl)
569 }
570}
571
572/// Mint a self-expiring authentication token valid for `ttl` from now, signed
573/// with `secret`.
574///
575/// This is the free-standing form of [AuthConfig::generate_token]: a token
576/// depends only on the persistent secret, so callers that mint tokens (often on
577/// a rotation timer) can do so without building — or cloning — a whole
578/// [AuthConfig]. Pass the same [Key] that the [AuthConfig::persistent_secret]
579/// driving the [AuthLayer] uses, otherwise the minted token will not validate.
580///
581/// The returned string is the value to place in the [TokenConfig::name] query
582/// parameter of the initial URL handed to the user out-of-band. Prefer a short
583/// `ttl`: a token only needs to live long enough for the first request, after
584/// which the client holds a session cookie. An absurdly large `ttl` saturates at
585/// the maximum representable expiry rather than panicking.
586pub fn generate_token(secret: &Key, ttl: std::time::Duration) -> String {
587 let expiry = saturating_expiry(OffsetDateTime::now_utc(), ttl);
588 sign_token(secret, expiry)
589}
590
591/// What the middleware should do with the session cookie for this request.
592enum SessionAction {
593 /// Issue a brand-new session cookie (authenticated via token or trusted
594 /// connection, with no valid existing session).
595 Issue(SessionKey),
596 /// An existing session is still valid; re-issue the cookie to slide its
597 /// expiry forward.
598 Renew(SessionKey),
599 /// An existing session is still valid and does not need renewing; leave the
600 /// cookie untouched.
601 Keep(SessionKey),
602}
603
604impl SessionAction {
605 fn session_key(&self) -> &SessionKey {
606 match self {
607 SessionAction::Issue(sk) | SessionAction::Renew(sk) | SessionAction::Keep(sk) => sk,
608 }
609 }
610}
611
612#[derive(Clone, Debug)]
613struct AccessInfo {
614 cookie_name: String,
615 token_config: Option<TokenConfig>,
616 session_expires: Option<std::time::Duration>,
617 cookie_secure: bool,
618 cookie_http_only: bool,
619 cookie_same_site: Option<SameSite>,
620 trusted_networks: Vec<CidrBlock>,
621 strip_token_redirect: bool,
622 key: Key,
623}
624
625impl AccessInfo {
626 /// Build access control information from the configuration.
627 fn new(cfg: AuthConfig<'_>) -> Self {
628 let AuthConfig {
629 cookie_name,
630 persistent_secret,
631 token_config,
632 session_expires,
633 cookie_secure,
634 cookie_http_only,
635 cookie_same_site,
636 trusted_networks,
637 strip_token_redirect,
638 } = cfg;
639
640 let key = persistent_secret;
641
642 Self {
643 cookie_name: cookie_name.into(),
644 token_config,
645 key,
646 session_expires,
647 cookie_secure,
648 cookie_http_only,
649 cookie_same_site,
650 trusted_networks,
651 strip_token_redirect,
652 }
653 }
654
655 /// Whether the request's immediate peer is in a configured trusted overlay
656 /// network (see [AuthConfig::trusted_networks]). The peer address is read
657 /// from the [`ConnectInfo<SocketAddr>`](ConnectInfo) request extension; if
658 /// it is absent the client is treated as untrusted.
659 fn is_trusted_client(&self, req: &Request) -> bool {
660 if self.trusted_networks.is_empty() {
661 return false;
662 }
663 let Some(ConnectInfo(peer)) = req.extensions().get::<ConnectInfo<SocketAddr>>() else {
664 return false;
665 };
666 let ip: IpAddr = peer.ip();
667 self.trusted_networks.iter().any(|net| net.contains(&ip))
668 }
669
670 /// Check whether the request carries a valid (signed, unexpired) token, is
671 /// from a trusted overlay network, or is exempt because the connection is
672 /// trusted (no [TokenConfig]).
673 fn check_token(&self, req: &Request, now: OffsetDateTime) -> bool {
674 // A peer on a trusted overlay network has already been authenticated by
675 // that network, so no token is required.
676 if self.is_trusted_client(req) {
677 return true;
678 }
679
680 let Some(token_config) = self.token_config.as_ref() else {
681 // No token configured: the connection is trusted.
682 return true;
683 };
684
685 let query = req.uri().query().unwrap_or("");
686 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
687 if key == token_config.name.as_str() && verify_token(&self.key, &value, now) {
688 return true;
689 }
690 }
691 false
692 }
693
694 /// If this request is a top-level browser navigation carrying a token in
695 /// its query string (and [AuthConfig::strip_token_redirect] is enabled),
696 /// return the location to redirect to with the token parameter removed, so
697 /// the token does not persist in the address bar, history, or `Referer`.
698 /// Returns `None` when no redirect should occur.
699 fn token_strip_redirect_location(&self, req: &Request) -> Option<String> {
700 if !self.strip_token_redirect || req.method() != Method::GET {
701 return None;
702 }
703 // Only the configured token parameter is stripped; if no token auth is
704 // configured there is nothing to strip.
705 let token_name = self.token_config.as_ref()?.name.as_str();
706
707 // Restrict to top-level browser navigations so programmatic clients
708 // (which authenticate with a token per request) are not redirected.
709 let accepts_html = req
710 .headers()
711 .get(header::ACCEPT)
712 .and_then(|v| v.to_str().ok())
713 .map(|accept| accept.contains("text/html"))
714 .unwrap_or(false);
715 if !accepts_html {
716 return None;
717 }
718
719 let uri = req.uri();
720 let query = uri.query()?;
721 let mut kept = Vec::new();
722 let mut had_token = false;
723 for pair in query.split('&') {
724 let name = pair.split('=').next().unwrap_or("");
725 if name == token_name {
726 had_token = true;
727 } else if !pair.is_empty() {
728 kept.push(pair);
729 }
730 }
731 if !had_token {
732 return None;
733 }
734
735 let path = uri.path();
736 // A `Location` without a fragment leaves the original fragment intact in
737 // the browser, matching the previous client-side strip behaviour.
738 Some(if kept.is_empty() {
739 path.to_string()
740 } else {
741 format!("{path}?{}", kept.join("&"))
742 })
743 }
744
745 /// Decide what to do for this request given any session cookie it presented.
746 fn authenticate(
747 &self,
748 req: &Request,
749 existing: Option<(SessionKey, Option<OffsetDateTime>)>,
750 now: OffsetDateTime,
751 ) -> Result<SessionAction, ValidationErrors> {
752 // Discard an existing session whose embedded expiry has passed. A
753 // session with no embedded expiry (a legacy cookie) is always kept.
754 let valid_session =
755 existing.filter(|&(_, expiry)| expiry.is_none_or(|expiry| now < expiry));
756
757 match valid_session {
758 Some((session_key, expiry)) => {
759 if self.should_renew(expiry, now) {
760 Ok(SessionAction::Renew(session_key))
761 } else {
762 Ok(SessionAction::Keep(session_key))
763 }
764 }
765 None => {
766 if self.check_token(req, now) {
767 Ok(SessionAction::Issue(SessionKey::default()))
768 } else {
769 Err(ValidationErrors(vec![
770 "No (valid) token in uri and no (valid) session.".into(),
771 ]))
772 }
773 }
774 }
775 }
776
777 /// Whether a still-valid session should have its expiry slid forward. We
778 /// renew once a session has passed the halfway point of its lifetime, which
779 /// keeps a returning client's session alive while avoiding a `Set-Cookie`
780 /// on every single request.
781 fn should_renew(&self, expiry: Option<OffsetDateTime>, now: OffsetDateTime) -> bool {
782 let Some(ttl) = self.session_expires else {
783 // No server-side expiry configured: nothing to slide.
784 return false;
785 };
786 match expiry {
787 // Cookie predates expiry support but we now want one: add it.
788 None => true,
789 Some(expiry) => {
790 let ttl = Duration::try_from(ttl).unwrap_or(Duration::ZERO);
791 (expiry - now) * 2 < ttl
792 }
793 }
794 }
795
796 /// Build the cookie value (and matching browser-side expiry) for a session
797 /// being issued or renewed now.
798 fn build_cookie_value(
799 &self,
800 session_key: &SessionKey,
801 now: OffsetDateTime,
802 ) -> (String, Option<OffsetDateTime>) {
803 match self.session_expires {
804 Some(ttl) => {
805 let expiry = saturating_expiry(now, ttl);
806 (
807 format!(
808 "{}.{}",
809 session_key.0.as_hyphenated(),
810 expiry.unix_timestamp()
811 ),
812 Some(expiry),
813 )
814 }
815 None => (format!("{}", session_key.0.as_hyphenated()), None),
816 }
817 }
818}
819
820impl<S> FromRequestParts<S> for SessionKey
821where
822 S: Send + Sync,
823{
824 type Rejection = (StatusCode, &'static str);
825
826 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
827 if let Some(session_key) = parts.extensions.remove::<SessionKey>() {
828 Ok(session_key.clone())
829 } else {
830 Err((StatusCode::UNAUTHORIZED, "(valid) session key is missing"))
831 }
832 }
833}
834
835/// Implements [Layer] for [AuthMiddleware]
836///
837/// See the crate-level documentation for an overview.
838#[derive(Clone, Debug)]
839pub struct AuthLayer {
840 access_info: AccessInfo,
841}
842
843impl<S> Layer<S> for AuthLayer {
844 type Service = tower_cookies::CookieManager<AuthMiddleware<S>>;
845
846 fn layer(&self, inner: S) -> Self::Service {
847 let auth_middleware = AuthMiddleware {
848 inner,
849 access_info: self.access_info.clone(),
850 };
851 tower_cookies::CookieManager::new(auth_middleware)
852 }
853}
854
855/// Middleware which checks if request is authenticated and, if so, extends the
856/// request to include [SessionKey] information.
857#[derive(Clone, Debug)]
858pub struct AuthMiddleware<S> {
859 inner: S,
860 access_info: AccessInfo,
861}
862
863impl<S> Service<Request> for AuthMiddleware<S>
864where
865 S: Service<Request, Response = Response> + Send + 'static,
866 S::Error: Into<BoxError>,
867 S::Future: Send + 'static,
868{
869 type Response = S::Response;
870 type Error = BoxError;
871 type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
872
873 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
874 match self.inner.poll_ready(cx) {
875 Poll::Pending => Poll::Pending,
876 Poll::Ready(r) => Poll::Ready(r.map_err(Into::into)),
877 }
878 }
879
880 fn call(&mut self, mut request: Request) -> Self::Future {
881 let Some(cookies) = request
882 .extensions()
883 .get::<tower_cookies::Cookies>()
884 .cloned()
885 else {
886 // In practice this should never happen because we wrap `CookieManager`
887 // directly.
888 tracing::error!("missing cookies request extension");
889 return Box::pin(std::future::ready(Err(Box::new(ValidationErrors(vec![
890 "missing cookies request extension".into(),
891 ])) as BoxError)));
892 };
893 let signed = cookies.signed(&self.access_info.key);
894
895 // Outcome of the auth check: either authorized (optionally with a
896 // redirect that strips the token from the URL) or a validation error.
897 let mut redirect_location: Option<String> = None;
898 let err_info = {
899 let now = OffsetDateTime::now_utc();
900
901 // Read and parse any existing session cookie. A malformed cookie is
902 // treated as absent rather than panicking.
903 let existing = signed
904 .get(&self.access_info.cookie_name)
905 .and_then(|received_cookie| parse_session_cookie(received_cookie.value()));
906
907 // check if authenticated
908 match self.access_info.authenticate(&request, existing, now) {
909 Ok(action) => {
910 let session_key = action.session_key().clone();
911 request.extensions_mut().insert(session_key.clone());
912
913 if matches!(action, SessionAction::Issue(_) | SessionAction::Renew(_)) {
914 let (value, expires) =
915 self.access_info.build_cookie_value(&session_key, now);
916 let mut set_cookie =
917 tower_cookies::Cookie::new(self.access_info.cookie_name.clone(), value);
918
919 // Apply the configured cookie security attributes (see
920 // `AuthConfig`). Defaults are HttpOnly, SameSite=Strict,
921 // and Secure off.
922 set_cookie.set_secure(self.access_info.cookie_secure);
923 set_cookie.set_http_only(self.access_info.cookie_http_only);
924 set_cookie.set_same_site(self.access_info.cookie_same_site);
925
926 if let Some(expires) = expires {
927 set_cookie.set_expires(expires);
928 }
929
930 signed.add(set_cookie);
931 }
932
933 // Now that the session cookie is set, redirect a browser
934 // navigation to a token-free URL so the token does not
935 // linger in the address bar, history, or `Referer`.
936 redirect_location = self.access_info.token_strip_redirect_location(&request);
937 None
938 }
939 Err(val_err) => Some(val_err),
940 }
941 };
942
943 if let Some(val_err) = err_info {
944 return Box::pin(std::future::ready(Err(val_err.into())));
945 }
946
947 // Short-circuit with a redirect that drops the token parameter. The
948 // outer `CookieManager` still serializes the session cookie set above
949 // onto this response, so the redirected request arrives authenticated.
950 if let Some(location) = redirect_location {
951 let response = Response::builder()
952 .status(StatusCode::SEE_OTHER)
953 .header(header::LOCATION, location)
954 .body(axum::body::Body::empty())
955 .expect("building a redirect response cannot fail");
956 return Box::pin(std::future::ready(Ok(response)));
957 }
958
959 // Build future which generates response.
960 let fut = self.inner.call(request);
961
962 // Await future.
963 Box::pin(async move {
964 let response: Response = fut.await.map_err(|e| e.into())?;
965 Ok(response)
966 })
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973 use anyhow::Result;
974 use axum::body::Body;
975 use cookie::Cookie;
976 use http::{Request, StatusCode};
977
978 use std::convert::Infallible;
979 use tower::{ServiceBuilder, ServiceExt};
980
981 async fn handler(_: Request<Body>) -> std::result::Result<Response<Body>, Infallible> {
982 Ok(Response::new(Body::empty()))
983 }
984
985 fn get_cfg() -> AuthConfig<'static> {
986 AuthConfig {
987 cookie_name: "auth",
988 persistent_secret: Key::generate(),
989 token_config: Some(TokenConfig::new("token")),
990 session_expires: None,
991 ..Default::default()
992 }
993 }
994
995 /// A token valid well into the future for the config's secret.
996 fn valid_token_uri(cfg: &AuthConfig<'_>) -> String {
997 let name = &cfg.token_config.as_ref().unwrap().name;
998 let token = cfg.generate_token(std::time::Duration::from_secs(300));
999 format!("http://example.com/path?{name}={token}")
1000 }
1001
1002 #[tokio::test]
1003 async fn fail_without_token_or_cookie() -> Result<()> {
1004 let auth_layer = get_cfg().into_layer();
1005 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1006
1007 let req = Request::builder().body(Body::empty())?;
1008 let res = svc.oneshot(req).await;
1009 assert!(
1010 !res.err()
1011 .unwrap()
1012 .downcast::<ValidationErrors>()
1013 .unwrap()
1014 .errors()
1015 .collect::<Vec<_>>()
1016 .is_empty()
1017 );
1018 Ok(())
1019 }
1020
1021 async fn get_second_response(
1022 cfg: AuthConfig<'_>,
1023 req: Request<Body>,
1024 ) -> Result<Response<Body>> {
1025 let auth_layer = cfg.into_layer();
1026 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1027
1028 // Make a request to get the cookie.
1029 let res = svc.clone().oneshot(req).await.unwrap();
1030
1031 // Extract the cookie
1032 let cookie = {
1033 let set_cookie: Vec<_> = res.headers().get_all(header::SET_COOKIE).iter().collect();
1034 assert_eq!(set_cookie.len(), 1);
1035 Cookie::parse(set_cookie[0].to_str()?.to_string())?
1036 };
1037
1038 // Now make a new request with the cookie.
1039 let req2 = Request::builder()
1040 .header(header::COOKIE, cookie.stripped().to_string())
1041 .body(Body::empty())
1042 .unwrap();
1043 let res2 = svc.oneshot(req2).await.unwrap();
1044 Ok(res2)
1045 }
1046
1047 #[tokio::test]
1048 async fn set_cookie_with_trusted_socket() -> Result<()> {
1049 let mut cfg = get_cfg();
1050 cfg.token_config = None;
1051 let uri = "http://example.com/path";
1052 let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1053
1054 let res2 = get_second_response(cfg, req).await?;
1055 assert_eq!(res2.status(), StatusCode::OK);
1056 Ok(())
1057 }
1058
1059 #[tokio::test]
1060 async fn set_cookie_with_valid_token() -> Result<()> {
1061 let cfg = get_cfg();
1062 let uri = valid_token_uri(&cfg);
1063 let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1064
1065 let res2 = get_second_response(cfg, req).await?;
1066
1067 assert_eq!(res2.status(), StatusCode::OK);
1068 Ok(())
1069 }
1070
1071 /// A session cookie issued by v0.2.x held a bare UUID (no embedded expiry),
1072 /// signed with the persistent secret. After upgrading to self-expiring
1073 /// sessions, such a cookie must still authenticate: the persistent secret is
1074 /// unchanged, so its signature verifies, and a missing embedded expiry is
1075 /// treated as "never expires" until the session is next renewed. This is the
1076 /// guarantee that existing in-browser cookies survive the upgrade.
1077 #[tokio::test]
1078 async fn legacy_bare_uuid_cookie_is_accepted() -> Result<()> {
1079 let key = Key::generate();
1080 let mut cfg = get_cfg();
1081 cfg.persistent_secret = key.clone();
1082 // Enabling server-side expiry must not reject the legacy cookie.
1083 cfg.session_expires = Some(std::time::Duration::from_secs(60 * 60 * 24 * 400));
1084 let cookie_name = cfg.cookie_name.to_string();
1085
1086 // Forge exactly what v0.2.x stored: a bare-UUID value signed with the
1087 // persistent secret, with no embedded expiry.
1088 let legacy_value = format!("{}", uuid::Uuid::new_v4().as_hyphenated());
1089 let mut jar = cookie::CookieJar::new();
1090 jar.signed_mut(&key)
1091 .add(Cookie::new(cookie_name.clone(), legacy_value));
1092 let signed = jar.get(&cookie_name).unwrap().stripped().to_string();
1093
1094 let auth_layer = cfg.into_layer();
1095 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1096
1097 // No token in the URI: the cookie alone must authenticate.
1098 let req = Request::builder()
1099 .uri("http://example.com/path")
1100 .header(header::COOKIE, signed)
1101 .body(Body::empty())
1102 .unwrap();
1103 let res = svc.oneshot(req).await.unwrap();
1104 assert_eq!(res.status(), StatusCode::OK);
1105 Ok(())
1106 }
1107
1108 #[tokio::test]
1109 async fn issued_cookie_is_httponly_and_samesite_strict() -> Result<()> {
1110 let mut cfg = get_cfg();
1111 cfg.token_config = None;
1112 let auth_layer = cfg.into_layer();
1113 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1114
1115 let req = Request::builder()
1116 .uri("http://example.com/path")
1117 .body(Body::empty())
1118 .unwrap();
1119 let res = svc.oneshot(req).await.unwrap();
1120
1121 let set_cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str()?;
1122 let cookie = Cookie::parse(set_cookie.to_string())?;
1123 assert_eq!(cookie.http_only(), Some(true));
1124 assert_eq!(cookie.same_site(), Some(SameSite::Strict));
1125 Ok(())
1126 }
1127
1128 #[tokio::test]
1129 async fn cookie_attributes_are_configurable() -> Result<()> {
1130 let mut cfg = get_cfg();
1131 cfg.token_config = None;
1132 cfg.cookie_secure = true;
1133 cfg.cookie_http_only = false;
1134 cfg.cookie_same_site = Some(SameSite::Lax);
1135 let auth_layer = cfg.into_layer();
1136 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1137
1138 let req = Request::builder()
1139 .uri("http://example.com/path")
1140 .body(Body::empty())
1141 .unwrap();
1142 let res = svc.oneshot(req).await.unwrap();
1143
1144 let set_cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str()?;
1145 let cookie = Cookie::parse(set_cookie.to_string())?;
1146 assert_eq!(cookie.secure(), Some(true));
1147 // `http_only(false)` omits the attribute entirely.
1148 assert_eq!(cookie.http_only(), None);
1149 assert_eq!(cookie.same_site(), Some(SameSite::Lax));
1150 Ok(())
1151 }
1152
1153 #[tokio::test]
1154 async fn reject_token_with_wrong_secret() -> Result<()> {
1155 // A token minted with a different secret must not be accepted.
1156 let other = get_cfg();
1157 let uri = valid_token_uri(&other);
1158
1159 let cfg = get_cfg();
1160 let auth_layer = cfg.into_layer();
1161 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1162 let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1163 let res = svc.oneshot(req).await;
1164 assert!(res.is_err());
1165 Ok(())
1166 }
1167
1168 #[test]
1169 fn cidr_block_parse_and_contains() {
1170 let net: CidrBlock = "100.64.0.0/10".parse().unwrap();
1171 assert!(net.contains(&"100.64.0.1".parse().unwrap()));
1172 assert!(net.contains(&"100.127.255.255".parse().unwrap()));
1173 assert!(!net.contains(&"100.128.0.0".parse().unwrap()));
1174 assert!(!net.contains(&"10.0.0.1".parse().unwrap()));
1175 // An IPv4 block never matches an IPv6 peer.
1176 assert!(!net.contains(&"::1".parse().unwrap()));
1177
1178 // /0 matches everything of its family; /32 and /128 match one address.
1179 assert!(
1180 "0.0.0.0/0"
1181 .parse::<CidrBlock>()
1182 .unwrap()
1183 .contains(&"8.8.8.8".parse().unwrap())
1184 );
1185 let host: CidrBlock = "192.168.1.5/32".parse().unwrap();
1186 assert!(host.contains(&"192.168.1.5".parse().unwrap()));
1187 assert!(!host.contains(&"192.168.1.6".parse().unwrap()));
1188
1189 let v6: CidrBlock = "fd00::/8".parse().unwrap();
1190 assert!(v6.contains(&"fd00::1".parse().unwrap()));
1191 assert!(!v6.contains(&"fe00::1".parse().unwrap()));
1192
1193 // Malformed input and out-of-range prefixes are rejected.
1194 assert!("100.64.0.0".parse::<CidrBlock>().is_err());
1195 assert!("100.64.0.0/33".parse::<CidrBlock>().is_err());
1196 assert!("fd00::/129".parse::<CidrBlock>().is_err());
1197 assert!("nonsense/8".parse::<CidrBlock>().is_err());
1198
1199 // Accessors expose the parsed address and prefix.
1200 assert_eq!(net.addr(), "100.64.0.0".parse::<IpAddr>().unwrap());
1201 assert_eq!(net.prefix_len(), 10);
1202 }
1203
1204 #[cfg(feature = "serde")]
1205 #[test]
1206 fn cidr_block_serde_roundtrip() {
1207 let net: CidrBlock = "100.64.0.0/10".parse().unwrap();
1208 let json = serde_json::to_string(&net).unwrap();
1209 assert_eq!(json, "\"100.64.0.0/10\"");
1210 assert_eq!(serde_json::from_str::<CidrBlock>(&json).unwrap(), net);
1211 // An invalid CIDR string is rejected during deserialization.
1212 assert!(serde_json::from_str::<CidrBlock>("\"nonsense\"").is_err());
1213 }
1214
1215 #[test]
1216 fn token_roundtrip_signature_and_expiry() {
1217 let key = Key::generate();
1218 let now = OffsetDateTime::now_utc();
1219 let token = sign_token(&key, now + Duration::minutes(5));
1220
1221 // Valid now, expired later.
1222 assert!(verify_token(&key, &token, now));
1223 assert!(!verify_token(&key, &token, now + Duration::minutes(6)));
1224
1225 // Tampering or a wrong key is rejected.
1226 assert!(!verify_token(&key, &format!("{token}x"), now));
1227 assert!(!verify_token(&Key::generate(), &token, now));
1228 assert!(!verify_token(&key, "not base64!!", now));
1229
1230 // A token whose version byte is altered is rejected (the version is
1231 // authenticated and unknown versions are refused).
1232 let mut bytes = TOKEN_B64.decode(&token).unwrap();
1233 bytes[0] = bytes[0].wrapping_add(1);
1234 assert!(!verify_token(&key, &TOKEN_B64.encode(bytes), now));
1235 }
1236
1237 #[test]
1238 fn expiry_saturates_instead_of_panicking() {
1239 let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1240
1241 // A normal duration adds as expected.
1242 let normal = saturating_expiry(now, std::time::Duration::from_secs(60));
1243 assert_eq!(normal, now + Duration::seconds(60));
1244
1245 // An absurd duration saturates at the maximum representable timestamp
1246 // rather than panicking.
1247 let huge = saturating_expiry(now, std::time::Duration::from_secs(u64::MAX));
1248 assert_eq!(huge, PrimitiveDateTime::MAX.assume_utc());
1249 }
1250
1251 #[test]
1252 fn session_cookie_parsing() {
1253 let sk = SessionKey::default();
1254 let expiry = OffsetDateTime::from_unix_timestamp(1_900_000_000).unwrap();
1255
1256 // Without an embedded expiry.
1257 let bare = format!("{}", sk.0.as_hyphenated());
1258 assert_eq!(parse_session_cookie(&bare), Some((sk.clone(), None)));
1259
1260 // With an embedded expiry.
1261 let with_exp = format!("{}.{}", sk.0.as_hyphenated(), expiry.unix_timestamp());
1262 assert_eq!(parse_session_cookie(&with_exp), Some((sk, Some(expiry))));
1263
1264 // Garbage parses to nothing rather than panicking.
1265 assert_eq!(parse_session_cookie("nonsense"), None);
1266 assert_eq!(parse_session_cookie(""), None);
1267 }
1268
1269 #[test]
1270 fn renews_past_halfway_point() {
1271 let mut cfg = get_cfg();
1272 cfg.session_expires = Some(std::time::Duration::from_secs(100));
1273 let access_info = AccessInfo::new(cfg);
1274 let now = OffsetDateTime::now_utc();
1275
1276 // 60s left of a 100s lifetime: still in the first half, keep as-is.
1277 assert!(!access_info.should_renew(Some(now + Duration::seconds(60)), now));
1278 // 40s left: past halfway, renew.
1279 assert!(access_info.should_renew(Some(now + Duration::seconds(40)), now));
1280 // A cookie with no embedded expiry gets one added.
1281 assert!(access_info.should_renew(None, now));
1282 }
1283
1284 /// A client whose peer address is inside a configured trusted overlay
1285 /// network is authenticated without any token, just like a trusted
1286 /// (token-less) connection.
1287 #[tokio::test]
1288 async fn trusted_network_skips_token() -> Result<()> {
1289 use axum::extract::ConnectInfo;
1290 use std::net::SocketAddr;
1291
1292 let mut cfg = get_cfg(); // token IS required by default
1293 cfg.trusted_networks = vec!["100.64.0.0/10".parse().unwrap()];
1294 let auth_layer = cfg.into_layer();
1295 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1296
1297 // From inside the overlay range: accepted with no token.
1298 let mut trusted = Request::builder()
1299 .uri("http://example.com/path")
1300 .body(Body::empty())
1301 .unwrap();
1302 trusted.extensions_mut().insert(ConnectInfo(
1303 "100.100.1.2:5555".parse::<SocketAddr>().unwrap(),
1304 ));
1305 assert_eq!(
1306 svc.clone().oneshot(trusted).await.unwrap().status(),
1307 StatusCode::OK
1308 );
1309
1310 // From outside the overlay range with no token: rejected.
1311 let mut untrusted = Request::builder()
1312 .uri("http://example.com/path")
1313 .body(Body::empty())
1314 .unwrap();
1315 untrusted.extensions_mut().insert(ConnectInfo(
1316 "192.168.1.2:5555".parse::<SocketAddr>().unwrap(),
1317 ));
1318 assert!(svc.oneshot(untrusted).await.is_err());
1319 Ok(())
1320 }
1321
1322 /// A browser navigation (GET + `Accept: text/html`) that authenticates with
1323 /// a token in the URL is redirected (303) to the same path with the token
1324 /// removed, and the session cookie is set on that redirect.
1325 #[tokio::test]
1326 async fn browser_token_auth_redirects_without_token() -> Result<()> {
1327 let cfg = get_cfg();
1328 // valid_token_uri yields `.../path?token=XXX`; add another parameter so
1329 // we can assert it survives the strip.
1330 let uri = format!("{}&keep=1", valid_token_uri(&cfg));
1331 let auth_layer = cfg.into_layer();
1332 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1333
1334 let req = Request::builder()
1335 .uri(uri)
1336 .header(header::ACCEPT, "text/html,application/xhtml+xml")
1337 .body(Body::empty())
1338 .unwrap();
1339 let res = svc.oneshot(req).await.unwrap();
1340
1341 assert_eq!(res.status(), StatusCode::SEE_OTHER);
1342 let location = res.headers().get(header::LOCATION).unwrap().to_str()?;
1343 // Token stripped, other query parameters preserved.
1344 assert_eq!(location, "/path?keep=1");
1345 // The session cookie is issued on the redirect itself.
1346 assert!(res.headers().contains_key(header::SET_COOKIE));
1347 Ok(())
1348 }
1349
1350 /// A programmatic client (no `Accept: text/html`) authenticating with a
1351 /// token is served normally rather than redirected, so non-browser callers
1352 /// that pass a token per request keep working.
1353 #[tokio::test]
1354 async fn programmatic_token_auth_is_not_redirected() -> Result<()> {
1355 let cfg = get_cfg();
1356 let uri = valid_token_uri(&cfg);
1357 let auth_layer = cfg.into_layer();
1358 let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1359
1360 let req = Request::builder()
1361 .uri(uri)
1362 .header(header::ACCEPT, "*/*")
1363 .body(Body::empty())
1364 .unwrap();
1365 let res = svc.oneshot(req).await.unwrap();
1366 assert_eq!(res.status(), StatusCode::OK);
1367 Ok(())
1368 }
1369}