1use std::time::Duration;
16
17use async_trait::async_trait;
18use ring::rand::{SecureRandom, SystemRandom};
19use serde::{Deserialize, Serialize};
20
21#[cfg(feature = "redis-store")]
22pub mod redis;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
27#[allow(dead_code)]
28pub struct SessionId(String);
29
30#[allow(dead_code)]
31impl SessionId {
32 pub fn random() -> Self {
35 let mut bytes = [0u8; 16];
36 SystemRandom::new()
37 .fill(&mut bytes)
38 .expect("system RNG must produce a session id");
39 Self(bytes.iter().map(|b| format!("{b:02x}")).collect())
40 }
41
42 pub fn parse(value: &str) -> Option<Self> {
45 if value.len() == 32 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
46 Some(Self(value.to_ascii_lowercase()))
47 } else {
48 None
49 }
50 }
51
52 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
60#[allow(dead_code)]
61pub struct SessionMeta {
62 #[serde(default)]
63 pub id: String,
64 #[serde(default)]
67 pub subject: String,
68 pub plugin: String,
69 #[serde(default)]
70 pub policy: String,
71 #[serde(default)]
72 pub route: String,
73 pub created_at: u64,
74 pub expires_at: u64,
75}
76
77#[derive(Debug, Clone, Default)]
79#[allow(dead_code)]
80pub struct SessionFilter {
81 pub subject: Option<String>,
82 pub plugin: Option<String>,
83 pub limit: usize,
84 pub cursor: Option<String>,
85}
86
87#[derive(Debug, Clone)]
89#[allow(dead_code)]
90pub struct SessionPage {
91 pub sessions: Vec<SessionMeta>,
92 pub next_cursor: Option<String>,
93}
94
95#[derive(Debug)]
98#[allow(dead_code)]
99pub struct StoreError(pub String);
100
101impl std::fmt::Display for StoreError {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 write!(f, "session store error: {}", self.0)
104 }
105}
106
107#[async_trait]
109#[allow(dead_code)]
110pub trait SessionStore: Send + Sync {
111 async fn put(
113 &self,
114 id: &SessionId,
115 sealed: &[u8],
116 ttl: Duration,
117 meta: &SessionMeta,
118 ) -> Result<(), StoreError>;
119 async fn get(&self, id: &SessionId) -> Result<Option<Vec<u8>>, StoreError>;
121 async fn delete(&self, id: &SessionId) -> Result<(), StoreError>;
123 async fn delete_subject(&self, subject: &str) -> Result<u64, StoreError>;
125 async fn list(&self, filter: &SessionFilter) -> Result<SessionPage, StoreError>;
127 async fn try_lock(&self, id: &SessionId, ttl: Duration) -> Result<bool, StoreError>;
129 async fn unlock(&self, id: &SessionId) -> Result<(), StoreError>;
130}
131
132#[cfg(test)]
134#[derive(Default)]
135pub struct FakeSessionStore {
136 #[allow(clippy::type_complexity)]
137 inner: std::sync::Mutex<
138 std::collections::HashMap<String, (Vec<u8>, SessionMeta, std::time::Instant)>,
139 >,
140 locks: std::sync::Mutex<std::collections::HashSet<String>>,
141 pub fail: std::sync::atomic::AtomicBool,
143}
144
145#[cfg(test)]
146impl FakeSessionStore {
147 fn check(&self) -> Result<(), StoreError> {
148 if self.fail.load(std::sync::atomic::Ordering::Relaxed) {
149 Err(StoreError("fake store failure".to_string()))
150 } else {
151 Ok(())
152 }
153 }
154}
155
156#[cfg(test)]
157#[async_trait]
158impl SessionStore for FakeSessionStore {
159 async fn put(
160 &self,
161 id: &SessionId,
162 sealed: &[u8],
163 ttl: Duration,
164 meta: &SessionMeta,
165 ) -> Result<(), StoreError> {
166 self.check()?;
167 self.inner.lock().unwrap().insert(
168 id.as_str().to_string(),
169 (
170 sealed.to_vec(),
171 meta.clone(),
172 std::time::Instant::now() + ttl,
173 ),
174 );
175 Ok(())
176 }
177
178 async fn get(&self, id: &SessionId) -> Result<Option<Vec<u8>>, StoreError> {
179 self.check()?;
180 let mut inner = self.inner.lock().unwrap();
181 match inner.get(id.as_str()) {
182 Some((_, _, exp)) if *exp <= std::time::Instant::now() => {
183 inner.remove(id.as_str());
184 Ok(None)
185 }
186 Some((sealed, _, _)) => Ok(Some(sealed.clone())),
187 None => Ok(None),
188 }
189 }
190
191 async fn delete(&self, id: &SessionId) -> Result<(), StoreError> {
192 self.check()?;
193 self.inner.lock().unwrap().remove(id.as_str());
194 Ok(())
195 }
196
197 async fn delete_subject(&self, subject: &str) -> Result<u64, StoreError> {
198 self.check()?;
199 let mut inner = self.inner.lock().unwrap();
200 let before = inner.len();
201 inner.retain(|_, (_, meta, _)| meta.subject != subject);
202 Ok((before - inner.len()) as u64)
203 }
204
205 async fn list(&self, filter: &SessionFilter) -> Result<SessionPage, StoreError> {
206 self.check()?;
207 let inner = self.inner.lock().unwrap();
208 let mut sessions: Vec<SessionMeta> = inner
209 .iter()
210 .map(|(id, (_, meta, _))| {
211 let mut m = meta.clone();
212 m.id = id.clone();
213 m
214 })
215 .filter(|m| filter.subject.as_deref().is_none_or(|s| m.subject == s))
216 .filter(|m| filter.plugin.as_deref().is_none_or(|p| m.plugin == p))
217 .collect();
218 sessions.sort_by(|a, b| a.id.cmp(&b.id));
219 if filter.limit > 0 {
220 sessions.truncate(filter.limit);
221 }
222 Ok(SessionPage {
223 sessions,
224 next_cursor: None,
225 })
226 }
227
228 async fn try_lock(&self, id: &SessionId, _ttl: Duration) -> Result<bool, StoreError> {
229 self.check()?;
230 Ok(self.locks.lock().unwrap().insert(id.as_str().to_string()))
231 }
232
233 async fn unlock(&self, id: &SessionId) -> Result<(), StoreError> {
234 self.check()?;
235 self.locks.lock().unwrap().remove(id.as_str());
236 Ok(())
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn test_session_id_random_and_parse() {
246 let id = SessionId::random();
247 assert_eq!(id.as_str().len(), 32);
248 assert!(id.as_str().bytes().all(|b| b.is_ascii_hexdigit()));
249 assert_ne!(SessionId::random().as_str(), id.as_str());
250
251 assert_eq!(SessionId::parse(id.as_str()), Some(id.clone()));
252 assert!(SessionId::parse(&id.as_str().to_uppercase()).is_some());
254 assert!(SessionId::parse("").is_none());
255 assert!(SessionId::parse("nothex-nothex-nothex-nothex-noth").is_none());
256 assert!(SessionId::parse("abcd").is_none());
257 }
258
259 #[tokio::test]
260 async fn test_fake_store_round_trip_and_revocation() {
261 let store = FakeSessionStore::default();
262 let id = SessionId::random();
263 let meta = SessionMeta {
264 id: String::new(),
265 subject: "alice".to_string(),
266 plugin: "openid-connect".to_string(),
267 policy: "p1".to_string(),
268 route: "r1".to_string(),
269 created_at: 1,
270 expires_at: 2,
271 };
272 store
273 .put(&id, b"sealed", Duration::from_secs(60), &meta)
274 .await
275 .unwrap();
276 assert_eq!(
277 store.get(&id).await.unwrap().as_deref(),
278 Some(&b"sealed"[..])
279 );
280
281 let page = store.list(&SessionFilter::default()).await.unwrap();
282 assert_eq!(page.sessions.len(), 1);
283 assert_eq!(page.sessions[0].id, id.as_str());
284 assert_eq!(page.sessions[0].subject, "alice");
285
286 assert_eq!(store.delete_subject("alice").await.unwrap(), 1);
287 assert_eq!(store.get(&id).await.unwrap(), None);
288 }
289
290 #[tokio::test]
291 async fn test_fake_store_lock_and_failure_mode() {
292 let store = FakeSessionStore::default();
293 let id = SessionId::random();
294 assert!(store.try_lock(&id, Duration::from_secs(10)).await.unwrap());
295 assert!(!store.try_lock(&id, Duration::from_secs(10)).await.unwrap());
296 store.unlock(&id).await.unwrap();
297 assert!(store.try_lock(&id, Duration::from_secs(10)).await.unwrap());
298
299 store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
300 assert!(store.get(&id).await.is_err());
301 }
302}