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};
use crate::device::store::DeviceStore;
use crate::device::types::{Device, DeviceTrustLevel, FingerprintHash};
#[derive(Clone)]
pub struct DeviceLifecycleService<S>
where
S: DeviceStore,
{
store: S,
event_sink: Arc<dyn DeviceEventSink>,
}
impl<S> DeviceLifecycleService<S>
where
S: DeviceStore,
{
pub fn new(store: S) -> Self {
Self {
store,
event_sink: Arc::new(NoopDeviceEventSink),
}
}
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?
{
self.store
.record_sighting(&tenant, &existing.id, now)
.await?;
return Ok(existing.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,
bindings: Vec::new(),
};
self.store.save(&device).await?;
let event = AuthEventBuilder::success(AuthEventType::DeviceFirstSeen)
.maybe_attributed_to(user.as_ref(), Some(&tenant))
.with_device(id)
.build();
self.event_sink.emit(event).await;
Ok(id)
}
}
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 => {
self.store
.set_trust_level(&tenant, &device_id, DeviceTrustLevel::Seen, now)
.await?;
self.store.record_sighting(&tenant, &device_id, now).await?;
Ok(Some(DeviceTrustLevel::Seen))
}
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 device = match self.store.load(&tenant, &device_id).await? {
Some(d) => d,
None => 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);
}
device
.bindings
.push(crate::device::types::DeviceBinding::WebAuthn {
credential_id: credential_id.clone(),
attestation_class,
bound_at: now,
last_used_at: now,
});
self.store.save(&device).await?;
let event = AuthEventBuilder::success(AuthEventType::DeviceBindingAdded)
.maybe_attributed_to(device.user_id.as_ref(), Some(&tenant))
.with_device(device_id)
.build();
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 {
let mut device = match self.store.load(&tenant, &device_id).await? {
Some(d) => d,
None => return Ok(()),
};
let mut touched = false;
for binding in &mut device.bindings {
if let crate::device::types::DeviceBinding::WebAuthn {
credential_id: cid,
last_used_at,
..
} = binding
{
if cid == &credential_id {
*last_used_at = now;
touched = true;
break;
}
}
}
if touched {
self.store.save(&device).await?;
}
Ok(())
}
}
}
#[cfg(test)]
mod tests;