1use std::{fmt, net::IpAddr};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::{
10 checks::Check,
11 fingerprint::Fingerprint,
12 intel::{Anonymity, IpInfo},
13 subject::Subject,
14};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum EventKind {
21 Login,
23 Logout,
25 StepUpRequired,
27 StepUpCompleted,
29 RiskRevoked,
32 TokenReuse,
35 TokenReissued,
37 Revoked,
41}
42
43impl EventKind {
44 pub const ALL: [EventKind; 8] = [
46 EventKind::Login,
47 EventKind::Logout,
48 EventKind::StepUpRequired,
49 EventKind::StepUpCompleted,
50 EventKind::RiskRevoked,
51 EventKind::TokenReuse,
52 EventKind::TokenReissued,
53 EventKind::Revoked,
54 ];
55
56 pub const fn as_str(self) -> &'static str {
58 match self {
59 EventKind::Login => "login",
60 EventKind::Logout => "logout",
61 EventKind::StepUpRequired => "step_up_required",
62 EventKind::StepUpCompleted => "step_up_completed",
63 EventKind::RiskRevoked => "risk_revoked",
64 EventKind::TokenReuse => "token_reuse",
65 EventKind::TokenReissued => "token_reissued",
66 EventKind::Revoked => "revoked",
67 }
68 }
69
70 pub fn parse(name: &str) -> Option<Self> {
72 Self::ALL.into_iter().find(|k| k.as_str() == name)
73 }
74
75 pub const fn is_security_relevant(self) -> bool {
77 matches!(
78 self,
79 EventKind::StepUpRequired | EventKind::RiskRevoked | EventKind::TokenReuse | EventKind::TokenReissued
80 )
81 }
82}
83
84impl fmt::Display for EventKind {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.write_str(self.as_str())
87 }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
99#[non_exhaustive]
100pub struct SessionEvent {
101 pub session_id: Uuid,
103 pub subject: String,
105 pub kind: EventKind,
107 pub enforced: bool,
110 pub score: Option<u32>,
112 pub signals: Vec<Check>,
114 pub ip: IpAddr,
116 pub user_agent: Option<String>,
118 pub client: ClientSummary,
121 pub at: DateTime<Utc>,
123}
124
125impl SessionEvent {
126 pub fn subject<S: Subject>(&self) -> Option<S> {
128 crate::subject::decode(&self.subject)
129 }
130}
131
132#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(default)]
136#[non_exhaustive]
137pub struct ClientSummary {
138 pub country: Option<String>,
140 pub city: Option<String>,
142 pub asn: Option<u32>,
144 pub network: Option<String>,
146 pub anonymity: Option<Anonymity>,
148 pub browser: Option<String>,
150 pub os: Option<String>,
152}
153
154impl ClientSummary {
155 pub(crate) fn observed(info: IpInfo, fingerprint: Fingerprint) -> Self {
156 Self {
157 country: fingerprint.country,
158 city: info.city.filter(|c| !c.is_empty()),
159 asn: fingerprint.asn,
160 network: info.network.filter(|n| !n.is_empty()),
161 anonymity: fingerprint.anonymity,
162 browser: fingerprint.browser,
163 os: fingerprint.os,
164 }
165 }
166
167 pub(crate) fn minimized(mut self) -> Self {
170 self.city = None;
171 self
172 }
173}
174
175#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179#[non_exhaustive]
180pub struct SessionOrigin {
181 pub at: DateTime<Utc>,
183 pub ip_prefix: IpAddr,
185 #[serde(default)]
187 pub ip: Option<IpAddr>,
188 #[serde(default)]
190 pub user_agent: Option<String>,
191 #[serde(default)]
193 pub client: ClientSummary,
194}
195
196pub(crate) fn mask_ip(ip: IpAddr) -> IpAddr {
198 let len = if ip.is_ipv4() { 24 } else { 48 };
199 ipnet::IpNet::new(ip, len).map_or(ip, |net| net.trunc().addr())
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn names_round_trip_and_match_serde() {
208 for kind in EventKind::ALL {
209 assert_eq!(EventKind::parse(kind.as_str()), Some(kind));
210 assert_eq!(serde_json::to_string(&kind).unwrap(), format!("\"{kind}\""));
211 }
212 assert_eq!(EventKind::parse("nope"), None);
213 }
214
215 #[test]
216 fn client_summary_reads_missing_fields_as_none() {
217 let client: ClientSummary = serde_json::from_str(r#"{"country":"BE","later":1}"#).unwrap();
218 assert_eq!(client.country.as_deref(), Some("BE"));
219 assert_eq!(client.city, None);
220 let all = ClientSummary {
221 country: Some("BE".into()),
222 city: Some("Ghent".into()),
223 asn: Some(64500),
224 network: Some("Example Net".into()),
225 anonymity: Some(Anonymity::Vpn),
226 browser: Some("Firefox".into()),
227 os: Some("Linux".into()),
228 };
229 let json = serde_json::to_string(&all).unwrap();
230 assert_eq!(serde_json::from_str::<ClientSummary>(&json).unwrap(), all);
231 }
232
233 #[test]
234 fn session_origin_reads_missing_fields_as_none() {
235 let origin: SessionOrigin =
236 serde_json::from_str(r#"{"at":"2026-03-01T12:00:00Z","ip_prefix":"203.0.113.0"}"#).unwrap();
237 assert_eq!(origin.ip_prefix, "203.0.113.0".parse::<IpAddr>().unwrap());
238 assert_eq!(origin.ip, None);
239 assert_eq!(origin.user_agent, None);
240 assert_eq!(origin.client, ClientSummary::default());
241 let full = SessionOrigin {
242 ip: Some("203.0.113.7".parse().unwrap()),
243 user_agent: Some("Firefox".into()),
244 client: ClientSummary {
245 city: Some("Ghent".into()),
246 ..ClientSummary::default()
247 },
248 ..origin
249 };
250 let json = serde_json::to_string(&full).unwrap();
251 assert_eq!(serde_json::from_str::<SessionOrigin>(&json).unwrap(), full);
252 }
253
254 #[test]
255 fn mask_ip_keeps_the_network() {
256 assert_eq!(
257 mask_ip("203.0.113.7".parse().unwrap()),
258 "203.0.113.0".parse::<IpAddr>().unwrap()
259 );
260 assert_eq!(
261 mask_ip("2001:db8:1:2::7".parse().unwrap()),
262 "2001:db8:1::".parse::<IpAddr>().unwrap()
263 );
264 }
265
266 #[test]
267 fn subject_decodes() {
268 let id = Uuid::new_v4();
269 let event = SessionEvent {
270 session_id: Uuid::new_v4(),
271 subject: crate::subject::encode(&id).unwrap(),
272 kind: EventKind::Login,
273 enforced: true,
274 score: None,
275 signals: vec![],
276 ip: "203.0.113.7".parse().unwrap(),
277 user_agent: None,
278 client: ClientSummary::default(),
279 at: Utc::now(),
280 };
281 assert_eq!(event.subject::<Uuid>(), Some(id));
282 assert_eq!(event.subject::<i64>(), None);
283 }
284}