Skip to main content

pylon_plugin/builtin/
session_expiry.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use crate::Plugin;
6
7/// Session with expiry tracking.
8#[derive(Debug, Clone)]
9#[allow(dead_code)]
10struct TrackedSession {
11    token: String,
12    user_id: String,
13    created_at: u64,
14    last_active: u64,
15    expires_at: u64,
16}
17
18/// Session expiry plugin. Tracks session lifetimes and rejects expired sessions.
19pub struct SessionExpiryPlugin {
20    /// Maximum session lifetime in seconds (absolute expiry).
21    max_lifetime: u64,
22    /// Idle timeout in seconds (expires if no activity).
23    idle_timeout: u64,
24    sessions: Mutex<HashMap<String, TrackedSession>>,
25}
26
27impl SessionExpiryPlugin {
28    /// Create with default settings: 24h max lifetime, 2h idle timeout.
29    pub fn new() -> Self {
30        Self {
31            max_lifetime: 86400, // 24 hours
32            idle_timeout: 7200,  // 2 hours
33            sessions: Mutex::new(HashMap::new()),
34        }
35    }
36
37    /// Create with custom timeouts.
38    pub fn with_timeouts(max_lifetime: Duration, idle_timeout: Duration) -> Self {
39        Self {
40            max_lifetime: max_lifetime.as_secs(),
41            idle_timeout: idle_timeout.as_secs(),
42            sessions: Mutex::new(HashMap::new()),
43        }
44    }
45
46    /// Register a session for tracking.
47    pub fn track(&self, token: &str, user_id: &str) {
48        let now = now_secs();
49        self.sessions.lock().unwrap().insert(
50            token.to_string(),
51            TrackedSession {
52                token: token.to_string(),
53                user_id: user_id.to_string(),
54                created_at: now,
55                last_active: now,
56                expires_at: now + self.max_lifetime,
57            },
58        );
59    }
60
61    /// Check if a session is still valid. Updates last_active if valid.
62    pub fn check(&self, token: &str) -> Result<String, String> {
63        let now = now_secs();
64        let mut sessions = self.sessions.lock().unwrap();
65
66        let session = sessions.get_mut(token).ok_or("Session not found")?;
67
68        // Check absolute expiry.
69        if now > session.expires_at {
70            sessions.remove(token);
71            return Err("Session expired".into());
72        }
73
74        // Check idle timeout.
75        if now - session.last_active > self.idle_timeout {
76            sessions.remove(token);
77            return Err("Session timed out due to inactivity".into());
78        }
79
80        // Session is valid — update last_active.
81        session.last_active = now;
82        Ok(session.user_id.clone())
83    }
84
85    /// Explicitly expire a session.
86    pub fn expire(&self, token: &str) -> bool {
87        self.sessions.lock().unwrap().remove(token).is_some()
88    }
89
90    /// Clean up all expired sessions.
91    pub fn cleanup(&self) -> usize {
92        let now = now_secs();
93        let mut sessions = self.sessions.lock().unwrap();
94        let before = sessions.len();
95        sessions.retain(|_, s| s.expires_at > now && (now - s.last_active) <= self.idle_timeout);
96        before - sessions.len()
97    }
98
99    /// Get the number of active sessions.
100    pub fn active_count(&self) -> usize {
101        self.sessions.lock().unwrap().len()
102    }
103
104    /// Refresh a session's expiry (extend the lifetime).
105    ///
106    /// The new `expires_at` is capped at `created_at + max_lifetime`, so a
107    /// session can be kept alive by activity but will still be forced to
108    /// re-authenticate when its absolute lifetime is up. Previously this
109    /// method set `expires_at = now + max_lifetime` unconditionally, which
110    /// meant a busy user could renew their session indefinitely — defeating
111    /// the whole point of an "absolute" lifetime cap.
112    pub fn refresh(&self, token: &str) -> bool {
113        let now = now_secs();
114        let mut sessions = self.sessions.lock().unwrap();
115        if let Some(session) = sessions.get_mut(token) {
116            let hard_cap = session.created_at.saturating_add(self.max_lifetime);
117            if now >= hard_cap {
118                // Session is past its absolute lifetime — refusing to renew.
119                sessions.remove(token);
120                return false;
121            }
122            session.last_active = now;
123            let proposed = now.saturating_add(self.max_lifetime);
124            session.expires_at = proposed.min(hard_cap);
125            true
126        } else {
127            false
128        }
129    }
130}
131
132impl Plugin for SessionExpiryPlugin {
133    fn name(&self) -> &str {
134        "session-expiry"
135    }
136}
137
138fn now_secs() -> u64 {
139    SystemTime::now()
140        .duration_since(UNIX_EPOCH)
141        .unwrap_or_default()
142        .as_secs()
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn track_and_check() {
151        let plugin = SessionExpiryPlugin::new();
152        plugin.track("token-1", "user-1");
153
154        let user_id = plugin.check("token-1").unwrap();
155        assert_eq!(user_id, "user-1");
156    }
157
158    #[test]
159    fn unknown_token_fails() {
160        let plugin = SessionExpiryPlugin::new();
161        assert!(plugin.check("unknown").is_err());
162    }
163
164    #[test]
165    fn expire_session() {
166        let plugin = SessionExpiryPlugin::new();
167        plugin.track("token-1", "user-1");
168
169        assert!(plugin.expire("token-1"));
170        assert!(plugin.check("token-1").is_err());
171    }
172
173    #[test]
174    fn active_count() {
175        let plugin = SessionExpiryPlugin::new();
176        assert_eq!(plugin.active_count(), 0);
177        plugin.track("t1", "u1");
178        plugin.track("t2", "u2");
179        assert_eq!(plugin.active_count(), 2);
180    }
181
182    #[test]
183    fn refresh_extends_lifetime() {
184        let plugin = SessionExpiryPlugin::new();
185        plugin.track("t1", "u1");
186        assert!(plugin.refresh("t1"));
187        assert!(plugin.check("t1").is_ok());
188    }
189
190    #[test]
191    fn refresh_unknown_returns_false() {
192        let plugin = SessionExpiryPlugin::new();
193        assert!(!plugin.refresh("unknown"));
194    }
195
196    #[test]
197    fn cleanup_removes_expired() {
198        let plugin = SessionExpiryPlugin::with_timeouts(
199            Duration::from_secs(86400),
200            Duration::from_secs(86400),
201        );
202        plugin.track("t1", "u1");
203        // Not expired yet, cleanup should remove 0.
204        let removed = plugin.cleanup();
205        assert_eq!(removed, 0);
206        assert_eq!(plugin.active_count(), 1);
207    }
208
209    #[test]
210    fn custom_timeouts() {
211        let plugin =
212            SessionExpiryPlugin::with_timeouts(Duration::from_secs(3600), Duration::from_secs(600));
213        plugin.track("t1", "u1");
214        assert!(plugin.check("t1").is_ok());
215    }
216}