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
93            .oauth
94            .as_ref()
95            .is_some_and(|o| matches!(o.registration, UpstreamOauthRegistration::Dynamic))
96        {
97            self.managers
98                .get(&config.name)
99                .map(|r| r.clone())
100                .ok_or_else(|| {
101                    OauthError::Internal(format!(
102                        "no oauth manager registered for upstream '{}'",
103                        config.name
104                    ))
105                })?
106                .stored_dynamic_client_id(subject)
107                .await?
108        } else {
109            None
110        };
111
112        self.get_or_insert_with(config, subject, dynamic_client_id.as_deref(), || async {
113            let manager = self
114                .managers
115                .get(&config.name)
116                .map(|r| r.clone())
117                .ok_or_else(|| {
118                    OauthError::Internal(format!(
119                        "no oauth manager registered for upstream '{}'",
120                        config.name
121                    ))
122                })?;
123            let auth_client = manager.build_auth_client(subject).await?;
124            Ok(Arc::new(auth_client))
125        })
126        .await
127    }
128
129    /// Build an `AuthClient<C>` wrapping the supplied HTTP client and return it
130    /// WITHOUT caching it.
131    ///
132    /// Entry point for callers that manage their own per-connection cache and
133    /// need to pass a pre-built HTTP client (e.g. one with a response-size
134    /// cap) so the OAuth path gets identical transport behavior to the
135    /// non-OAuth path. The caller is responsible for caching the resulting
136    /// `AuthClient` at whatever level it owns, so there is no double-caching
137    /// here.
138    pub async fn get_or_build_capped<C>(
139        &self,
140        config: &UpstreamConfig,
141        subject: &str,
142        http_client: C,
143    ) -> Result<AuthClient<C>, OauthError>
144    where
145        C: StreamableHttpClient + Clone,
146    {
147        let manager = self
148            .managers
149            .get(&config.name)
150            .map(|r| r.clone())
151            .ok_or_else(|| {
152                OauthError::Internal(format!(
153                    "no oauth manager registered for upstream '{}'",
154                    config.name
155                ))
156            })?;
157        manager.build_auth_client_with(subject, http_client).await
158    }
159
160    #[allow(dead_code)]
161    async fn get_or_insert_with<F, Fut>(
162        &self,
163        config: &UpstreamConfig,
164        subject: &str,
165        // For `Dynamic` upstreams: the stored `client_id` to fold into the
166        // fingerprint. `None` for non-dynamic upstreams.
167        dynamic_client_id: Option<&str>,
168        builder: F,
169    ) -> Result<Arc<AuthClient<reqwest::Client>>, OauthError>
170    where
171        F: FnOnce() -> Fut,
172        Fut: Future<Output = Result<Arc<AuthClient<reqwest::Client>>, OauthError>>,
173    {
174        let fingerprint = registration_fingerprint(config, dynamic_client_id)?;
175        let key = (config.name.clone(), subject.to_string());
176
177        if let Some(entry) = self.clients.get(&key)
178            && entry.fingerprint == fingerprint
179        {
180            return Ok(Arc::clone(&entry.client));
181        }
182
183        let lock = self
184            .build_locks
185            .entry(key.clone())
186            .or_insert_with(|| Arc::new(Mutex::new(())))
187            .clone();
188        let _guard = lock.lock().await;
189
190        // Re-check after acquiring the lock: another caller may have built
191        // the entry while we were waiting.
192        if let Some(entry) = self.clients.get(&key)
193            && entry.fingerprint == fingerprint
194        {
195            return Ok(Arc::clone(&entry.client));
196        }
197
198        let arc_client = builder().await?;
199
200        self.clients.insert(
201            key,
202            Arc::new(CachedAuthClient {
203                client: Arc::clone(&arc_client),
204                fingerprint,
205            }),
206        );
207
208        Ok(arc_client)
209    }
210
211    /// Evict the entry for a single `(upstream, subject)` pair.
212    ///
213    /// Used by API handlers when credentials are cleared or when a refresh
214    /// fails terminally and the next request must reauthenticate.
215    pub fn evict_subject(&self, upstream: &str, subject: &str) {
216        let key = (upstream.to_string(), subject.to_string());
217        self.clients.remove(&key);
218        // build_locks is intentionally NOT evicted: it serializes concurrent
219        // builders for the same (upstream, subject) key. Removing it creates a
220        // race window where two concurrent callers both see no cached client,
221        // both drop the lock guard, and then both start building in parallel.
222    }
223
224    /// Evict every entry for `upstream`.
225    ///
226    /// Used at config reload when an upstream is removed or its OAuth
227    /// registration changes, and when the whole server shuts down the
228    /// upstream's sessions.
229    pub fn evict_upstream(&self, upstream: &str) {
230        self.clients.retain(|(name, _), _| name != upstream);
231        // build_locks intentionally preserved — see comment in evict_subject.
232    }
233
234    /// Evict every entry whose upstream is not in `known`.
235    ///
236    /// Used at config reload to drop cached clients for upstreams that no
237    /// longer exist in config.
238    pub fn evict_upstreams_not_in(&self, known: &std::collections::HashSet<&str>) {
239        self.clients
240            .retain(|(name, _), _| known.contains(name.as_str()));
241    }
242
243    /// Number of cached clients. Intended for tests and observability.
244    #[allow(dead_code)]
245    #[must_use]
246    pub fn len(&self) -> usize {
247        self.clients.len()
248    }
249
250    /// True when the cache holds no clients.
251    #[allow(dead_code)]
252    #[must_use]
253    pub fn is_empty(&self) -> bool {
254        self.clients.is_empty()
255    }
256
257    /// Insert a pre-built `AuthClient` directly into the cache.
258    ///
259    /// Test-only seam: available in `labby-auth`'s own tests and downstream
260    /// debug test builds. It is intentionally not gated by a Cargo feature so
261    /// `--all-features --release` cannot expose it in production artifacts.
262    #[cfg(any(test, debug_assertions))]
263    pub fn insert_for_tests(
264        &self,
265        upstream: &str,
266        subject: &str,
267        fingerprint: &str,
268        client: Arc<AuthClient<reqwest::Client>>,
269    ) {
270        self.clients.insert(
271            (upstream.to_string(), subject.to_string()),
272            Arc::new(CachedAuthClient {
273                client,
274                fingerprint: fingerprint.to_string(),
275            }),
276        );
277    }
278}
279
280/// Compute a stable fingerprint of the OAuth registration.
281///
282/// When the fingerprint changes, the cached `AuthClient` is discarded.
283/// `Preregistered` changes when `client_id` rotates; `ClientMetadataDocument`
284/// changes when its URL moves; `Dynamic` includes the stored per-subject
285/// `client_id` so a re-registration cycle evicts the stale entry.
286#[allow(dead_code)]
287fn registration_fingerprint(
288    config: &UpstreamConfig,
289    dynamic_client_id: Option<&str>,
290) -> Result<String, OauthError> {
291    let oauth = config
292        .oauth
293        .as_ref()
294        .ok_or_else(|| OauthError::Internal("upstream has no oauth config".to_string()))?;
295
296    Ok(match &oauth.registration {
297        UpstreamOauthRegistration::Preregistered { client_id, .. } => {
298            format!("preregistered:{client_id}")
299        }
300        UpstreamOauthRegistration::ClientMetadataDocument { url } => {
301            format!("client_metadata_document:{url}")
302        }
303        UpstreamOauthRegistration::Dynamic => {
304            // Include the stored client_id so a re-registration evicts the
305            // stale cached AuthClient. Fall back to "none" when
306            // no client_id has been persisted yet (first-time registration
307            // in-flight) so the initial build is not blocked.
308            format!("dynamic:{}", dynamic_client_id.unwrap_or("none"))
309        }
310    })
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::upstream::config::{UpstreamOauthConfig, UpstreamOauthMode};
317    use rmcp_client::transport::AuthorizationManager;
318    use std::sync::atomic::{AtomicUsize, Ordering};
319
320    fn cfg(name: &str, client_id: &str) -> UpstreamConfig {
321        UpstreamConfig {
322            name: name.to_string(),
323            url: Some(format!("https://{name}.example/mcp")),
324            oauth: Some(UpstreamOauthConfig {
325                mode: UpstreamOauthMode::AuthorizationCodePkce,
326                registration: UpstreamOauthRegistration::Preregistered {
327                    client_id: client_id.to_string(),
328                    client_secret_env: None,
329                },
330                scopes: None,
331                prefer_client_metadata_document: None,
332            }),
333        }
334    }
335
336    #[test]
337    fn fingerprint_differs_on_client_id_change() {
338        let a = registration_fingerprint(&cfg("acme", "id-1"), None).unwrap();
339        let b = registration_fingerprint(&cfg("acme", "id-2"), None).unwrap();
340        assert_ne!(a, b);
341    }
342
343    #[test]
344    fn fingerprint_stable_for_identical_config() {
345        let a = registration_fingerprint(&cfg("acme", "id-1"), None).unwrap();
346        let b = registration_fingerprint(&cfg("acme", "id-1"), None).unwrap();
347        assert_eq!(a, b);
348    }
349
350    #[test]
351    fn empty_cache_is_empty() {
352        let cache = OauthClientCache::new(Arc::new(DashMap::new()));
353        assert!(cache.is_empty());
354        assert_eq!(cache.len(), 0);
355    }
356
357    async fn dummy_auth_client() -> Arc<AuthClient<reqwest::Client>> {
358        // See google.rs::GoogleProvider::new for why this call is needed
359        // under "rustls-no-provider" -- idempotent, safe to ignore Err.
360        drop(rustls::crypto::ring::default_provider().install_default());
361        let manager = AuthorizationManager::new("http://localhost")
362            .await
363            .expect("authorization manager");
364        Arc::new(AuthClient::new(reqwest::Client::new(), manager))
365    }
366
367    #[tokio::test]
368    async fn cache_atomic_first_request_no_double_build() {
369        let cache = OauthClientCache::new(Arc::new(DashMap::new()));
370        let config = cfg("acme", "id-1");
371        let builds = Arc::new(AtomicUsize::new(0));
372
373        let left = {
374            let cache = cache.clone();
375            let config = config.clone();
376            let builds = Arc::clone(&builds);
377            tokio::spawn(async move {
378                cache
379                    .get_or_insert_with(&config, "alice", None, || {
380                        let builds = Arc::clone(&builds);
381                        async move {
382                            builds.fetch_add(1, Ordering::SeqCst);
383                            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
384                            Ok(dummy_auth_client().await)
385                        }
386                    })
387                    .await
388                    .expect("left client")
389            })
390        };
391        let right = {
392            let cache = cache.clone();
393            let config = config.clone();
394            let builds = Arc::clone(&builds);
395            tokio::spawn(async move {
396                cache
397                    .get_or_insert_with(&config, "alice", None, || {
398                        let builds = Arc::clone(&builds);
399                        async move {
400                            builds.fetch_add(1, Ordering::SeqCst);
401                            Ok(dummy_auth_client().await)
402                        }
403                    })
404                    .await
405                    .expect("right client")
406            })
407        };
408
409        let left = left.await.expect("join left");
410        let right = right.await.expect("join right");
411
412        assert_eq!(builds.load(Ordering::SeqCst), 1);
413        assert!(Arc::ptr_eq(&left, &right));
414    }
415
416    #[tokio::test]
417    async fn cache_refuses_stale_client_id_after_config_change() {
418        let cache = OauthClientCache::new(Arc::new(DashMap::new()));
419        let old = cfg("acme", "id-1");
420        let new = cfg("acme", "id-2");
421        let old_fingerprint = registration_fingerprint(&old, None).expect("old fingerprint");
422        cache.insert_for_tests("acme", "alice", &old_fingerprint, dummy_auth_client().await);
423
424        let rebuilt = Arc::new(AtomicUsize::new(0));
425        let client = cache
426            .get_or_insert_with(&new, "alice", None, || {
427                let rebuilt = Arc::clone(&rebuilt);
428                async move {
429                    rebuilt.fetch_add(1, Ordering::SeqCst);
430                    Ok(dummy_auth_client().await)
431                }
432            })
433            .await
434            .expect("rebuilt client");
435
436        assert_eq!(rebuilt.load(Ordering::SeqCst), 1);
437        assert_eq!(cache.len(), 1);
438        let stored = cache
439            .clients
440            .get(&(String::from("acme"), String::from("alice")))
441            .expect("stored client");
442        assert_eq!(
443            stored.fingerprint,
444            registration_fingerprint(&new, None).unwrap()
445        );
446        assert!(Arc::ptr_eq(&stored.client, &client));
447    }
448
449    // End-to-end eviction tests live in the Task 4 Step 7 suite where a real
450    // `UpstreamOauthManager` and credential store are set up; constructing an
451    // `AuthClient` here requires an async network-touching call to
452    // `AuthorizationManager::new`, which is inappropriate for a unit test.
453}