Skip to main content

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}