use std::net::IpAddr;
use std::sync::Arc;
use axess_clock::Clock;
use axum::http::request::Parts;
use crate::authn::ids::{DeviceId, TenantId, UserId};
use crate::device::fingerprint::DeviceFingerprintExtractor;
use crate::device::lifecycle::DeviceLifecycleService;
use crate::device::resolver::DeviceResolver;
use crate::device::store::DeviceStore;
type TenantFn = Arc<dyn Fn(&Parts) -> Option<TenantId> + Send + Sync>;
type ClientIpFn = Arc<dyn Fn(&Parts) -> Option<IpAddr> + Send + Sync>;
type UserFn = Arc<dyn Fn(&Parts) -> Option<UserId> + Send + Sync>;
type NewIdFn = Arc<dyn Fn() -> DeviceId + Send + Sync>;
pub struct LifecycleDeviceResolver<E, S, C>
where
E: DeviceFingerprintExtractor,
S: DeviceStore,
C: Clock,
{
extractor: Arc<E>,
lifecycle: DeviceLifecycleService<S>,
clock: Arc<C>,
tenant_fn: TenantFn,
client_ip_fn: ClientIpFn,
user_fn: UserFn,
new_id_fn: NewIdFn,
}
impl<E, S, C> Clone for LifecycleDeviceResolver<E, S, C>
where
E: DeviceFingerprintExtractor,
S: DeviceStore,
C: Clock,
{
fn clone(&self) -> Self {
Self {
extractor: self.extractor.clone(),
lifecycle: self.lifecycle.clone(),
clock: self.clock.clone(),
tenant_fn: self.tenant_fn.clone(),
client_ip_fn: self.client_ip_fn.clone(),
user_fn: self.user_fn.clone(),
new_id_fn: self.new_id_fn.clone(),
}
}
}
impl<E, S, C> LifecycleDeviceResolver<E, S, C>
where
E: DeviceFingerprintExtractor,
S: DeviceStore,
C: Clock,
{
pub fn new(extractor: E, lifecycle: DeviceLifecycleService<S>, clock: C) -> Self {
Self {
extractor: Arc::new(extractor),
lifecycle,
clock: Arc::new(clock),
tenant_fn: default_tenant_fn(),
client_ip_fn: default_client_ip_fn(),
user_fn: default_user_fn(),
new_id_fn: default_new_id_fn(),
}
}
pub fn with_tenant_fn<F>(mut self, f: F) -> Self
where
F: Fn(&Parts) -> Option<TenantId> + Send + Sync + 'static,
{
self.tenant_fn = Arc::new(f);
self
}
pub fn with_client_ip_fn<F>(mut self, f: F) -> Self
where
F: Fn(&Parts) -> Option<IpAddr> + Send + Sync + 'static,
{
self.client_ip_fn = Arc::new(f);
self
}
pub fn with_user_fn<F>(mut self, f: F) -> Self
where
F: Fn(&Parts) -> Option<UserId> + Send + Sync + 'static,
{
self.user_fn = Arc::new(f);
self
}
pub fn with_new_id_fn<F>(mut self, f: F) -> Self
where
F: Fn() -> DeviceId + Send + Sync + 'static,
{
self.new_id_fn = Arc::new(f);
self
}
}
impl<E, S, C> DeviceResolver for LifecycleDeviceResolver<E, S, C>
where
E: DeviceFingerprintExtractor,
S: DeviceStore,
C: Clock,
{
type Error = S::Error;
async fn resolve(&self, parts: &Parts) -> Result<Option<DeviceId>, Self::Error> {
let Some(tenant) = (self.tenant_fn)(parts) else {
return Ok(None);
};
let client_ip = (self.client_ip_fn)(parts);
let Some(fp) = self.extractor.extract(&tenant, parts, client_ip) else {
return Ok(None);
};
let user = (self.user_fn)(parts);
let now = self.clock.now();
let new_id_fn = self.new_id_fn.clone();
let id = self
.lifecycle
.ensure_device(&tenant, user.as_ref(), fp, now, move || (new_id_fn)())
.await?;
Ok(Some(id))
}
}
fn default_tenant_fn() -> TenantFn {
Arc::new(|parts: &Parts| parts.extensions.get::<TenantId>().cloned())
}
fn default_client_ip_fn() -> ClientIpFn {
Arc::new(|parts: &Parts| {
parts.uri.host();
None
})
}
fn default_user_fn() -> UserFn {
Arc::new(|parts: &Parts| {
parts.uri.host();
None
})
}
fn default_new_id_fn() -> NewIdFn {
Arc::new(|| {
DeviceId::try_new(uuid::Uuid::new_v4().to_string())
.expect("uuid::Uuid::new_v4() always produces a DeviceId-valid string")
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::device::fingerprint::DefaultFingerprintExtractor;
use crate::device::store::MemoryDeviceStore;
use crate::device::types::DeviceTrustLevel;
use axess_clock::testing::MockClock;
use axum::body::Body;
use axum::http::Request;
use chrono::{TimeZone, Utc};
use std::net::{Ipv4Addr, SocketAddr};
fn fixed_pepper() -> super::super::fingerprint::TenantPepperResolver {
Arc::new(|t: &TenantId| {
t.as_uuid();
[42u8; 32]
})
}
fn make_resolver()
-> LifecycleDeviceResolver<DefaultFingerprintExtractor, MemoryDeviceStore, MockClock> {
let store = MemoryDeviceStore::new();
let lifecycle = DeviceLifecycleService::new(store);
let clock = MockClock::at(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
let extractor = DefaultFingerprintExtractor::new(fixed_pepper());
LifecycleDeviceResolver::new(extractor, lifecycle, clock)
}
#[derive(Clone)]
struct TestClientIp(SocketAddr);
fn req_with(ua: Option<&str>, tenant: Option<TenantId>, ip: Option<IpAddr>) -> Parts {
let mut req: Request<Body> = Request::new(Body::empty());
if let Some(v) = ua {
req.headers_mut().insert(
axum::http::header::USER_AGENT,
axum::http::HeaderValue::from_str(v).unwrap(),
);
}
if let Some(t) = tenant {
req.extensions_mut().insert(t);
}
if let Some(ip) = ip {
req.extensions_mut()
.insert(TestClientIp(SocketAddr::new(ip, 8080)));
}
req.into_parts().0
}
fn ip_from_test_extension(parts: &Parts) -> Option<IpAddr> {
parts.extensions.get::<TestClientIp>().map(|c| c.0.ip())
}
#[tokio::test]
async fn missing_tenant_yields_none() {
let resolver = make_resolver();
let parts = req_with(Some("Mozilla/5.0"), None, None);
let resolved = resolver.resolve(&parts).await.unwrap();
assert_eq!(resolved, None);
}
#[tokio::test]
async fn missing_user_agent_yields_none() {
let resolver = make_resolver();
let tenant = crate::authn::ids::testing::tenant("t1");
let parts = req_with(None, Some(tenant), None);
let resolved = resolver.resolve(&parts).await.unwrap();
assert_eq!(resolved, None);
}
#[tokio::test]
async fn happy_path_creates_then_finds() {
let resolver = make_resolver();
let tenant = crate::authn::ids::testing::tenant("t1");
let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)));
let parts1 = req_with(Some("Mozilla/5.0"), Some(tenant), ip);
let id1 = resolver.resolve(&parts1).await.unwrap().expect("created");
let parts2 = req_with(Some("Mozilla/5.0"), Some(tenant), ip);
let id2 = resolver.resolve(&parts2).await.unwrap().expect("found");
assert_eq!(id1, id2, "second resolve must return the same DeviceId");
let device = resolver
.lifecycle
.store()
.load(&tenant, &id1)
.await
.unwrap()
.unwrap();
assert_eq!(device.trust_level, DeviceTrustLevel::Unknown);
}
#[tokio::test]
async fn new_id_fn_override_is_honoured() {
let resolver =
make_resolver().with_new_id_fn(|| crate::authn::ids::testing::device("dev-fixed"));
let tenant = crate::authn::ids::testing::tenant("t1");
let parts = req_with(Some("Mozilla/5.0"), Some(tenant), None);
let id = resolver.resolve(&parts).await.unwrap().expect("created");
assert_eq!(id, crate::authn::ids::testing::device("dev-fixed"));
}
#[tokio::test]
async fn tenant_fn_override_is_honoured() {
let resolver = make_resolver().with_tenant_fn(|parts: &Parts| {
parts
.headers
.get("x-tenant")
.and_then(|v| v.to_str().ok())
.map(crate::authn::ids::testing::tenant)
});
let mut req: Request<Body> = Request::new(Body::empty());
req.headers_mut().insert(
axum::http::header::USER_AGENT,
axum::http::HeaderValue::from_static("UA"),
);
req.headers_mut().insert(
"x-tenant",
axum::http::HeaderValue::from_static("from-header"),
);
let parts = req.into_parts().0;
let id = resolver
.resolve(&parts)
.await
.unwrap()
.expect("created from header tenant");
let device = resolver
.lifecycle
.store()
.load(&crate::authn::ids::testing::tenant("from-header"), &id)
.await
.unwrap()
.expect("device persisted under header-derived tenant");
assert_eq!(
device.tenant_id,
crate::authn::ids::testing::tenant("from-header"),
"override must drive which tenant scope receives the device"
);
}
#[tokio::test]
async fn user_fn_override_populates_device_user_id() {
let resolver = make_resolver().with_user_fn(|parts: &Parts| {
parts.uri.host();
Some(crate::authn::ids::testing::user("u-from-extension"))
});
let tenant = crate::authn::ids::testing::tenant("t1");
let parts = req_with(Some("Mozilla/5.0"), Some(tenant), None);
let id = resolver.resolve(&parts).await.unwrap().expect("created");
let device = resolver
.lifecycle
.store()
.load(&tenant, &id)
.await
.unwrap()
.unwrap();
assert_eq!(
device.user_id,
Some(crate::authn::ids::testing::user("u-from-extension")),
"user_fn must populate device.user_id at creation time"
);
}
#[tokio::test]
async fn custom_client_ip_fn_distinguishes_subnets() {
let resolver = make_resolver().with_client_ip_fn(ip_from_test_extension);
let tenant = crate::authn::ids::testing::tenant("t1");
let p1 = req_with(
Some("Mozilla/5.0"),
Some(tenant),
Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
);
let p2 = req_with(
Some("Mozilla/5.0"),
Some(tenant),
Some(IpAddr::V4(Ipv4Addr::new(10, 0, 99, 1))),
);
let id_a = resolver.resolve(&p1).await.unwrap().expect("a");
let id_b = resolver.resolve(&p2).await.unwrap().expect("b");
assert_ne!(
id_a, id_b,
"different /24s on the same UA must yield different DeviceIds"
);
}
#[tokio::test]
async fn default_client_ip_fn_returns_none_so_ip_does_not_change_device() {
let resolver = make_resolver();
let tenant = crate::authn::ids::testing::tenant("t1");
let p1 = req_with(
Some("Mozilla/5.0"),
Some(tenant),
Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
);
let p2 = req_with(
Some("Mozilla/5.0"),
Some(tenant),
Some(IpAddr::V4(Ipv4Addr::new(10, 0, 99, 1))),
);
let id_a = resolver.resolve(&p1).await.unwrap().expect("a");
let id_b = resolver.resolve(&p2).await.unwrap().expect("b");
assert_eq!(
id_a, id_b,
"default extractor ignores IP unless client_ip_fn is overridden"
);
}
#[tokio::test]
async fn create_uses_injected_clock_for_timestamps() {
let store = MemoryDeviceStore::new();
let lifecycle = DeviceLifecycleService::new(store);
let frozen = Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap();
let clock = MockClock::at(frozen);
let extractor = DefaultFingerprintExtractor::new(fixed_pepper());
let resolver = LifecycleDeviceResolver::new(extractor, lifecycle, clock);
let tenant = crate::authn::ids::testing::tenant("t1");
let parts = req_with(Some("Mozilla/5.0"), Some(tenant), None);
let id = resolver.resolve(&parts).await.unwrap().expect("created");
let device = resolver
.lifecycle
.store()
.load(&tenant, &id)
.await
.unwrap()
.unwrap();
assert_eq!(device.first_seen_at, frozen);
assert_eq!(device.last_seen_at, frozen);
}
}