1use std::collections::HashMap;
22use std::sync::{Arc, Mutex};
23
24use axum::http::{header, HeaderMap};
25use base64::engine::general_purpose::URL_SAFE_NO_PAD;
26use base64::Engine as _;
27use rand::rngs::OsRng;
28use rand::TryRngCore;
29use time::{Duration, OffsetDateTime};
30
31pub const COOKIE: &str = "__Host-didbot_browser";
37
38pub const PATH: &str = "/oauth/browser";
40
41pub const BROWSER_TICKET_TTL: Duration = Duration::seconds(60);
43
44pub const REPLACED_ID_GRACE: Duration = Duration::seconds(10);
47
48pub const DEFAULT_BROWSER_SESSION_TTL: std::time::Duration =
51 std::time::Duration::from_secs(12 * 60 * 60);
52
53const SECRET_BYTES: usize = 32;
55
56#[derive(Debug, Clone)]
58pub struct Ticket {
59 pub account: String,
61 pub expires_at: OffsetDateTime,
63}
64
65#[derive(Debug, Clone, Default)]
67pub struct BrowserSession {
68 pub accounts: HashMap<String, OffsetDateTime>,
70}
71
72pub type Clock = Arc<dyn Fn() -> OffsetDateTime + Send + Sync>;
74
75#[derive(Debug, Clone)]
77pub struct Minted {
78 pub ticket: String,
80 pub expires_at: OffsetDateTime,
82}
83
84#[derive(Debug, Clone)]
86pub struct Browser {
87 pub session: String,
89 pub replaced: bool,
92 pub accounts: Vec<String>,
94 pub max_age: Duration,
96}
97
98impl Browser {
99 pub fn holds(&self, account: &str) -> bool {
101 self.accounts.iter().any(|held| held == account)
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct SignedIn {
108 pub account: String,
110 pub browser: Browser,
112}
113
114pub struct BrowserSessions {
121 lifetime: Duration,
122 clock: Clock,
123 inner: Mutex<Inner>,
124}
125
126#[derive(Debug, Default)]
127struct Inner {
128 tickets: HashMap<String, Ticket>,
130 ticket_of: HashMap<String, String>,
132 sessions: HashMap<String, BrowserSession>,
134 browser_of: HashMap<String, String>,
136 replaced: HashMap<String, (String, OffsetDateTime)>,
138}
139
140impl Default for BrowserSessions {
141 fn default() -> Self {
142 Self::new(DEFAULT_BROWSER_SESSION_TTL)
143 }
144}
145
146impl std::fmt::Debug for BrowserSessions {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.debug_struct("BrowserSessions")
149 .field("lifetime", &self.lifetime)
150 .finish_non_exhaustive()
151 }
152}
153
154impl BrowserSessions {
155 pub fn new(lifetime: std::time::Duration) -> Self {
158 Self::with_clock(lifetime, Arc::new(OffsetDateTime::now_utc))
159 }
160
161 pub fn with_clock(lifetime: std::time::Duration, clock: Clock) -> Self {
164 Self {
165 lifetime: Duration::try_from(lifetime).unwrap_or(Duration::MAX),
166 clock,
167 inner: Mutex::new(Inner::default()),
168 }
169 }
170
171 pub fn lifetime(&self) -> std::time::Duration {
173 std::time::Duration::try_from(self.lifetime).unwrap_or(std::time::Duration::MAX)
174 }
175
176 pub fn now(&self) -> OffsetDateTime {
178 (self.clock)()
179 }
180
181 fn inner(&self, now: OffsetDateTime) -> std::sync::MutexGuard<'_, Inner> {
182 let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
183 inner.sweep(now);
184 inner
185 }
186
187 pub fn mint(&self, account: &str) -> Minted {
191 let now = self.now();
192 let mut inner = self.inner(now);
193 if let Some(previous) = inner.ticket_of.remove(account) {
194 inner.tickets.remove(&previous);
195 }
196 let ticket = random_secret();
197 let expires_at = now + BROWSER_TICKET_TTL;
198 inner.tickets.insert(
199 ticket.clone(),
200 Ticket {
201 account: account.to_owned(),
202 expires_at,
203 },
204 );
205 inner.ticket_of.insert(account.to_owned(), ticket.clone());
206 Minted { ticket, expires_at }
207 }
208
209 pub fn redeem(&self, ticket: &str, cookie: Option<&str>) -> Option<SignedIn> {
220 let now = self.now();
221 let mut inner = self.inner(now);
222 let Ticket { account, .. } = inner.tickets.remove(ticket)?;
223 inner.ticket_of.remove(&account);
224
225 let joining = cookie.and_then(|cookie| inner.current(cookie));
226 if inner.browser_of.get(&account) != joining.as_ref() {
227 inner.leave(&account);
228 }
229 let mut session = joining
230 .as_ref()
231 .and_then(|id| inner.sessions.remove(id))
232 .unwrap_or_default();
233 session
234 .accounts
235 .insert(account.clone(), deadline(now, self.lifetime));
236
237 let id = random_secret();
238 if let Some(old) = joining {
239 for (current, _) in inner.replaced.values_mut() {
240 if *current == old {
241 current.clone_from(&id);
242 }
243 }
244 inner
245 .replaced
246 .insert(old, (id.clone(), now + REPLACED_ID_GRACE));
247 }
248 for held in session.accounts.keys() {
249 inner.browser_of.insert(held.clone(), id.clone());
250 }
251 let browser = view(&id, &session, false, now);
252 inner.sessions.insert(id, session);
253 Some(SignedIn { account, browser })
254 }
255
256 pub fn browser(&self, cookie: &str) -> Option<Browser> {
259 let now = self.now();
260 let inner = self.inner(now);
261 let id = inner.current(cookie)?;
262 let session = inner.sessions.get(&id)?;
263 Some(view(&id, session, id != cookie, now))
264 }
265
266 pub fn revoke_account(&self, account: &str) {
269 let mut inner = self.inner(self.now());
270 inner.leave(account);
271 if let Some(ticket) = inner.ticket_of.remove(account) {
272 inner.tickets.remove(&ticket);
273 }
274 }
275
276 pub fn revoke_all(&self) {
279 *self.inner.lock().unwrap_or_else(|p| p.into_inner()) = Inner::default();
280 }
281}
282
283impl Inner {
284 fn sweep(&mut self, now: OffsetDateTime) {
286 self.tickets.retain(|_, ticket| now < ticket.expires_at);
287 let tickets = &self.tickets;
288 self.ticket_of
289 .retain(|_, ticket| tickets.contains_key(ticket));
290
291 for session in self.sessions.values_mut() {
292 session.accounts.retain(|_, deadline| now < *deadline);
293 }
294 self.sessions
295 .retain(|_, session| !session.accounts.is_empty());
296 let sessions = &self.sessions;
297 self.browser_of.retain(|account, id| {
298 sessions
299 .get(id)
300 .is_some_and(|session| session.accounts.contains_key(account))
301 });
302 self.replaced
303 .retain(|_, (current, grace)| now < *grace && sessions.contains_key(current));
304 }
305
306 fn current(&self, cookie: &str) -> Option<String> {
308 if self.sessions.contains_key(cookie) {
309 return Some(cookie.to_owned());
310 }
311 self.replaced
312 .get(cookie)
313 .map(|(current, _)| current.clone())
314 }
315
316 fn leave(&mut self, account: &str) {
318 let Some(id) = self.browser_of.remove(account) else {
319 return;
320 };
321 let emptied = self.sessions.get_mut(&id).is_some_and(|session| {
322 session.accounts.remove(account);
323 session.accounts.is_empty()
324 });
325 if emptied {
326 self.sessions.remove(&id);
327 }
328 }
329}
330
331fn view(id: &str, session: &BrowserSession, replaced: bool, now: OffsetDateTime) -> Browser {
333 let mut accounts: Vec<String> = session.accounts.keys().cloned().collect();
334 accounts.sort();
335 let last = session.accounts.values().max().copied().unwrap_or(now);
336 Browser {
337 session: id.to_owned(),
338 replaced,
339 accounts,
340 max_age: (last - now).max(Duration::ZERO),
341 }
342}
343
344fn deadline(now: OffsetDateTime, span: Duration) -> OffsetDateTime {
347 now.checked_add(span)
348 .unwrap_or_else(|| time::PrimitiveDateTime::MAX.assume_utc())
349}
350
351fn random_secret() -> String {
353 let mut bytes = [0u8; SECRET_BYTES];
354 OsRng
355 .try_fill_bytes(&mut bytes)
356 .expect("the OS random source is available");
357 URL_SAFE_NO_PAD.encode(bytes)
358}
359
360pub fn cookie(browser: &Browser) -> String {
362 format!(
363 "{COOKIE}={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={}",
364 browser.session,
365 browser.max_age.whole_seconds()
366 )
367}
368
369pub fn presented(headers: &HeaderMap) -> Option<String> {
374 headers
375 .get_all(header::COOKIE)
376 .iter()
377 .filter_map(|value| value.to_str().ok())
378 .flat_map(|value| value.split(';'))
379 .filter_map(|pair| pair.split_once('='))
380 .find(|(name, _)| name.trim() == COOKIE)
381 .map(|(_, value)| value.trim().to_owned())
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 fn store(lifetime: std::time::Duration) -> (BrowserSessions, Arc<Mutex<OffsetDateTime>>) {
390 let time = Arc::new(Mutex::new(OffsetDateTime::now_utc()));
391 let read = time.clone();
392 let clock: Clock = Arc::new(move || *read.lock().unwrap());
393 (BrowserSessions::with_clock(lifetime, clock), time)
394 }
395
396 fn signed_in(store: &BrowserSessions, account: &str, cookie: Option<&str>) -> Browser {
397 let minted = store.mint(account);
398 store
399 .redeem(&minted.ticket, cookie)
400 .expect("a live ticket")
401 .browser
402 }
403
404 #[test]
409 fn two_sign_ins_in_flight_in_one_browser_both_land() {
410 let (store, time) = store(DEFAULT_BROWSER_SESSION_TTL);
411 let first = signed_in(&store, "did:web:a.example", None);
412
413 let second = signed_in(&store, "did:web:b.example", Some(&first.session));
414 let third = signed_in(&store, "did:web:c.example", Some(&first.session));
415 assert_ne!(second.session, third.session);
416 assert_eq!(
417 third.accounts,
418 [
419 "did:web:a.example",
420 "did:web:b.example",
421 "did:web:c.example"
422 ]
423 );
424 for old in [&first.session, &second.session] {
425 let browser = store.browser(old).expect("inside its grace");
426 assert_eq!(browser.session, third.session);
427 assert!(browser.replaced);
428 }
429
430 *time.lock().unwrap() += REPLACED_ID_GRACE;
431 assert!(store.browser(&first.session).is_none());
432 assert!(store.browser(&second.session).is_none());
433 assert!(!store.browser(&third.session).unwrap().replaced);
434 }
435
436 #[test]
439 fn the_store_is_bounded_by_the_accounts_it_holds() {
440 let (store, time) = store(DEFAULT_BROWSER_SESSION_TTL);
441 let mut cookie = None;
442 for _ in 0..20 {
443 store.mint("did:web:a.example");
444 let browser = signed_in(&store, "did:web:a.example", cookie.as_deref());
445 cookie = Some(browser.session);
446 signed_in(&store, "did:web:b.example", None);
448 }
449 *time.lock().unwrap() += REPLACED_ID_GRACE;
450 store.browser("prompts a sweep");
451 let inner = store.inner.lock().unwrap();
452 assert_eq!(inner.sessions.len(), 2);
453 assert_eq!(inner.browser_of.len(), 2);
454 assert!(inner.tickets.is_empty() && inner.ticket_of.is_empty());
455 assert!(inner.replaced.is_empty());
456 }
457}