Skip to main content

soma_auth/upstream/
cache.rs

1//! Per-`(upstream, subject)` `AuthClient` cache.
2//!
3//! Each entry binds one MCP upstream and one subject to a single
4//! `AuthClient<reqwest::Client>` so tokens are never shared between users.
5//! Entries are built lazily on first use via the upstream's
6//! [`UpstreamOauthManager`], cached by `(upstream_name, subject)`, and
7//! invalidated when the upstream's OAuth registration changes (e.g.
8//! `client_id` rotation) or when the upstream is removed from config at
9//! reload time.
10//!
11//! Intended to be injected into both a gateway's lifecycle/reload manager
12//! (for eviction during config reload) and its per-request connection pool
13//! (for per-request lookup from MCP handlers), keeping this cache decoupled
14//! from either — it needs no reference to a gateway or pool type.
15
16use std::future::Future;
17use std::sync::Arc;
18
19use dashmap::DashMap;
20use rmcp::transport::AuthClient;
21use rmcp::transport::streamable_http_client::StreamableHttpClient;
22use rmcp_client as rmcp;
23use tokio::sync::Mutex;
24
25use crate::upstream::config::{UpstreamConfig, UpstreamOauthRegistration};
26use crate::upstream::manager::UpstreamOauthManager;
27use crate::upstream::types::OauthError;
28
29/// A cached `AuthClient` plus the OAuth-registration fingerprint it was
30/// built from. When the current config's fingerprint differs, the entry
31/// is evicted and rebuilt so a stale `client_id` never signs a request.
32pub struct CachedAuthClient {
33    pub client: Arc<AuthClient<reqwest::Client>>,
34    fingerprint: String,
35}
36
37/// Per-`(upstream, subject)` `AuthClient` cache.
38///
39/// Cheap to clone (all state is behind `Arc`). Safe to share between the
40/// gateway manager and the upstream pool.
41/// `(upstream_name, subject)` cache key shared by [`OauthClientCache`]'s maps.
42type CacheKey = (String, String);
43
44#[derive(Clone)]
45pub struct OauthClientCache {
46    /// Cached clients keyed by `(upstream_name, subject)`.
47    clients: Arc<DashMap<CacheKey, Arc<CachedAuthClient>>>,
48    /// Per-upstream OAuth managers, owned by the gateway manager and
49    /// shared in by `Arc` so the cache can call `build_auth_client`.
50    managers: Arc<DashMap<String, UpstreamOauthManager>>,
51    /// Per-`(upstream, subject)` build lock so concurrent first-request
52    /// tasks don't issue duplicate token exchanges against the AS.
53    build_locks: Arc<DashMap<CacheKey, Arc<Mutex<()>>>>,
54}
55
56impl OauthClientCache {
57    /// Create a new cache backed by the gateway's OAuth manager map.
58    #[must_use]
59    pub fn new(managers: Arc<DashMap<String, UpstreamOauthManager>>) -> Self {
60        Self {
61            clients: Arc::new(DashMap::new()),
62            managers,
63            build_locks: Arc::new(DashMap::new()),
64        }
65    }
66
67    /// Return a cached `AuthClient<reqwest::Client>` for `(upstream, subject)`,
68    /// building one on first use.
69    ///
70    /// Kept for callers that need a shared `Arc<AuthClient<reqwest::Client>>`
71    /// (e.g. status-check endpoints).  The MCP connection path uses
72    /// `get_or_build_capped` instead so the `BodyCappedHttpClient` cap applies.
73    ///
74    /// If a cached entry exists but was built from a different OAuth
75    /// registration than the current `config`, the entry is evicted and
76    /// rebuilt so stale `client_id`s never sign requests.
77    ///
78    /// For `Dynamic` upstreams the fingerprint includes the stored
79    /// `client_id` (fetched from SQLite via the upstream manager) so a
80    /// re-registration cycle evicts the cached `AuthClient`.
81    ///
82    /// Concurrent first-request callers for the same key are serialised
83    /// by a per-key mutex so only one token exchange runs.
84    #[allow(dead_code)]
85    pub async fn get_or_build(
86        &self,
87        config: &UpstreamConfig,
88        subject: &str,
89    ) -> Result<Arc<AuthClient<reqwest::Client>>, OauthError> {
90        // For Dynamic upstreams, include the stored client_id in the
91        // fingerprint so a re-registration is detected.
92        let dynamic_client_id: Option<String> = if config.oauth.as_ref().is_some_and(|o| {
93            matches!(
94                o.registration,
95                UpstreamOauthRegistration::Auto | UpstreamOauthRegistration::Dynamic
96            )
97        }) {
98            self.managers
99                .get(&config.name)
100                .map(|r| r.clone())
101                .ok_or_else(|| {
102                    OauthError::Internal(format!(
103                        "no oauth manager registered for upstream '{}'",
104                        config.name
105                    ))
106                })?
107                .stored_dynamic_client_id(subject)
108                .await?
109        } else {
110            None
111        };
112
113        self.get_or_insert_with(config, subject, dynamic_client_id.as_deref(), || async {
114            let manager = self
115                .managers
116                .get(&config.name)
117                .map(|r| r.clone())
118                .ok_or_else(|| {
119                    OauthError::Internal(format!(
120                        "no oauth manager registered for upstream '{}'",
121                        config.name
122                    ))
123                })?;
124            let auth_client = manager.build_auth_client(subject).await?;
125            Ok(Arc::new(auth_client))
126        })
127        .await
128    }
129
130    /// Build an `AuthClient<C>` wrapping the supplied HTTP client and return it
131    /// WITHOUT caching it.
132    ///
133    /// Entry point for callers that manage their own per-connection cache and
134    /// need to pass a pre-built HTTP client (e.g. one with a response-size
135    /// cap) so the OAuth path gets identical transport behavior to the
136    /// non-OAuth path. The caller is responsible for caching the resulting
137    /// `AuthClient` at whatever level it owns, so there is no double-caching
138    /// here.
139    pub async fn get_or_build_capped<C>(
140        &self,
141        config: &UpstreamConfig,
142        subject: &str,
143        http_client: C,
144    ) -> Result<AuthClient<C>, OauthError>
145    where
146        C: StreamableHttpClient + Clone,
147    {
148        let manager = self
149            .managers
150            .get(&config.name)
151            .map(|r| r.clone())
152            .ok_or_else(|| {
153                OauthError::Internal(format!(
154                    "no oauth manager registered for upstream '{}'",
155                    config.name
156                ))
157            })?;
158        manager.build_auth_client_with(subject, http_client).await
159    }
160
161    #[allow(dead_code)]
162    async fn get_or_insert_with<F, Fut>(
163        &self,
164        config: &UpstreamConfig,
165        subject: &str,
166        // For `Dynamic` upstreams: the stored `client_id` to fold into the
167        // fingerprint. `None` for non-dynamic upstreams.
168        dynamic_client_id: Option<&str>,
169        builder: F,
170    ) -> Result<Arc<AuthClient<reqwest::Client>>, OauthError>
171    where
172        F: FnOnce() -> Fut,
173        Fut: Future<Output = Result<Arc<AuthClient<reqwest::Client>>, OauthError>>,
174    {
175        let fingerprint = registration_fingerprint(config, dynamic_client_id)?;
176        let key = (config.name.clone(), subject.to_string());
177
178        if let Some(entry) = self.clients.get(&key)
179            && entry.fingerprint == fingerprint
180        {
181            return Ok(Arc::clone(&entry.client));
182        }
183
184        let lock = self
185            .build_locks
186            .entry(key.clone())
187            .or_insert_with(|| Arc::new(Mutex::new(())))
188            .clone();
189        let _guard = lock.lock().await;
190
191        // Re-check after acquiring the lock: another caller may have built
192        // the entry while we were waiting.
193        if let Some(entry) = self.clients.get(&key)
194            && entry.fingerprint == fingerprint
195        {
196            return Ok(Arc::clone(&entry.client));
197        }
198
199        let arc_client = builder().await?;
200
201        self.clients.insert(
202            key,
203            Arc::new(CachedAuthClient {
204                client: Arc::clone(&arc_client),
205                fingerprint,
206            }),
207        );
208
209        Ok(arc_client)
210    }
211
212    /// Evict the entry for a single `(upstream, subject)` pair.
213    ///
214    /// Used by API handlers when credentials are cleared or when a refresh
215    /// fails terminally and the next request must reauthenticate.
216    pub fn evict_subject(&self, upstream: &str, subject: &str) {
217        let key = (upstream.to_string(), subject.to_string());
218        self.clients.remove(&key);
219        // build_locks is intentionally NOT evicted: it serializes concurrent
220        // builders for the same (upstream, subject) key. Removing it creates a
221        // race window where two concurrent callers both see no cached client,
222        // both drop the lock guard, and then both start building in parallel.
223    }
224
225    /// Evict every entry for `upstream`.
226    ///
227    /// Used at config reload when an upstream is removed or its OAuth
228    /// registration changes, and when the whole server shuts down the
229    /// upstream's sessions.
230    pub fn evict_upstream(&self, upstream: &str) {
231        self.clients.retain(|(name, _), _| name != upstream);
232        // build_locks intentionally preserved — see comment in evict_subject.
233    }
234
235    /// Evict every entry whose upstream is not in `known`.
236    ///
237    /// Used at config reload to drop cached clients for upstreams that no
238    /// longer exist in config.
239    pub fn evict_upstreams_not_in(&self, known: &std::collections::HashSet<&str>) {
240        self.clients
241            .retain(|(name, _), _| known.contains(name.as_str()));
242    }
243
244    /// Number of cached clients. Intended for tests and observability.
245    #[allow(dead_code)]
246    #[must_use]
247    pub fn len(&self) -> usize {
248        self.clients.len()
249    }
250
251    /// True when the cache holds no clients.
252    #[allow(dead_code)]
253    #[must_use]
254    pub fn is_empty(&self) -> bool {
255        self.clients.is_empty()
256    }
257
258    /// Insert a pre-built `AuthClient` directly into the cache.
259    ///
260    /// Test-only seam: available in `labby-auth`'s own tests and downstream
261    /// debug test builds. It is intentionally not gated by a Cargo feature so
262    /// `--all-features --release` cannot expose it in production artifacts.
263    #[cfg(any(test, debug_assertions))]
264    pub fn insert_for_tests(
265        &self,
266        upstream: &str,
267        subject: &str,
268        fingerprint: &str,
269        client: Arc<AuthClient<reqwest::Client>>,
270    ) {
271        self.clients.insert(
272            (upstream.to_string(), subject.to_string()),
273            Arc::new(CachedAuthClient {
274                client,
275                fingerprint: fingerprint.to_string(),
276            }),
277        );
278    }
279}
280
281/// Compute a stable fingerprint of the OAuth registration.
282///
283/// When the fingerprint changes, the cached `AuthClient` is discarded.
284/// `Preregistered` changes when `client_id` rotates; `ClientMetadataDocument`
285/// changes when its URL moves; `Dynamic` includes the stored per-subject
286/// `client_id` so a re-registration cycle evicts the stale entry.
287#[allow(dead_code)]
288fn registration_fingerprint(
289    config: &UpstreamConfig,
290    dynamic_client_id: Option<&str>,
291) -> Result<String, OauthError> {
292    let oauth = config
293        .oauth
294        .as_ref()
295        .ok_or_else(|| OauthError::Internal("upstream has no oauth config".to_string()))?;
296
297    Ok(match &oauth.registration {
298        UpstreamOauthRegistration::Auto => {
299            format!(
300                "auto:{}",
301                dynamic_client_id.unwrap_or("cimd-or-unregistered")
302            )
303        }
304        UpstreamOauthRegistration::Preregistered { client_id, .. } => {
305            format!("preregistered:{client_id}")
306        }
307        UpstreamOauthRegistration::ClientMetadataDocument { url } => {
308            format!("client_metadata_document:{url}")
309        }
310        UpstreamOauthRegistration::Dynamic => {
311            // Include the stored client_id so a re-registration evicts the
312            // stale cached AuthClient. Fall back to "none" when
313            // no client_id has been persisted yet (first-time registration
314            // in-flight) so the initial build is not blocked.
315            format!("dynamic:{}", dynamic_client_id.unwrap_or("none"))
316        }
317    })
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::upstream::config::{UpstreamOauthConfig, UpstreamOauthMode};
324    use rmcp_client::transport::AuthorizationManager;
325    use std::sync::atomic::{AtomicUsize, Ordering};
326
327    fn cfg(name: &str, client_id: &str) -> UpstreamConfig {
328        UpstreamConfig {
329            name: name.to_string(),
330            url: Some(format!("https://{name}.example/mcp")),
331            oauth: Some(UpstreamOauthConfig {
332                mode: UpstreamOauthMode::AuthorizationCodePkce,
333                registration: UpstreamOauthRegistration::Preregistered {
334                    client_id: client_id.to_string(),
335                    client_secret_env: None,
336                },
337                scopes: None,
338                prefer_client_metadata_document: None,
339            }),
340        }
341    }
342
343    #[test]
344    fn fingerprint_differs_on_client_id_change() {
345        let a = registration_fingerprint(&cfg("acme", "id-1"), None).unwrap();
346        let b = registration_fingerprint(&cfg("acme", "id-2"), None).unwrap();
347        assert_ne!(a, b);
348    }
349
350    #[test]
351    fn auto_fingerprint_tracks_dynamic_fallback_registration() {
352        let mut config = cfg("acme", "ignored");
353        config.oauth.as_mut().expect("oauth").registration = UpstreamOauthRegistration::Auto;
354        let first = registration_fingerprint(&config, Some("client-one")).unwrap();
355        let second = registration_fingerprint(&config, Some("client-two")).unwrap();
356        assert_ne!(first, second);
357    }
358
359    #[test]
360    fn fingerprint_stable_for_identical_config() {
361        let a = registration_fingerprint(&cfg("acme", "id-1"), None).unwrap();
362        let b = registration_fingerprint(&cfg("acme", "id-1"), None).unwrap();
363        assert_eq!(a, b);
364    }
365
366    #[test]
367    fn empty_cache_is_empty() {
368        let cache = OauthClientCache::new(Arc::new(DashMap::new()));
369        assert!(cache.is_empty());
370        assert_eq!(cache.len(), 0);
371    }
372
373    async fn dummy_auth_client() -> Arc<AuthClient<reqwest::Client>> {
374        // See google.rs::GoogleProvider::new for why this call is needed
375        // under "rustls-no-provider" -- idempotent, safe to ignore Err.
376        drop(rustls::crypto::ring::default_provider().install_default());
377        let manager = AuthorizationManager::new("http://localhost")
378            .await
379            .expect("authorization manager");
380        Arc::new(AuthClient::new(reqwest::Client::new(), manager))
381    }
382
383    #[tokio::test]
384    async fn cache_atomic_first_request_no_double_build() {
385        let cache = OauthClientCache::new(Arc::new(DashMap::new()));
386        let config = cfg("acme", "id-1");
387        let builds = Arc::new(AtomicUsize::new(0));
388
389        let left = {
390            let cache = cache.clone();
391            let config = config.clone();
392            let builds = Arc::clone(&builds);
393            tokio::spawn(async move {
394                cache
395                    .get_or_insert_with(&config, "alice", None, || {
396                        let builds = Arc::clone(&builds);
397                        async move {
398                            builds.fetch_add(1, Ordering::SeqCst);
399                            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
400                            Ok(dummy_auth_client().await)
401                        }
402                    })
403                    .await
404                    .expect("left client")
405            })
406        };
407        let right = {
408            let cache = cache.clone();
409            let config = config.clone();
410            let builds = Arc::clone(&builds);
411            tokio::spawn(async move {
412                cache
413                    .get_or_insert_with(&config, "alice", None, || {
414                        let builds = Arc::clone(&builds);
415                        async move {
416                            builds.fetch_add(1, Ordering::SeqCst);
417                            Ok(dummy_auth_client().await)
418                        }
419                    })
420                    .await
421                    .expect("right client")
422            })
423        };
424
425        let left = left.await.expect("join left");
426        let right = right.await.expect("join right");
427
428        assert_eq!(builds.load(Ordering::SeqCst), 1);
429        assert!(Arc::ptr_eq(&left, &right));
430    }
431
432    #[tokio::test]
433    async fn cache_refuses_stale_client_id_after_config_change() {
434        let cache = OauthClientCache::new(Arc::new(DashMap::new()));
435        let old = cfg("acme", "id-1");
436        let new = cfg("acme", "id-2");
437        let old_fingerprint = registration_fingerprint(&old, None).expect("old fingerprint");
438        cache.insert_for_tests("acme", "alice", &old_fingerprint, dummy_auth_client().await);
439
440        let rebuilt = Arc::new(AtomicUsize::new(0));
441        let client = cache
442            .get_or_insert_with(&new, "alice", None, || {
443                let rebuilt = Arc::clone(&rebuilt);
444                async move {
445                    rebuilt.fetch_add(1, Ordering::SeqCst);
446                    Ok(dummy_auth_client().await)
447                }
448            })
449            .await
450            .expect("rebuilt client");
451
452        assert_eq!(rebuilt.load(Ordering::SeqCst), 1);
453        assert_eq!(cache.len(), 1);
454        let stored = cache
455            .clients
456            .get(&(String::from("acme"), String::from("alice")))
457            .expect("stored client");
458        assert_eq!(
459            stored.fingerprint,
460            registration_fingerprint(&new, None).unwrap()
461        );
462        assert!(Arc::ptr_eq(&stored.client, &client));
463    }
464
465    // End-to-end eviction tests live in the Task 4 Step 7 suite where a real
466    // `UpstreamOauthManager` and credential store are set up; constructing an
467    // `AuthClient` here requires an async network-touching call to
468    // `AuthorizationManager::new`, which is inappropriate for a unit test.
469}