Skip to main content

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}