Skip to main content

soma_auth/upstream/
refresh.rs

1//! Single-flight refresh coordination for upstream OAuth clients.
2//!
3//! `RefreshLocks` prevents concurrent callers for the same `(upstream, subject)` pair
4//! from issuing simultaneous token refresh requests against the authorization server.
5//! One caller wins the lock and executes `get_access_token()` (which internally handles
6//! proactive refresh); all others wait and then return the already-refreshed token.
7//!
8//! **Scope:** This module handles *proactive* refresh triggered before making an MCP call.
9//! Reactive 401-retry logic is wired in Task 4 (`dispatch/gateway/`).
10//!
11//! ## rmcp refresh semantics
12//!
13//! `AuthorizationManager::get_access_token()` refreshes the token when fewer than 30 s
14//! remain before expiry.  It does **not** react to 401 responses from the resource server.
15//! A 401 with a locally-still-valid token requires an explicit `refresh_token()` call
16//! followed by a retry — that is the Task 4 responsibility.
17
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use dashmap::DashMap;
22use tokio::sync::Mutex;
23
24/// Per-`(upstream_name, subject)` mutex pool.
25///
26/// Entries are created lazily on first access and are never removed (the number of
27/// distinct `(upstream, subject)` pairs is bounded by the number of configured upstreams
28/// times the number of users, which is small in a homelab context).
29#[derive(Default)]
30pub struct RefreshLocks(DashMap<(String, String), Arc<Mutex<()>>>);
31
32impl RefreshLocks {
33    pub fn new() -> Self {
34        Self(DashMap::new())
35    }
36
37    /// Return the mutex for `(upstream_name, subject)`, creating it if absent.
38    pub fn acquire(&self, upstream_name: &str, subject: &str) -> Arc<Mutex<()>> {
39        let key = (upstream_name.to_string(), subject.to_string());
40        self.0
41            .entry(key)
42            .or_insert_with(|| Arc::new(Mutex::new(())))
43            .clone()
44    }
45}
46
47/// How long a confirmed refresh failure suppresses further live retries for the
48/// same `(upstream, subject)` pair. Chosen to be well short of any human patience
49/// window (so a fix shows up promptly) while still cutting a dead credential's
50/// call volume against the authorization server by roughly two orders of
51/// magnitude versus retrying on every single request.
52pub const REFRESH_FAILURE_COOLDOWN: Duration = Duration::from_secs(300);
53
54/// Per-`(upstream_name, subject)` "is this credential known-broken right now"
55/// cache.
56///
57/// Without this, a dead refresh token (revoked, expired, `invalid_grant`, ...)
58/// gets retried against the authorization server on every single request
59/// forever — `TokenRefreshState::refresh_due()` is purely time-based and has
60/// no memory of prior outcomes. That wastes latency on every real request
61/// touching the upstream, and can itself contribute to the authorization
62/// server rate-limiting or flagging the client_id, which is especially bad
63/// when multiple upstreams share one client_id (see `labby-auth::upstream`
64/// module docs).
65#[derive(Default)]
66pub struct RefreshFailureCache(DashMap<(String, String), Instant>);
67
68impl RefreshFailureCache {
69    pub fn new() -> Self {
70        Self(DashMap::new())
71    }
72
73    /// Record that a refresh just failed for `(upstream_name, subject)`.
74    pub fn record_failure(&self, upstream_name: &str, subject: &str) {
75        self.0.insert(
76            (upstream_name.to_string(), subject.to_string()),
77            Instant::now(),
78        );
79    }
80
81    /// Clear any recorded failure for `(upstream_name, subject)` — call this on
82    /// any successful refresh, a fresh authorization completing, or explicit
83    /// credential clearing, so a fix is picked up immediately instead of
84    /// waiting out the cooldown.
85    pub fn clear(&self, upstream_name: &str, subject: &str) {
86        self.0
87            .remove(&(upstream_name.to_string(), subject.to_string()));
88    }
89
90    /// Whether `(upstream_name, subject)` failed recently enough that a live
91    /// retry should be skipped.
92    pub fn recently_failed(&self, upstream_name: &str, subject: &str) -> bool {
93        self.0
94            .get(&(upstream_name.to_string(), subject.to_string()))
95            .is_some_and(|entry| entry.elapsed() < REFRESH_FAILURE_COOLDOWN)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::RefreshFailureCache;
102
103    #[test]
104    fn fresh_cache_has_no_recent_failures() {
105        let cache = RefreshFailureCache::new();
106        assert!(!cache.recently_failed("google-drive", "gateway"));
107    }
108
109    #[test]
110    fn recorded_failure_is_recently_failed_until_cleared() {
111        let cache = RefreshFailureCache::new();
112        cache.record_failure("google-drive", "gateway");
113        assert!(cache.recently_failed("google-drive", "gateway"));
114
115        cache.clear("google-drive", "gateway");
116        assert!(!cache.recently_failed("google-drive", "gateway"));
117    }
118
119    #[test]
120    fn failures_are_scoped_per_upstream_and_subject() {
121        let cache = RefreshFailureCache::new();
122        cache.record_failure("google-drive", "gateway");
123
124        assert!(!cache.recently_failed("google-gmail", "gateway"));
125        assert!(!cache.recently_failed("google-drive", "alice"));
126    }
127}