Skip to main content

didbot_serve/oauth/
browser.rs

1//! Browser sessions: an account signs its own browser in, so the authorize
2//! page can hand a code only to a browser signed in as the account a sign-in
3//! names. See `plan/browser-binding.md`.
4//!
5//! The account asks for a one-time ticket with its own agent token
6//! (`bot.did.createBrowserSignIn`). The browser that opens the ticket's link,
7//! `GET /oauth/browser?ticket=…`, is given [`COOKIE`] for a session holding
8//! the account. The session is not a [`crate::auth::Credential`]: it cannot
9//! approve, read or write. `GET /oauth/authorize` and `GET /oauth/browser`
10//! are the only routes that read it.
11//!
12//! One cookie names one session, and a session holds a set of accounts, each
13//! with its own deadline. An account is in one browser at most, so signing
14//! it in elsewhere takes it out of the old one. A session gets a new id
15//! whenever an account joins, and the old id keeps working for
16//! [`REPLACED_ID_GRACE`], so two sign-ins in flight in one browser both land.
17//!
18//! Everything here is in memory. A restart ends every session and ticket, as
19//! it ends every operator session.
20
21use 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
31/// The cookie a browser session rides in.
32///
33/// The `__Host-` prefix makes a browser take it only from the host that set
34/// it, over TLS, with `Path=/` and no `Domain`. A page on an agent's own
35/// host, a subdomain of this server's, cannot set one this server is handed.
36pub const COOKIE: &str = "__Host-didbot_browser";
37
38/// Where a browser signs in, and the page that lists the accounts it holds.
39pub const PATH: &str = "/oauth/browser";
40
41/// How long a ticket stays live. It is spent on first use.
42pub const BROWSER_TICKET_TTL: Duration = Duration::seconds(60);
43
44/// How long a session's old id keeps working after an account joining gave
45/// the session a new one.
46pub const REPLACED_ID_GRACE: Duration = Duration::seconds(10);
47
48/// How long an account stays signed in to a browser, absent `[oauth]
49/// browser_session_ttl_secs`: twelve hours, as an operator's session.
50pub const DEFAULT_BROWSER_SESSION_TTL: std::time::Duration =
51    std::time::Duration::from_secs(12 * 60 * 60);
52
53/// Random bytes a ticket and a session id are each drawn from.
54const SECRET_BYTES: usize = 32;
55
56/// A ticket, keyed by the ticket itself.
57#[derive(Debug, Clone)]
58pub struct Ticket {
59    /// The account the browser that redeems it signs in as.
60    pub account: String,
61    /// When it stops being live.
62    pub expires_at: OffsetDateTime,
63}
64
65/// A browser session, keyed by its id.
66#[derive(Debug, Clone, Default)]
67pub struct BrowserSession {
68    /// Each account this browser holds, with its deadline.
69    pub accounts: HashMap<String, OffsetDateTime>,
70}
71
72/// What [`BrowserSessions`] reads the time from.
73pub type Clock = Arc<dyn Fn() -> OffsetDateTime + Send + Sync>;
74
75/// A ticket just minted.
76#[derive(Debug, Clone)]
77pub struct Minted {
78    /// The ticket. Never logged.
79    pub ticket: String,
80    /// When it stops being live.
81    pub expires_at: OffsetDateTime,
82}
83
84/// The session a request's cookie resolves to.
85#[derive(Debug, Clone)]
86pub struct Browser {
87    /// The session's current id, which the cookie should carry.
88    pub session: String,
89    /// Whether the request carried an id the session has since replaced.
90    /// The cookie is then set to [`Browser::session`] again.
91    pub replaced: bool,
92    /// The accounts the session holds, in order.
93    pub accounts: Vec<String>,
94    /// How long until the last of their deadlines: the cookie's `Max-Age`.
95    pub max_age: Duration,
96}
97
98impl Browser {
99    /// Whether this browser is signed in as `account`.
100    pub fn holds(&self, account: &str) -> bool {
101        self.accounts.iter().any(|held| held == account)
102    }
103}
104
105/// A ticket redeemed: the account that joined, and the session it joined.
106#[derive(Debug, Clone)]
107pub struct SignedIn {
108    /// The account the ticket was minted for.
109    pub account: String,
110    /// The session, under its new id.
111    pub browser: Browser,
112}
113
114/// Every browser session and ticket this server holds.
115///
116/// Each map holds at most one entry per account, or one per sign-in inside
117/// its grace, so the store is bounded by the number of accounts. Every call
118/// first removes what has run out: expired tickets, accounts past their
119/// deadline, sessions left holding none, and replaced ids past their grace.
120pub struct BrowserSessions {
121    lifetime: Duration,
122    clock: Clock,
123    inner: Mutex<Inner>,
124}
125
126#[derive(Debug, Default)]
127struct Inner {
128    /// Live tickets, by ticket.
129    tickets: HashMap<String, Ticket>,
130    /// Each account's one live ticket.
131    ticket_of: HashMap<String, String>,
132    /// Sessions, by current id.
133    sessions: HashMap<String, BrowserSession>,
134    /// The one session holding each account.
135    browser_of: HashMap<String, String>,
136    /// A replaced id, to the current id and the end of its grace.
137    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    /// An empty store whose sign-ins last `lifetime`, counted from each
156    /// sign-in. Use does not extend it.
157    pub fn new(lifetime: std::time::Duration) -> Self {
158        Self::with_clock(lifetime, Arc::new(OffsetDateTime::now_utc))
159    }
160
161    /// The same, reading the time from `clock`, for a test that ages the
162    /// store without waiting.
163    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    /// How long a sign-in lasts.
172    pub fn lifetime(&self) -> std::time::Duration {
173        std::time::Duration::try_from(self.lifetime).unwrap_or(std::time::Duration::MAX)
174    }
175
176    /// The time this store reads.
177    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    /// Mints a ticket for `account`, replacing any it already had.
188    ///
189    /// The caller checks the e-stop and the account's own agent token.
190    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    /// Spends `ticket` and signs its account in to the browser that
210    /// presented `cookie`, or to a new session when it presented none that
211    /// is live.
212    ///
213    /// The account's deadline is now plus the lifetime. It leaves any other
214    /// session, and the session it joins gets a new id. `None` when the
215    /// ticket is unknown, used or expired, and then nothing changes.
216    ///
217    /// The caller checks the host, `Sec-Fetch-Site` and the e-stop first, so
218    /// a refused request leaves the ticket as it was.
219    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    /// The session `cookie` names: its current id, or an id it replaced
257    /// inside that id's grace.
258    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    /// Takes `account` out of its browser and drops its ticket, as an
267    /// account's deletion or erasure does.
268    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    /// Ends every session, replaced id and ticket, as the e-stop's Revoke
277    /// does. Nothing brings them back.
278    pub fn revoke_all(&self) {
279        *self.inner.lock().unwrap_or_else(|p| p.into_inner()) = Inner::default();
280    }
281}
282
283impl Inner {
284    /// Removes whatever has run out at `now`.
285    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    /// The current id of the session `cookie` names, if it names one.
307    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    /// Takes `account` out of whichever session holds it.
317    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
331/// `session` as a request sees it.
332fn 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
344/// `span` after `now`, or the last moment `time` can name when a configured
345/// lifetime runs past it.
346fn deadline(now: OffsetDateTime, span: Duration) -> OffsetDateTime {
347    now.checked_add(span)
348        .unwrap_or_else(|| time::PrimitiveDateTime::MAX.assume_utc())
349}
350
351/// 32 random bytes, base64url.
352fn 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
360/// The `Set-Cookie` value that carries `browser`'s session.
361pub 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
369/// The session id a request's `Cookie` headers carry, if any.
370///
371/// Every `Cookie` header is read: HTTP/2 may split the cookies of one
372/// request across several.
373pub 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    /// A store on a clock the test turns.
389    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    /// Two sign-ins in flight in one browser both present the id it held
405    /// before either answered. The second lands in the session the first
406    /// renamed, and every earlier id leads to the newest one inside its
407    /// grace.
408    #[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    /// Each map holds one entry per account however often it signs in, and
437    /// the replaced ids go once their grace has passed.
438    #[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            // A fresh browser each time as well, which moves the account.
447            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}