Skip to main content

synapse_pingora/session/
manager.rs

1//! Thread-safe session manager using DashMap for concurrent access.
2//!
3//! Implements session tracking with LRU eviction and hijack detection via JA4 fingerprint binding.
4
5use std::net::IpAddr;
6use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
7use std::sync::Arc;
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use dashmap::DashMap;
11use serde::{Deserialize, Serialize};
12use tokio::sync::Notify;
13
14// ============================================================================
15// Session Decision
16// ============================================================================
17
18/// Session validation decision returned by `validate_request`.
19#[derive(Debug, Clone, PartialEq)]
20pub enum SessionDecision {
21    /// Session is valid, continue processing.
22    Valid,
23    /// Session is new, tracking initiated.
24    New,
25    /// Session may be hijacked - contains the alert details.
26    Suspicious(HijackAlert),
27    /// Session has expired (TTL or idle timeout exceeded).
28    Expired,
29    /// Session is invalid for the specified reason.
30    Invalid(String),
31}
32
33// ============================================================================
34// Hijack Alert
35// ============================================================================
36
37/// Alert for potential session hijacking.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct HijackAlert {
40    /// Session ID that may be hijacked.
41    pub session_id: String,
42    /// Type of hijacking detected.
43    pub alert_type: HijackType,
44    /// Original bound value (e.g., original JA4 fingerprint).
45    pub original_value: String,
46    /// New value that triggered the alert.
47    pub new_value: String,
48    /// Timestamp when the alert was generated (ms since epoch).
49    pub timestamp: u64,
50    /// Confidence level of the hijack detection (0.0 - 1.0).
51    pub confidence: f64,
52}
53
54/// Type of session hijacking detected.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub enum HijackType {
57    /// JA4 TLS fingerprint mismatch (high confidence).
58    Ja4Mismatch,
59    /// IP address changed unexpectedly.
60    IpChange,
61    /// Impossible travel detected (IP geolocation suggests impossible speed).
62    ImpossibleTravel,
63    /// Session token rotation detected unexpectedly.
64    TokenRotation,
65}
66
67// ============================================================================
68// Session State
69// ============================================================================
70
71/// Per-session state tracking.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SessionState {
74    /// Unique session identifier (UUID v4).
75    pub session_id: String,
76    /// Hash of the session token (used as primary key).
77    pub token_hash: String,
78    /// Associated actor ID (if bound to an actor).
79    pub actor_id: Option<String>,
80    /// Creation timestamp (ms since epoch).
81    pub creation_time: u64,
82    /// Last activity timestamp (ms since epoch).
83    pub last_activity: u64,
84    /// Total request count for this session.
85    pub request_count: u64,
86    /// Bound JA4 fingerprint (for hijack detection).
87    pub bound_ja4: Option<String>,
88    /// Bound IP address (for strict mode hijack detection).
89    pub bound_ip: Option<IpAddr>,
90    /// Whether this session is flagged as suspicious.
91    pub is_suspicious: bool,
92    /// History of hijack alerts for this session.
93    pub hijack_alerts: Vec<HijackAlert>,
94}
95
96impl SessionState {
97    /// Create a new session state.
98    pub fn new(session_id: String, token_hash: String) -> Self {
99        let now = now_ms();
100        Self {
101            session_id,
102            token_hash,
103            actor_id: None,
104            creation_time: now,
105            last_activity: now,
106            request_count: 0,
107            bound_ja4: None,
108            bound_ip: None,
109            is_suspicious: false,
110            hijack_alerts: Vec::new(),
111        }
112    }
113
114    /// Update last activity timestamp and increment request count.
115    pub fn touch(&mut self) {
116        self.last_activity = now_ms();
117        self.request_count += 1;
118    }
119
120    /// Bind JA4 fingerprint to this session.
121    pub fn bind_ja4(&mut self, ja4: String) {
122        if self.bound_ja4.is_none() && !ja4.is_empty() {
123            self.bound_ja4 = Some(ja4);
124        }
125    }
126
127    /// Bind IP address to this session.
128    pub fn bind_ip(&mut self, ip: IpAddr) {
129        if self.bound_ip.is_none() {
130            self.bound_ip = Some(ip);
131        }
132    }
133
134    /// Add a hijack alert to this session.
135    pub fn add_alert(&mut self, alert: HijackAlert) {
136        self.is_suspicious = true;
137        self.hijack_alerts.push(alert);
138    }
139}
140
141// ============================================================================
142// Session Configuration
143// ============================================================================
144
145/// Configuration for SessionManager.
146#[derive(Debug, Clone)]
147pub struct SessionConfig {
148    /// Maximum number of sessions to track (LRU eviction when exceeded).
149    /// Default: 50,000
150    pub max_sessions: usize,
151
152    /// Session time-to-live in seconds (absolute expiration).
153    /// Default: 3600 (1 hour)
154    pub session_ttl_secs: u64,
155
156    /// Idle timeout in seconds (inactivity expiration).
157    /// Default: 900 (15 minutes)
158    pub idle_timeout_secs: u64,
159
160    /// Interval in seconds between cleanup cycles.
161    /// Default: 300 (5 minutes)
162    pub cleanup_interval_secs: u64,
163
164    /// Enable JA4 fingerprint binding for hijack detection.
165    /// Default: true
166    pub enable_ja4_binding: bool,
167
168    /// Enable IP binding for strict mode hijack detection.
169    /// Default: false (can cause false positives for mobile users)
170    pub enable_ip_binding: bool,
171
172    /// Number of JA4 mismatches before alerting (0 = immediate).
173    /// Default: 1 (immediate alert on mismatch)
174    pub ja4_mismatch_threshold: u32,
175
176    /// Window in seconds to allow IP changes (for mobile/VPN users).
177    /// Default: 60 seconds
178    pub ip_change_window_secs: u64,
179
180    /// Maximum number of hijack alerts to store per session.
181    /// Default: 10
182    pub max_alerts_per_session: usize,
183
184    /// Whether session tracking is enabled.
185    /// Default: true
186    pub enabled: bool,
187}
188
189impl Default for SessionConfig {
190    fn default() -> Self {
191        Self {
192            max_sessions: 50_000,
193            session_ttl_secs: 3600,
194            idle_timeout_secs: 900,
195            cleanup_interval_secs: 300,
196            enable_ja4_binding: true,
197            enable_ip_binding: false,
198            ja4_mismatch_threshold: 1,
199            ip_change_window_secs: 60,
200            max_alerts_per_session: 10,
201            enabled: true,
202        }
203    }
204}
205
206// ============================================================================
207// Session Statistics
208// ============================================================================
209
210/// Statistics for monitoring the session manager.
211#[derive(Debug, Default)]
212pub struct SessionStats {
213    /// Total number of sessions currently tracked.
214    pub total_sessions: AtomicU64,
215    /// Number of active sessions (not expired).
216    pub active_sessions: AtomicU64,
217    /// Number of suspicious sessions.
218    pub suspicious_sessions: AtomicU64,
219    /// Total hijack alerts generated.
220    pub hijack_alerts: AtomicU64,
221    /// Total sessions expired (TTL or idle).
222    pub expired_sessions: AtomicU64,
223    /// Total sessions evicted due to LRU capacity.
224    pub evictions: AtomicU64,
225    /// Total sessions created.
226    pub total_created: AtomicU64,
227    /// Total sessions invalidated.
228    pub total_invalidated: AtomicU64,
229}
230
231impl SessionStats {
232    /// Create a new stats instance.
233    pub fn new() -> Self {
234        Self::default()
235    }
236
237    /// Get a snapshot of the current statistics.
238    pub fn snapshot(&self) -> SessionStatsSnapshot {
239        SessionStatsSnapshot {
240            total_sessions: self.total_sessions.load(Ordering::Relaxed),
241            active_sessions: self.active_sessions.load(Ordering::Relaxed),
242            suspicious_sessions: self.suspicious_sessions.load(Ordering::Relaxed),
243            hijack_alerts: self.hijack_alerts.load(Ordering::Relaxed),
244            expired_sessions: self.expired_sessions.load(Ordering::Relaxed),
245            evictions: self.evictions.load(Ordering::Relaxed),
246            total_created: self.total_created.load(Ordering::Relaxed),
247            total_invalidated: self.total_invalidated.load(Ordering::Relaxed),
248        }
249    }
250}
251
252/// Snapshot of session statistics (for serialization).
253#[derive(Debug, Clone, Serialize)]
254pub struct SessionStatsSnapshot {
255    pub total_sessions: u64,
256    pub active_sessions: u64,
257    pub suspicious_sessions: u64,
258    pub hijack_alerts: u64,
259    pub expired_sessions: u64,
260    pub evictions: u64,
261    pub total_created: u64,
262    pub total_invalidated: u64,
263}
264
265// ============================================================================
266// Session Manager
267// ============================================================================
268
269/// Manages session state with LRU eviction and hijack detection.
270///
271/// Thread-safe implementation using DashMap for lock-free concurrent access.
272pub struct SessionManager {
273    /// Sessions by token_hash (primary storage).
274    sessions: DashMap<String, SessionState>,
275
276    /// Session ID to token_hash mapping (for lookup by session ID).
277    session_by_id: DashMap<String, String>,
278
279    /// Actor ID to session IDs mapping (for listing actor's sessions).
280    actor_sessions: DashMap<String, Vec<String>>,
281
282    /// Configuration.
283    config: SessionConfig,
284
285    /// Statistics.
286    stats: Arc<SessionStats>,
287
288    /// Shutdown signal for background tasks.
289    shutdown: Arc<Notify>,
290
291    /// Touch counter for lazy eviction.
292    touch_counter: AtomicU32,
293}
294
295impl SessionManager {
296    /// Create a new session manager with the given configuration.
297    pub fn new(config: SessionConfig) -> Self {
298        Self {
299            sessions: DashMap::with_capacity(config.max_sessions),
300            session_by_id: DashMap::with_capacity(config.max_sessions),
301            actor_sessions: DashMap::with_capacity(config.max_sessions / 10),
302            config,
303            stats: Arc::new(SessionStats::new()),
304            shutdown: Arc::new(Notify::new()),
305            touch_counter: AtomicU32::new(0),
306        }
307    }
308
309    /// Get the configuration.
310    pub fn config(&self) -> &SessionConfig {
311        &self.config
312    }
313
314    /// Check if session tracking is enabled.
315    pub fn is_enabled(&self) -> bool {
316        self.config.enabled
317    }
318
319    /// Get the number of tracked sessions.
320    pub fn len(&self) -> usize {
321        self.sessions.len()
322    }
323
324    /// Check if the store is empty.
325    pub fn is_empty(&self) -> bool {
326        self.sessions.is_empty()
327    }
328
329    // ========================================================================
330    // Primary API
331    // ========================================================================
332
333    /// Validate an incoming request's session.
334    ///
335    /// This is the primary entry point - call on every request with a session token.
336    ///
337    /// # Arguments
338    /// * `token_hash` - Hash of the session token (not the raw token)
339    /// * `ip` - Client IP address
340    /// * `ja4` - Optional JA4 TLS fingerprint
341    ///
342    /// # Returns
343    /// A `SessionDecision` indicating the validation result.
344    pub fn validate_request(
345        &self,
346        token_hash: &str,
347        ip: IpAddr,
348        ja4: Option<&str>,
349    ) -> SessionDecision {
350        if !self.config.enabled {
351            return SessionDecision::Valid;
352        }
353
354        // Check capacity and evict if needed
355        self.maybe_evict();
356
357        // Use entry API for atomic check-and-modify to prevent TOCTOU races
358        // This ensures the session state is consistent throughout the operation
359        match self.sessions.entry(token_hash.to_string()) {
360            dashmap::mapref::entry::Entry::Occupied(mut entry) => {
361                let session = entry.get_mut();
362
363                // Check expiration - if expired, remove atomically while holding the lock
364                if self.is_session_expired(session) {
365                    let session_id = session.session_id.clone();
366                    let actor_id = session.actor_id.clone();
367                    let was_suspicious = session.is_suspicious;
368
369                    // Remove from primary store (still holding entry lock)
370                    entry.remove();
371
372                    // Clean up secondary indices
373                    self.session_by_id.remove(&session_id);
374                    if let Some(aid) = actor_id {
375                        if let Some(mut actor_entry) = self.actor_sessions.get_mut(&aid) {
376                            actor_entry.retain(|id| id != &session_id);
377                        }
378                    }
379
380                    // Update stats
381                    self.stats.total_sessions.fetch_sub(1, Ordering::Relaxed);
382                    self.stats.active_sessions.fetch_sub(1, Ordering::Relaxed);
383                    self.stats.expired_sessions.fetch_add(1, Ordering::Relaxed);
384                    if was_suspicious {
385                        self.stats
386                            .suspicious_sessions
387                            .fetch_sub(1, Ordering::Relaxed);
388                    }
389
390                    return SessionDecision::Expired;
391                }
392
393                // Check for hijacking
394                if let Some(alert) = self.detect_hijack(session, ip, ja4) {
395                    let was_suspicious = session.is_suspicious;
396                    session.add_alert(alert.clone());
397                    session.touch();
398
399                    // Trim alerts if needed
400                    if session.hijack_alerts.len() > self.config.max_alerts_per_session {
401                        let excess =
402                            session.hijack_alerts.len() - self.config.max_alerts_per_session;
403                        session.hijack_alerts.drain(0..excess);
404                    }
405
406                    self.stats.hijack_alerts.fetch_add(1, Ordering::Relaxed);
407
408                    // Update suspicious count if first alert
409                    if !was_suspicious {
410                        self.stats
411                            .suspicious_sessions
412                            .fetch_add(1, Ordering::Relaxed);
413                    }
414
415                    return SessionDecision::Suspicious(alert);
416                }
417
418                // Valid session - update activity
419                session.touch();
420
421                // Bind fingerprint if first request or not yet bound
422                if let Some(ja4_str) = ja4 {
423                    session.bind_ja4(ja4_str.to_string());
424                }
425
426                if self.config.enable_ip_binding {
427                    session.bind_ip(ip);
428                }
429
430                SessionDecision::Valid
431            }
432            dashmap::mapref::entry::Entry::Vacant(entry) => {
433                // Session doesn't exist - create atomically
434                let session_id = generate_session_id();
435                let mut session = SessionState::new(session_id.clone(), token_hash.to_string());
436                session.touch();
437
438                // Bind fingerprint and IP
439                if let Some(ja4_str) = ja4 {
440                    session.bind_ja4(ja4_str.to_string());
441                }
442
443                if self.config.enable_ip_binding {
444                    session.bind_ip(ip);
445                }
446
447                // Insert atomically into primary store
448                entry.insert(session);
449
450                // Update secondary index
451                self.session_by_id
452                    .insert(session_id, token_hash.to_string());
453
454                // Update stats
455                self.stats.total_sessions.fetch_add(1, Ordering::Relaxed);
456                self.stats.active_sessions.fetch_add(1, Ordering::Relaxed);
457                self.stats.total_created.fetch_add(1, Ordering::Relaxed);
458
459                SessionDecision::New
460            }
461        }
462    }
463
464    /// Create a new session.
465    ///
466    /// # Arguments
467    /// * `token_hash` - Hash of the session token
468    /// * `ip` - Client IP address
469    /// * `ja4` - Optional JA4 TLS fingerprint
470    ///
471    /// # Returns
472    /// The newly created session state.
473    pub fn create_session(&self, token_hash: &str, ip: IpAddr, ja4: Option<&str>) -> SessionState {
474        if !self.config.enabled {
475            return SessionState::new(generate_session_id(), token_hash.to_string());
476        }
477
478        // Check capacity and evict if needed
479        self.maybe_evict();
480
481        let session_id = generate_session_id();
482        let mut session = SessionState::new(session_id.clone(), token_hash.to_string());
483        session.touch();
484
485        // Bind fingerprint and IP
486        if let Some(ja4_str) = ja4 {
487            session.bind_ja4(ja4_str.to_string());
488        }
489
490        if self.config.enable_ip_binding {
491            session.bind_ip(ip);
492        }
493
494        // Insert into maps
495        self.session_by_id
496            .insert(session_id.clone(), token_hash.to_string());
497        self.sessions
498            .insert(token_hash.to_string(), session.clone());
499
500        // Update stats
501        self.stats.total_sessions.fetch_add(1, Ordering::Relaxed);
502        self.stats.active_sessions.fetch_add(1, Ordering::Relaxed);
503        self.stats.total_created.fetch_add(1, Ordering::Relaxed);
504
505        session
506    }
507
508    /// Get session by token hash.
509    pub fn get_session(&self, token_hash: &str) -> Option<SessionState> {
510        self.sessions
511            .get(token_hash)
512            .map(|entry| entry.value().clone())
513    }
514
515    /// Get session by session ID.
516    pub fn get_session_by_id(&self, session_id: &str) -> Option<SessionState> {
517        self.session_by_id.get(session_id).and_then(|token_hash| {
518            self.sessions
519                .get(token_hash.value())
520                .map(|e| e.value().clone())
521        })
522    }
523
524    /// Touch session to update activity timestamp.
525    pub fn touch_session(&self, token_hash: &str) {
526        if let Some(mut entry) = self.sessions.get_mut(token_hash) {
527            entry.value_mut().touch();
528        }
529    }
530
531    /// Bind session to an actor.
532    ///
533    /// Uses atomic operations to prevent TOCTOU race conditions when
534    /// updating both the session and actor_sessions mappings.
535    ///
536    /// # Arguments
537    /// * `token_hash` - Hash of the session token
538    /// * `actor_id` - Actor ID to bind to
539    ///
540    /// # Returns
541    /// `true` if the binding was successful, `false` if session not found.
542    pub fn bind_to_actor(&self, token_hash: &str, actor_id: &str) -> bool {
543        // Use entry API for atomic modification
544        match self.sessions.entry(token_hash.to_string()) {
545            dashmap::mapref::entry::Entry::Occupied(mut entry) => {
546                let session = entry.get_mut();
547
548                // Check if already bound to same actor (idempotent)
549                if session.actor_id.as_deref() == Some(actor_id) {
550                    return true;
551                }
552
553                // If bound to different actor, remove from old actor's list first
554                if let Some(ref old_actor_id) = session.actor_id {
555                    if let Some(mut old_actor_entry) = self.actor_sessions.get_mut(old_actor_id) {
556                        old_actor_entry.retain(|id| id != &session.session_id);
557                    }
558                }
559
560                let session_id = session.session_id.clone();
561
562                // Update session's actor_id atomically while holding the lock
563                session.actor_id = Some(actor_id.to_string());
564
565                // Update actor_sessions mapping
566                self.actor_sessions
567                    .entry(actor_id.to_string())
568                    .or_insert_with(Vec::new)
569                    .push(session_id);
570
571                true
572            }
573            dashmap::mapref::entry::Entry::Vacant(_) => false,
574        }
575    }
576
577    /// Get all sessions for an actor.
578    ///
579    /// # Arguments
580    /// * `actor_id` - Actor ID to look up
581    ///
582    /// # Returns
583    /// Vector of session states associated with the actor.
584    pub fn get_actor_sessions(&self, actor_id: &str) -> Vec<SessionState> {
585        self.actor_sessions
586            .get(actor_id)
587            .map(|session_ids| {
588                session_ids
589                    .iter()
590                    .filter_map(|session_id| self.get_session_by_id(session_id))
591                    .collect()
592            })
593            .unwrap_or_default()
594    }
595
596    /// List sessions for an actor with pagination.
597    ///
598    /// Results are sorted by last_activity (most recent first).
599    pub fn list_sessions_by_actor(
600        &self,
601        actor_id: &str,
602        limit: usize,
603        offset: usize,
604    ) -> Vec<SessionState> {
605        let mut sessions = self.get_actor_sessions(actor_id);
606        sessions.sort_by_key(|s| std::cmp::Reverse(s.last_activity));
607        sessions.into_iter().skip(offset).take(limit).collect()
608    }
609
610    /// Invalidate a session.
611    ///
612    /// # Arguments
613    /// * `token_hash` - Hash of the session token to invalidate
614    ///
615    /// # Returns
616    /// `true` if the session was invalidated, `false` if not found.
617    pub fn invalidate_session(&self, token_hash: &str) -> bool {
618        if self.remove_session(token_hash) {
619            self.stats.total_invalidated.fetch_add(1, Ordering::Relaxed);
620            true
621        } else {
622            false
623        }
624    }
625
626    /// Mark session as suspicious with a hijack alert.
627    ///
628    /// Uses atomic operations to prevent TOCTOU race conditions.
629    ///
630    /// # Arguments
631    /// * `token_hash` - Hash of the session token
632    /// * `alert` - Hijack alert to add
633    ///
634    /// # Returns
635    /// `true` if the session was marked suspicious, `false` if not found.
636    pub fn mark_suspicious(&self, token_hash: &str, alert: HijackAlert) -> bool {
637        // Use entry API for atomic modification
638        match self.sessions.entry(token_hash.to_string()) {
639            dashmap::mapref::entry::Entry::Occupied(mut entry) => {
640                let session = entry.get_mut();
641                let was_suspicious = session.is_suspicious;
642                session.add_alert(alert);
643
644                // Trim alerts if needed
645                if session.hijack_alerts.len() > self.config.max_alerts_per_session {
646                    let excess = session.hijack_alerts.len() - self.config.max_alerts_per_session;
647                    session.hijack_alerts.drain(0..excess);
648                }
649
650                self.stats.hijack_alerts.fetch_add(1, Ordering::Relaxed);
651
652                // Update suspicious count if first alert
653                if !was_suspicious {
654                    self.stats
655                        .suspicious_sessions
656                        .fetch_add(1, Ordering::Relaxed);
657                }
658
659                true
660            }
661            dashmap::mapref::entry::Entry::Vacant(_) => false,
662        }
663    }
664
665    /// List sessions with pagination.
666    ///
667    /// # Arguments
668    /// * `limit` - Maximum number of sessions to return
669    /// * `offset` - Number of sessions to skip
670    ///
671    /// # Returns
672    /// Vector of session states sorted by last_activity (most recent first).
673    pub fn list_sessions(&self, limit: usize, offset: usize) -> Vec<SessionState> {
674        let mut sessions: Vec<SessionState> = self
675            .sessions
676            .iter()
677            .map(|entry| entry.value().clone())
678            .collect();
679
680        // Sort by last_activity (most recent first)
681        sessions.sort_by_key(|s| std::cmp::Reverse(s.last_activity));
682
683        // Apply pagination
684        sessions.into_iter().skip(offset).take(limit).collect()
685    }
686
687    /// List suspicious sessions.
688    ///
689    /// # Returns
690    /// Vector of session states that have been flagged as suspicious.
691    pub fn list_suspicious_sessions(&self) -> Vec<SessionState> {
692        self.sessions
693            .iter()
694            .filter(|entry| entry.value().is_suspicious)
695            .map(|entry| entry.value().clone())
696            .collect()
697    }
698
699    /// List suspicious sessions with pagination.
700    ///
701    /// Results are sorted by last_activity (most recent first).
702    pub fn list_suspicious_sessions_paginated(
703        &self,
704        limit: usize,
705        offset: usize,
706    ) -> Vec<SessionState> {
707        let mut sessions = self.list_suspicious_sessions();
708        sessions.sort_by_key(|s| std::cmp::Reverse(s.last_activity));
709        sessions.into_iter().skip(offset).take(limit).collect()
710    }
711
712    /// Start background cleanup tasks.
713    ///
714    /// Spawns a background task that periodically:
715    /// 1. Removes expired sessions (TTL and idle timeout)
716    /// 2. Evicts oldest sessions if over capacity
717    pub fn start_background_tasks(self: Arc<Self>) {
718        let manager = self;
719        let cleanup_interval = Duration::from_secs(manager.config.cleanup_interval_secs);
720
721        tokio::spawn(async move {
722            let mut interval = tokio::time::interval(cleanup_interval);
723
724            loop {
725                tokio::select! {
726                    _ = interval.tick() => {
727                        // Check shutdown
728                        if Arc::strong_count(&manager.shutdown) == 1 {
729                            // Only this task holds a reference, shutting down
730                            break;
731                        }
732
733                        // Cleanup expired sessions
734                        manager.cleanup_expired_sessions();
735
736                        // Evict if over capacity
737                        manager.evict_if_needed();
738                    }
739                    _ = manager.shutdown.notified() => {
740                        log::info!("Session manager background tasks shutting down");
741                        break;
742                    }
743                }
744            }
745        });
746    }
747
748    /// Signal shutdown for background tasks.
749    pub fn shutdown(&self) {
750        self.shutdown.notify_one();
751    }
752
753    /// Get statistics.
754    pub fn stats(&self) -> &SessionStats {
755        &self.stats
756    }
757
758    /// Clear all sessions (primarily for testing).
759    pub fn clear(&self) {
760        self.sessions.clear();
761        self.session_by_id.clear();
762        self.actor_sessions.clear();
763        self.stats.total_sessions.store(0, Ordering::Relaxed);
764        self.stats.active_sessions.store(0, Ordering::Relaxed);
765        self.stats.suspicious_sessions.store(0, Ordering::Relaxed);
766    }
767
768    // ========================================================================
769    // Private Methods
770    // ========================================================================
771
772    /// Detect potential session hijacking.
773    ///
774    /// # Arguments
775    /// * `session` - Current session state
776    /// * `ip` - Client IP address
777    /// * `ja4` - Optional JA4 TLS fingerprint
778    ///
779    /// # Returns
780    /// A hijack alert if hijacking is detected, None otherwise.
781    fn detect_hijack(
782        &self,
783        session: &SessionState,
784        ip: IpAddr,
785        ja4: Option<&str>,
786    ) -> Option<HijackAlert> {
787        let now = now_ms();
788
789        // Check JA4 fingerprint binding
790        if self.config.enable_ja4_binding {
791            if let (Some(bound_ja4), Some(current_ja4)) = (&session.bound_ja4, ja4) {
792                if bound_ja4 != current_ja4 {
793                    return Some(HijackAlert {
794                        session_id: session.session_id.clone(),
795                        alert_type: HijackType::Ja4Mismatch,
796                        original_value: bound_ja4.clone(),
797                        new_value: current_ja4.to_string(),
798                        timestamp: now,
799                        confidence: 0.9, // High confidence - fingerprint changed
800                    });
801                }
802            }
803        }
804
805        // Check IP binding (if enabled in strict mode)
806        if self.config.enable_ip_binding {
807            if let Some(bound_ip) = session.bound_ip {
808                if bound_ip != ip {
809                    // Allow IP changes within the grace window (for mobile users)
810                    // Only alert if the change happens OUTSIDE the allowed window
811                    let time_since_last = now.saturating_sub(session.last_activity);
812                    let window_ms = self.config.ip_change_window_secs * 1000;
813
814                    if time_since_last >= window_ms {
815                        return Some(HijackAlert {
816                            session_id: session.session_id.clone(),
817                            alert_type: HijackType::IpChange,
818                            original_value: bound_ip.to_string(),
819                            new_value: ip.to_string(),
820                            timestamp: now,
821                            confidence: 0.7, // Lower confidence - could be legitimate
822                        });
823                    }
824                }
825            }
826        }
827
828        None
829    }
830
831    /// Check if a session has expired.
832    ///
833    /// # Arguments
834    /// * `session` - Session state to check
835    ///
836    /// # Returns
837    /// `true` if the session has expired (TTL or idle timeout), `false` otherwise.
838    fn is_session_expired(&self, session: &SessionState) -> bool {
839        let now = now_ms();
840
841        // Check absolute TTL
842        let ttl_ms = self.config.session_ttl_secs * 1000;
843        if now.saturating_sub(session.creation_time) > ttl_ms {
844            return true;
845        }
846
847        // Check idle timeout
848        let idle_ms = self.config.idle_timeout_secs * 1000;
849        if now.saturating_sub(session.last_activity) > idle_ms {
850            return true;
851        }
852
853        false
854    }
855
856    /// Cleanup expired sessions.
857    fn cleanup_expired_sessions(&self) {
858        let mut to_remove = Vec::new();
859
860        for entry in self.sessions.iter() {
861            if self.is_session_expired(entry.value()) {
862                to_remove.push(entry.key().clone());
863            }
864        }
865
866        for token_hash in to_remove {
867            self.remove_session(&token_hash);
868            self.stats.expired_sessions.fetch_add(1, Ordering::Relaxed);
869        }
870    }
871
872    /// Evict sessions if over capacity.
873    fn evict_if_needed(&self) {
874        let current_len = self.sessions.len();
875        if current_len <= self.config.max_sessions {
876            return;
877        }
878
879        // Evict oldest 1% of sessions
880        let evict_count = (self.config.max_sessions / 100).max(1);
881        self.evict_oldest(evict_count);
882    }
883
884    /// Maybe evict oldest sessions if at capacity.
885    ///
886    /// Uses lazy eviction: only check every 100th operation.
887    fn maybe_evict(&self) {
888        let count = self.touch_counter.fetch_add(1, Ordering::Relaxed);
889        if !count.is_multiple_of(100) {
890            return;
891        }
892
893        if self.sessions.len() < self.config.max_sessions {
894            return;
895        }
896
897        // Evict oldest 1% of sessions
898        let evict_count = (self.config.max_sessions / 100).max(1);
899        self.evict_oldest(evict_count);
900    }
901
902    /// Evict the N oldest sessions by last_activity timestamp.
903    ///
904    /// Uses sampling to avoid O(n) collection of all sessions.
905    fn evict_oldest(&self, count: usize) {
906        let sample_size = (count * 10).min(1000).min(self.sessions.len());
907
908        if sample_size == 0 {
909            return;
910        }
911
912        // Sample sessions
913        let mut candidates: Vec<(String, u64)> = Vec::with_capacity(sample_size);
914        for entry in self.sessions.iter().take(sample_size) {
915            candidates.push((entry.key().clone(), entry.value().last_activity));
916        }
917
918        // Sort by last_activity (oldest first)
919        candidates.sort_unstable_by_key(|(_, ts)| *ts);
920
921        // Evict oldest N from sample
922        for (token_hash, _) in candidates.into_iter().take(count) {
923            self.remove_session(&token_hash);
924            self.stats.evictions.fetch_add(1, Ordering::Relaxed);
925        }
926    }
927
928    /// Remove a session and clean up all mappings.
929    fn remove_session(&self, token_hash: &str) -> bool {
930        if let Some((_, session)) = self.sessions.remove(token_hash) {
931            // Remove session_id mapping
932            self.session_by_id.remove(&session.session_id);
933
934            // Remove from actor's sessions list
935            if let Some(actor_id) = &session.actor_id {
936                if let Some(mut entry) = self.actor_sessions.get_mut(actor_id) {
937                    entry.retain(|id| id != &session.session_id);
938                    if entry.is_empty() {
939                        drop(entry);
940                        self.actor_sessions.remove(actor_id);
941                    }
942                }
943            }
944
945            // Update stats
946            self.stats.total_sessions.fetch_sub(1, Ordering::Relaxed);
947            self.stats.active_sessions.fetch_sub(1, Ordering::Relaxed);
948
949            if session.is_suspicious {
950                self.stats
951                    .suspicious_sessions
952                    .fetch_sub(1, Ordering::Relaxed);
953            }
954
955            return true;
956        }
957
958        false
959    }
960}
961
962impl Default for SessionManager {
963    fn default() -> Self {
964        Self::new(SessionConfig::default())
965    }
966}
967
968// ============================================================================
969// Helper Functions
970// ============================================================================
971
972/// Generate a unique session ID using cryptographically secure random bytes.
973fn generate_session_id() -> String {
974    // Use getrandom for cryptographically secure random bytes
975    let mut bytes = [0u8; 16];
976    if let Err(err) = getrandom::getrandom(&mut bytes) {
977        log::error!("Failed to get random bytes for session id: {}", err);
978        for byte in bytes.iter_mut() {
979            *byte = fastrand::u8(..);
980        }
981    }
982
983    // Format as UUID v4 with proper version and variant bits
984    bytes[6] = (bytes[6] & 0x0F) | 0x40; // Version 4
985    bytes[8] = (bytes[8] & 0x3F) | 0x80; // Variant 1
986
987    format!(
988        "sess-{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
989        u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
990        u16::from_be_bytes([bytes[4], bytes[5]]),
991        u16::from_be_bytes([bytes[6], bytes[7]]),
992        u16::from_be_bytes([bytes[8], bytes[9]]),
993        u64::from_be_bytes([
994            0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
995        ])
996    )
997}
998
999/// Get current time in milliseconds since Unix epoch.
1000#[inline]
1001fn now_ms() -> u64 {
1002    SystemTime::now()
1003        .duration_since(UNIX_EPOCH)
1004        .map(|d| d.as_millis() as u64)
1005        .unwrap_or(0)
1006}
1007
1008// ============================================================================
1009// Tests
1010// ============================================================================
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use std::thread;
1016
1017    // ========================================================================
1018    // Helper Functions
1019    // ========================================================================
1020
1021    fn create_test_manager() -> SessionManager {
1022        SessionManager::new(SessionConfig {
1023            max_sessions: 1000,
1024            session_ttl_secs: 3600,
1025            idle_timeout_secs: 900,
1026            ..Default::default()
1027        })
1028    }
1029
1030    fn create_test_ip(last_octet: u8) -> IpAddr {
1031        format!("192.168.1.{}", last_octet).parse().unwrap()
1032    }
1033
1034    // ========================================================================
1035    // Session Creation and Retrieval Tests
1036    // ========================================================================
1037
1038    #[test]
1039    fn test_session_creation() {
1040        let manager = create_test_manager();
1041        let ip = create_test_ip(1);
1042
1043        let session = manager.create_session("token_hash_1", ip, None);
1044
1045        assert!(!session.session_id.is_empty());
1046        assert!(session.session_id.starts_with("sess-"));
1047        assert_eq!(session.token_hash, "token_hash_1");
1048        assert_eq!(manager.len(), 1);
1049    }
1050
1051    #[test]
1052    fn test_session_retrieval_by_token_hash() {
1053        let manager = create_test_manager();
1054        let ip = create_test_ip(1);
1055
1056        manager.create_session("token_hash_1", ip, None);
1057
1058        let retrieved = manager.get_session("token_hash_1").unwrap();
1059        assert_eq!(retrieved.token_hash, "token_hash_1");
1060    }
1061
1062    #[test]
1063    fn test_session_retrieval_by_id() {
1064        let manager = create_test_manager();
1065        let ip = create_test_ip(1);
1066
1067        let session = manager.create_session("token_hash_1", ip, None);
1068        let retrieved = manager.get_session_by_id(&session.session_id).unwrap();
1069
1070        assert_eq!(retrieved.token_hash, "token_hash_1");
1071    }
1072
1073    #[test]
1074    fn test_session_nonexistent() {
1075        let manager = create_test_manager();
1076
1077        assert!(manager.get_session("nonexistent").is_none());
1078        assert!(manager.get_session_by_id("nonexistent").is_none());
1079    }
1080
1081    // ========================================================================
1082    // Session Validation Tests
1083    // ========================================================================
1084
1085    #[test]
1086    fn test_validate_new_session() {
1087        let manager = create_test_manager();
1088        let ip = create_test_ip(1);
1089
1090        let decision = manager.validate_request("token_hash_1", ip, None);
1091
1092        assert_eq!(decision, SessionDecision::New);
1093        assert_eq!(manager.len(), 1);
1094    }
1095
1096    #[test]
1097    fn test_validate_existing_session() {
1098        let manager = create_test_manager();
1099        let ip = create_test_ip(1);
1100
1101        // Create session first
1102        manager.create_session("token_hash_1", ip, Some("ja4_fingerprint"));
1103
1104        // Validate again with same fingerprint
1105        let decision = manager.validate_request("token_hash_1", ip, Some("ja4_fingerprint"));
1106
1107        assert_eq!(decision, SessionDecision::Valid);
1108        assert_eq!(manager.len(), 1);
1109    }
1110
1111    #[test]
1112    fn test_validate_increments_request_count() {
1113        let manager = create_test_manager();
1114        let ip = create_test_ip(1);
1115
1116        manager.validate_request("token_hash_1", ip, None);
1117        manager.validate_request("token_hash_1", ip, None);
1118        manager.validate_request("token_hash_1", ip, None);
1119
1120        let session = manager.get_session("token_hash_1").unwrap();
1121        assert_eq!(session.request_count, 3);
1122    }
1123
1124    // ========================================================================
1125    // JA4 Fingerprint Binding Tests
1126    // ========================================================================
1127
1128    #[test]
1129    fn test_ja4_binding() {
1130        let manager = create_test_manager();
1131        let ip = create_test_ip(1);
1132
1133        manager.create_session("token_hash_1", ip, Some("ja4_fingerprint_1"));
1134
1135        let session = manager.get_session("token_hash_1").unwrap();
1136        assert_eq!(session.bound_ja4, Some("ja4_fingerprint_1".to_string()));
1137    }
1138
1139    #[test]
1140    fn test_ja4_mismatch_detection() {
1141        let manager = create_test_manager();
1142        let ip = create_test_ip(1);
1143
1144        // Create session with fingerprint
1145        manager.create_session("token_hash_1", ip, Some("ja4_fingerprint_1"));
1146
1147        // Validate with different fingerprint
1148        let decision = manager.validate_request("token_hash_1", ip, Some("ja4_fingerprint_2"));
1149
1150        match decision {
1151            SessionDecision::Suspicious(alert) => {
1152                assert_eq!(alert.alert_type, HijackType::Ja4Mismatch);
1153                assert_eq!(alert.original_value, "ja4_fingerprint_1");
1154                assert_eq!(alert.new_value, "ja4_fingerprint_2");
1155                assert!(alert.confidence >= 0.9);
1156            }
1157            _ => panic!("Expected Suspicious decision, got {:?}", decision),
1158        }
1159    }
1160
1161    #[test]
1162    fn test_ja4_binding_first_value_only() {
1163        let manager = create_test_manager();
1164        let ip = create_test_ip(1);
1165
1166        // Create session without fingerprint
1167        manager.create_session("token_hash_1", ip, None);
1168
1169        // First request with fingerprint binds it
1170        manager.validate_request("token_hash_1", ip, Some("ja4_fingerprint_1"));
1171
1172        let session = manager.get_session("token_hash_1").unwrap();
1173        assert_eq!(session.bound_ja4, Some("ja4_fingerprint_1".to_string()));
1174    }
1175
1176    #[test]
1177    fn test_ja4_binding_disabled() {
1178        let config = SessionConfig {
1179            enable_ja4_binding: false,
1180            ..Default::default()
1181        };
1182        let manager = SessionManager::new(config);
1183        let ip = create_test_ip(1);
1184
1185        // Create session with fingerprint
1186        manager.create_session("token_hash_1", ip, Some("ja4_fingerprint_1"));
1187
1188        // Different fingerprint should NOT trigger alert when binding is disabled
1189        let decision = manager.validate_request("token_hash_1", ip, Some("ja4_fingerprint_2"));
1190
1191        assert_eq!(decision, SessionDecision::Valid);
1192    }
1193
1194    // ========================================================================
1195    // IP Binding Tests
1196    // ========================================================================
1197
1198    #[test]
1199    fn test_ip_binding_strict_mode_within_window() {
1200        let config = SessionConfig {
1201            enable_ip_binding: true,
1202            ip_change_window_secs: 60,
1203            ..Default::default()
1204        };
1205        let manager = SessionManager::new(config);
1206        let ip1 = create_test_ip(1);
1207        let ip2 = create_test_ip(2);
1208
1209        // Create session with IP1
1210        manager.create_session("token_hash_1", ip1, None);
1211
1212        // Validate with different IP immediately (within window) - should be allowed
1213        let decision = manager.validate_request("token_hash_1", ip2, None);
1214
1215        // IP changes within the grace window should be allowed (no alert)
1216        assert_eq!(decision, SessionDecision::Valid);
1217    }
1218
1219    #[test]
1220    fn test_ip_binding_strict_mode_outside_window() {
1221        let config = SessionConfig {
1222            enable_ip_binding: true,
1223            ip_change_window_secs: 0, // No grace window - immediate alert on IP change
1224            ..Default::default()
1225        };
1226        let manager = SessionManager::new(config);
1227        let ip1 = create_test_ip(1);
1228        let ip2 = create_test_ip(2);
1229
1230        // Create session with IP1
1231        manager.create_session("token_hash_1", ip1, None);
1232
1233        // Small sleep to ensure time passes beyond the 0-second window
1234        std::thread::sleep(std::time::Duration::from_millis(10));
1235
1236        // Validate with different IP - should trigger alert (outside window)
1237        let decision = manager.validate_request("token_hash_1", ip2, None);
1238
1239        match decision {
1240            SessionDecision::Suspicious(alert) => {
1241                assert_eq!(alert.alert_type, HijackType::IpChange);
1242                assert!(alert.confidence >= 0.5 && alert.confidence < 0.9);
1243            }
1244            _ => panic!("Expected Suspicious decision, got {:?}", decision),
1245        }
1246    }
1247
1248    #[test]
1249    fn test_ip_binding_disabled_by_default() {
1250        let manager = create_test_manager();
1251        let ip1 = create_test_ip(1);
1252        let ip2 = create_test_ip(2);
1253
1254        // Create session with IP1
1255        manager.create_session("token_hash_1", ip1, None);
1256
1257        // Different IP should NOT trigger alert when IP binding is disabled
1258        let decision = manager.validate_request("token_hash_1", ip2, None);
1259
1260        assert_eq!(decision, SessionDecision::Valid);
1261    }
1262
1263    // ========================================================================
1264    // Session Expiration Tests
1265    // ========================================================================
1266
1267    #[test]
1268    fn test_session_ttl_expiration() {
1269        let config = SessionConfig {
1270            session_ttl_secs: 0, // Immediate expiration
1271            idle_timeout_secs: 3600,
1272            ..Default::default()
1273        };
1274        let manager = SessionManager::new(config);
1275        let ip = create_test_ip(1);
1276
1277        manager.create_session("token_hash_1", ip, None);
1278
1279        // Small sleep to ensure time passes
1280        std::thread::sleep(std::time::Duration::from_millis(10));
1281
1282        let decision = manager.validate_request("token_hash_1", ip, None);
1283        assert_eq!(decision, SessionDecision::Expired);
1284    }
1285
1286    #[test]
1287    fn test_session_idle_expiration() {
1288        let config = SessionConfig {
1289            session_ttl_secs: 3600,
1290            idle_timeout_secs: 0, // Immediate idle timeout
1291            ..Default::default()
1292        };
1293        let manager = SessionManager::new(config);
1294        let ip = create_test_ip(1);
1295
1296        manager.create_session("token_hash_1", ip, None);
1297
1298        // Small sleep to ensure time passes
1299        std::thread::sleep(std::time::Duration::from_millis(10));
1300
1301        let decision = manager.validate_request("token_hash_1", ip, None);
1302        assert_eq!(decision, SessionDecision::Expired);
1303    }
1304
1305    // ========================================================================
1306    // Actor Binding Tests
1307    // ========================================================================
1308
1309    #[test]
1310    fn test_bind_to_actor() {
1311        let manager = create_test_manager();
1312        let ip = create_test_ip(1);
1313
1314        manager.create_session("token_hash_1", ip, None);
1315        let result = manager.bind_to_actor("token_hash_1", "actor_123");
1316
1317        assert!(result);
1318        let session = manager.get_session("token_hash_1").unwrap();
1319        assert_eq!(session.actor_id, Some("actor_123".to_string()));
1320    }
1321
1322    #[test]
1323    fn test_bind_to_actor_nonexistent() {
1324        let manager = create_test_manager();
1325
1326        let result = manager.bind_to_actor("nonexistent", "actor_123");
1327        assert!(!result);
1328    }
1329
1330    #[test]
1331    fn test_bind_to_actor_idempotent() {
1332        let manager = create_test_manager();
1333        let ip = create_test_ip(1);
1334
1335        manager.create_session("token_hash_1", ip, None);
1336
1337        // Bind twice to same actor should succeed
1338        assert!(manager.bind_to_actor("token_hash_1", "actor_123"));
1339        assert!(manager.bind_to_actor("token_hash_1", "actor_123"));
1340
1341        // Should still only have one entry in actor_sessions
1342        let sessions = manager.get_actor_sessions("actor_123");
1343        assert_eq!(sessions.len(), 1);
1344    }
1345
1346    #[test]
1347    fn test_bind_to_actor_rebind() {
1348        let manager = create_test_manager();
1349        let ip = create_test_ip(1);
1350
1351        manager.create_session("token_hash_1", ip, None);
1352
1353        // Bind to first actor
1354        assert!(manager.bind_to_actor("token_hash_1", "actor_123"));
1355        assert_eq!(manager.get_actor_sessions("actor_123").len(), 1);
1356
1357        // Rebind to second actor
1358        assert!(manager.bind_to_actor("token_hash_1", "actor_456"));
1359
1360        // Old actor should have no sessions
1361        assert_eq!(manager.get_actor_sessions("actor_123").len(), 0);
1362        // New actor should have the session
1363        assert_eq!(manager.get_actor_sessions("actor_456").len(), 1);
1364
1365        let session = manager.get_session("token_hash_1").unwrap();
1366        assert_eq!(session.actor_id, Some("actor_456".to_string()));
1367    }
1368
1369    #[test]
1370    fn test_remove_session_cleans_actor_sessions() {
1371        let manager = create_test_manager();
1372        let ip = create_test_ip(1);
1373
1374        manager.create_session("token_hash_1", ip, None);
1375        assert!(manager.bind_to_actor("token_hash_1", "actor_cleanup"));
1376        assert!(manager.actor_sessions.contains_key("actor_cleanup"));
1377
1378        assert!(manager.remove_session("token_hash_1"));
1379        assert!(!manager.actor_sessions.contains_key("actor_cleanup"));
1380    }
1381
1382    #[test]
1383    fn test_get_actor_sessions() {
1384        let manager = create_test_manager();
1385        let ip = create_test_ip(1);
1386
1387        // Create multiple sessions for same actor
1388        manager.create_session("token_1", ip, None);
1389        manager.create_session("token_2", ip, None);
1390        manager.create_session("token_3", ip, None);
1391
1392        assert!(manager.bind_to_actor("token_1", "actor_123"));
1393        assert!(manager.bind_to_actor("token_2", "actor_123"));
1394        assert!(manager.bind_to_actor("token_3", "actor_456"));
1395
1396        let actor_sessions = manager.get_actor_sessions("actor_123");
1397        assert_eq!(actor_sessions.len(), 2);
1398    }
1399
1400    // ========================================================================
1401    // LRU Eviction Tests
1402    // ========================================================================
1403
1404    #[test]
1405    fn test_lru_eviction() {
1406        let config = SessionConfig {
1407            max_sessions: 100,
1408            ..Default::default()
1409        };
1410        let manager = SessionManager::new(config);
1411
1412        // Add 150 sessions (over capacity)
1413        for i in 0..150 {
1414            let ip = create_test_ip((i % 256) as u8);
1415            manager.create_session(&format!("token_{}", i), ip, None);
1416        }
1417
1418        // Lazy eviction doesn't aggressively enforce the limit
1419        assert!(manager.len() <= 150);
1420
1421        // Force more evictions
1422        for i in 150..300 {
1423            let ip = create_test_ip((i % 256) as u8);
1424            manager.create_session(&format!("token_{}", i), ip, None);
1425        }
1426
1427        // Verify evictions occurred
1428        let evictions = manager.stats().evictions.load(Ordering::Relaxed);
1429        assert!(evictions > 0);
1430    }
1431
1432    // ========================================================================
1433    // Session Invalidation Tests
1434    // ========================================================================
1435
1436    #[test]
1437    fn test_invalidate_session() {
1438        let manager = create_test_manager();
1439        let ip = create_test_ip(1);
1440
1441        manager.create_session("token_hash_1", ip, None);
1442        assert_eq!(manager.len(), 1);
1443
1444        let result = manager.invalidate_session("token_hash_1");
1445        assert!(result);
1446        assert_eq!(manager.len(), 0);
1447    }
1448
1449    #[test]
1450    fn test_invalidate_nonexistent_session() {
1451        let manager = create_test_manager();
1452
1453        let result = manager.invalidate_session("nonexistent");
1454        assert!(!result);
1455    }
1456
1457    // ========================================================================
1458    // Suspicious Session Tests
1459    // ========================================================================
1460
1461    #[test]
1462    fn test_mark_suspicious() {
1463        let manager = create_test_manager();
1464        let ip = create_test_ip(1);
1465
1466        manager.create_session("token_hash_1", ip, None);
1467
1468        let alert = HijackAlert {
1469            session_id: "test".to_string(),
1470            alert_type: HijackType::Ja4Mismatch,
1471            original_value: "old".to_string(),
1472            new_value: "new".to_string(),
1473            timestamp: now_ms(),
1474            confidence: 0.9,
1475        };
1476
1477        let result = manager.mark_suspicious("token_hash_1", alert);
1478        assert!(result);
1479
1480        let session = manager.get_session("token_hash_1").unwrap();
1481        assert!(session.is_suspicious);
1482        assert_eq!(session.hijack_alerts.len(), 1);
1483    }
1484
1485    #[test]
1486    fn test_mark_suspicious_nonexistent() {
1487        let manager = create_test_manager();
1488
1489        let alert = HijackAlert {
1490            session_id: "test".to_string(),
1491            alert_type: HijackType::Ja4Mismatch,
1492            original_value: "old".to_string(),
1493            new_value: "new".to_string(),
1494            timestamp: now_ms(),
1495            confidence: 0.9,
1496        };
1497
1498        let result = manager.mark_suspicious("nonexistent", alert);
1499        assert!(!result);
1500    }
1501
1502    #[test]
1503    fn test_list_suspicious_sessions() {
1504        let manager = create_test_manager();
1505        let ip = create_test_ip(1);
1506
1507        // Create sessions and mark some as suspicious
1508        for i in 0..10 {
1509            manager.create_session(&format!("token_{}", i), ip, None);
1510        }
1511
1512        let alert = HijackAlert {
1513            session_id: "test".to_string(),
1514            alert_type: HijackType::Ja4Mismatch,
1515            original_value: "old".to_string(),
1516            new_value: "new".to_string(),
1517            timestamp: now_ms(),
1518            confidence: 0.9,
1519        };
1520
1521        assert!(manager.mark_suspicious("token_0", alert.clone()));
1522        assert!(manager.mark_suspicious("token_2", alert.clone()));
1523        assert!(manager.mark_suspicious("token_4", alert));
1524
1525        let suspicious = manager.list_suspicious_sessions();
1526        assert_eq!(suspicious.len(), 3);
1527    }
1528
1529    // ========================================================================
1530    // List Tests
1531    // ========================================================================
1532
1533    #[test]
1534    fn test_list_sessions() {
1535        let manager = create_test_manager();
1536
1537        for i in 0..10 {
1538            let ip = create_test_ip(i);
1539            manager.create_session(&format!("token_{}", i), ip, None);
1540            std::thread::sleep(std::time::Duration::from_millis(1));
1541        }
1542
1543        // Test pagination
1544        let first_page = manager.list_sessions(5, 0);
1545        assert_eq!(first_page.len(), 5);
1546
1547        let second_page = manager.list_sessions(5, 5);
1548        assert_eq!(second_page.len(), 5);
1549
1550        // Should be sorted by last_activity (most recent first)
1551        for window in first_page.windows(2) {
1552            assert!(window[0].last_activity >= window[1].last_activity);
1553        }
1554    }
1555
1556    // ========================================================================
1557    // Concurrent Access Tests
1558    // ========================================================================
1559
1560    #[test]
1561    fn test_concurrent_access() {
1562        let manager = Arc::new(create_test_manager());
1563        let mut handles = vec![];
1564
1565        // Spawn 10 threads, each creating and validating sessions
1566        for thread_id in 0..10 {
1567            let manager = Arc::clone(&manager);
1568            handles.push(thread::spawn(move || {
1569                for i in 0..100 {
1570                    let ip: IpAddr = format!("10.{}.0.{}", thread_id, i % 256).parse().unwrap();
1571                    let token = format!("token_t{}_{}", thread_id, i);
1572                    let ja4 = format!("ja4_t{}_{}", thread_id, i % 5);
1573
1574                    manager.validate_request(&token, ip, Some(&ja4));
1575                }
1576            }));
1577        }
1578
1579        for handle in handles {
1580            handle.join().unwrap();
1581        }
1582
1583        // Verify no panics and reasonable state
1584        assert!(manager.len() > 0);
1585        assert!(manager.stats().total_created.load(Ordering::Relaxed) > 0);
1586    }
1587
1588    #[test]
1589    fn test_stress_concurrent_sessions() {
1590        let manager = Arc::new(SessionManager::new(SessionConfig {
1591            max_sessions: 10_000,
1592            session_ttl_secs: 86_400,
1593            idle_timeout_secs: 86_400,
1594            ..Default::default()
1595        }));
1596        let mut handles = vec![];
1597
1598        for thread_id in 0..16 {
1599            let manager = Arc::clone(&manager);
1600            handles.push(thread::spawn(move || {
1601                let actor_id = format!("actor_{}", thread_id);
1602                for i in 0..300 {
1603                    let ip: IpAddr = format!("10.{}.{}.{}", thread_id, i / 256, i % 256)
1604                        .parse()
1605                        .unwrap();
1606                    let token = format!("token_t{}_{}", thread_id, i);
1607                    let ja4 = format!("ja4_t{}_{}", thread_id, i % 10);
1608
1609                    manager.validate_request(&token, ip, Some(&ja4));
1610
1611                    if i % 3 == 0 {
1612                        let _ = manager.bind_to_actor(&token, &actor_id);
1613                    }
1614                    if i % 2 == 0 {
1615                        manager.touch_session(&token);
1616                    }
1617                }
1618            }));
1619        }
1620
1621        for handle in handles {
1622            handle.join().unwrap();
1623        }
1624
1625        let stats = manager.stats();
1626        assert!(manager.len() > 0);
1627        assert!(stats.total_created.load(Ordering::Relaxed) > 0);
1628        assert!(!manager.get_actor_sessions("actor_0").is_empty());
1629    }
1630
1631    // ========================================================================
1632    // Statistics Tests
1633    // ========================================================================
1634
1635    #[test]
1636    fn test_stats() {
1637        let manager = create_test_manager();
1638
1639        // Initial stats
1640        let stats = manager.stats().snapshot();
1641        assert_eq!(stats.total_sessions, 0);
1642        assert_eq!(stats.suspicious_sessions, 0);
1643
1644        // Create sessions
1645        for i in 0..5 {
1646            let ip = create_test_ip(i);
1647            manager.create_session(&format!("token_{}", i), ip, Some(&format!("ja4_{}", i)));
1648        }
1649
1650        let stats = manager.stats().snapshot();
1651        assert_eq!(stats.total_sessions, 5);
1652        assert_eq!(stats.active_sessions, 5);
1653        assert_eq!(stats.total_created, 5);
1654    }
1655
1656    // ========================================================================
1657    // Clear Tests
1658    // ========================================================================
1659
1660    #[test]
1661    fn test_clear() {
1662        let manager = create_test_manager();
1663
1664        for i in 0..10 {
1665            let ip = create_test_ip(i);
1666            manager.create_session(&format!("token_{}", i), ip, None);
1667        }
1668
1669        assert_eq!(manager.len(), 10);
1670
1671        manager.clear();
1672
1673        assert_eq!(manager.len(), 0);
1674        assert!(manager.session_by_id.is_empty());
1675        assert!(manager.actor_sessions.is_empty());
1676    }
1677
1678    // ========================================================================
1679    // Default Implementation Tests
1680    // ========================================================================
1681
1682    #[test]
1683    fn test_default() {
1684        let manager = SessionManager::default();
1685
1686        assert!(manager.is_enabled());
1687        assert!(manager.is_empty());
1688        assert_eq!(manager.config().max_sessions, 50_000);
1689    }
1690
1691    // ========================================================================
1692    // Session ID Generation Tests
1693    // ========================================================================
1694
1695    #[test]
1696    fn test_session_id_uniqueness() {
1697        let mut ids = std::collections::HashSet::new();
1698        for _ in 0..1000 {
1699            let id = generate_session_id();
1700            assert!(!ids.contains(&id), "Duplicate ID generated: {}", id);
1701            ids.insert(id);
1702        }
1703    }
1704
1705    #[test]
1706    fn test_session_id_format() {
1707        let id = generate_session_id();
1708
1709        // Should be sess-xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx format
1710        assert!(id.starts_with("sess-"));
1711        assert_eq!(id.len(), 41); // "sess-" (5) + UUID (36)
1712    }
1713
1714    // ========================================================================
1715    // Edge Case Tests
1716    // ========================================================================
1717
1718    #[test]
1719    fn test_empty_ja4_fingerprint() {
1720        let manager = create_test_manager();
1721        let ip = create_test_ip(1);
1722
1723        manager.create_session("token_hash_1", ip, Some(""));
1724
1725        let session = manager.get_session("token_hash_1").unwrap();
1726        assert!(session.bound_ja4.is_none());
1727    }
1728
1729    #[test]
1730    fn test_ipv6_addresses() {
1731        let manager = create_test_manager();
1732
1733        let ipv6: IpAddr = "2001:db8::1".parse().unwrap();
1734
1735        let session = manager.create_session("token_hash_1", ipv6, None);
1736        assert_eq!(session.request_count, 1);
1737
1738        let decision = manager.validate_request("token_hash_1", ipv6, None);
1739        assert_eq!(decision, SessionDecision::Valid);
1740    }
1741
1742    #[test]
1743    fn test_disabled_manager() {
1744        let config = SessionConfig {
1745            enabled: false,
1746            ..Default::default()
1747        };
1748        let manager = SessionManager::new(config);
1749
1750        assert!(!manager.is_enabled());
1751
1752        let ip = create_test_ip(1);
1753        let decision = manager.validate_request("token_hash_1", ip, None);
1754
1755        // Should return Valid without creating session when disabled
1756        assert_eq!(decision, SessionDecision::Valid);
1757        assert!(manager.is_empty());
1758    }
1759
1760    // ========================================================================
1761    // Hijack Alert Trimming Tests
1762    // ========================================================================
1763
1764    #[test]
1765    fn test_alert_trimming() {
1766        let config = SessionConfig {
1767            max_alerts_per_session: 3,
1768            ..Default::default()
1769        };
1770        let manager = SessionManager::new(config);
1771        let ip = create_test_ip(1);
1772
1773        manager.create_session("token_hash_1", ip, Some("ja4_original"));
1774
1775        // Add more alerts than max
1776        for i in 0..10 {
1777            let alert = HijackAlert {
1778                session_id: "test".to_string(),
1779                alert_type: HijackType::Ja4Mismatch,
1780                original_value: "old".to_string(),
1781                new_value: format!("new_{}", i),
1782                timestamp: now_ms(),
1783                confidence: 0.9,
1784            };
1785            assert!(manager.mark_suspicious("token_hash_1", alert));
1786        }
1787
1788        let session = manager.get_session("token_hash_1").unwrap();
1789        assert_eq!(session.hijack_alerts.len(), 3);
1790
1791        // Should keep most recent
1792        assert_eq!(session.hijack_alerts[2].new_value, "new_9");
1793    }
1794
1795    // ========================================================================
1796    // Session Touch Tests
1797    // ========================================================================
1798
1799    #[test]
1800    fn test_touch_session() {
1801        let manager = create_test_manager();
1802        let ip = create_test_ip(1);
1803
1804        manager.create_session("token_hash_1", ip, None);
1805
1806        let before = manager.get_session("token_hash_1").unwrap().last_activity;
1807
1808        std::thread::sleep(std::time::Duration::from_millis(10));
1809
1810        manager.touch_session("token_hash_1");
1811
1812        let after = manager.get_session("token_hash_1").unwrap().last_activity;
1813        assert!(after > before);
1814    }
1815}