use chrono::{DateTime, Utc};
use std::future::Future;
use std::sync::Arc;
use crate::authn::event::{AuthEventBuilder, AuthEventType};
use crate::authn::ids::{DeviceId, TenantId, UserId};
use crate::authn::service::FactorOutcome;
use crate::device::events::{DeviceEventSink, NoopDeviceEventSink};
#[cfg(feature = "fido2")]
use crate::device::store::BindingsUpdate;
use crate::device::store::{DeviceStore, OwnerClaim, TrustTransition};
use crate::device::types::{Device, DeviceTrustLevel, FingerprintHash};
const DEFAULT_SIGHTING_GRANULARITY_SECS: i64 = 300;
#[cfg(feature = "fido2")]
const BINDINGS_RETRIES: usize = 16;
fn is_usable_by(device: &Device, user: Option<&UserId>) -> bool {
match (device.user_id.as_ref(), user) {
(Some(owner), Some(user)) => owner == user,
_ => true,
}
}
#[derive(Clone)]
pub struct DeviceLifecycleService<S>
where
S: DeviceStore,
{
store: S,
event_sink: Arc<dyn DeviceEventSink>,
sighting_granularity: chrono::Duration,
}
impl<S> DeviceLifecycleService<S>
where
S: DeviceStore,
{
pub fn new(store: S) -> Self {
Self {
store,
event_sink: Arc::new(NoopDeviceEventSink),
sighting_granularity: chrono::Duration::seconds(DEFAULT_SIGHTING_GRANULARITY_SECS),
}
}
pub fn with_sighting_granularity(mut self, granularity: chrono::Duration) -> Self {
self.sighting_granularity = granularity;
self
}
pub fn with_event_sink<E: DeviceEventSink>(mut self, sink: E) -> Self {
self.event_sink = Arc::new(sink);
self
}
pub fn store(&self) -> &S {
&self.store
}
pub fn ensure_device(
&self,
tenant: &TenantId,
user: Option<&UserId>,
fingerprint: FingerprintHash,
now: DateTime<Utc>,
new_id_fn: impl FnOnce() -> DeviceId + Send,
) -> impl Future<Output = Result<DeviceId, S::Error>> + Send {
let tenant = *tenant;
let user = user.cloned();
async move {
if let Some(existing) = self
.store
.find_by_fingerprint(&tenant, &fingerprint)
.await?
{
if is_usable_by(&existing, user.as_ref()) {
let usable = match (existing.user_id.as_ref(), user.as_ref()) {
(None, Some(user)) => matches!(
self.store
.claim_owner(&tenant, &existing.id, user, now)
.await?,
OwnerClaim::Claimed
),
_ => true,
};
if usable {
self.note_sighting(&tenant, &existing, now).await?;
return Ok(existing.id);
}
}
if let Some(user) = user.as_ref() {
let own = self
.store
.find_by_fingerprint_for_user(&tenant, user, &fingerprint)
.await?;
if let Some(own) = own {
self.note_sighting(&tenant, &own, now).await?;
return Ok(own.id);
}
}
}
let id = new_id_fn();
let device = Device {
id,
tenant_id: tenant,
user_id: user,
trust_level: DeviceTrustLevel::Unknown,
fingerprint_hash: fingerprint,
first_seen_at: now,
last_seen_at: now,
revoked_at: None,
demoted_at: None,
bindings: Vec::new(),
};
self.store.save(&device, now).await?;
let event = AuthEventBuilder::success(AuthEventType::DeviceFirstSeen)
.maybe_attributed_to(user.as_ref(), Some(&tenant))
.with_device(id)
.build_at(now);
self.event_sink.emit(event).await;
Ok(id)
}
}
async fn note_sighting(
&self,
tenant: &TenantId,
device: &Device,
now: DateTime<Utc>,
) -> Result<(), S::Error> {
if now.signed_duration_since(device.last_seen_at) < self.sighting_granularity
&& now >= device.last_seen_at
{
return Ok(());
}
self.store.record_sighting(tenant, &device.id, now).await
}
pub fn promote_on_authn(
&self,
tenant: &TenantId,
device_id: &DeviceId,
now: DateTime<Utc>,
) -> impl Future<Output = Result<Option<DeviceTrustLevel>, S::Error>> + Send {
let tenant = *tenant;
let device_id = *device_id;
async move {
let device = match self.store.load(&tenant, &device_id).await? {
Some(d) => d,
None => return Ok(None),
};
match device.trust_level {
DeviceTrustLevel::Unknown => {
match self
.store
.set_trust_level_if(
&tenant,
&device_id,
DeviceTrustLevel::Unknown,
DeviceTrustLevel::Seen,
now,
)
.await?
{
TrustTransition::Applied => {
self.store.record_sighting(&tenant, &device_id, now).await?;
Ok(Some(DeviceTrustLevel::Seen))
}
TrustTransition::Refused {
current: DeviceTrustLevel::Revoked,
} => Ok(Some(DeviceTrustLevel::Revoked)),
TrustTransition::Refused { current } => {
self.store.record_sighting(&tenant, &device_id, now).await?;
Ok(Some(current))
}
TrustTransition::Missing => Ok(None),
}
}
DeviceTrustLevel::Seen | DeviceTrustLevel::Trusted => {
self.store.record_sighting(&tenant, &device_id, now).await?;
Ok(Some(device.trust_level))
}
DeviceTrustLevel::Revoked => {
Ok(Some(DeviceTrustLevel::Revoked))
}
}
}
}
pub fn promote_if_authenticated(
&self,
outcome: &FactorOutcome,
tenant: &TenantId,
device_id: &DeviceId,
now: DateTime<Utc>,
) -> impl Future<Output = Result<Option<DeviceTrustLevel>, S::Error>> + Send {
let is_authenticated = matches!(outcome, FactorOutcome::Authenticated);
let tenant = *tenant;
let device_id = *device_id;
async move {
if is_authenticated {
self.promote_on_authn(&tenant, &device_id, now).await
} else {
Ok(None)
}
}
}
#[cfg(feature = "fido2")]
pub fn bind_webauthn_credential(
&self,
tenant: &TenantId,
device_id: &DeviceId,
credential_id: String,
attestation_class: crate::device::types::AttestationClass,
now: DateTime<Utc>,
) -> impl Future<Output = Result<bool, S::Error>> + Send {
let tenant = *tenant;
let device_id = *device_id;
let event_sink = self.event_sink.clone();
async move {
let mut attempts = 0;
let device = loop {
let mut device = match self.store.load(&tenant, &device_id).await? {
Some(d) => d,
None => return Ok(false),
};
if device.trust_level == DeviceTrustLevel::Revoked {
return Ok(false);
}
let already_bound = device.bindings.iter().any(|b| {
matches!(
b,
crate::device::types::DeviceBinding::WebAuthn {
credential_id: cid, ..
} if cid == &credential_id
)
});
if already_bound {
return Ok(false);
}
let before = device.bindings.clone();
device
.bindings
.push(crate::device::types::DeviceBinding::WebAuthn {
credential_id: credential_id.clone(),
attestation_class,
bound_at: now,
last_used_at: now,
});
match self
.store
.replace_bindings(&tenant, &device_id, &before, &device.bindings, now)
.await?
{
BindingsUpdate::Written => break device,
BindingsUpdate::Conflict if attempts < BINDINGS_RETRIES => attempts += 1,
BindingsUpdate::Conflict => {
tracing::warn!(
"device bindings kept changing under concurrent writers; \
WebAuthn binding not recorded"
);
return Ok(false);
}
BindingsUpdate::Revoked | BindingsUpdate::Missing => return Ok(false),
}
};
let event = AuthEventBuilder::success(AuthEventType::DeviceBindingAdded)
.maybe_attributed_to(device.user_id.as_ref(), Some(&tenant))
.with_device(device_id)
.build_at(now);
event_sink.emit(event).await;
Ok(true)
}
}
#[cfg(feature = "fido2")]
pub fn record_webauthn_usage(
&self,
tenant: &TenantId,
device_id: &DeviceId,
credential_id: &str,
now: DateTime<Utc>,
) -> impl Future<Output = Result<(), S::Error>> + Send {
let tenant = *tenant;
let device_id = *device_id;
let credential_id = credential_id.to_owned();
async move {
for _ in 0..=BINDINGS_RETRIES {
let device = match self.store.load(&tenant, &device_id).await? {
Some(d) => d,
None => return Ok(()),
};
let mut bindings = device.bindings.clone();
let mut touched = false;
for binding in &mut bindings {
if let crate::device::types::DeviceBinding::WebAuthn {
credential_id: cid,
last_used_at,
..
} = binding
&& cid == &credential_id
{
*last_used_at = now;
touched = true;
break;
}
}
if !touched {
return Ok(());
}
match self
.store
.replace_bindings(&tenant, &device_id, &device.bindings, &bindings, now)
.await?
{
BindingsUpdate::Conflict => continue,
_ => return Ok(()),
}
}
tracing::warn!(
"device bindings kept changing under concurrent writers; \
WebAuthn last-used time not recorded"
);
Ok(())
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod ownership;
#[cfg(test)]
mod sighting;
#[cfg(test)]
mod races;