1use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use crate::context::Context;
15use crate::plugins::resources::PluginResources;
16use crate::sessions::{SessionId, SessionMeta, SessionStore, StoreError};
17
18use super::cookie_session::{build_set_cookie, delete_cookie, CookieAttrs, CookieSealer};
19
20pub enum SessionBackend {
21 Cookie,
22 Store {
23 store: Arc<dyn SessionStore>,
24 store_name: String,
25 },
26}
27
28impl std::fmt::Debug for SessionBackend {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 Self::Cookie => write!(f, "SessionBackend::Cookie"),
32 Self::Store { store_name, .. } => {
33 write!(f, "SessionBackend::Store({store_name})")
34 }
35 }
36 }
37}
38
39fn nested_or_flat<'a>(
40 config: &'a HashMap<String, serde_json::Value>,
41 nested: &str,
42 flat: &str,
43) -> Option<&'a str> {
44 config
45 .get("session")
46 .and_then(|s| s.get(nested))
47 .or_else(|| config.get(flat))
48 .and_then(|v| v.as_str())
49 .filter(|s| !s.is_empty())
50}
51
52pub fn parse_backend(
55 config: &HashMap<String, serde_json::Value>,
56 resources: &Arc<PluginResources>,
57 plugin: &str,
58) -> Result<SessionBackend, String> {
59 match nested_or_flat(config, "storage", "session_storage").unwrap_or("cookie") {
60 "cookie" => Ok(SessionBackend::Cookie),
61 "redis" => {
62 let name = nested_or_flat(config, "store", "session_store").ok_or_else(|| {
63 format!(
64 "{plugin}: session.storage 'redis' requires 'session.store' naming a declared stores: entry"
65 )
66 })?;
67 let store = resources.stores.load().session_store(name)?;
68 Ok(SessionBackend::Store {
69 store,
70 store_name: name.to_string(),
71 })
72 }
73 other => Err(format!(
74 "{plugin}: unknown session.storage '{other}' — supported: cookie, redis"
75 )),
76 }
77}
78
79fn now_unix() -> u64 {
80 SystemTime::now()
81 .duration_since(UNIX_EPOCH)
82 .map(|d| d.as_secs())
83 .unwrap_or(0)
84}
85
86pub fn meta_now(ctx: &Context, plugin: &str, subject: &str, ttl: Duration) -> SessionMeta {
88 let var = |k: &str| {
89 ctx.message
90 .get(k)
91 .and_then(|v| v.as_str())
92 .unwrap_or("")
93 .to_string()
94 };
95 let now = now_unix();
96 SessionMeta {
97 id: String::new(),
98 subject: subject.to_string(),
99 plugin: plugin.to_string(),
100 policy: var("__policy"),
101 route: var("__route"),
102 created_at: now,
103 expires_at: now.saturating_add(ttl.as_secs()),
104 }
105}
106
107pub async fn establish(
111 backend: &SessionBackend,
112 sealer: &CookieSealer,
113 payload: &[u8],
114 ttl: Duration,
115 meta: SessionMeta,
116 cookie_name: &str,
117 attrs: &CookieAttrs<'_>,
118) -> Result<String, StoreError> {
119 let sealed = sealer.seal(payload, ttl);
120 match backend {
121 SessionBackend::Cookie => Ok(build_set_cookie(cookie_name, &sealed, attrs)),
122 SessionBackend::Store { store, .. } => {
123 let id = SessionId::random();
124 store.put(&id, sealed.as_bytes(), ttl, &meta).await?;
125 Ok(build_set_cookie(cookie_name, id.as_str(), attrs))
126 }
127 }
128}
129
130pub async fn load(
133 backend: &SessionBackend,
134 sealer: &CookieSealer,
135 cookie_value: &str,
136) -> Result<Option<Vec<u8>>, StoreError> {
137 match backend {
138 SessionBackend::Cookie => Ok(sealer.open(cookie_value).ok()),
139 SessionBackend::Store { store, .. } => {
140 let Some(id) = SessionId::parse(cookie_value) else {
141 return Ok(None);
142 };
143 let Some(sealed) = store.get(&id).await? else {
144 return Ok(None);
145 };
146 let sealed = String::from_utf8(sealed).unwrap_or_default();
147 Ok(sealer.open(&sealed).ok())
148 }
149 }
150}
151
152pub async fn destroy(
157 backend: &SessionBackend,
158 cookie_value: Option<&str>,
159 cookie_name: &str,
160 path: &str,
161) -> Result<String, StoreError> {
162 if let (SessionBackend::Store { store, .. }, Some(value)) = (backend, cookie_value) {
163 if let Some(id) = SessionId::parse(value) {
164 store.delete(&id).await?;
165 }
166 }
167 Ok(delete_cookie(cookie_name, path))
168}
169
170#[allow(clippy::too_many_arguments)] pub async fn update(
175 backend: &SessionBackend,
176 sealer: &CookieSealer,
177 cookie_value: &str,
178 payload: &[u8],
179 ttl: Duration,
180 meta: SessionMeta,
181 cookie_name: &str,
182 attrs: &CookieAttrs<'_>,
183) -> Result<Option<String>, StoreError> {
184 let sealed = sealer.seal(payload, ttl);
185 match backend {
186 SessionBackend::Cookie => Ok(Some(build_set_cookie(cookie_name, &sealed, attrs))),
187 SessionBackend::Store { store, .. } => {
188 let Some(id) = SessionId::parse(cookie_value) else {
189 return Ok(None);
190 };
191 store.put(&id, sealed.as_bytes(), ttl, &meta).await?;
192 Ok(None)
193 }
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::sessions::FakeSessionStore;
201
202 fn store_backend(fake: Arc<FakeSessionStore>) -> SessionBackend {
203 SessionBackend::Store {
204 store: fake,
205 store_name: "s1".to_string(),
206 }
207 }
208
209 fn meta() -> SessionMeta {
210 SessionMeta {
211 id: String::new(),
212 subject: "alice".to_string(),
213 plugin: "test".to_string(),
214 policy: String::new(),
215 route: String::new(),
216 created_at: 0,
217 expires_at: 0,
218 }
219 }
220
221 #[tokio::test]
222 async fn test_cookie_mode_matches_legacy_shape() {
223 let sealer = CookieSealer::new("k");
224 let set = establish(
225 &SessionBackend::Cookie,
226 &sealer,
227 b"payload",
228 Duration::from_secs(60),
229 meta(),
230 "test_session",
231 &CookieAttrs::default(),
232 )
233 .await
234 .unwrap();
235 let value = set
237 .strip_prefix("test_session=")
238 .unwrap()
239 .split(';')
240 .next()
241 .unwrap()
242 .to_string();
243 assert_eq!(
244 load(&SessionBackend::Cookie, &sealer, &value)
245 .await
246 .unwrap()
247 .as_deref(),
248 Some(&b"payload"[..])
249 );
250 }
251
252 #[tokio::test]
253 async fn test_store_mode_round_trip_id_cookie_and_revocation() {
254 let fake = Arc::new(FakeSessionStore::default());
255 let backend = store_backend(fake.clone());
256 let sealer = CookieSealer::new("k");
257 let set = establish(
258 &backend,
259 &sealer,
260 b"payload",
261 Duration::from_secs(60),
262 meta(),
263 "test_session",
264 &CookieAttrs::default(),
265 )
266 .await
267 .unwrap();
268 let value = set
269 .strip_prefix("test_session=")
270 .unwrap()
271 .split(';')
272 .next()
273 .unwrap()
274 .to_string();
275 assert_eq!(value.len(), 32);
277 assert!(crate::sessions::SessionId::parse(&value).is_some());
278
279 assert_eq!(
280 load(&backend, &sealer, &value).await.unwrap().as_deref(),
281 Some(&b"payload"[..])
282 );
283 assert_eq!(load(&backend, &sealer, "not-an-id").await.unwrap(), None);
285
286 destroy(&backend, Some(&value), "test_session", "/")
288 .await
289 .unwrap();
290 assert_eq!(load(&backend, &sealer, &value).await.unwrap(), None);
291 }
292
293 #[tokio::test]
294 async fn test_store_outage_is_error_not_unauthenticated() {
295 let fake = Arc::new(FakeSessionStore::default());
296 fake.fail.store(true, std::sync::atomic::Ordering::Relaxed);
297 let backend = store_backend(fake);
298 let sealer = CookieSealer::new("k");
299 let id = crate::sessions::SessionId::random();
300 assert!(load(&backend, &sealer, id.as_str()).await.is_err());
301 assert!(destroy(&backend, Some(id.as_str()), "n", "/")
302 .await
303 .is_err());
304 }
305
306 #[tokio::test]
307 async fn test_update_keeps_id_in_store_mode() {
308 let fake = Arc::new(FakeSessionStore::default());
309 let backend = store_backend(fake);
310 let sealer = CookieSealer::new("k");
311 let set = establish(
312 &backend,
313 &sealer,
314 b"v1",
315 Duration::from_secs(60),
316 meta(),
317 "n",
318 &CookieAttrs::default(),
319 )
320 .await
321 .unwrap();
322 let value = set
323 .strip_prefix("n=")
324 .unwrap()
325 .split(';')
326 .next()
327 .unwrap()
328 .to_string();
329 let out = update(
330 &backend,
331 &sealer,
332 &value,
333 b"v2",
334 Duration::from_secs(60),
335 meta(),
336 "n",
337 &CookieAttrs::default(),
338 )
339 .await
340 .unwrap();
341 assert!(out.is_none(), "store mode must not reissue the cookie");
342 assert_eq!(
343 load(&backend, &sealer, &value).await.unwrap().as_deref(),
344 Some(&b"v2"[..])
345 );
346 }
347
348 #[test]
349 fn test_parse_backend_errors() {
350 use crate::plugins::resources::PluginResources;
351 let resources = PluginResources::empty();
352 let cfg = |json: serde_json::Value| -> HashMap<String, serde_json::Value> {
353 serde_json::from_value(json).unwrap()
354 };
355 assert!(matches!(
356 parse_backend(&cfg(serde_json::json!({})), &resources, "p").unwrap(),
357 SessionBackend::Cookie
358 ));
359 let err = parse_backend(
360 &cfg(serde_json::json!({"session": {"storage": "redis"}})),
361 &resources,
362 "p",
363 )
364 .unwrap_err();
365 assert!(err.contains("requires 'session.store'"), "{err}");
366 let err = parse_backend(
367 &cfg(serde_json::json!({"session": {"storage": "memcached"}})),
368 &resources,
369 "p",
370 )
371 .unwrap_err();
372 assert!(err.contains("unknown session.storage"), "{err}");
373 let err = parse_backend(
374 &cfg(serde_json::json!({"session_storage": "redis", "session_store": "nope"})),
375 &resources,
376 "p",
377 )
378 .unwrap_err();
379 assert!(err.contains("'nope'"), "{err}");
380 }
381}