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 session_id = Uuid::new_v4();
235        let token = self.new_token(session_id)?;
236        let now = self.now();
237        let info = inner.intel.lookup(ctx.ip);
238        let fingerprint = Fingerprint::capture_cached(ctx, &info, &inner.ua_cache);
239        let client = ClientSummary::observed(info, fingerprint.clone());
240        let keep = inner.config.keep_origin_client;
241        let origin = SessionOrigin {
242            at: now,
243            ip_prefix: mask_ip(ctx.ip),
244            ip: keep.then_some(ctx.ip),
245            user_agent: keep.then(|| self.user_agent(ctx)).flatten(),
246            client: if keep {
247                client.clone()
248            } else {
249                client.clone().minimized()
250            },
251        };
252        let record = SessionRecord {
253            id: session_id,
254            subject: encoded,
255            created_at: now,
256            authenticated_at: now,
257            last_seen_at: now,
258            expires_at: now + inner.config.session_lifetime,
259            step_up_required: false,
260            baseline: Baseline::new(fingerprint),
261            origin: Some(origin),
262        };
263        inner.store.insert_session(&record, &token.hash()).await?;
264        self.emit_with(record.id, &record.subject, EventKind::Login, true, ctx, None, client)
265            .await;
266        Ok(NewSession {
267            session_id: record.id,
268            token: self.issue(&token, record.expires_at),
269        })
270    }
271
272    /// Checks a request's token with the configured checks.
273    ///
274    /// On success the request may proceed; send [`Authenticated::rotated`] back as
275    /// `Set-Cookie` when present. Every [`Rejection`] maps to 401.
276    ///
277    /// # Errors
278    ///
279    /// Store failures only. Bad tokens and risky clients are [`Outcome::Rejected`].
280    pub async fn authenticate(&self, token: &str, ctx: &ClientContext) -> Result<Outcome<S>, GuardError> {
281        self.authenticate_with(token, ctx, &self.inner.config.checks).await
282    }
283
284    /// Like [`authenticate`](Self::authenticate) with other checks, for routes that need
285    /// stricter (or looser) treatment. The baseline learns only the signals `checks` scored.
286    ///
287    /// # Errors
288    ///
289    /// Store failures only.
290    pub async fn authenticate_with(
291        &self,
292        token: &str,
293        ctx: &ClientContext,
294        checks: &Checks,
295    ) -> Result<Outcome<S>, GuardError> {
296        self.authenticate_inner(token, ctx, checks).await
297    }
298
299    /// A token for `session_id`; on a [sharded](SessionStore::sharded) store, one that lands in
300    /// the session's bucket.
301    fn new_token(&self, session_id: Uuid) -> Result<SessionToken, GuardError> {
302        if self.inner.store.sharded() {
303            SessionToken::generate_in_bucket(crate::store::session_bucket(session_id))
304        } else {
305            SessionToken::generate()
306        }
307    }
308
309    /// The rotation due for a token in `state`, with its replacement.
310    fn plan_rotation(
311        &self,
312        session_id: Uuid,
313        state: TokenState,
314        issued_at: DateTime<Utc>,
315        hash: TokenHash,
316        now: DateTime<Utc>,
317    ) -> Result<Option<(Rotation, SessionToken)>, GuardError> {
318        let config = &self.inner.config;
319        let supersede = match state {
320            TokenState::Current if issued_at <= now - config.rotation_interval => Supersede::Exact(hash),
321            TokenState::Orphaned => Supersede::CurrentIssuedBefore(now - config.rotation_grace),
322            _ => return Ok(None),
323        };
324        let token = self.new_token(session_id)?;
325        let rotation = Rotation {
326            new_token: token.hash(),
327            supersede,
328            prune_superseded_before: now - config.reuse_detection,
329        };
330        Ok(Some((rotation, token)))
331    }
332
333    /// Scores the request: the fingerprint against the baseline, plus the token's own state.
334    fn assess(
335        &self,
336        baseline: &Baseline,
337        fingerprint: &Fingerprint,
338        state: TokenState,
339        now: DateTime<Utc>,
340        checks: &Checks,
341    ) -> crate::risk::Assessment {
342        let window = self.inner.config.ping_pong_window;
343        let mut assessment = crate::risk::assess(baseline, fingerprint, now, window, checks);
344        if state == TokenState::Orphaned {
345            assessment.add(Check::OrphanedToken, checks);
346        }
347        assessment
348    }
349
350    async fn authenticate_inner(
351        &self,
352        token: &str,
353        ctx: &ClientContext,
354        checks: &Checks,
355    ) -> Result<Outcome<S>, GuardError> {
356        let inner = &*self.inner;
357        let config = &inner.config;
358        let now = self.now();
359        let resolved = match self.resolve(token, now).await? {
360            Ok(resolved) => resolved,
361            Err(rejection) => return Ok(Outcome::Rejected(rejection)),
362        };
363        let Resolved {
364            lookup,
365            hash,
366            subject,
367            state,
368        } = resolved;
369        let session = &lookup.session;
370
371        if state == TokenState::Reuse {
372            self.revoke_for_reuse(session, ctx).await?;
373            return Ok(Outcome::Rejected(Rejection::Revoked));
374        }
375
376        let fingerprint = self.fingerprint(ctx);
377        let assessment = self.assess(&session.baseline, &fingerprint, state, now, checks);
378        let decision = config.thresholds.decide(assessment.score);
379        crate::telemetry::assessed(decision, &assessment.signals);
380        let shadow = config.mode == Mode::Shadow;
381        let step_up = decision == Decision::StepUp;
382
383        if decision == Decision::Revoke || (step_up && config.step_up_policy == StepUpPolicy::Revoke) {
384            self.revoke_for_risk(session, ctx, &assessment, !shadow).await?;
385            if !shadow {
386                return Ok(Outcome::Rejected(Rejection::Revoked));
387            }
388        } else if session.step_up_required && !shadow {
389            return Ok(Outcome::Rejected(Rejection::StepUpRequired));
390        } else if step_up {
391            let enforced = config.step_up_policy == StepUpPolicy::Require && !shadow;
392            if enforced {
393                let mut update = SessionUpdate::touch(session.id, now);
394                update.step_up_required = Some(true);
395                update.mark_used =
396                    (state == TokenState::Current && lookup.token.first_used_at.is_none()).then_some(hash);
397                inner.store.apply(&update).await?;
398            }
399            // `apply` does not say whether the flag was already set, so racing requests each
400            // report it.
401            self.emit(
402                session.id,
403                &session.subject,
404                EventKind::StepUpRequired,
405                enforced,
406                ctx,
407                Some(&assessment),
408            )
409            .await;
410            if enforced {
411                return Ok(Outcome::Rejected(Rejection::StepUpRequired));
412            }
413        }
414
415        let mut update = SessionUpdate::touch(session.id, now);
416        if decision == Decision::Allow {
417            let mut baseline = session.baseline.clone();
418            if baseline.absorb(&fingerprint, now, checks) {
419                update.baseline = Some(baseline);
420            }
421        }
422
423        let new_token = match self.plan_rotation(lookup.session.id, state, lookup.token.issued_at, hash, now)? {
424            Some((rotation, token)) => {
425                update.rotation = Some(rotation);
426                Some(token)
427            }
428            None => None,
429        };
430        // A reissued token counts as used from the start, so the orphan it replaces turns into
431        // reuse: one reissue per orphan, or a thief could keep a stolen token alive by
432        // dropping every successor.
433        update.mark_used = match (state, &update.rotation) {
434            (TokenState::Current, _) if lookup.token.first_used_at.is_none() => Some(hash),
435            (TokenState::Orphaned, Some(rotation)) => Some(rotation.new_token),
436            _ => None,
437        };
438
439        let stale = now - session.last_seen_at >= config.last_seen_write_interval;
440        let mut rotated = None;
441        if update.rotation.is_some() || update.mark_used.is_some() || update.baseline.is_some() || stale {
442            let did_rotate = inner.store.apply(&update).await?;
443            if did_rotate && let Some(token) = &new_token {
444                rotated = Some(self.issue(token, session.expires_at));
445                if state == TokenState::Orphaned {
446                    self.emit(session.id, &session.subject, EventKind::TokenReissued, true, ctx, None)
447                        .await;
448                }
449            }
450        }
451
452        Ok(Outcome::Authenticated(Authenticated {
453            session: AuthSession::new(session, subject),
454            rotated,
455            assessment,
456        }))
457    }
458
459    /// Looks a token up without changing anything: no rotation, no `last_seen` write, no
460    /// checks. `None` unless the token is current (or within its grace period) and the session
461    /// is live.
462    ///
463    /// # Errors
464    ///
465    /// Store failures only.
466    pub async fn peek(&self, token: &str) -> Result<Option<SessionInfo<S>>, GuardError> {
467        Ok(match self.resolve(token, self.now()).await? {
468            Ok(resolved) if matches!(resolved.state, TokenState::Current | TokenState::Grace) => {
469                SessionInfo::from_record(&resolved.lookup.session)
470            }
471            _ => None,
472        })
473    }
474
475    /// Clears a pending step-up after the app has re-verified the subject (password, passkey,
476    /// OTP…). The current client becomes the new baseline, `authenticated_at` resets and a new
477    /// token is issued.
478    ///
479    /// `verified_subject` must be the subject the app just verified; a mismatch changes
480    /// nothing. Rate-limit the verification yourself.
481    ///
482    /// # Errors
483    ///
484    /// Store failures or an unavailable RNG.
485    pub async fn complete_step_up(
486        &self,
487        token: &str,
488        ctx: &ClientContext,
489        verified_subject: &S,
490    ) -> Result<StepUpOutcome, GuardError> {
491        let inner = &*self.inner;
492        let now = self.now();
493        let resolved = match self.resolve(token, now).await? {
494            Ok(resolved) => resolved,
495            Err(rejection) => return Ok(StepUpOutcome::Rejected(rejection)),
496        };
497        let session = &resolved.lookup.session;
498        if resolved.state == TokenState::Reuse {
499            self.revoke_for_reuse(session, ctx).await?;
500            return Ok(StepUpOutcome::Rejected(Rejection::Revoked));
501        }
502        if resolved.subject != *verified_subject {
503            return Ok(StepUpOutcome::SubjectMismatch);
504        }
505
506        let token = self.new_token(session.id)?;
507        let mut update = SessionUpdate::touch(session.id, now);
508        update.baseline = Some(Baseline::new(self.fingerprint(ctx)));
509        update.step_up_required = Some(false);
510        update.authenticated_at = Some(now);
511        // With the current token, only one of several concurrent submits replaces it.
512        let supersede = match resolved.state {
513            TokenState::Current => Supersede::Exact(resolved.hash),
514            _ => Supersede::Current,
515        };
516        let rotation = Rotation {
517            new_token: token.hash(),
518            supersede,
519            prune_superseded_before: now - inner.config.reuse_detection,
520        };
521        update.rotation = Some(rotation);
522        if inner.store.apply(&update).await? {
523            self.emit(
524                session.id,
525                &session.subject,
526                EventKind::StepUpCompleted,
527                true,
528                ctx,
529                None,
530            )
531            .await;
532        } else {
533            // A concurrent submit won and reports it; this one still gets a token. Or the
534            // session vanished between lookup and update.
535            update.rotation = Some(Rotation {
536                supersede: Supersede::Current,
537                ..rotation
538            });
539            if !inner.store.apply(&update).await? {
540                return Ok(StepUpOutcome::Rejected(Rejection::Revoked));
541            }
542        }
543        Ok(StepUpOutcome::Completed(self.issue(&token, session.expires_at)))
544    }
545
546    /// Ends the session the token belongs to. Returns whether there was one.
547    ///
548    /// # Errors
549    ///
550    /// Store failures only.
551    pub async fn logout(&self, token: &str, ctx: &ClientContext) -> Result<bool, GuardError> {
552        let inner = &*self.inner;
553        let Some(hash) = self.presented_hash(token) else {
554            return Ok(false);
555        };
556        let Some(lookup) = inner.store.find_by_token(&hash).await? else {
557            return Ok(false);
558        };
559        let deleted = inner.store.delete_session(lookup.session.id).await?;
560        if deleted {
561            self.emit(
562                lookup.session.id,
563                &lookup.session.subject,
564                EventKind::Logout,
565                true,
566                ctx,
567                None,
568            )
569            .await;
570        }
571        Ok(deleted)
572    }
573
574    /// Deletes one session of `subject` by id, e.g. from a "your devices" page. Returns whether
575    /// it existed; a session of another subject is left alone. `ctx` is whoever asked; it goes
576    /// into the [`EventKind::Revoked`] event.
577    ///
578    /// # Errors
579    ///
580    /// Store failures or a subject that does not serialize.
581    pub async fn revoke_session(&self, subject: &S, session_id: Uuid, ctx: &ClientContext) -> Result<bool, GuardError> {
582        let encoded = crate::subject::encode(subject)?;
583        let sessions = self.inner.store.list_subject_sessions(&encoded).await?;
584        if !sessions.iter().any(|s| s.id == session_id) {
585            return Ok(false);
586        }
587        let deleted = self.inner.store.delete_session(session_id).await?;
588        if deleted {
589            self.emit(session_id, &encoded, EventKind::Revoked, true, ctx, None)
590                .await;
591        }
592        Ok(deleted)
593    }
594
595    /// Deletes every session of `subject`, e.g. after a password change. Returns how many.
596    /// `ctx` is whoever asked; each session gets an [`EventKind::Revoked`] event.
597    ///
598    /// # Errors
599    ///
600    /// Store failures or a subject that does not serialize.
601    pub async fn revoke_all_for_subject(&self, subject: &S, ctx: &ClientContext) -> Result<u64, GuardError> {
602        let encoded = crate::subject::encode(subject)?;
603        let deleted = self.inner.store.delete_subject_sessions(&encoded).await?;
604        for &session_id in &deleted {
605            self.emit(session_id, &encoded, EventKind::Revoked, true, ctx, None)
606                .await;
607        }
608        Ok(deleted.len() as u64)
609    }
610
611    /// The live sessions of `subject`.
612    ///
613    /// # Errors
614    ///
615    /// Store failures or a subject that does not serialize.
616    pub async fn list_sessions(&self, subject: &S) -> Result<Vec<SessionInfo<S>>, GuardError> {
617        let encoded = crate::subject::encode(subject)?;
618        let now = self.now();
619        let records = self.inner.store.list_subject_sessions(&encoded).await?;
620        Ok(records
621            .iter()
622            .filter(|r| self.is_live(r, now))
623            .filter_map(SessionInfo::from_record)
624            .collect())
625    }
626
627    /// Deletes expired sessions and events older than the retention period. Run it
628    /// periodically (hourly is plenty).
629    ///
630    /// # Errors
631    ///
632    /// Store failures only.
633    pub async fn purge_expired(&self) -> Result<PurgeReport, GuardError> {
634        let config = &self.inner.config;
635        let now = self.now();
636        let sessions = self.inner.store.delete_expired(now, now - config.idle_timeout).await?;
637        let events = self
638            .inner
639            .store
640            .delete_events_before(now - config.event_retention)
641            .await?;
642        Ok(PurgeReport { sessions, events })
643    }
644
645    /// Runs [`purge_expired`](Self::purge_expired) now and then every `every` on the tokio
646    /// runtime, logging each report (`info`) or failure (`warn`) with `tracing`. A failed run
647    /// does not stop the task; abort the handle to stop it.
648    ///
649    /// # Panics
650    ///
651    /// Outside a tokio runtime, or if `every` is zero.
652    #[cfg(feature = "tokio")]
653    #[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
654    pub fn spawn_purge(&self, every: Duration) -> tokio::task::JoinHandle<()> {
655        let guard = self.clone();
656        tokio::spawn(async move {
657            let mut ticker = tokio::time::interval(every);
658            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
659            loop {
660                ticker.tick().await;
661                match guard.purge_expired().await {
662                    Ok(report) => tracing::info!(
663                        sessions = report.sessions,
664                        events = report.events,
665                        "purged expired sessions"
666                    ),
667                    Err(error) => tracing::warn!(%error, "purging expired sessions failed"),
668                }
669            }
670        })
671    }
672
673    /// Builds the [`ClientContext`] for a request from the TCP peer address and the request
674    /// headers.
675    ///
676    /// `X-Forwarded-For` is honoured only when `peer` is a configured trusted proxy, walking
677    /// right to left past trusted hops. Header values are capped at the configured length.
678    pub fn client_context<H: HeaderSource + ?Sized>(&self, peer: IpAddr, headers: &H) -> ClientContext {
679        let config = &self.inner.config;
680        // A non-UTF-8 value stays in the chain as an empty, unparseable hop, so the peer is used
681        // instead of whatever valid hops surround it.
682        let forwarded: Vec<&str> = headers
683            .header_values("x-forwarded-for")
684            .map(|v| std::str::from_utf8(v).unwrap_or(""))
685            .collect();
686        if config.trusted_proxies.is_empty()
687            && !forwarded.is_empty()
688            && !self.inner.xff_warned.swap(true, Ordering::Relaxed)
689        {
690            tracing::warn!(
691                "X-Forwarded-For received but no trusted proxies are configured; using the peer address. \
692                 Configure trusted_proxies if the app runs behind a reverse proxy."
693            );
694        }
695        let ip = resolve_client_ip(peer, forwarded, &config.trusted_proxies);
696        ClientContext::from_headers(
697            ip,
698            headers,
699            config.tls_fingerprint_header.as_deref(),
700            config.max_header_len,
701        )
702    }
703
704    /// The session cookie's value from the request's `Cookie` headers, if present.
705    pub fn cookie_token<'a, H: HeaderSource + ?Sized>(&self, headers: &'a H) -> Option<&'a str> {
706        headers
707            .header_values("cookie")
708            .filter_map(|v| std::str::from_utf8(v).ok())
709            .find_map(|v| self.inner.config.cookie.find_in(v))
710    }
711
712    /// A `Set-Cookie` value that deletes the session cookie.
713    pub fn clear_cookie(&self) -> String {
714        self.inner.config.cookie.clear_cookie()
715    }
716
717    #[cfg(feature = "aide")]
718    pub(crate) fn cookie_name(&self) -> &str {
719        self.inner.config.cookie.cookie_name()
720    }
721
722    /// Whether the subject last proved who they are more than `max_age` ago. Use it to demand
723    /// a fresh login before sensitive actions.
724    ///
725    /// With a zero `max_age` it is true as soon as any time has passed since the login or
726    /// step-up, so it is true for practically every request.
727    pub fn requires_reauth(&self, session: &AuthSession<S>, max_age: Duration) -> bool {
728        self.now() - session.authenticated_at > td(max_age)
729    }
730
731    /// Whether `value` has the shape of this guard's tokens (configured prefix, then 43
732    /// base64url characters). Does not touch the store.
733    pub fn is_session_token(&self, value: &str) -> bool {
734        self.presented_hash(value).is_some()
735    }
736
737    fn now(&self) -> DateTime<Utc> {
738        self.inner.clock.now()
739    }
740
741    fn fingerprint(&self, ctx: &ClientContext) -> Fingerprint {
742        Fingerprint::capture_cached(ctx, &self.inner.intel.lookup(ctx.ip), &self.inner.ua_cache)
743    }
744
745    /// The request's User-Agent, truncated to the header cap.
746    fn user_agent(&self, ctx: &ClientContext) -> Option<String> {
747        ctx.user_agent
748            .as_deref()
749            .map(|ua| crate::client::truncate(ua, self.inner.config.max_header_len).to_owned())
750    }
751
752    fn client_summary(&self, ctx: &ClientContext) -> ClientSummary {
753        let info = self.inner.intel.lookup(ctx.ip);
754        let fingerprint = Fingerprint::capture_cached(ctx, &info, &self.inner.ua_cache);
755        ClientSummary::observed(info, fingerprint)
756    }
757
758    fn presented_hash(&self, presented: &str) -> Option<TokenHash> {
759        let bare = match &self.inner.config.token_prefix {
760            Some(prefix) => presented.strip_prefix(prefix.as_str())?,
761            None => presented,
762        };
763        TokenHash::from_presented(bare)
764    }
765
766    fn issue(&self, token: &SessionToken, expires_at: DateTime<Utc>) -> IssuedToken {
767        // Exact capacity, so neither building the value nor boxing it into the secret leaves an
768        // unwiped copy behind in a reallocated buffer.
769        let prefix = self.inner.config.token_prefix.as_deref().unwrap_or("");
770        let mut value = String::with_capacity(prefix.len() + token.expose().len());
771        value.push_str(prefix);
772        value.push_str(token.expose());
773        let max_age = (expires_at - self.now()).num_seconds();
774        let set_cookie = Zeroizing::new(self.inner.config.cookie.set_cookie(&value, max_age));
775        IssuedToken {
776            value: SecretString::from(value),
777            expires_at,
778            set_cookie,
779        }
780    }
781
782    fn is_live(&self, record: &SessionRecord, now: DateTime<Utc>) -> bool {
783        now < record.expires_at && now - record.last_seen_at < self.inner.config.idle_timeout
784    }
785
786    fn classify(&self, lookup: &crate::store::TokenLookup, now: DateTime<Utc>) -> TokenState {
787        match lookup.token.superseded_at {
788            None => TokenState::Current,
789            Some(at) if now - at <= self.inner.config.rotation_grace => TokenState::Grace,
790            Some(_) if !lookup.newer_token_used => TokenState::Orphaned,
791            Some(_) => TokenState::Reuse,
792        }
793    }
794
795    /// Whether an orphaned token was superseded longer ago than the reissue window allows.
796    fn orphan_too_old(&self, lookup: &crate::store::TokenLookup, now: DateTime<Utc>) -> bool {
797        match (self.inner.config.orphan_reissue_window, lookup.token.superseded_at) {
798            (Some(window), Some(at)) => now - at > window,
799            _ => false,
800        }
801    }
802
803    /// Finds a token's live session. Expired sessions are deleted on sight.
804    async fn resolve(&self, token: &str, now: DateTime<Utc>) -> Result<Result<Resolved<S>, Rejection>, GuardError> {
805        let store = &self.inner.store;
806        let Some(hash) = self.presented_hash(token) else {
807            return Ok(Err(Rejection::NotAuthenticated));
808        };
809        let Some(lookup) = store.find_by_token(&hash).await? else {
810            return Ok(Err(Rejection::NotAuthenticated));
811        };
812        if !self.is_live(&lookup.session, now) {
813            store.delete_session(lookup.session.id).await?;
814            return Ok(Err(Rejection::Expired));
815        }
816        let Some(subject) = crate::subject::decode::<S>(&lookup.session.subject) else {
817            tracing::warn!(session_id = %lookup.session.id, "stored subject does not decode as the guard's subject type");
818            return Ok(Err(Rejection::NotAuthenticated));
819        };
820        let state = self.classify(&lookup, now);
821        if state == TokenState::Orphaned && self.orphan_too_old(&lookup, now) {
822            // Treated like a pruned token: the session stays with whoever holds the successor.
823            return Ok(Err(Rejection::NotAuthenticated));
824        }
825        Ok(Ok(Resolved {
826            lookup,
827            hash,
828            subject,
829            state,
830        }))
831    }
832
833    /// Deletes the session when `enforced`. Only the request that deleted it reports the
834    /// revocation; an unenforced one is reported once per dedup window.
835    async fn revoke_for_risk(
836        &self,
837        session: &SessionRecord,
838        ctx: &ClientContext,
839        assessment: &crate::risk::Assessment,
840        enforced: bool,
841    ) -> Result<(), GuardError> {
842        if !enforced || self.inner.store.delete_session(session.id).await? {
843            self.emit(
844                session.id,
845                &session.subject,
846                EventKind::RiskRevoked,
847                enforced,
848                ctx,
849                Some(assessment),
850            )
851            .await;
852        }
853        Ok(())
854    }
855
856    /// Deletes the session. Only the request that deleted it reports the reuse.
857    async fn revoke_for_reuse(&self, session: &SessionRecord, ctx: &ClientContext) -> Result<(), GuardError> {
858        if self.inner.store.delete_session(session.id).await? {
859            self.emit(session.id, &session.subject, EventKind::TokenReuse, true, ctx, None)
860                .await;
861        }
862        Ok(())
863    }
864
865    /// Records an event, calls the hook and updates metrics. Never fails: a store error is
866    /// logged, a hook panic is caught. Unenforced events are recorded at most once per session
867    /// and kind per dedup window. With an alert dedup window, enforced security-relevant events
868    /// reach the hook at most once per subject and kind per window.
869    async fn emit(
870        &self,
871        session_id: Uuid,
872        subject: &str,
873        kind: EventKind,
874        enforced: bool,
875        ctx: &ClientContext,
876        assessment: Option<&crate::risk::Assessment>,
877    ) {
878        let client = self.client_summary(ctx);
879        self.emit_with(session_id, subject, kind, enforced, ctx, assessment, client)
880            .await;
881    }
882
883    /// [`emit`](Self::emit) with the client summary already built.
884    #[allow(clippy::too_many_arguments)]
885    async fn emit_with(
886        &self,
887        session_id: Uuid,
888        subject: &str,
889        kind: EventKind,
890        enforced: bool,
891        ctx: &ClientContext,
892        assessment: Option<&crate::risk::Assessment>,
893        client: ClientSummary,
894    ) {
895        let inner = &*self.inner;
896        let config = &inner.config;
897        let now = self.now();
898        if !enforced {
899            if self.seen_recently(session_id, kind, now) {
900                return;
901            }
902            match inner
903                .store
904                .claim_event(ClaimScope::Session(session_id), kind, now, config.shadow_dedup_window)
905                .await
906            {
907                Ok(true) => {}
908                Ok(false) => return,
909                // Better a duplicate than a lost event.
910                Err(error) => {
911                    tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to claim session event");
912                }
913            }
914        }
915        let alert = if inner.hook.is_some()
916            && enforced
917            && kind.is_security_relevant()
918            && !config.alert_dedup_window.is_zero()
919        {
920            match inner
921                .store
922                .claim_event(ClaimScope::Subject(subject), kind, now, config.alert_dedup_window)
923                .await
924            {
925                Ok(claimed) => claimed,
926                // Better a duplicate than a lost alert.
927                Err(error) => {
928                    tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to claim subject alert");
929                    true
930                }
931            }
932        } else {
933            true
934        };
935
936        let event = SessionEvent {
937            session_id,
938            subject: subject.to_owned(),
939            kind,
940            enforced,
941            score: assessment.map(|a| a.score),
942            signals: assessment.map(|a| a.signals.clone()).unwrap_or_default(),
943            ip: ctx.ip,
944            user_agent: self.user_agent(ctx),
945            client,
946            at: now,
947        };
948        if kind.is_security_relevant() {
949            tracing::warn!(%session_id, kind = kind.as_str(), enforced, score = ?event.score, "session event");
950        } else {
951            tracing::debug!(%session_id, kind = kind.as_str(), "session event");
952        }
953        let stored = if config.minimize_stored_events {
954            Cow::Owned(SessionEvent {
955                ip: mask_ip(event.ip),
956                user_agent: None,
957                client: event.client.clone().minimized(),
958                ..event.clone()
959            })
960        } else {
961            Cow::Borrowed(&event)
962        };
963        if let Err(error) = inner.store.record_event(&stored).await {
964            tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to record session event");
965        }
966        if alert
967            && let Some(hook) = &inner.hook
968            && catch_unwind(AssertUnwindSafe(|| hook(&event))).is_err()
969        {
970            tracing::error!(%session_id, kind = kind.as_str(), "on_event hook panicked");
971        }
972        crate::telemetry::event(kind, enforced);
973    }
974
975    /// Whether this process already handled an unenforced `kind` event for the session within
976    /// the dedup window; notes this one if not. A cheap first filter in front of the store's
977    /// cross-process [`SessionStore::claim_event`].
978    fn seen_recently(&self, session_id: Uuid, kind: EventKind, now: DateTime<Utc>) -> bool {
979        let window = self.inner.config.shadow_dedup_window;
980        let mut state = self.inner.shadow_seen.lock().unwrap_or_else(PoisonError::into_inner);
981        if now - state.pruned_at >= window || state.seen.len() >= SHADOW_SEEN_CAPACITY {
982            state.seen.retain(|_, at| now - *at < window);
983            state.pruned_at = now;
984            if state.seen.len() >= SHADOW_SEEN_CAPACITY {
985                // A flood of distinct sessions: forget everything and let the store dedupe.
986                state.seen.clear();
987            }
988        }
989        match state.seen.get(&(session_id, kind)) {
990            Some(at) if now - *at < window => true,
991            _ => {
992                state.seen.insert((session_id, kind), now);
993                false
994            }
995        }
996    }
997}
998
999fn td(duration: Duration) -> TimeDelta {
1000    TimeDelta::from_std(duration).unwrap_or(TimeDelta::MAX)
1001}