Skip to main content

brows3r_lib/media_server/
tokens.rs

1//! Token registry for the loopback media server.
2//!
3//! # Overview
4//!
5//! Each call to [`TokenRegistry::mint`] produces a cryptographically random,
6//! URL-safe base64 token (48 raw bytes → 64 base64 chars) that maps to a
7//! [`TokenRecord`] containing the S3 coordinates, TTL, and the session that
8//! owns it.
9//!
10//! # OCP
11//!
12//! - The registry is decoupled from the HTTP server so it can be swapped for a
13//!   more durable store (e.g. `redb`) if cross-restart tokens ever become
14//!   necessary.  In v1 tokens are session-scoped and in-memory is sufficient.
15//! - `revoke_session` is the single sweep point for session-end cleanup.
16
17use std::{
18    collections::HashMap,
19    sync::{Arc, RwLock},
20    time::{Duration, SystemTime, UNIX_EPOCH},
21};
22
23use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
24// rand 0.10 moved `RngCore` to the `rand_core` crate; importing `Rng` brings
25// the `fill_bytes` extension method into scope without an extra dependency.
26use rand::Rng;
27
28use crate::ids::{BucketId, ProfileId};
29
30// ---------------------------------------------------------------------------
31// TokenRecord
32// ---------------------------------------------------------------------------
33
34/// Everything the server needs to serve a single media token.
35#[derive(Debug, Clone)]
36pub struct TokenRecord {
37    /// Profile whose credentials are used to fetch the object.
38    pub profile_id: ProfileId,
39    /// S3 bucket containing the object.
40    pub bucket: BucketId,
41    /// Full S3 object key.
42    pub key: String,
43    /// AWS region for the bucket (needed to route the get_object call).
44    pub region: String,
45    /// Unix epoch seconds at which this token expires.
46    pub expires_at: i64,
47    /// Session that minted this token; used by [`TokenRegistry::revoke_session`].
48    pub session_id: String,
49}
50
51impl TokenRecord {
52    /// Returns `true` when the token has passed its expiry time.
53    pub fn is_expired(&self) -> bool {
54        let now = SystemTime::now()
55            .duration_since(UNIX_EPOCH)
56            .unwrap_or(Duration::ZERO)
57            .as_secs() as i64;
58        now >= self.expires_at
59    }
60}
61
62// ---------------------------------------------------------------------------
63// TokenRegistry
64// ---------------------------------------------------------------------------
65
66/// Thread-safe, in-memory store of live token records.
67///
68/// Wrapped in `Arc` so it can be cloned cheaply into the axum app state and
69/// the Tauri-managed [`super::MediaServerHandle`].
70#[derive(Default)]
71pub struct TokenRegistry {
72    map: RwLock<HashMap<String, TokenRecord>>,
73}
74
75impl TokenRegistry {
76    /// Create an empty registry.
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Mint a fresh token for the given S3 object.
82    ///
83    /// Returns `(token, expires_at)` where `token` is the URL-safe base64
84    /// string (64 chars, 48 random bytes) and `expires_at` is a Unix epoch
85    /// seconds timestamp.
86    ///
87    /// # Arguments
88    ///
89    /// - `profile_id` — profile whose credentials service the request.
90    /// - `bucket` / `key` — S3 coordinates.
91    /// - `region` — AWS region for the bucket (used to route get_object).
92    /// - `ttl_secs` — seconds until expiry (1-hour default → pass `3600`).
93    /// - `session_id` — calling session; used by `revoke_session`.
94    pub fn mint(
95        &self,
96        profile_id: ProfileId,
97        bucket: BucketId,
98        key: String,
99        region: String,
100        ttl_secs: u64,
101        session_id: String,
102    ) -> (String, i64) {
103        let mut bytes = [0u8; 48];
104        rand::rng().fill_bytes(&mut bytes);
105        let token = URL_SAFE_NO_PAD.encode(bytes);
106
107        let expires_at = SystemTime::now()
108            .duration_since(UNIX_EPOCH)
109            .unwrap_or(Duration::ZERO)
110            .as_secs() as i64
111            + ttl_secs as i64;
112
113        let record = TokenRecord {
114            profile_id,
115            bucket,
116            key,
117            region,
118            expires_at,
119            session_id,
120        };
121
122        self.map
123            .write()
124            .expect("token registry lock poisoned")
125            .insert(token.clone(), record);
126
127        (token, expires_at)
128    }
129
130    /// Look up a token.  Returns `None` when unknown **or** expired.
131    pub fn lookup(&self, token: &str) -> Option<TokenRecord> {
132        let guard = self.map.read().expect("token registry lock poisoned");
133        let record = guard.get(token)?;
134        if record.is_expired() {
135            return None;
136        }
137        Some(record.clone())
138    }
139
140    /// Variant of `lookup` that distinguishes "token unknown" from "token expired".
141    ///
142    /// - `Ok(Some(record))` — token is known and live.
143    /// - `Ok(None)` — token is known but expired → 403.
144    /// - `Err(())` — token does not exist → 404.
145    pub fn lookup_with_status(&self, token: &str) -> Result<Option<TokenRecord>, ()> {
146        let guard = self.map.read().expect("token registry lock poisoned");
147        match guard.get(token) {
148            None => Err(()),
149            Some(record) if record.is_expired() => Ok(None),
150            Some(record) => Ok(Some(record.clone())),
151        }
152    }
153
154    /// Revoke a single token by removing it from the registry.
155    pub fn revoke(&self, token: &str) {
156        self.map
157            .write()
158            .expect("token registry lock poisoned")
159            .remove(token);
160    }
161
162    /// Remove all tokens belonging to `session_id`.
163    ///
164    /// Called on session end so no token outlives its session.
165    pub fn revoke_session(&self, session_id: &str) {
166        self.map
167            .write()
168            .expect("token registry lock poisoned")
169            .retain(|_, record| record.session_id != session_id);
170    }
171
172    /// Remove all expired tokens.  Call periodically to avoid unbounded growth.
173    pub fn gc(&self) {
174        self.map
175            .write()
176            .expect("token registry lock poisoned")
177            .retain(|_, record| !record.is_expired());
178    }
179}
180
181/// Shared, heap-allocated token registry.
182pub type TokenRegistryHandle = Arc<TokenRegistry>;
183
184// ---------------------------------------------------------------------------
185// Tests
186// ---------------------------------------------------------------------------
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn profile() -> ProfileId {
193        ProfileId::new("test-profile")
194    }
195
196    fn bucket() -> BucketId {
197        BucketId::new("test-bucket")
198    }
199
200    #[test]
201    fn mint_produces_unique_64_char_tokens() {
202        let registry = TokenRegistry::new();
203        let (t1, _) = registry.mint(
204            profile(),
205            bucket(),
206            "k1".into(),
207            "us-east-1".into(),
208            3600,
209            "s1".into(),
210        );
211        let (t2, _) = registry.mint(
212            profile(),
213            bucket(),
214            "k2".into(),
215            "us-east-1".into(),
216            3600,
217            "s1".into(),
218        );
219
220        // 48 raw bytes → 64 URL-safe base64 chars (no padding).
221        assert_eq!(t1.len(), 64, "token must be 64 chars");
222        assert_eq!(t2.len(), 64, "token must be 64 chars");
223        assert_ne!(t1, t2, "tokens must be unique");
224
225        // Only URL-safe base64 alphabet characters.
226        for ch in t1.chars() {
227            assert!(
228                ch.is_ascii_alphanumeric() || ch == '-' || ch == '_',
229                "unexpected char: {ch}"
230            );
231        }
232    }
233
234    #[test]
235    fn lookup_returns_record_for_live_token() {
236        let registry = TokenRegistry::new();
237        let (token, _) = registry.mint(
238            profile(),
239            bucket(),
240            "my/key".into(),
241            "us-east-1".into(),
242            3600,
243            "s1".into(),
244        );
245        let record = registry.lookup(&token).expect("live token must be found");
246        assert_eq!(record.key, "my/key");
247        assert_eq!(record.session_id, "s1");
248    }
249
250    #[test]
251    fn lookup_returns_none_for_unknown_token() {
252        let registry = TokenRegistry::new();
253        assert!(registry.lookup("does-not-exist").is_none());
254    }
255
256    #[test]
257    fn expired_token_returns_none_from_lookup() {
258        let registry = TokenRegistry::new();
259        // TTL = 0 → already expired.
260        let (token, _) = registry.mint(
261            profile(),
262            bucket(),
263            "k".into(),
264            "us-east-1".into(),
265            0,
266            "s1".into(),
267        );
268        // Give `expires_at` exactly `now`; is_expired checks `now >= expires_at`.
269        assert!(
270            registry.lookup(&token).is_none(),
271            "expired token must not be found"
272        );
273    }
274
275    #[test]
276    fn revoke_removes_token() {
277        let registry = TokenRegistry::new();
278        let (token, _) = registry.mint(
279            profile(),
280            bucket(),
281            "k".into(),
282            "us-east-1".into(),
283            3600,
284            "s1".into(),
285        );
286        registry.revoke(&token);
287        assert!(
288            registry.lookup(&token).is_none(),
289            "revoked token must not be found"
290        );
291    }
292
293    #[test]
294    fn revoke_session_removes_all_tokens_for_session() {
295        let registry = TokenRegistry::new();
296        let (t1, _) = registry.mint(
297            profile(),
298            bucket(),
299            "k1".into(),
300            "us-east-1".into(),
301            3600,
302            "session-a".into(),
303        );
304        let (t2, _) = registry.mint(
305            profile(),
306            bucket(),
307            "k2".into(),
308            "us-east-1".into(),
309            3600,
310            "session-a".into(),
311        );
312        let (t3, _) = registry.mint(
313            profile(),
314            bucket(),
315            "k3".into(),
316            "us-east-1".into(),
317            3600,
318            "session-b".into(),
319        );
320
321        registry.revoke_session("session-a");
322
323        assert!(
324            registry.lookup(&t1).is_none(),
325            "session-a token 1 must be gone"
326        );
327        assert!(
328            registry.lookup(&t2).is_none(),
329            "session-a token 2 must be gone"
330        );
331        assert!(
332            registry.lookup(&t3).is_some(),
333            "session-b token must survive"
334        );
335    }
336
337    #[test]
338    fn gc_removes_expired_tokens() {
339        let registry = TokenRegistry::new();
340        // Expired immediately (ttl=0).
341        let (expired_tok, _) = registry.mint(
342            profile(),
343            bucket(),
344            "expired".into(),
345            "us-east-1".into(),
346            0,
347            "s".into(),
348        );
349        // Live token.
350        let (live_tok, _) = registry.mint(
351            profile(),
352            bucket(),
353            "live".into(),
354            "us-east-1".into(),
355            3600,
356            "s".into(),
357        );
358
359        registry.gc();
360
361        assert!(
362            registry.lookup(&expired_tok).is_none(),
363            "gc must remove expired tokens"
364        );
365        assert!(
366            registry.lookup(&live_tok).is_some(),
367            "gc must keep live tokens"
368        );
369    }
370}