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}