cratestack_axum/idempotency/layer.rs
1//! Tower layer + companion `Service` constructor.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use axum::extract::{ConnectInfo, Request};
8use http::header;
9use sha2::{Digest, Sha256};
10use tower::Layer;
11
12use super::service::IdempotencyService;
13use super::store::IdempotencyStore;
14
15/// Tower layer that wires an `IdempotencyStore` into the request pipeline.
16#[derive(Clone)]
17pub struct IdempotencyLayer {
18 pub(super) store: Arc<dyn IdempotencyStore>,
19 pub(super) ttl: Duration,
20 pub(super) principal_fingerprint: Arc<dyn Fn(&Request) -> String + Send + Sync>,
21}
22
23impl IdempotencyLayer {
24 /// Construct with a default principal fingerprint derived from the
25 /// `Authorization` header, falling back to the verified TCP peer address
26 /// (via axum's `ConnectInfo<SocketAddr>`, requires serving through
27 /// `into_make_service_with_connect_info::<SocketAddr>()`) when it's
28 /// absent. Callers running mTLS or session-cookie auth should swap this
29 /// via [`with_principal_fingerprint`].
30 pub fn new(store: Arc<dyn IdempotencyStore>, ttl: Duration) -> Self {
31 Self {
32 store,
33 ttl,
34 principal_fingerprint: Arc::new(default_principal_fingerprint),
35 }
36 }
37
38 /// Override how the layer derives a principal-scoped namespace for the
39 /// idempotency key. Without this, two callers sharing a key (across
40 /// tenants) would collide.
41 pub fn with_principal_fingerprint(
42 mut self,
43 f: impl Fn(&Request) -> String + Send + Sync + 'static,
44 ) -> Self {
45 self.principal_fingerprint = Arc::new(f);
46 self
47 }
48}
49
50pub(super) fn default_principal_fingerprint(req: &Request) -> String {
51 // Prefer Authorization header for authenticated requests.
52 if let Some(auth_header) = req.headers().get(header::AUTHORIZATION)
53 && let Ok(auth_str) = auth_header.to_str()
54 {
55 let mut h = Sha256::new();
56 h.update(auth_str.as_bytes());
57 return format!("{:x}", h.finalize());
58 }
59
60 // Fall back to the real TCP peer address for unauthenticated requests, to
61 // avoid collisions between distinct callers. This is deliberately *not*
62 // `Forwarded`/`X-Forwarded-For`: those headers are client-supplied and
63 // this crate has no trusted-proxy configuration to verify or strip them,
64 // so trusting them here would let an attacker land in another caller's
65 // idempotency namespace just by guessing/spoofing that caller's apparent
66 // IP. `ConnectInfo` is populated by axum from the actual accepted socket
67 // (when the server is served via `into_make_service_with_connect_info::<SocketAddr>()`)
68 // and cannot be spoofed by the client.
69 if let Some(ConnectInfo(addr)) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
70 return addr.ip().to_string();
71 }
72
73 // Only if both Authorization and a verified peer address are absent
74 // (e.g. the server isn't wired through `into_make_service_with_connect_info`),
75 // fall back to a single shared namespace. This matches the pre-existing,
76 // safe-by-default behavior: unauthenticated traffic that can't be
77 // distinguished falls back to the coarse default rather than trusting an
78 // unverifiable, attacker-controlled key.
79 "anonymous".to_owned()
80}
81
82impl<S> Layer<S> for IdempotencyLayer {
83 type Service = IdempotencyService<S>;
84
85 fn layer(&self, inner: S) -> Self::Service {
86 IdempotencyService {
87 inner,
88 store: self.store.clone(),
89 ttl: self.ttl,
90 principal_fingerprint: self.principal_fingerprint.clone(),
91 }
92 }
93}