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 _;
136pub use cookie::time::OffsetDateTime;
137use cookie::time::{Duration, PrimitiveDateTime};
138pub use cookie::{Key, SameSite};
139use futures_util::future::BoxFuture;
140use hmac::{Hmac, Mac};
141use sha2::Sha256;
142use std::net::{IpAddr, SocketAddr};
143use std::task::{Context, Poll};
144use tower_layer::Layer;
145use tower_service::Service;
146
147type HmacSha256 = Hmac<Sha256>;
148
149/// A CIDR network range — an IP address paired with a prefix length — used to
150/// populate [AuthConfig::trusted_networks].
151///
152/// Parse one from CIDR notation with [`str::parse`]:
153///
154/// ```
155/// use axum_token_auth::CidrBlock;
156/// let net: CidrBlock = "100.64.0.0/10".parse().unwrap();
157/// ```
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub struct CidrBlock {
160    addr: IpAddr,
161    prefix_len: u8,
162}
163
164impl CidrBlock {
165    /// The network's base address, e.g. the `100.64.0.0` of `100.64.0.0/10`.
166    pub fn addr(&self) -> IpAddr {
167        self.addr
168    }
169
170    /// The prefix length in bits, e.g. the `10` of `100.64.0.0/10`.
171    pub fn prefix_len(&self) -> u8 {
172        self.prefix_len
173    }
174
175    /// Whether `ip` falls within this network. An IPv4 block never contains an
176    /// IPv6 address, and vice versa.
177    pub fn contains(&self, ip: &IpAddr) -> bool {
178        match (self.addr, ip) {
179            (IpAddr::V4(net), IpAddr::V4(ip)) => {
180                let mask = if self.prefix_len == 0 {
181                    0
182                } else {
183                    u32::MAX << (32 - self.prefix_len)
184                };
185                net.to_bits() & mask == ip.to_bits() & mask
186            }
187            (IpAddr::V6(net), IpAddr::V6(ip)) => {
188                let mask = if self.prefix_len == 0 {
189                    0
190                } else {
191                    u128::MAX << (128 - self.prefix_len)
192                };
193                net.to_bits() & mask == ip.to_bits() & mask
194            }
195            _ => false,
196        }
197    }
198}
199
200impl std::fmt::Display for CidrBlock {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        write!(f, "{}/{}", self.addr, self.prefix_len)
203    }
204}
205
206/// Error returned when a string cannot be parsed as a [`CidrBlock`].
207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
208pub struct CidrParseError;
209
210impl std::fmt::Display for CidrParseError {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.write_str("invalid CIDR block (expected `address/prefix`)")
213    }
214}
215
216impl std::error::Error for CidrParseError {}
217
218impl std::str::FromStr for CidrBlock {
219    type Err = CidrParseError;
220
221    fn from_str(s: &str) -> Result<Self, Self::Err> {
222        let (addr_str, prefix_str) = s.split_once('/').ok_or(CidrParseError)?;
223        let addr: IpAddr = addr_str.parse().map_err(|_| CidrParseError)?;
224        let prefix_len: u8 = prefix_str.parse().map_err(|_| CidrParseError)?;
225        let max_prefix = if addr.is_ipv4() { 32 } else { 128 };
226        if prefix_len > max_prefix {
227            return Err(CidrParseError);
228        }
229        Ok(CidrBlock { addr, prefix_len })
230    }
231}
232
233/// Serialize a [CidrBlock] as its CIDR string (e.g. `"100.64.0.0/10"`), matching
234/// the [`FromStr`](std::str::FromStr) representation.
235#[cfg(feature = "serde")]
236impl serde::Serialize for CidrBlock {
237    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
238        serializer.collect_str(self)
239    }
240}
241
242/// Deserialize a [CidrBlock] from a CIDR string (e.g. `"100.64.0.0/10"`).
243#[cfg(feature = "serde")]
244impl<'de> serde::Deserialize<'de> for CidrBlock {
245    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
246        let s = std::borrow::Cow::<'de, str>::deserialize(deserializer)?;
247        s.parse().map_err(serde::de::Error::custom)
248    }
249}
250
251/// Label used to derive the dedicated token-MAC key from the master secret.
252/// This domain-separates the token MAC from the key `tower-cookies` uses to
253/// sign cookies at the key-derivation level.
254const TOKEN_KEY_INFO: &[u8] = b"axum-token-auth/token-mac/v1";
255
256/// Base64 engine used to encode tokens (URL-safe, no padding so the token can
257/// be dropped into a URL query parameter unescaped).
258const TOKEN_B64: base64::engine::general_purpose::GeneralPurpose =
259    base64::engine::general_purpose::URL_SAFE_NO_PAD;
260
261/// Current token wire-format version, carried as the first byte of every token
262/// and authenticated by the MAC. A token whose version this build does not
263/// recognise is rejected, so the format can evolve later (e.g. a new payload
264/// field) without older tokens being silently misinterpreted.
265const TOKEN_VERSION: u8 = 1;
266
267/// Derive a dedicated token-MAC key from the master secret via a single-step
268/// HMAC KDF: `HMAC-SHA256(master, info)`. The cookie-signing key and this key
269/// are derived independently from the master secret, so token MACs and cookie
270/// signatures share no key material.
271fn token_mac_key(key: &Key) -> [u8; 32] {
272    let mut kdf =
273        HmacSha256::new_from_slice(key.master()).expect("HMAC accepts keys of any length");
274    kdf.update(TOKEN_KEY_INFO);
275    let out = kdf.finalize().into_bytes();
276    let mut subkey = [0u8; 32];
277    subkey.copy_from_slice(&out);
278    subkey
279}
280
281/// Begin a token MAC over the version byte and expiry, keyed by the derived
282/// token-MAC key. The version is authenticated so it cannot be flipped to
283/// reinterpret a token under different format rules.
284fn token_mac(key: &Key, version: u8, expiry_unix: i64) -> HmacSha256 {
285    let mut mac =
286        HmacSha256::new_from_slice(&token_mac_key(key)).expect("HMAC accepts keys of any length");
287    mac.update(&[version]);
288    mac.update(&expiry_unix.to_le_bytes());
289    mac
290}
291
292/// Create a self-expiring, signed token valid until `expiry`.
293///
294/// The token is `base64url(version_u8 ‖ expiry_i64_le ‖ HMAC-SHA256(token_mac_key,
295/// version ‖ expiry))`, where `token_mac_key` is derived from `key` (see
296/// [token_mac_key]). Validation requires only `key` and the current time, so the
297/// server stores no per-token state.
298fn sign_token(key: &Key, expiry: OffsetDateTime) -> String {
299    let expiry_unix = expiry.unix_timestamp();
300    let mac = token_mac(key, TOKEN_VERSION, expiry_unix)
301        .finalize()
302        .into_bytes();
303    let mut buf = Vec::with_capacity(1 + 8 + mac.len());
304    buf.push(TOKEN_VERSION);
305    buf.extend_from_slice(&expiry_unix.to_le_bytes());
306    buf.extend_from_slice(&mac);
307    TOKEN_B64.encode(buf)
308}
309
310/// Reasons a token check can fail, distinguished for diagnostic messages.
311#[derive(Clone, Debug)]
312enum TokenCheckResult {
313    /// Token is valid and not yet expired.
314    Valid,
315    /// No token parameter was present in the query string.
316    NoToken,
317    /// Token's embedded expiry has passed. Carries the expiry timestamp for logging.
318    Expired(OffsetDateTime),
319    /// Token is well-formed but HMAC does not verify (wrong key or corrupted).
320    BadSignature,
321    /// Token is not valid base64url, or too short, or malformed.
322    Malformed,
323    /// Token's version byte is not recognized (wire format version mismatch).
324    UnknownVersion,
325}
326
327/// Split a token into its `(version, expiry_unix, mac)` parts without
328/// interpreting any of them, or `None` if it is not decodable or is too short
329/// to hold the fixed-size header.
330///
331/// Nothing here is authenticated: the caller decides what to do with the parts.
332fn split_token(token: &str) -> Option<(u8, i64, Vec<u8>)> {
333    let buf = TOKEN_B64.decode(token).ok()?;
334    // Layout: version (1 byte) ‖ expiry (8 bytes) ‖ MAC. Split it without any
335    // indexing that could panic on a short or truncated token.
336    let (&version, rest) = buf.split_first()?;
337    let (expiry_bytes, mac_bytes) = rest.split_first_chunk::<8>()?;
338    Some((
339        version,
340        i64::from_le_bytes(*expiry_bytes),
341        mac_bytes.to_vec(),
342    ))
343}
344
345/// Read the expiry embedded in a token **without verifying its signature**.
346///
347/// This exists so a server can tell an operator when a token it hands out (in a
348/// logged URL, a QR code) stops working, without every caller re-deriving this
349/// crate's wire format. It returns `None` for anything that is not a
350/// well-formed token of a recognised version.
351///
352/// SECURITY: the result is unauthenticated. Anyone can craft a string with any
353/// expiry they like, so this must be used for display and diagnostics only —
354/// never to decide whether a request is authorized. The middleware's own check
355/// ([AuthConfig::into_layer]) is the only thing that may make that decision.
356pub fn token_expiry(token: &str) -> Option<OffsetDateTime> {
357    let (version, expiry_unix, _mac) = split_token(token)?;
358    if version != TOKEN_VERSION {
359        return None;
360    }
361    OffsetDateTime::from_unix_timestamp(expiry_unix).ok()
362}
363
364/// Verify a token produced by [sign_token]: check the version, the signature (in
365/// constant time), and that it has not yet expired relative to `now`. Returns a
366/// [TokenCheckResult] so callers can distinguish expiry from signature failure
367/// and emit specific diagnostic messages.
368fn verify_token(key: &Key, token: &str, now: OffsetDateTime) -> TokenCheckResult {
369    let Some((version, expiry_unix, mac_bytes)) = split_token(token) else {
370        return TokenCheckResult::Malformed;
371    };
372    if version != TOKEN_VERSION {
373        return TokenCheckResult::UnknownVersion;
374    }
375
376    // Constant-time signature check.
377    if token_mac(key, version, expiry_unix)
378        .verify_slice(&mac_bytes)
379        .is_err()
380    {
381        return TokenCheckResult::BadSignature;
382    }
383
384    match OffsetDateTime::from_unix_timestamp(expiry_unix) {
385        Ok(expiry) => {
386            if now < expiry {
387                TokenCheckResult::Valid
388            } else {
389                TokenCheckResult::Expired(expiry)
390            }
391        }
392        Err(_) => TokenCheckResult::Malformed,
393    }
394}
395
396/// Compute `now + ttl`, saturating at the maximum representable timestamp
397/// instead of panicking if `ttl` is absurdly large (or otherwise unrepresentable
398/// as a [time::Duration][Duration] or [OffsetDateTime] offset).
399fn saturating_expiry(now: OffsetDateTime, ttl: std::time::Duration) -> OffsetDateTime {
400    Duration::try_from(ttl)
401        .ok()
402        .and_then(|ttl| now.checked_add(ttl))
403        .unwrap_or_else(|| PrimitiveDateTime::MAX.assume_utc())
404}
405
406/// Parse a session cookie value of the form `uuid` or `uuid.expiry_unix`.
407///
408/// Returns the [SessionKey] and, if present, the embedded server-side expiry.
409/// Returns `None` if the value cannot be parsed (e.g. a malformed or truncated
410/// cookie), in which case it is treated as if no cookie were present.
411fn parse_session_cookie(value: &str) -> Option<(SessionKey, Option<OffsetDateTime>)> {
412    let (uuid_str, expiry) = match value.split_once('.') {
413        Some((uuid_str, expiry_str)) => {
414            let secs: i64 = expiry_str.parse().ok()?;
415            (
416                uuid_str,
417                Some(OffsetDateTime::from_unix_timestamp(secs).ok()?),
418            )
419        }
420        None => (value, None),
421    };
422    let uuid = uuid::Uuid::parse_str(uuid_str).ok()?;
423    Some((SessionKey(uuid), expiry))
424}
425
426/// One or more validation errors
427#[derive(thiserror::Error, Debug)]
428#[error("one or more validation errors")]
429pub struct ValidationErrors(Vec<String>);
430
431impl ValidationErrors {
432    /// Return an iterator over the validation errors that ocurred
433    pub fn errors(&self) -> impl Iterator<Item = &str> {
434        self.0.iter().map(String::as_str)
435    }
436}
437
438/// Identifier for each session (one per client browser).
439#[derive(Debug, Clone, Eq, PartialEq, Hash)]
440pub struct SessionKey(pub uuid::Uuid);
441
442impl SessionKey {
443    /// Ensures at compile-time that a session key is present.
444    ///
445    /// A handler which called this method can only be called with a (valid)
446    /// session key and thus do not present a security hole. Furthermore, having
447    /// such a method call in the handler prevents accidental removal of the
448    /// `SessionKey` argument to the handler.
449    pub fn is_present(&self) {}
450}
451
452impl Default for SessionKey {
453    fn default() -> Self {
454        SessionKey(uuid::Uuid::new_v4())
455    }
456}
457
458/// Configuration for URI query parameters to implement token-based
459/// authentication.
460///
461/// The token *value* is not stored here. Instead, tokens are self-expiring,
462/// signed values minted with [AuthConfig::generate_token] and validated against
463/// [AuthConfig::persistent_secret], so the server keeps no per-token state. A
464/// token is accepted as long as its signature verifies and it has not yet
465/// expired.
466#[derive(Clone, Debug)]
467pub struct TokenConfig {
468    /// The key of the token in the URI query parameters.
469    pub name: String,
470}
471
472impl TokenConfig {
473    /// Create a [TokenConfig] for the given query parameter name.
474    pub fn new(name: &str) -> Self {
475        Self { name: name.into() }
476    }
477}
478
479/// Configuration for [AuthLayer] and [AuthMiddleware].
480///
481/// This struct is `#[non_exhaustive]`, so new fields can be added in future
482/// releases without breaking downstream code. Construct it with [AuthConfig::new]
483/// (or [Default::default]) and then set the public fields you need rather than
484/// with a struct literal.
485#[derive(Clone, Debug)]
486#[non_exhaustive]
487pub struct AuthConfig<'a> {
488    /// The cookie name
489    ///
490    /// This is the name of the cookie stored in the clients' browsers.
491    pub cookie_name: &'a str,
492    /// A long lived secret used to sign cookies set to the users.
493    ///
494    /// The secret is not shared with users.
495    ///
496    /// All issued session keys are valid as long as the persistent secret is
497    /// unchanged. There is no mechanism to invalidate individual sessions.
498    pub persistent_secret: Key,
499    /// The authentication token configuration.
500    ///
501    /// Set to `None` if the entire connection is trusted (e.g. it is on a
502    /// loopback interface). In this case, token checking is disabled but
503    /// [SessionKey] is still provided by [AuthMiddleware].
504    pub token_config: Option<TokenConfig>,
505    /// If set, issued sessions expire this long after they are issued, and the
506    /// session is renewed (its expiry slid forward) once it passes the halfway
507    /// point of its lifetime.
508    ///
509    /// The expiry is embedded in the (signed, tamper-proof) cookie and enforced
510    /// by the server, so an expired cookie stops being accepted even if the
511    /// client keeps presenting it. The cookie's browser-side `Expires`
512    /// attribute is set to the same instant on every (re)issue. Because the
513    /// expiry slides forward on use, a regularly-returning client keeps a valid
514    /// session indefinitely without ever needing the token again — including
515    /// past the ~400 day cap browsers place on a single cookie's lifetime.
516    ///
517    /// If `None`, issued sessions never expire (they remain valid as long as
518    /// [Self::persistent_secret] is unchanged) and the cookie is a "session
519    /// cookie" with no `Expires` attribute, saved only until the browser quits.
520    pub session_expires: Option<std::time::Duration>,
521    /// Whether the session cookie is marked `Secure` (sent only over HTTPS).
522    ///
523    /// Defaults to `false` so the cookie still works over plain HTTP on a
524    /// loopback interface, which is a common deployment for this crate. Set to
525    /// `true` whenever the server is reached over HTTPS.
526    pub cookie_secure: bool,
527    /// Whether the session cookie is marked `HttpOnly` (hidden from client-side
528    /// JavaScript, mitigating session theft via XSS).
529    ///
530    /// Defaults to `true`; this crate never needs to read the cookie from JS.
531    pub cookie_http_only: bool,
532    /// The `SameSite` attribute of the session cookie (CSRF defense).
533    ///
534    /// Defaults to `Some(SameSite::Strict)`. Use `Some(SameSite::Lax)` if
535    /// clients must stay authenticated when following cross-site links into the
536    /// app, or `None` to omit the attribute entirely. Note that
537    /// `Some(SameSite::None)` implies `Secure` per the cookie specification.
538    pub cookie_same_site: Option<SameSite>,
539    /// Client networks that are trusted to have already authenticated the peer,
540    /// so a request from one is accepted without a token (as if
541    /// [Self::token_config] were `None` for that client).
542    ///
543    /// This is for deployments fronted by a trusted overlay network — e.g.
544    /// Tailscale (`100.64.0.0/10`) or a WireGuard subnet — where the overlay
545    /// authenticates and encrypts the peer connection, making an application
546    /// token redundant. The client's address is taken from the
547    /// [`ConnectInfo<SocketAddr>`](axum::extract::ConnectInfo) request
548    /// extension, so the server must be run with
549    /// [`into_make_service_with_connect_info`] for this to take effect; if the
550    /// extension is absent the client is treated as untrusted.
551    ///
552    /// Defaults to empty (no overlay trust). Note that the address checked is
553    /// the immediate TCP peer, so this must not include ranges that could be
554    /// spoofed via an intermediate reverse proxy.
555    ///
556    /// [`into_make_service_with_connect_info`]: axum::routing::Router::into_make_service_with_connect_info
557    pub trusted_networks: Vec<CidrBlock>,
558    /// When a browser navigation authenticates with a token in the query
559    /// string, reply with a redirect to the same location minus the token
560    /// parameter, so the token does not linger in the address bar, browser
561    /// history, or `Referer` headers.
562    ///
563    /// Only top-level navigations (a `GET` whose `Accept` header includes
564    /// `text/html`) are redirected, so programmatic clients that authenticate
565    /// with a token on every request are unaffected. Defaults to `true`.
566    pub strip_token_redirect: bool,
567}
568
569impl Default for AuthConfig<'_> {
570    fn default() -> Self {
571        Self {
572            cookie_name: env!["CARGO_PKG_NAME"],
573            persistent_secret: Key::generate(),
574            token_config: None,
575            session_expires: None,
576            cookie_secure: false,
577            cookie_http_only: true,
578            cookie_same_site: Some(SameSite::Strict),
579            trusted_networks: Vec::new(),
580            strip_token_redirect: true,
581        }
582    }
583}
584
585impl AuthConfig<'_> {
586    /// Create a configuration with the given persistent secret and the default
587    /// value for every other field.
588    ///
589    /// Because [AuthConfig] is `#[non_exhaustive]`, downstream crates cannot
590    /// build it with a struct literal; start here (or from [Default::default])
591    /// and set the public fields you need:
592    ///
593    /// ```
594    /// use axum_token_auth::{AuthConfig, Key, TokenConfig};
595    /// let mut cfg = AuthConfig::new(Key::generate());
596    /// cfg.token_config = Some(TokenConfig::new("token"));
597    /// let layer = cfg.into_layer();
598    /// ```
599    pub fn new(persistent_secret: Key) -> Self {
600        Self {
601            persistent_secret,
602            ..Self::default()
603        }
604    }
605
606    /// Convert [Self] to an [AuthLayer].
607    pub fn into_layer(self) -> AuthLayer {
608        let access_info = AccessInfo::new(self);
609        AuthLayer { access_info }
610    }
611
612    /// Mint a self-expiring authentication token valid for `ttl` from now.
613    ///
614    /// The returned string is the value to place in the [TokenConfig::name]
615    /// query parameter of the initial URL handed to the user out-of-band. It is
616    /// signed with [Self::persistent_secret] and carries its own expiry, so the
617    /// server validates it without storing anything. Prefer a short `ttl`: a
618    /// token only needs to live long enough for the first request, after which
619    /// the client holds a session cookie. An absurdly large `ttl` saturates at
620    /// the maximum representable expiry rather than panicking.
621    pub fn generate_token(&self, ttl: std::time::Duration) -> String {
622        generate_token(&self.persistent_secret, ttl)
623    }
624}
625
626/// Mint a self-expiring authentication token valid for `ttl` from now, signed
627/// with `secret`.
628///
629/// This is the free-standing form of [AuthConfig::generate_token]: a token
630/// depends only on the persistent secret, so callers that mint tokens (often on
631/// a rotation timer) can do so without building — or cloning — a whole
632/// [AuthConfig]. Pass the same [Key] that the [AuthConfig::persistent_secret]
633/// driving the [AuthLayer] uses, otherwise the minted token will not validate.
634///
635/// The returned string is the value to place in the [TokenConfig::name] query
636/// parameter of the initial URL handed to the user out-of-band. Prefer a short
637/// `ttl`: a token only needs to live long enough for the first request, after
638/// which the client holds a session cookie. An absurdly large `ttl` saturates at
639/// the maximum representable expiry rather than panicking.
640pub fn generate_token(secret: &Key, ttl: std::time::Duration) -> String {
641    let expiry = saturating_expiry(OffsetDateTime::now_utc(), ttl);
642    sign_token(secret, expiry)
643}
644
645/// What the middleware should do with the session cookie for this request.
646enum SessionAction {
647    /// Issue a brand-new session cookie (authenticated via token or trusted
648    /// connection, with no valid existing session).
649    Issue(SessionKey),
650    /// An existing session is still valid; re-issue the cookie to slide its
651    /// expiry forward.
652    Renew(SessionKey),
653    /// An existing session is still valid and does not need renewing; leave the
654    /// cookie untouched.
655    Keep(SessionKey),
656}
657
658impl SessionAction {
659    fn session_key(&self) -> &SessionKey {
660        match self {
661            SessionAction::Issue(sk) | SessionAction::Renew(sk) | SessionAction::Keep(sk) => sk,
662        }
663    }
664}
665
666#[derive(Clone, Debug)]
667struct AccessInfo {
668    cookie_name: String,
669    token_config: Option<TokenConfig>,
670    session_expires: Option<std::time::Duration>,
671    cookie_secure: bool,
672    cookie_http_only: bool,
673    cookie_same_site: Option<SameSite>,
674    trusted_networks: Vec<CidrBlock>,
675    strip_token_redirect: bool,
676    key: Key,
677}
678
679/// Format a token check result into a human-readable error message, or None if valid.
680fn format_token_error(result: &TokenCheckResult) -> Option<String> {
681    match result {
682        TokenCheckResult::Valid => None,
683        TokenCheckResult::NoToken => Some("no access token in query string".into()),
684        TokenCheckResult::Expired(expiry) => Some(format!("access token expired at {}", expiry)),
685        TokenCheckResult::BadSignature => Some("access token signature invalid".into()),
686        TokenCheckResult::Malformed => Some("access token malformed".into()),
687        // Distinct from `Malformed`: the token decoded cleanly but was minted
688        // by a build using a different wire format, which points at a
689        // version skew rather than a corrupted URL.
690        TokenCheckResult::UnknownVersion => {
691            Some("access token uses an unrecognized format version".into())
692        }
693    }
694}
695
696/// Describes the result of checking for a session cookie.
697#[derive(Clone, Debug)]
698enum SessionCheckResult {
699    /// Session cookie is valid and not yet expired.
700    Valid,
701    /// No session cookie was presented at all.
702    NoCookie,
703    /// A session cookie was presented but could not be parsed or verified.
704    Invalid,
705    /// Session cookie is well-formed and signature-verified but has expired.
706    Expired(OffsetDateTime),
707}
708
709/// Format a session check result into a human-readable error message, or None if valid.
710fn format_session_error(result: &SessionCheckResult) -> Option<String> {
711    match result {
712        SessionCheckResult::Valid => None,
713        SessionCheckResult::NoCookie => Some("no session cookie".into()),
714        SessionCheckResult::Invalid => Some("session cookie invalid".into()),
715        SessionCheckResult::Expired(expiry) => Some(format!("session expired at {}", expiry)),
716    }
717}
718
719impl AccessInfo {
720    /// Build access control information from the configuration.
721    fn new(cfg: AuthConfig<'_>) -> Self {
722        let AuthConfig {
723            cookie_name,
724            persistent_secret,
725            token_config,
726            session_expires,
727            cookie_secure,
728            cookie_http_only,
729            cookie_same_site,
730            trusted_networks,
731            strip_token_redirect,
732        } = cfg;
733
734        let key = persistent_secret;
735
736        Self {
737            cookie_name: cookie_name.into(),
738            token_config,
739            key,
740            session_expires,
741            cookie_secure,
742            cookie_http_only,
743            cookie_same_site,
744            trusted_networks,
745            strip_token_redirect,
746        }
747    }
748
749    /// Whether the request's immediate peer is in a configured trusted overlay
750    /// network (see [AuthConfig::trusted_networks]). The peer address is read
751    /// from the [`ConnectInfo<SocketAddr>`](ConnectInfo) request extension; if
752    /// it is absent the client is treated as untrusted.
753    fn is_trusted_client(&self, req: &Request) -> bool {
754        if self.trusted_networks.is_empty() {
755            return false;
756        }
757        let Some(ConnectInfo(peer)) = req.extensions().get::<ConnectInfo<SocketAddr>>() else {
758            return false;
759        };
760        let ip: IpAddr = peer.ip();
761        self.trusted_networks.iter().any(|net| net.contains(&ip))
762    }
763
764    /// Check whether the request carries a valid (signed, unexpired) token and
765    /// return a result describing success or the nature of the failure. Also
766    /// checks for trusted connections and trusted overlay networks that bypass
767    /// token requirements.
768    fn check_token(&self, req: &Request, now: OffsetDateTime) -> TokenCheckResult {
769        // A peer on a trusted overlay network has already been authenticated by
770        // that network, so no token is required.
771        if self.is_trusted_client(req) {
772            return TokenCheckResult::Valid;
773        }
774
775        let Some(token_config) = self.token_config.as_ref() else {
776            // No token configured: the connection is trusted.
777            return TokenCheckResult::Valid;
778        };
779
780        let query = req.uri().query().unwrap_or("");
781        for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
782            if key == token_config.name.as_str() {
783                // Token parameter found; return the specific result (valid or one of the failures).
784                return verify_token(&self.key, &value, now);
785            }
786        }
787        // No token parameter in the query string.
788        TokenCheckResult::NoToken
789    }
790
791    /// If this request is a top-level browser navigation carrying a token in
792    /// its query string (and [AuthConfig::strip_token_redirect] is enabled),
793    /// return the location to redirect to with the token parameter removed, so
794    /// the token does not persist in the address bar, history, or `Referer`.
795    /// Returns `None` when no redirect should occur.
796    fn token_strip_redirect_location(&self, req: &Request) -> Option<String> {
797        if !self.strip_token_redirect || req.method() != Method::GET {
798            return None;
799        }
800        // Only the configured token parameter is stripped; if no token auth is
801        // configured there is nothing to strip.
802        let token_name = self.token_config.as_ref()?.name.as_str();
803
804        // Restrict to top-level browser navigations so programmatic clients
805        // (which authenticate with a token per request) are not redirected.
806        let accepts_html = req
807            .headers()
808            .get(header::ACCEPT)
809            .and_then(|v| v.to_str().ok())
810            .map(|accept| accept.contains("text/html"))
811            .unwrap_or(false);
812        if !accepts_html {
813            return None;
814        }
815
816        let uri = req.uri();
817        let query = uri.query()?;
818        let mut kept = Vec::new();
819        let mut had_token = false;
820        for pair in query.split('&') {
821            let name = pair.split('=').next().unwrap_or("");
822            if name == token_name {
823                had_token = true;
824            } else if !pair.is_empty() {
825                kept.push(pair);
826            }
827        }
828        if !had_token {
829            return None;
830        }
831
832        let path = uri.path();
833        // A `Location` without a fragment leaves the original fragment intact in
834        // the browser, matching the previous client-side strip behaviour.
835        Some(if kept.is_empty() {
836            path.to_string()
837        } else {
838            format!("{path}?{}", kept.join("&"))
839        })
840    }
841
842    /// Decide what to do for this request given any session cookie it presented.
843    /// Also takes the parsed session if it's valid. Collects detailed error
844    /// messages distinguishing session and token failures.
845    fn authenticate(
846        &self,
847        req: &Request,
848        session_check: SessionCheckResult,
849        existing_session: Option<(SessionKey, Option<OffsetDateTime>)>,
850        now: OffsetDateTime,
851    ) -> Result<SessionAction, ValidationErrors> {
852        // If the session is still valid, use it (and potentially renew it).
853        if let SessionCheckResult::Valid = session_check {
854            if let Some((session_key, expiry)) = existing_session {
855                if self.should_renew(expiry, now) {
856                    return Ok(SessionAction::Renew(session_key));
857                } else {
858                    return Ok(SessionAction::Keep(session_key));
859                }
860            }
861        }
862
863        // Session is not valid; try the token path and collect detailed errors.
864        let token_check = self.check_token(req, now);
865        if matches!(token_check, TokenCheckResult::Valid) {
866            return Ok(SessionAction::Issue(SessionKey::default()));
867        }
868
869        // Both session and token failed; collect both error messages.
870        let mut errors = Vec::new();
871        if let Some(session_err) = format_session_error(&session_check) {
872            errors.push(session_err);
873        }
874        if let Some(token_err) = format_token_error(&token_check) {
875            errors.push(token_err);
876        }
877        Err(ValidationErrors(errors))
878    }
879
880    /// Whether a still-valid session should have its expiry slid forward. We
881    /// renew once a session has passed the halfway point of its lifetime, which
882    /// keeps a returning client's session alive while avoiding a `Set-Cookie`
883    /// on every single request.
884    fn should_renew(&self, expiry: Option<OffsetDateTime>, now: OffsetDateTime) -> bool {
885        let Some(ttl) = self.session_expires else {
886            // No server-side expiry configured: nothing to slide.
887            return false;
888        };
889        match expiry {
890            // Cookie predates expiry support but we now want one: add it.
891            None => true,
892            Some(expiry) => {
893                let ttl = Duration::try_from(ttl).unwrap_or(Duration::ZERO);
894                (expiry - now) * 2 < ttl
895            }
896        }
897    }
898
899    /// Build the cookie value (and matching browser-side expiry) for a session
900    /// being issued or renewed now.
901    fn build_cookie_value(
902        &self,
903        session_key: &SessionKey,
904        now: OffsetDateTime,
905    ) -> (String, Option<OffsetDateTime>) {
906        match self.session_expires {
907            Some(ttl) => {
908                let expiry = saturating_expiry(now, ttl);
909                (
910                    format!(
911                        "{}.{}",
912                        session_key.0.as_hyphenated(),
913                        expiry.unix_timestamp()
914                    ),
915                    Some(expiry),
916                )
917            }
918            None => (format!("{}", session_key.0.as_hyphenated()), None),
919        }
920    }
921}
922
923impl<S> FromRequestParts<S> for SessionKey
924where
925    S: Send + Sync,
926{
927    type Rejection = (StatusCode, &'static str);
928
929    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
930        if let Some(session_key) = parts.extensions.remove::<SessionKey>() {
931            Ok(session_key.clone())
932        } else {
933            Err((StatusCode::UNAUTHORIZED, "(valid) session key is missing"))
934        }
935    }
936}
937
938/// Implements [Layer] for [AuthMiddleware]
939///
940/// See the crate-level documentation for an overview.
941#[derive(Clone, Debug)]
942pub struct AuthLayer {
943    access_info: AccessInfo,
944}
945
946impl<S> Layer<S> for AuthLayer {
947    type Service = tower_cookies::CookieManager<AuthMiddleware<S>>;
948
949    fn layer(&self, inner: S) -> Self::Service {
950        let auth_middleware = AuthMiddleware {
951            inner,
952            access_info: self.access_info.clone(),
953        };
954        tower_cookies::CookieManager::new(auth_middleware)
955    }
956}
957
958/// Middleware which checks if request is authenticated and, if so, extends the
959/// request to include [SessionKey] information.
960#[derive(Clone, Debug)]
961pub struct AuthMiddleware<S> {
962    inner: S,
963    access_info: AccessInfo,
964}
965
966impl<S> Service<Request> for AuthMiddleware<S>
967where
968    S: Service<Request, Response = Response> + Send + 'static,
969    S::Error: Into<BoxError>,
970    S::Future: Send + 'static,
971{
972    type Response = S::Response;
973    type Error = BoxError;
974    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
975
976    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
977        match self.inner.poll_ready(cx) {
978            Poll::Pending => Poll::Pending,
979            Poll::Ready(r) => Poll::Ready(r.map_err(Into::into)),
980        }
981    }
982
983    fn call(&mut self, mut request: Request) -> Self::Future {
984        let Some(cookies) = request
985            .extensions()
986            .get::<tower_cookies::Cookies>()
987            .cloned()
988        else {
989            // In practice this should never happen because we wrap `CookieManager`
990            // directly.
991            tracing::error!("missing cookies request extension");
992            return Box::pin(std::future::ready(Err(Box::new(ValidationErrors(vec![
993                "missing cookies request extension".into(),
994            ])) as BoxError)));
995        };
996        let signed = cookies.signed(&self.access_info.key);
997
998        // Outcome of the auth check: either authorized (optionally with a
999        // redirect that strips the token from the URL) or a validation error.
1000        let mut redirect_location: Option<String> = None;
1001        let err_info = {
1002            let now = OffsetDateTime::now_utc();
1003
1004            // Check whether a session cookie was presented (signed) and whether it's valid.
1005            // First, check if the unsigned cookie exists to distinguish "no cookie" from "invalid".
1006            let unsigned_cookie_exists = cookies.get(&self.access_info.cookie_name).is_some();
1007            let (session_check, existing_session) =
1008                if let Some(received_cookie) = signed.get(&self.access_info.cookie_name) {
1009                    // A session cookie was presented and its signature verified. Now check expiry.
1010                    if let Some(parsed) = parse_session_cookie(received_cookie.value()) {
1011                        let (session_key, expiry) = parsed;
1012                        if expiry.is_none_or(|expiry| now < expiry) {
1013                            (SessionCheckResult::Valid, Some((session_key, expiry)))
1014                        } else {
1015                            // expiry is Some and has passed
1016                            (
1017                                SessionCheckResult::Expired(expiry.unwrap()),
1018                                Some((session_key, expiry)),
1019                            )
1020                        }
1021                    } else {
1022                        // parse_session_cookie failed (malformed value)
1023                        (SessionCheckResult::Invalid, None)
1024                    }
1025                } else if unsigned_cookie_exists {
1026                    // An unsigned cookie was presented but signature verification failed.
1027                    (SessionCheckResult::Invalid, None)
1028                } else {
1029                    // No cookie at all.
1030                    (SessionCheckResult::NoCookie, None)
1031                };
1032
1033            // check if authenticated
1034            match self
1035                .access_info
1036                .authenticate(&request, session_check, existing_session, now)
1037            {
1038                Ok(action) => {
1039                    let session_key = action.session_key().clone();
1040                    request.extensions_mut().insert(session_key.clone());
1041
1042                    if matches!(action, SessionAction::Issue(_) | SessionAction::Renew(_)) {
1043                        let (value, expires) =
1044                            self.access_info.build_cookie_value(&session_key, now);
1045                        let mut set_cookie =
1046                            tower_cookies::Cookie::new(self.access_info.cookie_name.clone(), value);
1047
1048                        // Apply the configured cookie security attributes (see
1049                        // `AuthConfig`). Defaults are HttpOnly, SameSite=Strict,
1050                        // and Secure off.
1051                        set_cookie.set_secure(self.access_info.cookie_secure);
1052                        set_cookie.set_http_only(self.access_info.cookie_http_only);
1053                        set_cookie.set_same_site(self.access_info.cookie_same_site);
1054
1055                        if let Some(expires) = expires {
1056                            set_cookie.set_expires(expires);
1057                        }
1058
1059                        signed.add(set_cookie);
1060                    }
1061
1062                    // Now that the session cookie is set, redirect a browser
1063                    // navigation to a token-free URL so the token does not
1064                    // linger in the address bar, history, or `Referer`.
1065                    redirect_location = self.access_info.token_strip_redirect_location(&request);
1066                    None
1067                }
1068                Err(val_err) => Some(val_err),
1069            }
1070        };
1071
1072        if let Some(val_err) = err_info {
1073            return Box::pin(std::future::ready(Err(val_err.into())));
1074        }
1075
1076        // Short-circuit with a redirect that drops the token parameter. The
1077        // outer `CookieManager` still serializes the session cookie set above
1078        // onto this response, so the redirected request arrives authenticated.
1079        if let Some(location) = redirect_location {
1080            let response = Response::builder()
1081                .status(StatusCode::SEE_OTHER)
1082                .header(header::LOCATION, location)
1083                .body(axum::body::Body::empty())
1084                .expect("building a redirect response cannot fail");
1085            return Box::pin(std::future::ready(Ok(response)));
1086        }
1087
1088        // Build future which generates response.
1089        let fut = self.inner.call(request);
1090
1091        // Await future.
1092        Box::pin(async move {
1093            let response: Response = fut.await.map_err(|e| e.into())?;
1094            Ok(response)
1095        })
1096    }
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102    use anyhow::Result;
1103    use axum::body::Body;
1104    use cookie::Cookie;
1105    use http::{Request, StatusCode};
1106
1107    use std::convert::Infallible;
1108    use tower::{ServiceBuilder, ServiceExt};
1109
1110    async fn handler(_: Request<Body>) -> std::result::Result<Response<Body>, Infallible> {
1111        Ok(Response::new(Body::empty()))
1112    }
1113
1114    fn get_cfg() -> AuthConfig<'static> {
1115        AuthConfig {
1116            cookie_name: "auth",
1117            persistent_secret: Key::generate(),
1118            token_config: Some(TokenConfig::new("token")),
1119            session_expires: None,
1120            ..Default::default()
1121        }
1122    }
1123
1124    /// A token valid well into the future for the config's secret.
1125    fn valid_token_uri(cfg: &AuthConfig<'_>) -> String {
1126        let name = &cfg.token_config.as_ref().unwrap().name;
1127        let token = cfg.generate_token(std::time::Duration::from_secs(300));
1128        format!("http://example.com/path?{name}={token}")
1129    }
1130
1131    #[tokio::test]
1132    async fn fail_without_token_or_cookie() -> Result<()> {
1133        let auth_layer = get_cfg().into_layer();
1134        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1135
1136        let req = Request::builder().body(Body::empty())?;
1137        let res = svc.oneshot(req).await;
1138        assert!(
1139            !res.err()
1140                .unwrap()
1141                .downcast::<ValidationErrors>()
1142                .unwrap()
1143                .errors()
1144                .collect::<Vec<_>>()
1145                .is_empty()
1146        );
1147        Ok(())
1148    }
1149
1150    async fn get_second_response(
1151        cfg: AuthConfig<'_>,
1152        req: Request<Body>,
1153    ) -> Result<Response<Body>> {
1154        let auth_layer = cfg.into_layer();
1155        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1156
1157        // Make a request to get the cookie.
1158        let res = svc.clone().oneshot(req).await.unwrap();
1159
1160        // Extract the cookie
1161        let cookie = {
1162            let set_cookie: Vec<_> = res.headers().get_all(header::SET_COOKIE).iter().collect();
1163            assert_eq!(set_cookie.len(), 1);
1164            Cookie::parse(set_cookie[0].to_str()?.to_string())?
1165        };
1166
1167        // Now make a new request with the cookie.
1168        let req2 = Request::builder()
1169            .header(header::COOKIE, cookie.stripped().to_string())
1170            .body(Body::empty())
1171            .unwrap();
1172        let res2 = svc.oneshot(req2).await.unwrap();
1173        Ok(res2)
1174    }
1175
1176    #[tokio::test]
1177    async fn set_cookie_with_trusted_socket() -> Result<()> {
1178        let mut cfg = get_cfg();
1179        cfg.token_config = None;
1180        let uri = "http://example.com/path";
1181        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1182
1183        let res2 = get_second_response(cfg, req).await?;
1184        assert_eq!(res2.status(), StatusCode::OK);
1185        Ok(())
1186    }
1187
1188    #[tokio::test]
1189    async fn set_cookie_with_valid_token() -> Result<()> {
1190        let cfg = get_cfg();
1191        let uri = valid_token_uri(&cfg);
1192        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1193
1194        let res2 = get_second_response(cfg, req).await?;
1195
1196        assert_eq!(res2.status(), StatusCode::OK);
1197        Ok(())
1198    }
1199
1200    /// A session cookie issued by v0.2.x held a bare UUID (no embedded expiry),
1201    /// signed with the persistent secret. After upgrading to self-expiring
1202    /// sessions, such a cookie must still authenticate: the persistent secret is
1203    /// unchanged, so its signature verifies, and a missing embedded expiry is
1204    /// treated as "never expires" until the session is next renewed. This is the
1205    /// guarantee that existing in-browser cookies survive the upgrade.
1206    #[tokio::test]
1207    async fn legacy_bare_uuid_cookie_is_accepted() -> Result<()> {
1208        let key = Key::generate();
1209        let mut cfg = get_cfg();
1210        cfg.persistent_secret = key.clone();
1211        // Enabling server-side expiry must not reject the legacy cookie.
1212        cfg.session_expires = Some(std::time::Duration::from_secs(60 * 60 * 24 * 400));
1213        let cookie_name = cfg.cookie_name.to_string();
1214
1215        // Forge exactly what v0.2.x stored: a bare-UUID value signed with the
1216        // persistent secret, with no embedded expiry.
1217        let legacy_value = format!("{}", uuid::Uuid::new_v4().as_hyphenated());
1218        let mut jar = cookie::CookieJar::new();
1219        jar.signed_mut(&key)
1220            .add(Cookie::new(cookie_name.clone(), legacy_value));
1221        let signed = jar.get(&cookie_name).unwrap().stripped().to_string();
1222
1223        let auth_layer = cfg.into_layer();
1224        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1225
1226        // No token in the URI: the cookie alone must authenticate.
1227        let req = Request::builder()
1228            .uri("http://example.com/path")
1229            .header(header::COOKIE, signed)
1230            .body(Body::empty())
1231            .unwrap();
1232        let res = svc.oneshot(req).await.unwrap();
1233        assert_eq!(res.status(), StatusCode::OK);
1234        Ok(())
1235    }
1236
1237    #[tokio::test]
1238    async fn issued_cookie_is_httponly_and_samesite_strict() -> Result<()> {
1239        let mut cfg = get_cfg();
1240        cfg.token_config = None;
1241        let auth_layer = cfg.into_layer();
1242        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1243
1244        let req = Request::builder()
1245            .uri("http://example.com/path")
1246            .body(Body::empty())
1247            .unwrap();
1248        let res = svc.oneshot(req).await.unwrap();
1249
1250        let set_cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str()?;
1251        let cookie = Cookie::parse(set_cookie.to_string())?;
1252        assert_eq!(cookie.http_only(), Some(true));
1253        assert_eq!(cookie.same_site(), Some(SameSite::Strict));
1254        Ok(())
1255    }
1256
1257    #[tokio::test]
1258    async fn cookie_attributes_are_configurable() -> Result<()> {
1259        let mut cfg = get_cfg();
1260        cfg.token_config = None;
1261        cfg.cookie_secure = true;
1262        cfg.cookie_http_only = false;
1263        cfg.cookie_same_site = Some(SameSite::Lax);
1264        let auth_layer = cfg.into_layer();
1265        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1266
1267        let req = Request::builder()
1268            .uri("http://example.com/path")
1269            .body(Body::empty())
1270            .unwrap();
1271        let res = svc.oneshot(req).await.unwrap();
1272
1273        let set_cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str()?;
1274        let cookie = Cookie::parse(set_cookie.to_string())?;
1275        assert_eq!(cookie.secure(), Some(true));
1276        // `http_only(false)` omits the attribute entirely.
1277        assert_eq!(cookie.http_only(), None);
1278        assert_eq!(cookie.same_site(), Some(SameSite::Lax));
1279        Ok(())
1280    }
1281
1282    #[tokio::test]
1283    async fn reject_token_with_wrong_secret() -> Result<()> {
1284        // A token minted with a different secret must not be accepted.
1285        let other = get_cfg();
1286        let uri = valid_token_uri(&other);
1287
1288        let cfg = get_cfg();
1289        let auth_layer = cfg.into_layer();
1290        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1291        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1292        let res = svc.oneshot(req).await;
1293        assert!(res.is_err());
1294        Ok(())
1295    }
1296
1297    #[test]
1298    fn cidr_block_parse_and_contains() {
1299        let net: CidrBlock = "100.64.0.0/10".parse().unwrap();
1300        assert!(net.contains(&"100.64.0.1".parse().unwrap()));
1301        assert!(net.contains(&"100.127.255.255".parse().unwrap()));
1302        assert!(!net.contains(&"100.128.0.0".parse().unwrap()));
1303        assert!(!net.contains(&"10.0.0.1".parse().unwrap()));
1304        // An IPv4 block never matches an IPv6 peer.
1305        assert!(!net.contains(&"::1".parse().unwrap()));
1306
1307        // /0 matches everything of its family; /32 and /128 match one address.
1308        assert!(
1309            "0.0.0.0/0"
1310                .parse::<CidrBlock>()
1311                .unwrap()
1312                .contains(&"8.8.8.8".parse().unwrap())
1313        );
1314        let host: CidrBlock = "192.168.1.5/32".parse().unwrap();
1315        assert!(host.contains(&"192.168.1.5".parse().unwrap()));
1316        assert!(!host.contains(&"192.168.1.6".parse().unwrap()));
1317
1318        let v6: CidrBlock = "fd00::/8".parse().unwrap();
1319        assert!(v6.contains(&"fd00::1".parse().unwrap()));
1320        assert!(!v6.contains(&"fe00::1".parse().unwrap()));
1321
1322        // Malformed input and out-of-range prefixes are rejected.
1323        assert!("100.64.0.0".parse::<CidrBlock>().is_err());
1324        assert!("100.64.0.0/33".parse::<CidrBlock>().is_err());
1325        assert!("fd00::/129".parse::<CidrBlock>().is_err());
1326        assert!("nonsense/8".parse::<CidrBlock>().is_err());
1327
1328        // Accessors expose the parsed address and prefix.
1329        assert_eq!(net.addr(), "100.64.0.0".parse::<IpAddr>().unwrap());
1330        assert_eq!(net.prefix_len(), 10);
1331    }
1332
1333    #[cfg(feature = "serde")]
1334    #[test]
1335    fn cidr_block_serde_roundtrip() {
1336        let net: CidrBlock = "100.64.0.0/10".parse().unwrap();
1337        let json = serde_json::to_string(&net).unwrap();
1338        assert_eq!(json, "\"100.64.0.0/10\"");
1339        assert_eq!(serde_json::from_str::<CidrBlock>(&json).unwrap(), net);
1340        // An invalid CIDR string is rejected during deserialization.
1341        assert!(serde_json::from_str::<CidrBlock>("\"nonsense\"").is_err());
1342    }
1343
1344    #[test]
1345    fn token_roundtrip_signature_and_expiry() {
1346        let key = Key::generate();
1347        let now = OffsetDateTime::now_utc();
1348        let token = sign_token(&key, now + Duration::minutes(5));
1349
1350        // Valid now, expired later.
1351        assert!(matches!(
1352            verify_token(&key, &token, now),
1353            TokenCheckResult::Valid
1354        ));
1355        assert!(matches!(
1356            verify_token(&key, &token, now + Duration::minutes(6)),
1357            TokenCheckResult::Expired(_)
1358        ));
1359
1360        // Tampering or a wrong key is rejected.
1361        // Truncated token is too short.
1362        assert!(matches!(
1363            verify_token(&key, &token[..5], now),
1364            TokenCheckResult::Malformed
1365        ));
1366        assert!(matches!(
1367            verify_token(&Key::generate(), &token, now),
1368            TokenCheckResult::BadSignature
1369        ));
1370        // Completely invalid base64 (non-URL-safe characters like spaces and +).
1371        assert!(matches!(
1372            verify_token(&key, "not base64 string", now),
1373            TokenCheckResult::Malformed
1374        ));
1375
1376        // A token whose version byte is altered is rejected (the version is
1377        // authenticated and unknown versions are refused).
1378        let mut bytes = TOKEN_B64.decode(&token).unwrap();
1379        bytes[0] = bytes[0].wrapping_add(1);
1380        assert!(matches!(
1381            verify_token(&key, &TOKEN_B64.encode(bytes), now),
1382            TokenCheckResult::UnknownVersion
1383        ));
1384    }
1385
1386    /// [token_expiry] reports the embedded expiry for display, without needing
1387    /// the key and without caring whether the token has already expired.
1388    #[test]
1389    fn token_expiry_reads_the_embedded_instant() {
1390        let key = Key::generate();
1391        let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1392        let expiry = now + Duration::minutes(30);
1393        let token = sign_token(&key, expiry);
1394
1395        assert_eq!(token_expiry(&token), Some(expiry));
1396        // Still readable once expired -- that is the point of showing it.
1397        assert!(matches!(
1398            verify_token(&key, &token, expiry + Duration::seconds(1)),
1399            TokenCheckResult::Expired(_)
1400        ));
1401        assert_eq!(token_expiry(&token), Some(expiry));
1402    }
1403
1404    /// [token_expiry] is deliberately unauthenticated, so it reads a token
1405    /// signed by a stranger -- but it still refuses anything that is not a
1406    /// well-formed token of a known version.
1407    #[test]
1408    fn token_expiry_rejects_unparseable_input() {
1409        let expiry = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1410        let foreign = sign_token(&Key::generate(), expiry);
1411        assert_eq!(token_expiry(&foreign), Some(expiry));
1412
1413        assert_eq!(token_expiry(""), None);
1414        assert_eq!(token_expiry("not base64!!"), None);
1415        // Decodes cleanly but is shorter than the fixed header.
1416        assert_eq!(token_expiry(&TOKEN_B64.encode([1u8, 2, 3])), None);
1417
1418        let mut bytes = TOKEN_B64.decode(&foreign).unwrap();
1419        bytes[0] = bytes[0].wrapping_add(1);
1420        assert_eq!(token_expiry(&TOKEN_B64.encode(bytes)), None);
1421    }
1422
1423    #[test]
1424    fn expiry_saturates_instead_of_panicking() {
1425        let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1426
1427        // A normal duration adds as expected.
1428        let normal = saturating_expiry(now, std::time::Duration::from_secs(60));
1429        assert_eq!(normal, now + Duration::seconds(60));
1430
1431        // An absurd duration saturates at the maximum representable timestamp
1432        // rather than panicking.
1433        let huge = saturating_expiry(now, std::time::Duration::from_secs(u64::MAX));
1434        assert_eq!(huge, PrimitiveDateTime::MAX.assume_utc());
1435    }
1436
1437    #[test]
1438    fn session_cookie_parsing() {
1439        let sk = SessionKey::default();
1440        let expiry = OffsetDateTime::from_unix_timestamp(1_900_000_000).unwrap();
1441
1442        // Without an embedded expiry.
1443        let bare = format!("{}", sk.0.as_hyphenated());
1444        assert_eq!(parse_session_cookie(&bare), Some((sk.clone(), None)));
1445
1446        // With an embedded expiry.
1447        let with_exp = format!("{}.{}", sk.0.as_hyphenated(), expiry.unix_timestamp());
1448        assert_eq!(parse_session_cookie(&with_exp), Some((sk, Some(expiry))));
1449
1450        // Garbage parses to nothing rather than panicking.
1451        assert_eq!(parse_session_cookie("nonsense"), None);
1452        assert_eq!(parse_session_cookie(""), None);
1453    }
1454
1455    #[test]
1456    fn renews_past_halfway_point() {
1457        let mut cfg = get_cfg();
1458        cfg.session_expires = Some(std::time::Duration::from_secs(100));
1459        let access_info = AccessInfo::new(cfg);
1460        let now = OffsetDateTime::now_utc();
1461
1462        // 60s left of a 100s lifetime: still in the first half, keep as-is.
1463        assert!(!access_info.should_renew(Some(now + Duration::seconds(60)), now));
1464        // 40s left: past halfway, renew.
1465        assert!(access_info.should_renew(Some(now + Duration::seconds(40)), now));
1466        // A cookie with no embedded expiry gets one added.
1467        assert!(access_info.should_renew(None, now));
1468    }
1469
1470    /// A client whose peer address is inside a configured trusted overlay
1471    /// network is authenticated without any token, just like a trusted
1472    /// (token-less) connection.
1473    #[tokio::test]
1474    async fn trusted_network_skips_token() -> Result<()> {
1475        use axum::extract::ConnectInfo;
1476        use std::net::SocketAddr;
1477
1478        let mut cfg = get_cfg(); // token IS required by default
1479        cfg.trusted_networks = vec!["100.64.0.0/10".parse().unwrap()];
1480        let auth_layer = cfg.into_layer();
1481        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1482
1483        // From inside the overlay range: accepted with no token.
1484        let mut trusted = Request::builder()
1485            .uri("http://example.com/path")
1486            .body(Body::empty())
1487            .unwrap();
1488        trusted.extensions_mut().insert(ConnectInfo(
1489            "100.100.1.2:5555".parse::<SocketAddr>().unwrap(),
1490        ));
1491        assert_eq!(
1492            svc.clone().oneshot(trusted).await.unwrap().status(),
1493            StatusCode::OK
1494        );
1495
1496        // From outside the overlay range with no token: rejected.
1497        let mut untrusted = Request::builder()
1498            .uri("http://example.com/path")
1499            .body(Body::empty())
1500            .unwrap();
1501        untrusted.extensions_mut().insert(ConnectInfo(
1502            "192.168.1.2:5555".parse::<SocketAddr>().unwrap(),
1503        ));
1504        assert!(svc.oneshot(untrusted).await.is_err());
1505        Ok(())
1506    }
1507
1508    /// A browser navigation (GET + `Accept: text/html`) that authenticates with
1509    /// a token in the URL is redirected (303) to the same path with the token
1510    /// removed, and the session cookie is set on that redirect.
1511    #[tokio::test]
1512    async fn browser_token_auth_redirects_without_token() -> Result<()> {
1513        let cfg = get_cfg();
1514        // valid_token_uri yields `.../path?token=XXX`; add another parameter so
1515        // we can assert it survives the strip.
1516        let uri = format!("{}&keep=1", valid_token_uri(&cfg));
1517        let auth_layer = cfg.into_layer();
1518        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1519
1520        let req = Request::builder()
1521            .uri(uri)
1522            .header(header::ACCEPT, "text/html,application/xhtml+xml")
1523            .body(Body::empty())
1524            .unwrap();
1525        let res = svc.oneshot(req).await.unwrap();
1526
1527        assert_eq!(res.status(), StatusCode::SEE_OTHER);
1528        let location = res.headers().get(header::LOCATION).unwrap().to_str()?;
1529        // Token stripped, other query parameters preserved.
1530        assert_eq!(location, "/path?keep=1");
1531        // The session cookie is issued on the redirect itself.
1532        assert!(res.headers().contains_key(header::SET_COOKIE));
1533        Ok(())
1534    }
1535
1536    /// A programmatic client (no `Accept: text/html`) authenticating with a
1537    /// token is served normally rather than redirected, so non-browser callers
1538    /// that pass a token per request keep working.
1539    #[tokio::test]
1540    async fn programmatic_token_auth_is_not_redirected() -> Result<()> {
1541        let cfg = get_cfg();
1542        let uri = valid_token_uri(&cfg);
1543        let auth_layer = cfg.into_layer();
1544        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1545
1546        let req = Request::builder()
1547            .uri(uri)
1548            .header(header::ACCEPT, "*/*")
1549            .body(Body::empty())
1550            .unwrap();
1551        let res = svc.oneshot(req).await.unwrap();
1552        assert_eq!(res.status(), StatusCode::OK);
1553        Ok(())
1554    }
1555
1556    /// An expired token produces a message containing "expired".
1557    #[tokio::test]
1558    async fn expired_token_produces_specific_error() -> Result<()> {
1559        let cfg = get_cfg();
1560        // Create an already-expired token.
1561        let expiry = OffsetDateTime::now_utc() - Duration::minutes(1);
1562        let token = sign_token(&cfg.persistent_secret, expiry);
1563        let token_name = &cfg.token_config.as_ref().unwrap().name;
1564        let uri = format!("http://example.com/path?{token_name}={token}");
1565
1566        let auth_layer = cfg.into_layer();
1567        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1568
1569        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1570        let res = svc.oneshot(req).await;
1571        let err = res.err().unwrap();
1572        let val_err = err.downcast::<ValidationErrors>().unwrap();
1573        let errors: Vec<&str> = val_err.errors().collect();
1574        assert!(errors.iter().any(|e| e.contains("expired")));
1575        Ok(())
1576    }
1577
1578    /// A token signed with a different key produces the "signature invalid" message.
1579    #[tokio::test]
1580    async fn token_with_wrong_signature_produces_specific_error() -> Result<()> {
1581        let other_key = Key::generate();
1582        let now = OffsetDateTime::now_utc();
1583        let expiry = now + Duration::minutes(5);
1584        let token = sign_token(&other_key, expiry);
1585
1586        let cfg = get_cfg(); // Uses a different secret
1587        let token_name = &cfg.token_config.as_ref().unwrap().name;
1588        let uri = format!("http://example.com/path?{token_name}={token}");
1589
1590        let auth_layer = cfg.into_layer();
1591        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1592
1593        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1594        let res = svc.oneshot(req).await;
1595        let err = res.err().unwrap();
1596        let val_err = err.downcast::<ValidationErrors>().unwrap();
1597        let errors: Vec<&str> = val_err.errors().collect();
1598        assert!(errors.iter().any(|e| e.contains("signature")));
1599        Ok(())
1600    }
1601
1602    /// Garbage token produces a "malformed" message.
1603    #[tokio::test]
1604    async fn malformed_token_produces_specific_error() -> Result<()> {
1605        let cfg = get_cfg();
1606        let token_name = &cfg.token_config.as_ref().unwrap().name;
1607        let uri = format!("http://example.com/path?{token_name}=garbage@#$");
1608
1609        let auth_layer = cfg.into_layer();
1610        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1611
1612        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
1613        let res = svc.oneshot(req).await;
1614        let err = res.err().unwrap();
1615        let val_err = err.downcast::<ValidationErrors>().unwrap();
1616        let errors: Vec<&str> = val_err.errors().collect();
1617        assert!(errors.iter().any(|e| e.contains("malformed")));
1618        Ok(())
1619    }
1620
1621    /// No credentials at all (no session cookie, no token) produces both errors.
1622    #[tokio::test]
1623    async fn no_credentials_produces_both_errors() -> Result<()> {
1624        let cfg = get_cfg();
1625        let auth_layer = cfg.into_layer();
1626        let svc = ServiceBuilder::new().layer(auth_layer).service_fn(handler);
1627
1628        // No cookie, no token in URI.
1629        let req = Request::builder()
1630            .uri("http://example.com/path")
1631            .body(Body::empty())
1632            .unwrap();
1633        let res = svc.oneshot(req).await;
1634        let err = res.err().unwrap();
1635        let val_err = err.downcast::<ValidationErrors>().unwrap();
1636        let errors: Vec<&str> = val_err.errors().collect();
1637        // Should contain both "no session cookie" and "no access token" messages.
1638        assert!(errors.iter().any(|e| e.contains("session")));
1639        assert!(errors.iter().any(|e| e.contains("token")));
1640        assert_eq!(errors.len(), 2);
1641        Ok(())
1642    }
1643}