axess_core/device/resolver.rs
1//! [`DeviceResolver`]: the request-time bridge from raw HTTP requests to a
2//! [`DeviceId`].
3//!
4//! # What an implementation does
5//!
6//! Per the design in [`docs/identity/device.md`](../../../../docs/identity/device.md),
7//! a production `DeviceResolver` performs:
8//!
9//! 1. Parse the long-lived device-binding cookie from `Cookie:` header (if
10//! present) → candidate [`DeviceId`].
11//! 2. Compute the keyed [`FingerprintHash`](super::types::FingerprintHash)
12//! from `User-Agent`, `Accept-Language`, `Accept`, and any other inputs
13//! configured per tenant.
14//! 3. If the cookie supplied an id, [`DeviceStore::load`](super::store::DeviceStore::load) it and verify the
15//! stored fingerprint matches → emit `DeviceFingerprintMismatch
16//! Suspicious` on mismatch, return `None` (caller invalidates the
17//! session). Match → return that id and call
18//! [`DeviceStore::record_sighting`](super::store::DeviceStore::record_sighting).
19//! 4. If no cookie, [`DeviceStore::find_by_fingerprint`](super::store::DeviceStore::find_by_fingerprint) on `(tenant, hash)`
20//! → return that id (and emit `DeviceFirstSeen` if newly inserted).
21//! 5. If neither path resolves, [`DeviceStore::save`](super::store::DeviceStore::save) a fresh `Unknown`
22//! [`Device`](super::types::Device) and return its id, emitting
23//! `DeviceFirstSeen`.
24//!
25//! All of (1) through (5) is application-glue: the resolver implementation owns
26//! both the [`DeviceStore`] and the per-tenant fingerprint key registry,
27//! and decides how to extract tenant context from request headers /
28//! routing / TLS SNI / extensions populated by upstream middleware.
29//!
30//! # Layer integration
31//!
32//! [`SessionLayer::with_device_resolver`](crate::session::SessionLayer::with_device_resolver)
33//! wires a `DeviceResolver` into the session middleware. On every request
34//! the layer calls [`DeviceResolver::resolve`] before the inner handler
35//! runs and stamps the result onto
36//! [`SessionData::device_id`](crate::session::SessionData::device_id),
37//! marking the session modified iff the value changed (so the new id is
38//! persisted on response).
39//!
40//! Errors returned by `resolve` are logged and treated as `None`; device
41//! resolution is best-effort and never causes the request to fail.
42//!
43//! [`DeviceStore`]: super::store::DeviceStore
44
45use crate::authn::ids::DeviceId;
46use axum::http::request::Parts;
47use std::future::Future;
48use std::pin::Pin;
49
50/// Resolve (or create) the [`DeviceId`] associated with an inbound HTTP
51/// request.
52///
53/// Implementors hold whatever state they need (a [`DeviceStore`], a tenant
54/// fingerprint key registry, etc.) and produce a `DeviceId` per request.
55///
56/// # Failure semantics
57///
58/// `Ok(None)` means "no device could be associated with this request",
59/// not an error. Reserve `Self::Error` for genuine storage / configuration
60/// faults the caller should propagate. The session layer treats `Ok(None)`
61/// as a no-op (leaves
62/// [`SessionData::device_id`](crate::session::SessionData::device_id) at
63/// `None`); it logs `Err(_)` and continues with `None`.
64///
65/// # Tenant context
66///
67/// Implementations that need tenant scoping should read the [`TenantId`]
68/// from `request.extensions()` populated by an upstream tenant-resolver
69/// middleware. The trait does not pass tenant explicitly because the
70/// session layer that drives the resolver is itself tenant-agnostic.
71///
72/// [`DeviceStore`]: super::store::DeviceStore
73/// [`TenantId`]: crate::authn::ids::TenantId
74pub trait DeviceResolver: Send + Sync + 'static {
75 /// Storage / configuration error type; typically the
76 /// [`DeviceStore::Error`](super::store::DeviceStore::Error) of the
77 /// underlying store.
78 type Error: std::error::Error + Send + Sync + 'static;
79
80 /// Resolve the device for `parts`. Always best-effort: returning
81 /// `Ok(None)` is the documented "no device" outcome; the layer never
82 /// fails the request on `Err(_)`.
83 ///
84 /// Takes [`axum::http::request::Parts`] rather than the full
85 /// `Request<Body>` because `axum::body::Body` is `!Sync`; borrowing
86 /// the request across an `await` boundary is forbidden in a `Send`
87 /// future. The session layer splits the request before calling and
88 /// reassembles it after.
89 fn resolve(
90 &self,
91 parts: &Parts,
92 ) -> impl Future<Output = Result<Option<DeviceId>, Self::Error>> + Send;
93}
94
95/// No-op resolver used as the default plug when the `device` feature is on
96/// but the application has not configured a real
97/// [`DeviceResolver`].
98///
99/// Always returns `Ok(None)`, leaving
100/// [`SessionData::device_id`](crate::session::SessionData::device_id) at
101/// `None`. Applications that want device tracking must replace this with
102/// an implementation backed by a [`DeviceStore`](super::store::DeviceStore).
103#[derive(Debug, Clone, Copy, Default)]
104pub struct NoopDeviceResolver;
105
106impl DeviceResolver for NoopDeviceResolver {
107 type Error = std::convert::Infallible;
108
109 async fn resolve(&self, parts: &Parts) -> Result<Option<DeviceId>, Self::Error> {
110 tracing::trace!(
111 target: "axess::device",
112 method = %parts.method,
113 uri = %parts.uri,
114 "NoopDeviceResolver: device tracking disabled, returning None",
115 );
116 Ok(None)
117 }
118}
119
120// ── ErasedDeviceResolver ──────────────────────────────────────────────────────
121
122/// Internal dyn-safe wrapper around [`DeviceResolver`].
123///
124/// The user-facing trait uses RPITIT (`impl Future<...>`) and an associated
125/// `Error` type, neither of which is dyn-compatible today. The session
126/// layer needs to hold an arbitrary resolver behind `Arc<dyn ...>` so the
127/// layer's type signature does not pick up an extra generic parameter that
128/// would ripple through every `SessionLayer::new` call site.
129///
130/// This trait closes the gap: a blanket impl for every `R: DeviceResolver`
131/// boxes the future and swallows the error to a `tracing::warn!` log,
132/// honouring the documented best-effort contract.
133pub(crate) trait ErasedDeviceResolver: Send + Sync + 'static {
134 fn resolve_erased<'a>(
135 &'a self,
136 parts: &'a Parts,
137 ) -> Pin<Box<dyn Future<Output = Option<DeviceId>> + Send + 'a>>;
138}
139
140impl<R: DeviceResolver> ErasedDeviceResolver for R {
141 fn resolve_erased<'a>(
142 &'a self,
143 parts: &'a Parts,
144 ) -> Pin<Box<dyn Future<Output = Option<DeviceId>> + Send + 'a>> {
145 Box::pin(async move {
146 match self.resolve(parts).await {
147 Ok(id) => id,
148 Err(e) => {
149 tracing::warn!(
150 error = %e,
151 "DeviceResolver failed; continuing with device_id = None"
152 );
153 None
154 }
155 }
156 })
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use axum::body::Body;
164 use axum::http::Request;
165
166 fn empty_parts() -> Parts {
167 let req: Request<Body> = Request::builder().uri("/").body(Body::empty()).unwrap();
168 req.into_parts().0
169 }
170
171 #[tokio::test]
172 async fn noop_resolver_always_returns_none() {
173 let resolver = NoopDeviceResolver;
174 let parts = empty_parts();
175 let resolved = resolver.resolve(&parts).await.unwrap();
176 assert_eq!(resolved, None);
177 }
178
179 #[tokio::test]
180 async fn erased_resolver_swallows_errors_to_none() {
181 // A resolver that always errors should appear as `None` to
182 // dyn-trait callers; the warn log is fire-and-forget.
183 #[derive(Debug, thiserror::Error)]
184 #[error("synthetic resolver failure")]
185 struct BoomError;
186
187 #[derive(Default)]
188 struct BoomResolver;
189
190 impl DeviceResolver for BoomResolver {
191 type Error = BoomError;
192 async fn resolve(&self, _: &Parts) -> Result<Option<DeviceId>, Self::Error> {
193 Err(BoomError)
194 }
195 }
196
197 let resolver = BoomResolver;
198 let parts = empty_parts();
199 let outcome = resolver.resolve_erased(&parts).await;
200 assert_eq!(outcome, None);
201 }
202
203 #[tokio::test]
204 async fn erased_resolver_passes_through_some() {
205 struct StaticResolver(DeviceId);
206
207 impl DeviceResolver for StaticResolver {
208 type Error = std::convert::Infallible;
209 async fn resolve(&self, _: &Parts) -> Result<Option<DeviceId>, Self::Error> {
210 Ok(Some(self.0))
211 }
212 }
213
214 let id = axess_identity::testing::device("dev-static");
215 let resolver = StaticResolver(id);
216 let parts = empty_parts();
217 let outcome = resolver.resolve_erased(&parts).await;
218 assert_eq!(outcome, Some(id));
219 }
220}