1use std::{fmt, net::IpAddr, sync::LazyLock};
4
5use chrono::{DateTime, TimeDelta, Utc};
6use redis::{Script, aio::ConnectionManager};
7use serde::Serialize;
8use uuid::Uuid;
9
10use super::{SessionRecord, SessionStore, SessionUpdate, Supersede, TokenLookup, TokenRecord};
11use crate::{
12 checks::Check,
13 error::StoreError,
14 event::{EventKind, SessionEvent},
15 fingerprint::Baseline,
16 token::TokenHash,
17};
18
19const PURGE_BATCH: usize = 500;
21
22macro_rules! lua_helpers {
24 () => {
25 r"
26local function read_session(P, sid)
27 local v = redis.call('HMGET', P .. 's:' .. sid,
28 'subject', 'created', 'auth', 'seen', 'expires', 'step_up', 'baseline')
29 if not v[1] then return nil end
30 return {sid, v[1], v[2], v[3], v[4], v[5], v[6], v[7]}
31end
32
33local function del_session(P, sid)
34 local s = P .. 's:' .. sid
35 local k = P .. 'k:' .. sid
36 local subject = redis.call('HGET', s, 'subject')
37 for _, hex in ipairs(redis.call('ZRANGE', k, 0, -1)) do
38 redis.call('DEL', P .. 't:' .. hex)
39 end
40 redis.call('DEL', k)
41 redis.call('ZREM', P .. 'x', sid)
42 redis.call('ZREM', P .. 'i', sid)
43 if subject then
44 redis.call('SREM', P .. 'u:' .. subject, sid)
45 redis.call('DEL', s)
46 return 1
47 end
48 return 0
49end
50"
51 };
52}
53
54static INSERT: LazyLock<Script> = LazyLock::new(|| {
58 Script::new(
59 r"
60local P = ARGV[1]
61if redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end
62redis.call('HSET', KEYS[1], 'subject', ARGV[4], 'created', ARGV[5], 'auth', ARGV[6],
63 'seen', ARGV[7], 'expires', ARGV[8], 'step_up', ARGV[9], 'baseline', ARGV[10],
64 'exp_ms', ARGV[11], 'cur', ARGV[3], 'seq', 1)
65redis.call('HSET', KEYS[2], 'sid', ARGV[2], 'seq', 1, 'issued', ARGV[5])
66redis.call('ZADD', KEYS[3], 1, ARGV[3])
67redis.call('SADD', KEYS[4], ARGV[2])
68redis.call('ZADD', P .. 'x', ARGV[8], ARGV[2])
69redis.call('ZADD', P .. 'i', ARGV[7], ARGV[2])
70redis.call('PEXPIREAT', KEYS[1], ARGV[11])
71redis.call('PEXPIREAT', KEYS[2], ARGV[11])
72redis.call('PEXPIREAT', KEYS[3], ARGV[11])
73local pt = redis.call('PEXPIRETIME', KEYS[4])
74if pt == -1 or pt < tonumber(ARGV[11]) then redis.call('PEXPIREAT', KEYS[4], ARGV[11]) end
75return 1
76",
77 )
78});
79
80static FIND: LazyLock<Script> = LazyLock::new(|| {
82 Script::new(concat!(
83 lua_helpers!(),
84 r"
85local P = ARGV[1]
86local t = redis.call('HMGET', KEYS[1], 'sid', 'seq', 'issued', 'used', 'sup')
87if not t[1] then return false end
88local rec = read_session(P, t[1])
89if not rec then return false end
90local newer = 0
91for _, hex in ipairs(redis.call('ZRANGEBYSCORE', P .. 'k:' .. t[1], '(' .. t[2], '+inf')) do
92 if redis.call('HEXISTS', P .. 't:' .. hex, 'used') == 1 then
93 newer = 1
94 break
95 end
96end
97rec[9] = t[3]
98rec[10] = t[4] or ''
99rec[11] = t[5] or ''
100rec[12] = tostring(newer)
101return rec
102",
103 ))
104});
105
106static APPLY: LazyLock<Script> = LazyLock::new(|| {
110 Script::new(
111 r"
112local s = KEYS[1]
113if redis.call('EXISTS', s) == 0 then return 0 end
114local P, sid, now, mode = ARGV[1], ARGV[2], ARGV[3], ARGV[4]
115local rotated = 0
116if mode ~= '' then
117 local cur = redis.call('HGET', s, 'cur')
118 if cur then
119 local ck = P .. 't:' .. cur
120 local holds = false
121 if mode == 'exact' then
122 holds = cur == ARGV[5]
123 elseif mode == 'before' then
124 local issued = tonumber(redis.call('HGET', ck, 'issued'))
125 holds = issued ~= nil and issued < tonumber(ARGV[5])
126 elseif mode == 'current' then
127 holds = redis.call('EXISTS', ck) == 1
128 end
129 local nk = P .. 't:' .. ARGV[6]
130 if holds and redis.call('EXISTS', nk) == 0 then
131 local k = P .. 'k:' .. sid
132 redis.call('HSET', ck, 'sup', now)
133 local seq = redis.call('HINCRBY', s, 'seq', 1)
134 redis.call('HSET', nk, 'sid', sid, 'seq', seq, 'issued', now)
135 local exp = redis.call('HGET', s, 'exp_ms')
136 if exp then redis.call('PEXPIREAT', nk, exp) end
137 redis.call('ZADD', k, seq, ARGV[6])
138 redis.call('HSET', s, 'cur', ARGV[6])
139 local cutoff = tonumber(ARGV[7])
140 for _, hex in ipairs(redis.call('ZRANGE', k, 0, -1)) do
141 local sup = tonumber(redis.call('HGET', P .. 't:' .. hex, 'sup'))
142 if sup ~= nil and sup < cutoff then
143 redis.call('DEL', P .. 't:' .. hex)
144 redis.call('ZREM', k, hex)
145 end
146 end
147 rotated = 1
148 end
149 end
150end
151if ARGV[8] ~= '' then
152 local mk = P .. 't:' .. ARGV[8]
153 if redis.call('HGET', mk, 'sid') == sid and redis.call('HEXISTS', mk, 'used') == 0 then
154 redis.call('HSET', mk, 'used', now)
155 end
156end
157redis.call('HSET', s, 'seen', now)
158redis.call('ZADD', P .. 'i', now, sid)
159if ARGV[9] ~= '' then redis.call('HSET', s, 'baseline', ARGV[9]) end
160if ARGV[10] ~= '' then redis.call('HSET', s, 'step_up', ARGV[10]) end
161if ARGV[11] ~= '' then redis.call('HSET', s, 'auth', ARGV[11]) end
162return rotated
163",
164 )
165});
166
167static DELETE: LazyLock<Script> =
169 LazyLock::new(|| Script::new(concat!(lua_helpers!(), "return del_session(ARGV[1], ARGV[2])\n")));
170
171static DELETE_SUBJECT: LazyLock<Script> = LazyLock::new(|| {
173 Script::new(concat!(
174 lua_helpers!(),
175 r"
176local n = 0
177for _, sid in ipairs(redis.call('SMEMBERS', KEYS[1])) do
178 n = n + del_session(ARGV[1], sid)
179end
180redis.call('DEL', KEYS[1])
181return n
182",
183 ))
184});
185
186static LIST_SUBJECT: LazyLock<Script> = LazyLock::new(|| {
188 Script::new(concat!(
189 lua_helpers!(),
190 r"
191local out = {}
192for _, sid in ipairs(redis.call('SMEMBERS', KEYS[1])) do
193 local rec = read_session(ARGV[1], sid)
194 if rec then
195 out[#out + 1] = rec
196 else
197 redis.call('SREM', KEYS[1], sid)
198 end
199end
200return out
201",
202 ))
203});
204
205static DELETE_EXPIRED: LazyLock<Script> = LazyLock::new(|| {
207 Script::new(concat!(
208 lua_helpers!(),
209 r"
210local P = ARGV[1]
211local found, n = 0, 0
212for _, idx in ipairs({{'x', ARGV[2]}, {'i', ARGV[3]}}) do
213 local ids = redis.call('ZRANGEBYSCORE', P .. idx[1], '-inf', idx[2], 'LIMIT', 0, ARGV[4])
214 found = found + #ids
215 for _, sid in ipairs(ids) do n = n + del_session(P, sid) end
216end
217return {n, found}
218",
219 ))
220});
221
222static DELETE_EVENTS: LazyLock<Script> = LazyLock::new(|| {
224 Script::new(
225 r"
226local bound = '(' .. ARGV[2]
227local n = redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', bound)
228for _, m in ipairs(redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', bound)) do
229 redis.call('DEL', ARGV[1] .. 'c:' .. m)
230end
231redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', bound)
232return n
233",
234 )
235});
236
237static CLAIM: LazyLock<Script> = LazyLock::new(|| {
239 Script::new(
240 r"
241local at = tonumber(redis.call('GET', KEYS[1]))
242if at ~= nil and tonumber(ARGV[1]) - at < tonumber(ARGV[2]) then return 0 end
243redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
244redis.call('ZADD', KEYS[2], ARGV[1], ARGV[4])
245return 1
246",
247 )
248});
249
250#[derive(Clone)]
284pub struct ValkeyStore {
285 conn: ConnectionManager,
286 prefix: String,
287}
288
289impl fmt::Debug for ValkeyStore {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 f.debug_struct("ValkeyStore")
292 .field("key_prefix", &self.prefix)
293 .finish_non_exhaustive()
294 }
295}
296
297impl ValkeyStore {
298 pub fn new(conn: ConnectionManager) -> Self {
300 Self {
301 conn,
302 prefix: "sg:".to_owned(),
303 }
304 }
305
306 pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
313 let client = redis::Client::open(url)?;
314 Ok(Self::new(ConnectionManager::new(client).await?))
315 }
316
317 #[must_use]
320 pub fn key_prefix(mut self, prefix: impl Into<String>) -> Self {
321 self.prefix = prefix.into();
322 self
323 }
324
325 fn key(&self, kind: &str, id: &str) -> String {
326 format!("{}{kind}:{id}", self.prefix)
327 }
328
329 fn conn(&self) -> ConnectionManager {
330 self.conn.clone()
331 }
332}
333
334fn micros(at: DateTime<Utc>) -> String {
335 at.timestamp_micros().to_string()
336}
337
338fn parse_time(value: &str) -> Result<DateTime<Utc>, StoreError> {
339 value
340 .parse::<i64>()
341 .ok()
342 .and_then(DateTime::from_timestamp_micros)
343 .ok_or_else(|| "valkey store: malformed timestamp".into())
344}
345
346fn parse_optional_time(value: &str) -> Result<Option<DateTime<Utc>>, StoreError> {
347 if value.is_empty() {
348 Ok(None)
349 } else {
350 parse_time(value).map(Some)
351 }
352}
353
354fn parse_session(fields: &[String]) -> Result<SessionRecord, StoreError> {
356 let [id, subject, created, auth, seen, expires, step_up, baseline] = fields else {
357 return Err("valkey store: malformed session".into());
358 };
359 let baseline: Baseline = serde_json::from_str(baseline)?;
360 Ok(SessionRecord {
361 id: Uuid::parse_str(id)?,
362 subject: subject.clone(),
363 created_at: parse_time(created)?,
364 authenticated_at: parse_time(auth)?,
365 last_seen_at: parse_time(seen)?,
366 expires_at: parse_time(expires)?,
367 step_up_required: step_up == "1",
368 baseline,
369 })
370}
371
372#[derive(Serialize)]
373struct EventRow<'a> {
374 id: Uuid,
376 session_id: Uuid,
377 subject: &'a str,
378 kind: EventKind,
379 enforced: bool,
380 score: Option<u32>,
381 signals: &'a [Check],
382 ip: IpAddr,
383 user_agent: Option<&'a str>,
384 at: DateTime<Utc>,
385}
386
387impl SessionStore for ValkeyStore {
388 async fn insert_session(&self, session: &SessionRecord, token: &TokenHash) -> Result<(), StoreError> {
389 let sid = session.id.to_string();
390 let hex = token.to_hex();
391 let exp_ms = session
392 .expires_at
393 .timestamp_micros()
394 .saturating_add(999)
395 .div_euclid(1000);
396 let inserted: i64 = INSERT
397 .key(self.key("s", &sid))
398 .key(self.key("t", &hex))
399 .key(self.key("k", &sid))
400 .key(self.key("u", &session.subject))
401 .arg(&self.prefix)
402 .arg(&sid)
403 .arg(&hex)
404 .arg(&session.subject)
405 .arg(micros(session.created_at))
406 .arg(micros(session.authenticated_at))
407 .arg(micros(session.last_seen_at))
408 .arg(micros(session.expires_at))
409 .arg(if session.step_up_required { "1" } else { "0" })
410 .arg(serde_json::to_string(&session.baseline)?)
411 .arg(exp_ms)
412 .invoke_async(&mut self.conn())
413 .await?;
414 if inserted == 1 {
415 Ok(())
416 } else {
417 Err("duplicate session id or token".into())
418 }
419 }
420
421 async fn find_by_token(&self, token: &TokenHash) -> Result<Option<TokenLookup>, StoreError> {
422 let fields: Option<Vec<String>> = FIND
423 .key(self.key("t", &token.to_hex()))
424 .arg(&self.prefix)
425 .invoke_async(&mut self.conn())
426 .await?;
427 let Some(fields) = fields else {
428 return Ok(None);
429 };
430 let [session @ .., issued, used, superseded, newer] = fields.as_slice() else {
431 return Err("valkey store: malformed token lookup".into());
432 };
433 Ok(Some(TokenLookup {
434 session: parse_session(session)?,
435 token: TokenRecord {
436 hash: *token,
437 issued_at: parse_time(issued)?,
438 first_used_at: parse_optional_time(used)?,
439 superseded_at: parse_optional_time(superseded)?,
440 },
441 newer_token_used: newer == "1",
442 }))
443 }
444
445 async fn apply(&self, update: &SessionUpdate) -> Result<bool, StoreError> {
446 let sid = update.session_id.to_string();
447 let (mode, mode_arg, new_token, prune) = match &update.rotation {
448 None => ("", String::new(), String::new(), String::new()),
449 Some(rotation) => {
450 let (mode, arg) = match rotation.supersede {
451 Supersede::Exact(hash) => ("exact", hash.to_hex()),
452 Supersede::CurrentIssuedBefore(at) => ("before", micros(at)),
453 Supersede::Current => ("current", String::new()),
454 };
455 (
456 mode,
457 arg,
458 rotation.new_token.to_hex(),
459 micros(rotation.prune_superseded_before),
460 )
461 }
462 };
463 let baseline = match &update.baseline {
464 Some(baseline) => serde_json::to_string(baseline)?,
465 None => String::new(),
466 };
467 let step_up = match update.step_up_required {
468 Some(true) => "1",
469 Some(false) => "0",
470 None => "",
471 };
472 let rotated: i64 = APPLY
473 .key(self.key("s", &sid))
474 .arg(&self.prefix)
475 .arg(&sid)
476 .arg(micros(update.now))
477 .arg(mode)
478 .arg(mode_arg)
479 .arg(new_token)
480 .arg(prune)
481 .arg(update.mark_used.map(|h| h.to_hex()).unwrap_or_default())
482 .arg(baseline)
483 .arg(step_up)
484 .arg(update.authenticated_at.map(micros).unwrap_or_default())
485 .invoke_async(&mut self.conn())
486 .await?;
487 Ok(rotated == 1)
488 }
489
490 async fn delete_session(&self, session_id: Uuid) -> Result<bool, StoreError> {
491 let deleted: i64 = DELETE
492 .arg(&self.prefix)
493 .arg(session_id.to_string())
494 .invoke_async(&mut self.conn())
495 .await?;
496 Ok(deleted == 1)
497 }
498
499 async fn delete_subject_sessions(&self, subject: &str) -> Result<u64, StoreError> {
500 let deleted: u64 = DELETE_SUBJECT
501 .key(self.key("u", subject))
502 .arg(&self.prefix)
503 .invoke_async(&mut self.conn())
504 .await?;
505 Ok(deleted)
506 }
507
508 async fn list_subject_sessions(&self, subject: &str) -> Result<Vec<SessionRecord>, StoreError> {
509 let rows: Vec<Vec<String>> = LIST_SUBJECT
510 .key(self.key("u", subject))
511 .arg(&self.prefix)
512 .invoke_async(&mut self.conn())
513 .await?;
514 rows.iter().map(|row| parse_session(row)).collect()
515 }
516
517 async fn delete_expired(&self, now: DateTime<Utc>, idle_cutoff: DateTime<Utc>) -> Result<u64, StoreError> {
518 let mut total = 0;
519 loop {
520 let (deleted, found): (u64, u64) = DELETE_EXPIRED
521 .arg(&self.prefix)
522 .arg(micros(now))
523 .arg(micros(idle_cutoff))
524 .arg(PURGE_BATCH)
525 .invoke_async(&mut self.conn())
526 .await?;
527 total += deleted;
528 if found == 0 {
529 return Ok(total);
530 }
531 }
532 }
533
534 async fn record_event(&self, event: &SessionEvent) -> Result<(), StoreError> {
535 let row = EventRow {
536 id: Uuid::new_v4(),
537 session_id: event.session_id,
538 subject: &event.subject,
539 kind: event.kind,
540 enforced: event.enforced,
541 score: event.score,
542 signals: &event.signals,
543 ip: event.ip,
544 user_agent: event.user_agent.as_deref(),
545 at: event.at,
546 };
547 let _added: i64 = redis::cmd("ZADD")
548 .arg(format!("{}e", self.prefix))
549 .arg(event.at.timestamp_micros())
550 .arg(serde_json::to_string(&row)?)
551 .query_async(&mut self.conn())
552 .await?;
553 Ok(())
554 }
555
556 async fn delete_events_before(&self, cutoff: DateTime<Utc>) -> Result<u64, StoreError> {
557 let deleted: u64 = DELETE_EVENTS
558 .key(format!("{}e", self.prefix))
559 .key(format!("{}cz", self.prefix))
560 .arg(&self.prefix)
561 .arg(micros(cutoff))
562 .invoke_async(&mut self.conn())
563 .await?;
564 Ok(deleted)
565 }
566
567 async fn claim_event(
568 &self,
569 session_id: Uuid,
570 kind: EventKind,
571 now: DateTime<Utc>,
572 window: TimeDelta,
573 ) -> Result<bool, StoreError> {
574 let member = format!("{session_id}:{kind}");
575 let micros_window = window.num_microseconds().unwrap_or(i64::MAX);
576 let millis_window = window.num_milliseconds().max(1);
577 let claimed: i64 = CLAIM
578 .key(self.key("c", &member))
579 .key(format!("{}cz", self.prefix))
580 .arg(micros(now))
581 .arg(micros_window)
582 .arg(millis_window)
583 .arg(&member)
584 .invoke_async(&mut self.conn())
585 .await?;
586 Ok(claimed == 1)
587 }
588}