pylon_plugin/builtin/
session_expiry.rs1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use crate::Plugin;
6
7#[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
18pub struct SessionExpiryPlugin {
20 max_lifetime: u64,
22 idle_timeout: u64,
24 sessions: Mutex<HashMap<String, TrackedSession>>,
25}
26
27impl SessionExpiryPlugin {
28 pub fn new() -> Self {
30 Self {
31 max_lifetime: 86400, idle_timeout: 7200, sessions: Mutex::new(HashMap::new()),
34 }
35 }
36
37 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 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 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 if now > session.expires_at {
70 sessions.remove(token);
71 return Err("Session expired".into());
72 }
73
74 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.last_active = now;
82 Ok(session.user_id.clone())
83 }
84
85 pub fn expire(&self, token: &str) -> bool {
87 self.sessions.lock().unwrap().remove(token).is_some()
88 }
89
90 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 pub fn active_count(&self) -> usize {
101 self.sessions.lock().unwrap().len()
102 }
103
104 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 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 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}