Skip to main content

session_guard/guard/
mod.rs

1//! The guard: issues, checks, rotates and revokes sessions.
2
3mod outcome;
4#[cfg(test)]
5mod tests;
6
7use std::{
8    borrow::Cow,
9    collections::HashMap,
10    marker::PhantomData,
11    net::IpAddr,
12    panic::{AssertUnwindSafe, catch_unwind},
13    sync::{
14        Arc, Mutex, PoisonError,
15        atomic::{AtomicBool, Ordering},
16    },
17    time::Duration,
18};
19
20use chrono::{DateTime, TimeDelta, Utc};
21use uuid::Uuid;
22use zeroize::Zeroizing;
23
24pub use outcome::{
25    AuthSession, Authenticated, IssuedToken, NewSession, Outcome, PurgeReport, Rejection, SessionInfo, StepUpOutcome,
26};
27
28use crate::{
29    checks::{Check, Checks, Decision},
30    client::{ClientContext, HeaderSource, resolve_client_ip},
31    clock::{Clock, SystemClock},
32    config::{GuardConfig, Mode, StepUpPolicy},
33    error::GuardError,
34    event::{ClientSummary, EventKind, SessionEvent, SessionOrigin, mask_ip},
35    fingerprint::{Baseline, Fingerprint, UaCache},
36    intel::{IpIntel, NoIpIntel},
37    store::{ClaimScope, Rotation, SessionRecord, SessionStore, SessionUpdate, Supersede},
38    subject::Subject,
39    token::{SessionToken, TokenHash},
40};
41
42use secrecy::SecretString;
43
44type Hook = Arc<dyn Fn(&SessionEvent) + Send + Sync>;
45
46/// Issues, checks, rotates and revokes sessions. Cheap to clone; clones share state.
47///
48/// `St` is the [`SessionStore`]; `S` is your subject type (default [`Uuid`]), stored opaquely.
49pub struct Guard<St, S = Uuid> {
50    inner: Arc<Inner<St>>,
51    _subject: PhantomData<fn() -> S>,
52}
53
54struct Inner<St> {
55    store: St,
56    config: GuardConfig,
57    intel: Box<dyn IpIntel>,
58    clock: Box<dyn Clock>,
59    hook: Option<Hook>,
60    ua_cache: UaCache,
61    xff_warned: AtomicBool,
62    shadow_seen: Mutex<ShadowSeen>,
63}
64
65/// Last time each unenforced event was recorded, for shadow-mode deduplication.
66struct ShadowSeen {
67    seen: HashMap<(Uuid, EventKind), DateTime<Utc>>,
68    pruned_at: DateTime<Utc>,
69}
70
71impl<St, S> Clone for Guard<St, S> {
72    fn clone(&self) -> Self {
73        Self {
74            inner: Arc::clone(&self.inner),
75            _subject: PhantomData,
76        }
77    }
78}
79
80impl<St, S> std::fmt::Debug for Guard<St, S> {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("Guard")
83            .field("config", &self.inner.config)
84            .finish_non_exhaustive()
85    }
86}
87
88/// Builds a [`Guard`]. Start with [`Guard::builder`].
89#[must_use]
90pub struct GuardBuilder<St, S = Uuid> {
91    store: St,
92    config: GuardConfig,
93    intel: Box<dyn IpIntel>,
94    clock: Box<dyn Clock>,
95    hook: Option<Hook>,
96    _subject: PhantomData<fn() -> S>,
97}
98
99impl<St: SessionStore> Guard<St> {
100    /// A guard with the default configuration and [`Uuid`] subjects.
101    pub fn new(store: St) -> Self {
102        Self::builder(store, GuardConfig::default()).build()
103    }
104
105    /// Starts building a guard. Subjects are [`Uuid`] unless changed with
106    /// [`GuardBuilder::subject`].
107    pub fn builder(store: St, config: GuardConfig) -> GuardBuilder<St> {
108        GuardBuilder {
109            store,
110            config,
111            intel: Box::new(NoIpIntel),
112            clock: Box::new(SystemClock),
113            hook: None,
114            _subject: PhantomData,
115        }
116    }
117}
118
119impl<St: SessionStore, S: Subject> GuardBuilder<St, S> {
120    /// Uses `T` as the subject type.
121    pub fn subject<T: Subject>(self) -> GuardBuilder<St, T> {
122        GuardBuilder {
123            store: self.store,
124            config: self.config,
125            intel: self.intel,
126            clock: self.clock,
127            hook: self.hook,
128            _subject: PhantomData,
129        }
130    }
131
132    /// Country / ASN source. Without one, IP changes are judged by network prefix only.
133    pub fn ip_intel(mut self, intel: impl IpIntel) -> Self {
134        self.intel = Box::new(intel);
135        self
136    }
137
138    /// Called synchronously once per state change, in the process that made the change, after
139    /// the event is stored. When concurrent requests race, only the one whose change took
140    /// effect reports it; the exception is `step_up_required`, which two requests that both
141    /// trip it may each report. Keep it fast; a panic in the hook is caught and logged.
142    ///
143    /// Delivery is at most once. There is no outbox, so a crash between the change and the
144    /// hook loses the event.
145    ///
146    /// With [`alert_dedup_window`](crate::GuardConfigBuilder::alert_dedup_window) set, enforced
147    /// security-relevant events reach the hook at most once per subject and kind per window;
148    /// the rest are still logged and stored. If the store cannot claim the alert, the hook is
149    /// called anyway.
150    pub fn on_event(mut self, hook: impl Fn(&SessionEvent) + Send + Sync + 'static) -> Self {
151        self.hook = Some(Arc::new(hook));
152        self
153    }
154
155    /// Time source (default [`SystemClock`]).
156    pub fn clock(mut self, clock: impl Clock) -> Self {
157        self.clock = Box::new(clock);
158        self
159    }
160
161    /// Builds the guard.
162    pub fn build(self) -> Guard<St, S> {
163        Guard {
164            inner: Arc::new(Inner {
165                store: self.store,
166                config: self.config,
167                intel: self.intel,
168                clock: self.clock,
169                hook: self.hook,
170                ua_cache: UaCache::new(UA_CACHE_CAPACITY),
171                xff_warned: AtomicBool::new(false),
172                shadow_seen: Mutex::new(ShadowSeen {
173                    seen: HashMap::new(),
174                    pruned_at: DateTime::<Utc>::MIN_UTC,
175                }),
176            }),
177            _subject: PhantomData,
178        }
179    }
180}
181
182const UA_CACHE_CAPACITY: usize = 1024;
183
184/// Most (session, kind) pairs the local shadow dedup cache holds, about 6 MB at most.
185const SHADOW_SEEN_CAPACITY: usize = 100_000;
186
187/// How a presented token relates to its session's current token.
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189enum TokenState {
190    /// The current token.
191    Current,
192    /// Superseded moments ago; a request that raced the rotation.
193    Grace,
194    /// Superseded, but its successor was never used: the rotation response was lost.
195    Orphaned,
196    /// Superseded and its successor is in use: two parties hold the session.
197    Reuse,
198}
199
200/// A token resolved to a live session with a decodable subject.
201struct Resolved<S> {
202    lookup: crate::store::TokenLookup,
203    hash: TokenHash,
204    subject: S,
205    state: TokenState,
206}
207
208impl<St: SessionStore, S: Subject> Guard<St, S> {
209    /// Creates a session for `subject` and returns its first token.
210    ///
211    /// Pass the token the client presented, if any: that session is revoked first, so a token
212    /// planted before login (session fixation) never becomes authenticated. Call this only
213    /// after verifying the subject's credentials, and rate-limit that endpoint yourself.
214    ///
215    /// # Errors
216    ///
217    /// Store failures, an unavailable RNG, or a subject that does not serialize.
218    pub async fn login(
219        &self,
220        subject: &S,
221        ctx: &ClientContext,
222        previous_token: Option<&str>,
223    ) -> Result<NewSession, GuardError> {
224        let inner = &*self.inner;
225        if let Some(hash) = previous_token.and_then(|t| self.presented_hash(t))
226            && let Some(old) = inner.store.find_by_token(&hash).await?
227            && inner.store.delete_session(old.session.id).await?
228        {
229            self.emit(old.session.id, &old.session.subject, EventKind::Logout, true, ctx, None)
230                .await;
231        }
232
233        let encoded = crate::subject::encode(subject)?;
234        let token = SessionToken::generate()?;
235        let now = self.now();
236        let info = inner.intel.lookup(ctx.ip);
237        let fingerprint = Fingerprint::capture_cached(ctx, &info, &inner.ua_cache);
238        let client = ClientSummary::observed(info, fingerprint.clone());
239        let keep = inner.config.keep_origin_client;
240        let origin = SessionOrigin {
241            at: now,
242            ip_prefix: mask_ip(ctx.ip),
243            ip: keep.then_some(ctx.ip),
244            user_agent: keep.then(|| self.user_agent(ctx)).flatten(),
245            client: if keep {
246                client.clone()
247            } else {
248                client.clone().minimized()
249            },
250        };
251        let record = SessionRecord {
252            id: Uuid::new_v4(),
253            subject: encoded,
254            created_at: now,
255            authenticated_at: now,
256            last_seen_at: now,
257            expires_at: now + inner.config.session_lifetime,
258            step_up_required: false,
259            baseline: Baseline::new(fingerprint),
260            origin: Some(origin),
261        };
262        inner.store.insert_session(&record, &token.hash()).await?;
263        self.emit_with(record.id, &record.subject, EventKind::Login, true, ctx, None, client)
264            .await;
265        Ok(NewSession {
266            session_id: record.id,
267            token: self.issue(&token, record.expires_at),
268        })
269    }
270
271    /// Checks a request's token with the configured checks.
272    ///
273    /// On success the request may proceed; send [`Authenticated::rotated`] back as
274    /// `Set-Cookie` when present. Every [`Rejection`] maps to 401.
275    ///
276    /// # Errors
277    ///
278    /// Store failures only. Bad tokens and risky clients are [`Outcome::Rejected`].
279    pub async fn authenticate(&self, token: &str, ctx: &ClientContext) -> Result<Outcome<S>, GuardError> {
280        self.authenticate_with(token, ctx, &self.inner.config.checks).await
281    }
282
283    /// Like [`authenticate`](Self::authenticate) with other checks, for routes that need
284    /// stricter (or looser) treatment. The baseline learns only the signals `checks` scored.
285    ///
286    /// # Errors
287    ///
288    /// Store failures only.
289    pub async fn authenticate_with(
290        &self,
291        token: &str,
292        ctx: &ClientContext,
293        checks: &Checks,
294    ) -> Result<Outcome<S>, GuardError> {
295        self.authenticate_inner(token, ctx, checks).await
296    }
297
298    /// The rotation due for a token in `state`, with its replacement.
299    fn plan_rotation(
300        &self,
301        state: TokenState,
302        issued_at: DateTime<Utc>,
303        hash: TokenHash,
304        now: DateTime<Utc>,
305    ) -> Result<Option<(Rotation, SessionToken)>, GuardError> {
306        let config = &self.inner.config;
307        let supersede = match state {
308            TokenState::Current if issued_at <= now - config.rotation_interval => Supersede::Exact(hash),
309            TokenState::Orphaned => Supersede::CurrentIssuedBefore(now - config.rotation_grace),
310            _ => return Ok(None),
311        };
312        let token = SessionToken::generate()?;
313        let rotation = Rotation {
314            new_token: token.hash(),
315            supersede,
316            prune_superseded_before: now - config.reuse_detection,
317        };
318        Ok(Some((rotation, token)))
319    }
320
321    /// Scores the request: the fingerprint against the baseline, plus the token's own state.
322    fn assess(
323        &self,
324        baseline: &Baseline,
325        fingerprint: &Fingerprint,
326        state: TokenState,
327        now: DateTime<Utc>,
328        checks: &Checks,
329    ) -> crate::risk::Assessment {
330        let window = self.inner.config.ping_pong_window;
331        let mut assessment = crate::risk::assess(baseline, fingerprint, now, window, checks);
332        if state == TokenState::Orphaned {
333            assessment.add(Check::OrphanedToken, checks);
334        }
335        assessment
336    }
337
338    async fn authenticate_inner(
339        &self,
340        token: &str,
341        ctx: &ClientContext,
342        checks: &Checks,
343    ) -> Result<Outcome<S>, GuardError> {
344        let inner = &*self.inner;
345        let config = &inner.config;
346        let now = self.now();
347        let resolved = match self.resolve(token, now).await? {
348            Ok(resolved) => resolved,
349            Err(rejection) => return Ok(Outcome::Rejected(rejection)),
350        };
351        let Resolved {
352            lookup,
353            hash,
354            subject,
355            state,
356        } = resolved;
357        let session = &lookup.session;
358
359        if state == TokenState::Reuse {
360            self.revoke_for_reuse(session, ctx).await?;
361            return Ok(Outcome::Rejected(Rejection::Revoked));
362        }
363
364        let fingerprint = self.fingerprint(ctx);
365        let assessment = self.assess(&session.baseline, &fingerprint, state, now, checks);
366        let decision = config.thresholds.decide(assessment.score);
367        crate::telemetry::assessed(decision, &assessment.signals);
368        let shadow = config.mode == Mode::Shadow;
369        let step_up = decision == Decision::StepUp;
370
371        if decision == Decision::Revoke || (step_up && config.step_up_policy == StepUpPolicy::Revoke) {
372            self.revoke_for_risk(session, ctx, &assessment, !shadow).await?;
373            if !shadow {
374                return Ok(Outcome::Rejected(Rejection::Revoked));
375            }
376        } else if session.step_up_required && !shadow {
377            return Ok(Outcome::Rejected(Rejection::StepUpRequired));
378        } else if step_up {
379            let enforced = config.step_up_policy == StepUpPolicy::Require && !shadow;
380            if enforced {
381                let mut update = SessionUpdate::touch(session.id, now);
382                update.step_up_required = Some(true);
383                update.mark_used =
384                    (state == TokenState::Current && lookup.token.first_used_at.is_none()).then_some(hash);
385                inner.store.apply(&update).await?;
386            }
387            // `apply` does not say whether the flag was already set, so racing requests each
388            // report it.
389            self.emit(
390                session.id,
391                &session.subject,
392                EventKind::StepUpRequired,
393                enforced,
394                ctx,
395                Some(&assessment),
396            )
397            .await;
398            if enforced {
399                return Ok(Outcome::Rejected(Rejection::StepUpRequired));
400            }
401        }
402
403        let mut update = SessionUpdate::touch(session.id, now);
404        if decision == Decision::Allow {
405            let mut baseline = session.baseline.clone();
406            if baseline.absorb(&fingerprint, now, checks) {
407                update.baseline = Some(baseline);
408            }
409        }
410
411        let new_token = match self.plan_rotation(state, lookup.token.issued_at, hash, now)? {
412            Some((rotation, token)) => {
413                update.rotation = Some(rotation);
414                Some(token)
415            }
416            None => None,
417        };
418        // A reissued token counts as used from the start, so the orphan it replaces turns into
419        // reuse: one reissue per orphan, or a thief could keep a stolen token alive by
420        // dropping every successor.
421        update.mark_used = match (state, &update.rotation) {
422            (TokenState::Current, _) if lookup.token.first_used_at.is_none() => Some(hash),
423            (TokenState::Orphaned, Some(rotation)) => Some(rotation.new_token),
424            _ => None,
425        };
426
427        let stale = now - session.last_seen_at >= config.last_seen_write_interval;
428        let mut rotated = None;
429        if update.rotation.is_some() || update.mark_used.is_some() || update.baseline.is_some() || stale {
430            let did_rotate = inner.store.apply(&update).await?;
431            if did_rotate && let Some(token) = &new_token {
432                rotated = Some(self.issue(token, session.expires_at));
433                if state == TokenState::Orphaned {
434                    self.emit(session.id, &session.subject, EventKind::TokenReissued, true, ctx, None)
435                        .await;
436                }
437            }
438        }
439
440        Ok(Outcome::Authenticated(Authenticated {
441            session: AuthSession::new(session, subject),
442            rotated,
443            assessment,
444        }))
445    }
446
447    /// Looks a token up without changing anything: no rotation, no `last_seen` write, no
448    /// checks. `None` unless the token is current (or within its grace period) and the session
449    /// is live.
450    ///
451    /// # Errors
452    ///
453    /// Store failures only.
454    pub async fn peek(&self, token: &str) -> Result<Option<SessionInfo<S>>, GuardError> {
455        Ok(match self.resolve(token, self.now()).await? {
456            Ok(resolved) if matches!(resolved.state, TokenState::Current | TokenState::Grace) => {
457                SessionInfo::from_record(&resolved.lookup.session)
458            }
459            _ => None,
460        })
461    }
462
463    /// Clears a pending step-up after the app has re-verified the subject (password, passkey,
464    /// OTP…). The current client becomes the new baseline, `authenticated_at` resets and a new
465    /// token is issued.
466    ///
467    /// `verified_subject` must be the subject the app just verified; a mismatch changes
468    /// nothing. Rate-limit the verification yourself.
469    ///
470    /// # Errors
471    ///
472    /// Store failures or an unavailable RNG.
473    pub async fn complete_step_up(
474        &self,
475        token: &str,
476        ctx: &ClientContext,
477        verified_subject: &S,
478    ) -> Result<StepUpOutcome, GuardError> {
479        let inner = &*self.inner;
480        let now = self.now();
481        let resolved = match self.resolve(token, now).await? {
482            Ok(resolved) => resolved,
483            Err(rejection) => return Ok(StepUpOutcome::Rejected(rejection)),
484        };
485        let session = &resolved.lookup.session;
486        if resolved.state == TokenState::Reuse {
487            self.revoke_for_reuse(session, ctx).await?;
488            return Ok(StepUpOutcome::Rejected(Rejection::Revoked));
489        }
490        if resolved.subject != *verified_subject {
491            return Ok(StepUpOutcome::SubjectMismatch);
492        }
493
494        let token = SessionToken::generate()?;
495        let mut update = SessionUpdate::touch(session.id, now);
496        update.baseline = Some(Baseline::new(self.fingerprint(ctx)));
497        update.step_up_required = Some(false);
498        update.authenticated_at = Some(now);
499        // With the current token, only one of several concurrent submits replaces it.
500        let supersede = match resolved.state {
501            TokenState::Current => Supersede::Exact(resolved.hash),
502            _ => Supersede::Current,
503        };
504        let rotation = Rotation {
505            new_token: token.hash(),
506            supersede,
507            prune_superseded_before: now - inner.config.reuse_detection,
508        };
509        update.rotation = Some(rotation);
510        if inner.store.apply(&update).await? {
511            self.emit(
512                session.id,
513                &session.subject,
514                EventKind::StepUpCompleted,
515                true,
516                ctx,
517                None,
518            )
519            .await;
520        } else {
521            // A concurrent submit won and reports it; this one still gets a token. Or the
522            // session vanished between lookup and update.
523            update.rotation = Some(Rotation {
524                supersede: Supersede::Current,
525                ..rotation
526            });
527            if !inner.store.apply(&update).await? {
528                return Ok(StepUpOutcome::Rejected(Rejection::Revoked));
529            }
530        }
531        Ok(StepUpOutcome::Completed(self.issue(&token, session.expires_at)))
532    }
533
534    /// Ends the session the token belongs to. Returns whether there was one.
535    ///
536    /// # Errors
537    ///
538    /// Store failures only.
539    pub async fn logout(&self, token: &str, ctx: &ClientContext) -> Result<bool, GuardError> {
540        let inner = &*self.inner;
541        let Some(hash) = self.presented_hash(token) else {
542            return Ok(false);
543        };
544        let Some(lookup) = inner.store.find_by_token(&hash).await? else {
545            return Ok(false);
546        };
547        let deleted = inner.store.delete_session(lookup.session.id).await?;
548        if deleted {
549            self.emit(
550                lookup.session.id,
551                &lookup.session.subject,
552                EventKind::Logout,
553                true,
554                ctx,
555                None,
556            )
557            .await;
558        }
559        Ok(deleted)
560    }
561
562    /// Deletes one session of `subject` by id, e.g. from a "your devices" page. Returns whether
563    /// it existed; a session of another subject is left alone. `ctx` is whoever asked; it goes
564    /// into the [`EventKind::Revoked`] event.
565    ///
566    /// # Errors
567    ///
568    /// Store failures or a subject that does not serialize.
569    pub async fn revoke_session(&self, subject: &S, session_id: Uuid, ctx: &ClientContext) -> Result<bool, GuardError> {
570        let encoded = crate::subject::encode(subject)?;
571        let sessions = self.inner.store.list_subject_sessions(&encoded).await?;
572        if !sessions.iter().any(|s| s.id == session_id) {
573            return Ok(false);
574        }
575        let deleted = self.inner.store.delete_session(session_id).await?;
576        if deleted {
577            self.emit(session_id, &encoded, EventKind::Revoked, true, ctx, None)
578                .await;
579        }
580        Ok(deleted)
581    }
582
583    /// Deletes every session of `subject`, e.g. after a password change. Returns how many.
584    /// `ctx` is whoever asked; each session gets an [`EventKind::Revoked`] event.
585    ///
586    /// # Errors
587    ///
588    /// Store failures or a subject that does not serialize.
589    pub async fn revoke_all_for_subject(&self, subject: &S, ctx: &ClientContext) -> Result<u64, GuardError> {
590        let encoded = crate::subject::encode(subject)?;
591        let deleted = self.inner.store.delete_subject_sessions(&encoded).await?;
592        for &session_id in &deleted {
593            self.emit(session_id, &encoded, EventKind::Revoked, true, ctx, None)
594                .await;
595        }
596        Ok(deleted.len() as u64)
597    }
598
599    /// The live sessions of `subject`.
600    ///
601    /// # Errors
602    ///
603    /// Store failures or a subject that does not serialize.
604    pub async fn list_sessions(&self, subject: &S) -> Result<Vec<SessionInfo<S>>, GuardError> {
605        let encoded = crate::subject::encode(subject)?;
606        let now = self.now();
607        let records = self.inner.store.list_subject_sessions(&encoded).await?;
608        Ok(records
609            .iter()
610            .filter(|r| self.is_live(r, now))
611            .filter_map(SessionInfo::from_record)
612            .collect())
613    }
614
615    /// Deletes expired sessions and events older than the retention period. Run it
616    /// periodically (hourly is plenty).
617    ///
618    /// # Errors
619    ///
620    /// Store failures only.
621    pub async fn purge_expired(&self) -> Result<PurgeReport, GuardError> {
622        let config = &self.inner.config;
623        let now = self.now();
624        let sessions = self.inner.store.delete_expired(now, now - config.idle_timeout).await?;
625        let events = self
626            .inner
627            .store
628            .delete_events_before(now - config.event_retention)
629            .await?;
630        Ok(PurgeReport { sessions, events })
631    }
632
633    /// Runs [`purge_expired`](Self::purge_expired) now and then every `every` on the tokio
634    /// runtime, logging each report (`info`) or failure (`warn`) with `tracing`. A failed run
635    /// does not stop the task; abort the handle to stop it.
636    ///
637    /// # Panics
638    ///
639    /// Outside a tokio runtime, or if `every` is zero.
640    #[cfg(feature = "tokio")]
641    #[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
642    pub fn spawn_purge(&self, every: Duration) -> tokio::task::JoinHandle<()> {
643        let guard = self.clone();
644        tokio::spawn(async move {
645            let mut ticker = tokio::time::interval(every);
646            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
647            loop {
648                ticker.tick().await;
649                match guard.purge_expired().await {
650                    Ok(report) => tracing::info!(
651                        sessions = report.sessions,
652                        events = report.events,
653                        "purged expired sessions"
654                    ),
655                    Err(error) => tracing::warn!(%error, "purging expired sessions failed"),
656                }
657            }
658        })
659    }
660
661    /// Builds the [`ClientContext`] for a request from the TCP peer address and the request
662    /// headers.
663    ///
664    /// `X-Forwarded-For` is honoured only when `peer` is a configured trusted proxy, walking
665    /// right to left past trusted hops. Header values are capped at the configured length.
666    pub fn client_context<H: HeaderSource + ?Sized>(&self, peer: IpAddr, headers: &H) -> ClientContext {
667        let config = &self.inner.config;
668        // A non-UTF-8 value stays in the chain as an empty, unparseable hop, so the peer is used
669        // instead of whatever valid hops surround it.
670        let forwarded: Vec<&str> = headers
671            .header_values("x-forwarded-for")
672            .map(|v| std::str::from_utf8(v).unwrap_or(""))
673            .collect();
674        if config.trusted_proxies.is_empty()
675            && !forwarded.is_empty()
676            && !self.inner.xff_warned.swap(true, Ordering::Relaxed)
677        {
678            tracing::warn!(
679                "X-Forwarded-For received but no trusted proxies are configured; using the peer address. \
680                 Configure trusted_proxies if the app runs behind a reverse proxy."
681            );
682        }
683        let ip = resolve_client_ip(peer, forwarded, &config.trusted_proxies);
684        ClientContext::from_headers(
685            ip,
686            headers,
687            config.tls_fingerprint_header.as_deref(),
688            config.max_header_len,
689        )
690    }
691
692    /// The session cookie's value from the request's `Cookie` headers, if present.
693    pub fn cookie_token<'a, H: HeaderSource + ?Sized>(&self, headers: &'a H) -> Option<&'a str> {
694        headers
695            .header_values("cookie")
696            .filter_map(|v| std::str::from_utf8(v).ok())
697            .find_map(|v| self.inner.config.cookie.find_in(v))
698    }
699
700    /// A `Set-Cookie` value that deletes the session cookie.
701    pub fn clear_cookie(&self) -> String {
702        self.inner.config.cookie.clear_cookie()
703    }
704
705    #[cfg(feature = "aide")]
706    pub(crate) fn cookie_name(&self) -> &str {
707        self.inner.config.cookie.cookie_name()
708    }
709
710    /// Whether the subject last proved who they are more than `max_age` ago. Use it to demand
711    /// a fresh login before sensitive actions.
712    ///
713    /// With a zero `max_age` it is true as soon as any time has passed since the login or
714    /// step-up, so it is true for practically every request.
715    pub fn requires_reauth(&self, session: &AuthSession<S>, max_age: Duration) -> bool {
716        self.now() - session.authenticated_at > td(max_age)
717    }
718
719    /// Whether `value` has the shape of this guard's tokens (configured prefix, then 43
720    /// base64url characters). Does not touch the store.
721    pub fn is_session_token(&self, value: &str) -> bool {
722        self.presented_hash(value).is_some()
723    }
724
725    fn now(&self) -> DateTime<Utc> {
726        self.inner.clock.now()
727    }
728
729    fn fingerprint(&self, ctx: &ClientContext) -> Fingerprint {
730        Fingerprint::capture_cached(ctx, &self.inner.intel.lookup(ctx.ip), &self.inner.ua_cache)
731    }
732
733    /// The request's User-Agent, truncated to the header cap.
734    fn user_agent(&self, ctx: &ClientContext) -> Option<String> {
735        ctx.user_agent
736            .as_deref()
737            .map(|ua| crate::client::truncate(ua, self.inner.config.max_header_len).to_owned())
738    }
739
740    fn client_summary(&self, ctx: &ClientContext) -> ClientSummary {
741        let info = self.inner.intel.lookup(ctx.ip);
742        let fingerprint = Fingerprint::capture_cached(ctx, &info, &self.inner.ua_cache);
743        ClientSummary::observed(info, fingerprint)
744    }
745
746    fn presented_hash(&self, presented: &str) -> Option<TokenHash> {
747        let bare = match &self.inner.config.token_prefix {
748            Some(prefix) => presented.strip_prefix(prefix.as_str())?,
749            None => presented,
750        };
751        TokenHash::from_presented(bare)
752    }
753
754    fn issue(&self, token: &SessionToken, expires_at: DateTime<Utc>) -> IssuedToken {
755        // Exact capacity, so neither building the value nor boxing it into the secret leaves an
756        // unwiped copy behind in a reallocated buffer.
757        let prefix = self.inner.config.token_prefix.as_deref().unwrap_or("");
758        let mut value = String::with_capacity(prefix.len() + token.expose().len());
759        value.push_str(prefix);
760        value.push_str(token.expose());
761        let max_age = (expires_at - self.now()).num_seconds();
762        let set_cookie = Zeroizing::new(self.inner.config.cookie.set_cookie(&value, max_age));
763        IssuedToken {
764            value: SecretString::from(value),
765            expires_at,
766            set_cookie,
767        }
768    }
769
770    fn is_live(&self, record: &SessionRecord, now: DateTime<Utc>) -> bool {
771        now < record.expires_at && now - record.last_seen_at < self.inner.config.idle_timeout
772    }
773
774    fn classify(&self, lookup: &crate::store::TokenLookup, now: DateTime<Utc>) -> TokenState {
775        match lookup.token.superseded_at {
776            None => TokenState::Current,
777            Some(at) if now - at <= self.inner.config.rotation_grace => TokenState::Grace,
778            Some(_) if !lookup.newer_token_used => TokenState::Orphaned,
779            Some(_) => TokenState::Reuse,
780        }
781    }
782
783    /// Whether an orphaned token was superseded longer ago than the reissue window allows.
784    fn orphan_too_old(&self, lookup: &crate::store::TokenLookup, now: DateTime<Utc>) -> bool {
785        match (self.inner.config.orphan_reissue_window, lookup.token.superseded_at) {
786            (Some(window), Some(at)) => now - at > window,
787            _ => false,
788        }
789    }
790
791    /// Finds a token's live session. Expired sessions are deleted on sight.
792    async fn resolve(&self, token: &str, now: DateTime<Utc>) -> Result<Result<Resolved<S>, Rejection>, GuardError> {
793        let store = &self.inner.store;
794        let Some(hash) = self.presented_hash(token) else {
795            return Ok(Err(Rejection::NotAuthenticated));
796        };
797        let Some(lookup) = store.find_by_token(&hash).await? else {
798            return Ok(Err(Rejection::NotAuthenticated));
799        };
800        if !self.is_live(&lookup.session, now) {
801            store.delete_session(lookup.session.id).await?;
802            return Ok(Err(Rejection::Expired));
803        }
804        let Some(subject) = crate::subject::decode::<S>(&lookup.session.subject) else {
805            tracing::warn!(session_id = %lookup.session.id, "stored subject does not decode as the guard's subject type");
806            return Ok(Err(Rejection::NotAuthenticated));
807        };
808        let state = self.classify(&lookup, now);
809        if state == TokenState::Orphaned && self.orphan_too_old(&lookup, now) {
810            // Treated like a pruned token: the session stays with whoever holds the successor.
811            return Ok(Err(Rejection::NotAuthenticated));
812        }
813        Ok(Ok(Resolved {
814            lookup,
815            hash,
816            subject,
817            state,
818        }))
819    }
820
821    /// Deletes the session when `enforced`. Only the request that deleted it reports the
822    /// revocation; an unenforced one is reported once per dedup window.
823    async fn revoke_for_risk(
824        &self,
825        session: &SessionRecord,
826        ctx: &ClientContext,
827        assessment: &crate::risk::Assessment,
828        enforced: bool,
829    ) -> Result<(), GuardError> {
830        if !enforced || self.inner.store.delete_session(session.id).await? {
831            self.emit(
832                session.id,
833                &session.subject,
834                EventKind::RiskRevoked,
835                enforced,
836                ctx,
837                Some(assessment),
838            )
839            .await;
840        }
841        Ok(())
842    }
843
844    /// Deletes the session. Only the request that deleted it reports the reuse.
845    async fn revoke_for_reuse(&self, session: &SessionRecord, ctx: &ClientContext) -> Result<(), GuardError> {
846        if self.inner.store.delete_session(session.id).await? {
847            self.emit(session.id, &session.subject, EventKind::TokenReuse, true, ctx, None)
848                .await;
849        }
850        Ok(())
851    }
852
853    /// Records an event, calls the hook and updates metrics. Never fails: a store error is
854    /// logged, a hook panic is caught. Unenforced events are recorded at most once per session
855    /// and kind per dedup window. With an alert dedup window, enforced security-relevant events
856    /// reach the hook at most once per subject and kind per window.
857    async fn emit(
858        &self,
859        session_id: Uuid,
860        subject: &str,
861        kind: EventKind,
862        enforced: bool,
863        ctx: &ClientContext,
864        assessment: Option<&crate::risk::Assessment>,
865    ) {
866        let client = self.client_summary(ctx);
867        self.emit_with(session_id, subject, kind, enforced, ctx, assessment, client)
868            .await;
869    }
870
871    /// [`emit`](Self::emit) with the client summary already built.
872    #[allow(clippy::too_many_arguments)]
873    async fn emit_with(
874        &self,
875        session_id: Uuid,
876        subject: &str,
877        kind: EventKind,
878        enforced: bool,
879        ctx: &ClientContext,
880        assessment: Option<&crate::risk::Assessment>,
881        client: ClientSummary,
882    ) {
883        let inner = &*self.inner;
884        let config = &inner.config;
885        let now = self.now();
886        if !enforced {
887            if self.seen_recently(session_id, kind, now) {
888                return;
889            }
890            match inner
891                .store
892                .claim_event(ClaimScope::Session(session_id), kind, now, config.shadow_dedup_window)
893                .await
894            {
895                Ok(true) => {}
896                Ok(false) => return,
897                // Better a duplicate than a lost event.
898                Err(error) => {
899                    tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to claim session event");
900                }
901            }
902        }
903        let alert = if inner.hook.is_some()
904            && enforced
905            && kind.is_security_relevant()
906            && !config.alert_dedup_window.is_zero()
907        {
908            match inner
909                .store
910                .claim_event(ClaimScope::Subject(subject), kind, now, config.alert_dedup_window)
911                .await
912            {
913                Ok(claimed) => claimed,
914                // Better a duplicate than a lost alert.
915                Err(error) => {
916                    tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to claim subject alert");
917                    true
918                }
919            }
920        } else {
921            true
922        };
923
924        let event = SessionEvent {
925            session_id,
926            subject: subject.to_owned(),
927            kind,
928            enforced,
929            score: assessment.map(|a| a.score),
930            signals: assessment.map(|a| a.signals.clone()).unwrap_or_default(),
931            ip: ctx.ip,
932            user_agent: self.user_agent(ctx),
933            client,
934            at: now,
935        };
936        if kind.is_security_relevant() {
937            tracing::warn!(%session_id, kind = kind.as_str(), enforced, score = ?event.score, "session event");
938        } else {
939            tracing::debug!(%session_id, kind = kind.as_str(), "session event");
940        }
941        let stored = if config.minimize_stored_events {
942            Cow::Owned(SessionEvent {
943                ip: mask_ip(event.ip),
944                user_agent: None,
945                client: event.client.clone().minimized(),
946                ..event.clone()
947            })
948        } else {
949            Cow::Borrowed(&event)
950        };
951        if let Err(error) = inner.store.record_event(&stored).await {
952            tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to record session event");
953        }
954        if alert
955            && let Some(hook) = &inner.hook
956            && catch_unwind(AssertUnwindSafe(|| hook(&event))).is_err()
957        {
958            tracing::error!(%session_id, kind = kind.as_str(), "on_event hook panicked");
959        }
960        crate::telemetry::event(kind, enforced);
961    }
962
963    /// Whether this process already handled an unenforced `kind` event for the session within
964    /// the dedup window; notes this one if not. A cheap first filter in front of the store's
965    /// cross-process [`SessionStore::claim_event`].
966    fn seen_recently(&self, session_id: Uuid, kind: EventKind, now: DateTime<Utc>) -> bool {
967        let window = self.inner.config.shadow_dedup_window;
968        let mut state = self.inner.shadow_seen.lock().unwrap_or_else(PoisonError::into_inner);
969        if now - state.pruned_at >= window || state.seen.len() >= SHADOW_SEEN_CAPACITY {
970            state.seen.retain(|_, at| now - *at < window);
971            state.pruned_at = now;
972            if state.seen.len() >= SHADOW_SEEN_CAPACITY {
973                // A flood of distinct sessions: forget everything and let the store dedupe.
974                state.seen.clear();
975            }
976        }
977        match state.seen.get(&(session_id, kind)) {
978            Some(at) if now - *at < window => true,
979            _ => {
980                state.seen.insert((session_id, kind), now);
981                false
982            }
983        }
984    }
985}
986
987fn td(duration: Duration) -> TimeDelta {
988    TimeDelta::from_std(duration).unwrap_or(TimeDelta::MAX)
989}