Skip to main content

feather_reader/oauth/
session.rs

1//! Holding a session valid: refresh-on-read, rotation, and what a failure means.
2//!
3//! Three things here are easy to get wrong and all three present as *random*
4//! logouts rather than as a bug:
5//!
6//! * **Refresh tokens rotate and are single-use.** The new one must be stored
7//!   atomically with the new access token, and a response that omits it means
8//!   keep the old one — not store an empty string.
9//! * **Concurrent refreshes must be serialized.** Two readers of the same
10//!   session both presenting the same single-use token means the second gets
11//!   `invalid_grant`, and most authorization servers revoke the first one's
12//!   freshly issued tokens along with it. The scheduler plus one user request is
13//!   already enough concurrency.
14//! * **Only `invalid_grant` invalidates.** Deleting on a network blip or a 5xx
15//!   logs people out for a server hiccup; treating a dead grant as transient
16//!   retries forever and never prompts a re-login.
17
18use anyhow::{bail, Result};
19use std::collections::HashMap;
20use std::sync::{Arc, Mutex};
21use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
22
23use super::store::OAuthSession;
24use super::token::TokenResponse;
25
26/// Fold a refresh response into the stored session.
27///
28/// Pure, so the rotation rules are testable without a server — which matters,
29/// because every one of them fails as a mysterious logout rather than as an
30/// error anyone can trace.
31pub fn apply_refresh(
32    session: &OAuthSession,
33    response: &TokenResponse,
34    now: i64,
35) -> Result<OAuthSession> {
36    // A refresh must never be able to move a session to another account.
37    if response.sub != session.sub {
38        bail!(
39            "refresh returned subject {:?}, expected {:?}; refusing to rebind the session",
40            response.sub,
41            session.sub
42        );
43    }
44
45    Ok(OAuthSession {
46        access_token: response.access_token.clone(),
47        // Rotation is expected but not guaranteed. Storing an empty string when
48        // the server omits it would destroy the session on the next refresh,
49        // with nothing to indicate why.
50        refresh_token: response
51            .refresh_token
52            .clone()
53            .unwrap_or_else(|| session.refresh_token.clone()),
54        token_type: response.token_type.clone(),
55        // The GRANTED scope, which the server may have narrowed. Keeping the old
56        // value would make later write failures unexplainable.
57        granted_scope: response.granted_scope.clone(),
58        expires_at: response.expires_in.map(|seconds| now + seconds),
59        // Properties of the session, not of any one token response.
60        sub: session.sub.clone(),
61        issuer: session.issuer.clone(),
62        aud: session.aud.clone(),
63        dpop_key_jwk: session.dpop_key_jwk.clone(),
64    })
65}
66
67/// Per-subject refresh locks.
68///
69/// Two concurrent refreshes of one session both present the same single-use
70/// refresh token; the loser gets `invalid_grant`, and most authorization servers
71/// revoke the winner's freshly issued tokens along with it. The symptom is
72/// random, unreproducible logouts. The scheduler plus a single user request is
73/// already enough concurrency to hit this.
74///
75/// **In-process only.** A second process — the invite bot, a rolling deploy —
76/// is not covered, which is why the refresh path also re-reads the stored
77/// session on `invalid_grant` before concluding the grant is dead.
78#[derive(Default, Clone)]
79pub struct RefreshLocks {
80    locks: Arc<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
81}
82
83impl RefreshLocks {
84    /// Acquire the lock for one subject. Unrelated subjects never contend.
85    pub async fn lock(&self, sub: &str) -> OwnedMutexGuard<()> {
86        let entry = {
87            // Recover rather than panic: this gates every token refresh, so a
88            // poisoned lock here would lock every user out until a restart.
89            let mut locks = self.locks.lock().unwrap_or_else(|p| p.into_inner());
90            Arc::clone(locks.entry(sub.to_string()).or_default())
91        };
92        entry.lock_owned().await
93    }
94}
95
96/// Refuse a re-discovered issuer that is not the one the grant belongs to.
97///
98/// Discovery runs again from the network on both the callback and the refresh
99/// path, and the token endpoint comes out of THAT document. Every discovery
100/// check is internally consistent, so a hostile pair of documents satisfies all
101/// of them; only this comparison notices that the pair describes a different
102/// authorization server than the one that issued the grant.
103///
104/// A free function so it is reachable from a test. The refresh path's copy was
105/// written inline, and deleting it — the single most serious defect found in
106/// this branch, on the path that carries the REFRESH TOKEN — passed every test.
107pub fn same_issuer(discovered: &str, expected: &str) -> Result<()> {
108    if discovered != expected {
109        anyhow::bail!(
110            "the PDS now names a different authorization server ({discovered:?}) than this \
111             grant was issued by ({expected:?}); refusing to send credentials to it"
112        );
113    }
114    Ok(())
115}
116
117/// What a refresh needs beyond the session itself.
118pub struct RefreshContext<'a> {
119    pub token_endpoint: &'a str,
120    pub client_id: &'a str,
121    pub auth_method: super::client_auth::AuthMethod,
122    /// The confidential client's signing key. Required for `private_key_jwt`,
123    /// unused by the localhost dev client.
124    pub client_key: Option<&'a super::keys::SigningKey>,
125}
126
127/// Read a session, refreshing it first if it is close to expiry.
128///
129/// **Do not wrap this in a timeout.** Once the refresh request is in flight the
130/// authorization server has consumed the single-use refresh token; abandoning
131/// the future means the replacement is never stored and the session is dead.
132/// The reference carries the same warning for the same reason.
133pub async fn valid_session(
134    pool: &sqlx::SqlitePool,
135    codec: &super::crypto::Codec,
136    http: &reqwest::Client,
137    locks: &RefreshLocks,
138    sub: &str,
139    ctx: &RefreshContext<'_>,
140    now: i64,
141) -> Result<OAuthSession> {
142    let session = super::store::get_session(pool, codec, sub)
143        .await?
144        .ok_or_else(|| anyhow::anyhow!("no session for {sub}"))?;
145    if !super::token::is_stale(session.expires_at, now) {
146        return Ok(session);
147    }
148
149    let _guard = locks.lock(sub).await;
150
151    // Re-read AFTER acquiring the lock: whoever held it may have refreshed while
152    // we waited, and presenting the token they just replaced would burn it.
153    let session = super::store::get_session(pool, codec, sub)
154        .await?
155        .ok_or_else(|| anyhow::anyhow!("session for {sub} disappeared while waiting to refresh"))?;
156    if !super::token::is_stale(session.expires_at, now) {
157        return Ok(session);
158    }
159
160    // NOT timed here. `oauth_refresh` wraps this call AND the discovery that
161    // precedes it, in `Repo::session` — a review found that timing only this
162    // line missed every refresh that failed in discovery, which is where the two
163    // likeliest failures live (an unreachable PDS, and the issuer-mismatch
164    // check). See the span in `repo.rs`.
165    refresh_locked(pool, codec, http, &session, ctx, now).await
166}
167
168/// The refresh itself. Called with the subject's lock held.
169async fn refresh_locked(
170    pool: &sqlx::SqlitePool,
171    codec: &super::crypto::Codec,
172    http: &reqwest::Client,
173    session: &OAuthSession,
174    ctx: &RefreshContext<'_>,
175    now: i64,
176) -> Result<OAuthSession> {
177    let key = super::keys::SigningKey::from_jwk_json(&session.dpop_key_jwk, "session-dpop")?;
178
179    let assertion = match ctx.auth_method {
180        super::client_auth::AuthMethod::PrivateKeyJwt => {
181            let client_key = ctx
182                .client_key
183                .ok_or_else(|| anyhow::anyhow!("private_key_jwt refresh needs the client key"))?;
184            Some(super::client_auth::client_assertion(
185                client_key,
186                ctx.client_id,
187                &session.issuer,
188                now,
189            )?)
190        }
191        super::client_auth::AuthMethod::None => None,
192    };
193
194    let mut params = super::token::refresh_request_params(&session.refresh_token);
195    params.extend(super::client_auth::credential_params(
196        ctx.auth_method,
197        ctx.client_id,
198        assertion.as_deref(),
199    )?);
200    let borrowed: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
201
202    let outcome = super::request::send_with_dpop(
203        http,
204        pool,
205        &super::request::DpopRequest {
206            endpoint: super::dpop::Endpoint::AuthorizationServer,
207            url: ctx.token_endpoint,
208            key: &key,
209            access_token: None,
210            body: super::request::DpopBody::Form(&borrowed),
211            // A refresh is safe to repeat on a nonce challenge: unlike the code
212            // exchange, a rejected attempt consumes nothing.
213            retry: super::request::Retry::Allowed,
214        },
215    )
216    .await?;
217
218    if outcome.is_success() {
219        let response = super::token::parse_token_response(&outcome.json()?)?;
220        let updated = apply_refresh(session, &response, now)?;
221        super::store::put_session(pool, codec, &updated).await?;
222        // Logged because a refresh is otherwise INVISIBLE: it rotates both
223        // tokens and is the one path that can silently end a session, but it
224        // happens inside an ordinary page load and the metrics record that call
225        // no differently. Without this line, "everyone was logged out overnight"
226        // has nothing to correlate against. No token material is logged — only
227        // that it happened, and when the replacement expires.
228        tracing::info!(
229            sub = %updated.sub,
230            expires_at = ?updated.expires_at,
231            "refreshed the OAuth session"
232        );
233        return Ok(updated);
234    }
235
236    match super::token::classify_refresh_failure(outcome.status, &outcome.body) {
237        super::token::RefreshFailure::Transient => {
238            // Network, 5xx, anything unrecognised: leave the stored tokens
239            // exactly as they are and fail THIS request. Deleting here would log
240            // someone out for a server hiccup.
241            bail!(
242                "refresh for {} failed transiently (status {}); the session is left intact",
243                session.sub,
244                outcome.status
245            )
246        }
247        super::token::RefreshFailure::SessionInvalid => {
248            // Before concluding the grant is dead, check whether another PROCESS
249            // refreshed it while we held only an in-process lock. If the stored
250            // refresh token has changed, ours was simply stale and theirs is
251            // live — use it rather than deleting a working session.
252            if let Some(current) = super::store::get_session(pool, codec, &session.sub).await? {
253                if current.refresh_token != session.refresh_token {
254                    return Ok(current);
255                }
256            }
257            super::store::delete_session(pool, &session.sub).await?;
258            bail!(
259                "refresh for {} was rejected as invalid_grant; the session has been \
260                 removed and the user must log in again",
261                session.sub
262            )
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    const DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
272    const NOW: i64 = 1_700_000_000;
273
274    fn session() -> OAuthSession {
275        OAuthSession {
276            sub: DID.into(),
277            issuer: "https://auth.example.com".into(),
278            aud: "https://pds.example.com".into(),
279            dpop_key_jwk: r#"{"kty":"EC","d":"k"}"#.into(),
280            access_token: "old-access".into(),
281            refresh_token: "old-refresh".into(),
282            token_type: "DPoP".into(),
283            granted_scope: "atproto transition:generic".into(),
284            expires_at: Some(NOW + 60),
285        }
286    }
287
288    fn response() -> TokenResponse {
289        TokenResponse {
290            access_token: "new-access".into(),
291            refresh_token: Some("new-refresh".into()),
292            token_type: "DPoP".into(),
293            granted_scope: "atproto transition:generic".into(),
294            sub: DID.into(),
295            expires_in: Some(3600),
296        }
297    }
298
299    #[test]
300    fn a_refresh_replaces_both_tokens_and_the_expiry() {
301        let updated = apply_refresh(&session(), &response(), NOW).unwrap();
302        assert_eq!(updated.access_token, "new-access");
303        assert_eq!(updated.refresh_token, "new-refresh");
304        assert_eq!(updated.expires_at, Some(NOW + 3600));
305    }
306
307    /// **Rotation is expected but not guaranteed.** A response omitting
308    /// `refresh_token` means keep the one we have — storing an empty string
309    /// would destroy the session on the next refresh with no way to tell why.
310    #[test]
311    fn an_omitted_refresh_token_keeps_the_existing_one() {
312        let mut response = response();
313        response.refresh_token = None;
314        let updated = apply_refresh(&session(), &response, NOW).unwrap();
315        assert_eq!(updated.refresh_token, "old-refresh");
316        assert_eq!(
317            updated.access_token, "new-access",
318            "the access token still rotates"
319        );
320    }
321
322    /// `expires_in` is optional, and absent means no proactive refresh rather
323    /// than an invented deadline.
324    #[test]
325    fn an_omitted_expiry_clears_rather_than_invents_one() {
326        let mut response = response();
327        response.expires_in = None;
328        assert_eq!(
329            apply_refresh(&session(), &response, NOW)
330                .unwrap()
331                .expires_at,
332            None
333        );
334    }
335
336    /// **A refresh must not be able to move a session to another account.**
337    /// The reference re-checks `sub` on the refresh response for this reason.
338    #[test]
339    fn a_refresh_for_a_different_subject_is_rejected() {
340        let mut response = response();
341        response.sub = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".into();
342        assert!(apply_refresh(&session(), &response, NOW).is_err());
343    }
344
345    /// A narrowed grant must be recorded, not silently kept at the old value —
346    /// otherwise writes start failing with nothing to explain it.
347    #[test]
348    fn the_granted_scope_is_taken_from_the_response() {
349        let mut response = response();
350        response.granted_scope = "atproto".into();
351        assert_eq!(
352            apply_refresh(&session(), &response, NOW)
353                .unwrap()
354                .granted_scope,
355            "atproto"
356        );
357    }
358
359    /// The DPoP key and the PDS are properties of the session, not of any one
360    /// token response; a refresh must leave them alone.
361    #[test]
362    fn a_refresh_preserves_the_session_key_and_audience() {
363        let updated = apply_refresh(&session(), &response(), NOW).unwrap();
364        assert_eq!(updated.dpop_key_jwk, session().dpop_key_jwk);
365        assert_eq!(updated.aud, session().aud);
366        assert_eq!(updated.issuer, session().issuer);
367        assert_eq!(updated.sub, session().sub);
368    }
369
370    // ── the per-subject lock ─────────────────────────────────────────────────
371
372    /// Two concurrent refreshes of the SAME subject must not overlap: both would
373    /// present the same single-use refresh token, and the loser's
374    /// `invalid_grant` typically revokes the winner's new tokens too.
375    #[tokio::test]
376    async fn the_same_subject_is_serialized() {
377        let locks = RefreshLocks::default();
378        let held = locks.lock(DID).await;
379
380        let second = locks.lock(DID);
381        tokio::pin!(second);
382        assert!(
383            futures_lite_poll_pending(&mut second),
384            "a second holder acquired the lock while the first held it"
385        );
386        drop(held);
387        // Once released, the waiter proceeds.
388        let _ = second.await;
389    }
390
391    /// Different subjects must NOT block each other, or one slow refresh stalls
392    /// every other account.
393    #[tokio::test]
394    async fn different_subjects_do_not_block_each_other() {
395        let locks = RefreshLocks::default();
396        let _a = locks.lock(DID).await;
397        let b = locks.lock("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa");
398        tokio::pin!(b);
399        assert!(
400            !futures_lite_poll_pending(&mut b),
401            "an unrelated subject was blocked"
402        );
403    }
404
405    /// Poll a future once; true if it is still pending.
406    fn futures_lite_poll_pending<F: std::future::Future>(fut: &mut std::pin::Pin<&mut F>) -> bool {
407        use std::task::{Context, Poll, Waker};
408        let mut cx = Context::from_waker(Waker::noop());
409        matches!(fut.as_mut().poll(&mut cx), Poll::Pending)
410    }
411
412    /// **The re-discovered issuer must be the one the grant belongs to.**
413    ///
414    /// This was written inline on both the callback and the refresh path, and
415    /// deleting the refresh copy — which sends the REFRESH TOKEN, the credential
416    /// that mints every other one — passed all 575 tests. A hostile pair of
417    /// documents satisfies every discovery check, because those checks only ask
418    /// whether the documents agree with each other.
419    #[test]
420    fn a_re_discovered_issuer_must_match_the_grants_own() {
421        same_issuer("https://pds.example.com", "https://pds.example.com")
422            .expect("the same issuer must pass");
423
424        let err = same_issuer("https://evil.example", "https://pds.example.com")
425            .expect_err("a different authorization server must be refused");
426        let rendered = format!("{err:#}");
427        assert!(
428            rendered.contains("evil.example") && rendered.contains("pds.example.com"),
429            "the error must name both, or an operator cannot tell what moved: {rendered}"
430        );
431    }
432
433    /// Exact comparison. Issuers are canonicalised by `validate_issuer_form`
434    /// before they are ever stored, so a trailing slash or a case difference is
435    /// a DIFFERENT issuer, not a spelling of the same one.
436    #[test]
437    fn the_issuer_comparison_is_exact() {
438        assert!(same_issuer("https://pds.example.com/", "https://pds.example.com").is_err());
439        assert!(same_issuer("https://PDS.example.com", "https://pds.example.com").is_err());
440        assert!(same_issuer("", "https://pds.example.com").is_err());
441    }
442}