Skip to main content

roboticus_api/
ws_ticket.rs

1//! Short-lived, single-use tickets for WebSocket authentication.
2//!
3//! Replaces the old `?token=<api_key>` pattern that leaked persistent
4//! credentials into proxy/CDN/browser logs.
5//!
6//! ## Flow
7//!
8//! 1. Client `POST /api/ws-ticket` with `x-api-key` header → `{"ticket":"wst_…","expires_in":30}`
9//! 2. Client connects to `/ws?ticket=wst_…` within 30 seconds
10//! 3. Server validates (exists, not expired, not already used), consumes, upgrades
11
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex};
14use std::time::Instant;
15
16use rand::RngCore;
17
18/// Time-to-live for issued tickets.
19const TICKET_TTL: std::time::Duration = std::time::Duration::from_secs(30);
20
21/// Maximum outstanding tickets before forced cleanup.
22const MAX_OUTSTANDING: usize = 1000;
23
24/// Minimum interval between lazy cleanup sweeps.
25const CLEANUP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
26
27struct TicketEntry {
28    issued_at: Instant,
29}
30
31/// In-memory store for short-lived WebSocket upgrade tickets.
32///
33/// Tickets are 30-second TTL, single-use, and stored only in memory —
34/// a server restart invalidates all outstanding tickets (acceptable
35/// because WS connections are also lost on restart).
36#[derive(Clone)]
37pub struct TicketStore {
38    inner: Arc<Mutex<TicketStoreInner>>,
39}
40
41struct TicketStoreInner {
42    tickets: HashMap<String, TicketEntry>,
43    last_cleanup: Instant,
44}
45
46fn lock_or_recover<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
47    m.lock().unwrap_or_else(|poisoned| {
48        tracing::warn!("ticket store mutex poisoned; recovering state");
49        poisoned.into_inner()
50    })
51}
52
53impl Default for TicketStore {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl TicketStore {
60    pub fn new() -> Self {
61        Self {
62            inner: Arc::new(Mutex::new(TicketStoreInner {
63                tickets: HashMap::new(),
64                last_cleanup: Instant::now(),
65            })),
66        }
67    }
68
69    /// Issue a new ticket. Returns the ticket string (e.g. `wst_<64 hex chars>`).
70    pub fn issue(&self) -> String {
71        let mut bytes = [0u8; 32];
72        rand::rngs::OsRng.fill_bytes(&mut bytes);
73        let ticket = format!("wst_{}", hex::encode(bytes));
74
75        let mut inner = lock_or_recover(&self.inner);
76
77        // Lazy cleanup: evict expired tickets periodically or when store is large
78        if inner.tickets.len() >= MAX_OUTSTANDING
79            || inner.last_cleanup.elapsed() >= CLEANUP_INTERVAL
80        {
81            let now = Instant::now();
82            inner
83                .tickets
84                .retain(|_, e| now.duration_since(e.issued_at) < TICKET_TTL);
85            inner.last_cleanup = now;
86        }
87
88        inner.tickets.insert(
89            ticket.clone(),
90            TicketEntry {
91                issued_at: Instant::now(),
92            },
93        );
94        ticket
95    }
96
97    /// Attempt to redeem a ticket. Returns `true` if the ticket was valid,
98    /// not expired, and not previously used. The ticket is consumed atomically.
99    pub fn redeem(&self, ticket: &str) -> bool {
100        let mut inner = lock_or_recover(&self.inner);
101        match inner.tickets.remove(ticket) {
102            Some(entry) => entry.issued_at.elapsed() < TICKET_TTL,
103            None => false,
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::thread;
112    use std::time::Duration;
113
114    #[test]
115    fn issue_format() {
116        let store = TicketStore::new();
117        let ticket = store.issue();
118        assert!(ticket.starts_with("wst_"), "ticket should have wst_ prefix");
119        // wst_ (4 chars) + 64 hex chars = 68 total
120        assert_eq!(ticket.len(), 68, "ticket should be 68 chars total");
121        // Verify the hex portion is valid hex
122        assert!(
123            hex::decode(&ticket[4..]).is_ok(),
124            "suffix should be valid hex"
125        );
126    }
127
128    #[test]
129    fn issue_unique() {
130        let store = TicketStore::new();
131        let t1 = store.issue();
132        let t2 = store.issue();
133        assert_ne!(t1, t2, "tickets should be unique");
134    }
135
136    #[test]
137    fn redeem_valid() {
138        let store = TicketStore::new();
139        let ticket = store.issue();
140        assert!(store.redeem(&ticket), "valid ticket should redeem");
141    }
142
143    #[test]
144    fn redeem_invalid() {
145        let store = TicketStore::new();
146        assert!(
147            !store.redeem("wst_0000000000000000000000000000000000000000000000000000000000000000"),
148            "unknown ticket should not redeem"
149        );
150    }
151
152    #[test]
153    fn redeem_single_use() {
154        let store = TicketStore::new();
155        let ticket = store.issue();
156        assert!(store.redeem(&ticket), "first redeem should succeed");
157        assert!(!store.redeem(&ticket), "second redeem should fail");
158    }
159
160    #[test]
161    fn redeem_expired() {
162        // We can't easily fast-forward Instant, so we test via a manual entry
163        let store = TicketStore::new();
164        {
165            let mut inner = store.inner.lock().unwrap();
166            inner.tickets.insert(
167                "wst_expired".to_string(),
168                TicketEntry {
169                    issued_at: Instant::now() - Duration::from_secs(60),
170                },
171            );
172        }
173        assert!(
174            !store.redeem("wst_expired"),
175            "expired ticket should not redeem"
176        );
177    }
178
179    #[test]
180    fn cleanup_evicts_expired() {
181        let store = TicketStore::new();
182        // Insert an expired entry
183        {
184            let mut inner = store.inner.lock().unwrap();
185            inner.tickets.insert(
186                "wst_old".to_string(),
187                TicketEntry {
188                    issued_at: Instant::now() - Duration::from_secs(60),
189                },
190            );
191            // Force cleanup on next issue by setting last_cleanup far in the past
192            inner.last_cleanup = Instant::now() - Duration::from_secs(120);
193        }
194        // Issue a new ticket to trigger cleanup
195        let _new = store.issue();
196        let inner = store.inner.lock().unwrap();
197        assert!(
198            !inner.tickets.contains_key("wst_old"),
199            "expired ticket should be cleaned up"
200        );
201    }
202
203    #[test]
204    fn empty_string_not_redeemable() {
205        let store = TicketStore::new();
206        assert!(!store.redeem(""), "empty string should not redeem");
207    }
208
209    #[test]
210    fn concurrent_issue_and_redeem() {
211        let store = TicketStore::new();
212        let tickets: Vec<String> = (0..100).map(|_| store.issue()).collect();
213
214        let store_clone = store.clone();
215        let tickets_clone = tickets.clone();
216        let handle = thread::spawn(move || {
217            tickets_clone
218                .iter()
219                .filter(|t| store_clone.redeem(t))
220                .count()
221        });
222
223        let count_main = tickets.iter().filter(|t| store.redeem(t)).count();
224        let count_thread = handle.join().unwrap();
225
226        // Each ticket should be redeemed exactly once across both threads
227        assert_eq!(
228            count_main + count_thread,
229            100,
230            "all tickets should be redeemed exactly once"
231        );
232    }
233}