brows3r_lib/media_server/
tokens.rs1use 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 _};
24use rand::Rng;
27
28use crate::ids::{BucketId, ProfileId};
29
30#[derive(Debug, Clone)]
36pub struct TokenRecord {
37 pub profile_id: ProfileId,
39 pub bucket: BucketId,
41 pub key: String,
43 pub region: String,
45 pub expires_at: i64,
47 pub session_id: String,
49}
50
51impl TokenRecord {
52 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#[derive(Default)]
71pub struct TokenRegistry {
72 map: RwLock<HashMap<String, TokenRecord>>,
73}
74
75impl TokenRegistry {
76 pub fn new() -> Self {
78 Self::default()
79 }
80
81 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 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 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 pub fn revoke(&self, token: &str) {
156 self.map
157 .write()
158 .expect("token registry lock poisoned")
159 .remove(token);
160 }
161
162 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 pub fn gc(&self) {
174 self.map
175 .write()
176 .expect("token registry lock poisoned")
177 .retain(|_, record| !record.is_expired());
178 }
179}
180
181pub type TokenRegistryHandle = Arc<TokenRegistry>;
183
184#[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 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 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 let (token, _) = registry.mint(
261 profile(),
262 bucket(),
263 "k".into(),
264 "us-east-1".into(),
265 0,
266 "s1".into(),
267 );
268 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 let (expired_tok, _) = registry.mint(
342 profile(),
343 bucket(),
344 "expired".into(),
345 "us-east-1".into(),
346 0,
347 "s".into(),
348 );
349 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}