use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use rand::Rng; /// Short-lived single-use tickets that let `EventSource` clients open the SSE /// stream without putting the JWT in the URL (where it would leak via access /// logs / referer / browser history). /// /// Flow: client `POST /api/v1/stream/ticket` with `Authorization: Bearer `, /// server returns an opaque ticket, client passes it as `?ticket=...` on the /// stream open. Tickets are consumed on use and expire after `TTL`. const TTL: Duration = Duration::from_secs(30); #[derive(Clone)] pub struct SseTicketStore { inner: Arc>>, } #[derive(Clone)] struct Entry { token_hash: String, issued_at: Instant, } impl SseTicketStore { pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(HashMap::new())), } } /// Mint a new ticket bound to the caller's session (identified by token hash). pub fn issue(&self, token_hash: String) -> String { let ticket = random_ticket(); let mut map = self.inner.lock().unwrap(); map.insert( ticket.clone(), Entry { token_hash, issued_at: Instant::now(), }, ); ticket } /// Consume a ticket. Returns `Some(token_hash)` if the ticket exists and is /// not expired. Single-use: the ticket is removed regardless of whether it /// was still fresh, so a replay can't slip through after expiry. pub fn consume(&self, ticket: &str) -> Option { let mut map = self.inner.lock().unwrap(); let entry = map.remove(ticket)?; if entry.issued_at.elapsed() > TTL { return None; } Some(entry.token_hash) } /// Drop expired entries — called from the background maintenance task so a /// long-running process doesn't accumulate stale tickets. pub fn prune(&self) { let mut map = self.inner.lock().unwrap(); map.retain(|_, e| e.issued_at.elapsed() <= TTL); } } fn random_ticket() -> String { // 192 bits of randomness, base32-ish hex. Plenty of entropy and URL-safe. let mut rng = rand::rng(); let mut bytes = [0u8; 24]; rng.fill(&mut bytes); bytes.iter().map(|b| format!("{b:02x}")).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn issue_then_consume_returns_the_hash_exactly_once() { let store = SseTicketStore::new(); let ticket = store.issue("hash-1".into()); assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1")); // Single-use: a replay of the same ticket is rejected. assert_eq!(store.consume(&ticket), None, "a consumed ticket must not be reusable"); } #[test] fn unknown_ticket_consumes_to_none() { let store = SseTicketStore::new(); assert_eq!(store.consume("never-issued"), None); } #[test] fn issued_tickets_are_unique_and_hex() { let store = SseTicketStore::new(); let a = store.issue("h".into()); let b = store.issue("h".into()); assert_ne!(a, b, "each ticket must be unique"); assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars"); assert!(a.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn fresh_ticket_survives_prune() { let store = SseTicketStore::new(); let ticket = store.issue("h".into()); store.prune(); // not expired → kept assert_eq!(store.consume(&ticket).as_deref(), Some("h")); } #[test] fn expired_ticket_consumes_to_none() { // Construct an entry that is already past the TTL and confirm consume() rejects it. let store = SseTicketStore::new(); let stale = "stale-ticket".to_string(); store.inner.lock().unwrap().insert( stale.clone(), Entry { token_hash: "h".into(), issued_at: Instant::now() .checked_sub(TTL + Duration::from_secs(1)) .expect("host uptime should exceed the ticket TTL"), }, ); assert_eq!(store.consume(&stale), None, "an expired ticket must not authenticate"); } }