use std::{fmt, net::IpAddr};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
checks::Check,
fingerprint::Fingerprint,
intel::{Anonymity, IpInfo},
subject::Subject,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EventKind {
Login,
Logout,
StepUpRequired,
StepUpCompleted,
RiskRevoked,
TokenReuse,
TokenReissued,
Revoked,
}
impl EventKind {
pub const ALL: [EventKind; 8] = [
EventKind::Login,
EventKind::Logout,
EventKind::StepUpRequired,
EventKind::StepUpCompleted,
EventKind::RiskRevoked,
EventKind::TokenReuse,
EventKind::TokenReissued,
EventKind::Revoked,
];
pub const fn as_str(self) -> &'static str {
match self {
EventKind::Login => "login",
EventKind::Logout => "logout",
EventKind::StepUpRequired => "step_up_required",
EventKind::StepUpCompleted => "step_up_completed",
EventKind::RiskRevoked => "risk_revoked",
EventKind::TokenReuse => "token_reuse",
EventKind::TokenReissued => "token_reissued",
EventKind::Revoked => "revoked",
}
}
pub fn parse(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.as_str() == name)
}
pub const fn is_security_relevant(self) -> bool {
matches!(
self,
EventKind::StepUpRequired | EventKind::RiskRevoked | EventKind::TokenReuse | EventKind::TokenReissued
)
}
}
impl fmt::Display for EventKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SessionEvent {
pub session_id: Uuid,
pub subject: String,
pub kind: EventKind,
pub enforced: bool,
pub score: Option<u32>,
pub signals: Vec<Check>,
pub ip: IpAddr,
pub user_agent: Option<String>,
pub client: ClientSummary,
pub at: DateTime<Utc>,
}
impl SessionEvent {
pub fn subject<S: Subject>(&self) -> Option<S> {
crate::subject::decode(&self.subject)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct ClientSummary {
pub country: Option<String>,
pub city: Option<String>,
pub asn: Option<u32>,
pub network: Option<String>,
pub anonymity: Option<Anonymity>,
pub browser: Option<String>,
pub os: Option<String>,
}
impl ClientSummary {
pub(crate) fn observed(info: IpInfo, fingerprint: Fingerprint) -> Self {
Self {
country: fingerprint.country,
city: info.city.filter(|c| !c.is_empty()),
asn: fingerprint.asn,
network: info.network.filter(|n| !n.is_empty()),
anonymity: fingerprint.anonymity,
browser: fingerprint.browser,
os: fingerprint.os,
}
}
pub(crate) fn minimized(mut self) -> Self {
self.city = None;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SessionOrigin {
pub at: DateTime<Utc>,
pub ip_prefix: IpAddr,
#[serde(default)]
pub ip: Option<IpAddr>,
#[serde(default)]
pub user_agent: Option<String>,
#[serde(default)]
pub client: ClientSummary,
}
pub(crate) fn mask_ip(ip: IpAddr) -> IpAddr {
let len = if ip.is_ipv4() { 24 } else { 48 };
ipnet::IpNet::new(ip, len).map_or(ip, |net| net.trunc().addr())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_round_trip_and_match_serde() {
for kind in EventKind::ALL {
assert_eq!(EventKind::parse(kind.as_str()), Some(kind));
assert_eq!(serde_json::to_string(&kind).unwrap(), format!("\"{kind}\""));
}
assert_eq!(EventKind::parse("nope"), None);
}
#[test]
fn client_summary_reads_missing_fields_as_none() {
let client: ClientSummary = serde_json::from_str(r#"{"country":"BE","later":1}"#).unwrap();
assert_eq!(client.country.as_deref(), Some("BE"));
assert_eq!(client.city, None);
let all = ClientSummary {
country: Some("BE".into()),
city: Some("Ghent".into()),
asn: Some(64500),
network: Some("Example Net".into()),
anonymity: Some(Anonymity::Vpn),
browser: Some("Firefox".into()),
os: Some("Linux".into()),
};
let json = serde_json::to_string(&all).unwrap();
assert_eq!(serde_json::from_str::<ClientSummary>(&json).unwrap(), all);
}
#[test]
fn session_origin_reads_missing_fields_as_none() {
let origin: SessionOrigin =
serde_json::from_str(r#"{"at":"2026-03-01T12:00:00Z","ip_prefix":"203.0.113.0"}"#).unwrap();
assert_eq!(origin.ip_prefix, "203.0.113.0".parse::<IpAddr>().unwrap());
assert_eq!(origin.ip, None);
assert_eq!(origin.user_agent, None);
assert_eq!(origin.client, ClientSummary::default());
let full = SessionOrigin {
ip: Some("203.0.113.7".parse().unwrap()),
user_agent: Some("Firefox".into()),
client: ClientSummary {
city: Some("Ghent".into()),
..ClientSummary::default()
},
..origin
};
let json = serde_json::to_string(&full).unwrap();
assert_eq!(serde_json::from_str::<SessionOrigin>(&json).unwrap(), full);
}
#[test]
fn mask_ip_keeps_the_network() {
assert_eq!(
mask_ip("203.0.113.7".parse().unwrap()),
"203.0.113.0".parse::<IpAddr>().unwrap()
);
assert_eq!(
mask_ip("2001:db8:1:2::7".parse().unwrap()),
"2001:db8:1::".parse::<IpAddr>().unwrap()
);
}
#[test]
fn subject_decodes() {
let id = Uuid::new_v4();
let event = SessionEvent {
session_id: Uuid::new_v4(),
subject: crate::subject::encode(&id).unwrap(),
kind: EventKind::Login,
enforced: true,
score: None,
signals: vec![],
ip: "203.0.113.7".parse().unwrap(),
user_agent: None,
client: ClientSummary::default(),
at: Utc::now(),
};
assert_eq!(event.subject::<Uuid>(), Some(id));
assert_eq!(event.subject::<i64>(), None);
}
}