Files
EventSnap/backend/src/services/sse_tickets.rs
fabi ee554e7f38 style(backend): rustfmt the whole tree; gate cargo fmt --check in CI
The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files)
so no future functional diff is buried under formatting churn, then gating `cargo fmt
--check` in checks.yml so it stays clean.

Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat:
cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean.

This is the deferred cleanup noted when CI's Format step was first left out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:52:17 +02:00

145 lines
4.6 KiB
Rust

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 <jwt>`,
/// 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<Mutex<HashMap<String, Entry>>>,
}
#[derive(Clone)]
struct Entry {
token_hash: String,
issued_at: Instant,
}
impl SseTicketStore {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Drop every outstanding ticket. Used by the e2e TRUNCATE endpoint: tickets are bound to a
/// session token hash, and TRUNCATE deletes the sessions out from under them, so anything left
/// here is a dangling reference to a user that no longer exists.
pub fn clear(&self) {
self.inner.lock().unwrap().clear();
}
/// 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<String> {
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"
);
}
}