Skip to main content

soma_auth/upstream/manager/
client.rs

1//! `AuthClient` construction, forced refresh, and OAuth client-config
2//! resolution for [`super::UpstreamOauthManager`]. Split out of
3//! `manager.rs` to keep that module under the repo's file-size contract —
4//! this is a second `impl` block for the same type, not a separate
5//! abstraction; being a child module of `manager`, it sees `manager`'s
6//! private fields and helper functions the same as if this were still one
7//! file.
8
9use rmcp::transport::auth::OAuthClientConfig;
10use rmcp::transport::streamable_http_client::StreamableHttpClient;
11use rmcp::transport::{AuthClient, AuthorizationManager};
12use rmcp_client as rmcp;
13
14use crate::upstream::config::UpstreamOauthRegistration;
15use crate::upstream::types::OauthError;
16
17use super::{DynamicClientRegistrationUse, TokenRefreshState, UpstreamOauthManager, now_unix};
18
19impl UpstreamOauthManager {
20    /// Return an `AuthClient` ready for use, proactively refreshing if near expiry.
21    ///
22    /// Creates a fresh `AuthorizationManager` backed by stored credentials.  Uses
23    /// cached AS metadata to avoid an extra HTTP round-trip.
24    ///
25    /// Returns `OauthError::NeedsReauth` when no credentials are stored or the
26    /// refresh token has been revoked.
27    pub async fn build_auth_client(
28        &self,
29        subject: &str,
30    ) -> Result<AuthClient<reqwest::Client>, OauthError> {
31        let started = std::time::Instant::now();
32        let lock = self.locks.acquire(&self.upstream.name, subject);
33        let _guard = lock.lock().await;
34
35        let mut manager = self
36            .configured_authorization_manager(
37                subject,
38                DynamicClientRegistrationUse::StoredCredentials,
39            )
40            .await
41            .inspect_err(|e| {
42                tracing::warn!(
43                    upstream = %self.upstream.name,
44                    provider = %self.oauth_provider_label(),
45                    subject,
46                    scope = %self.oauth_scope_label(),
47                    kind = e.kind(),
48                    elapsed_ms = started.elapsed().as_millis(),
49                    fallback = "reauthorization_required",
50                    "upstream oauth: failed to build auth client manager"
51                );
52            })?;
53        let initialized = manager.initialize_from_store().await.map_err(|e| {
54            tracing::warn!(
55                upstream = %self.upstream.name,
56                provider = %self.oauth_provider_label(),
57                subject,
58                scope = %self.oauth_scope_label(),
59                kind = "internal_error",
60                elapsed_ms = started.elapsed().as_millis(),
61                fallback = "reauthorization_required",
62                "upstream oauth: failed to initialize auth client from credential store"
63            );
64            OauthError::Internal(format!("initialize from store: {e}"))
65        })?;
66
67        if !initialized {
68            tracing::warn!(
69                upstream = %self.upstream.name,
70                provider = %self.oauth_provider_label(),
71                subject,
72                scope = %self.oauth_scope_label(),
73                kind = "oauth_needs_reauth",
74                elapsed_ms = started.elapsed().as_millis(),
75                fallback = "reauthorization_required",
76                "upstream oauth: no stored credentials for auth client"
77            );
78            return Err(OauthError::NeedsReauth(format!(
79                "no stored credentials for upstream '{}' subject '{subject}'",
80                self.upstream.name
81            )));
82        }
83
84        let credential_row = self.credential_row(subject).await?;
85        let refresh_state = credential_row
86            .as_ref()
87            .and_then(|row| TokenRefreshState::from_row(row, now_unix().ok()?));
88        let refresh_due = refresh_state
89            .as_ref()
90            .is_some_and(TokenRefreshState::refresh_due);
91        if let Some(state) = refresh_state.as_ref() {
92            self.log_expiring_token(subject, state, started.elapsed().as_millis());
93            self.log_refresh_attempt(subject, state, started.elapsed().as_millis());
94        }
95
96        if refresh_due
97            && self
98                .refresh_failures
99                .recently_failed(&self.upstream.name, subject)
100        {
101            tracing::warn!(
102                upstream = %self.upstream.name,
103                provider = %self.oauth_provider_label(),
104                subject,
105                scope = %self.oauth_scope_label(),
106                kind = "oauth_needs_reauth",
107                elapsed_ms = started.elapsed().as_millis(),
108                fallback = "reauthorization_required",
109                "upstream oauth: token refresh skipped, recently failed"
110            );
111            return Err(OauthError::NeedsReauth(format!(
112                "upstream '{}' subject '{subject}' refresh failed recently; skipping retry until cooldown elapses",
113                self.upstream.name
114            )));
115        }
116
117        manager.get_access_token().await.map_err(|e| {
118            let mapped = super::map_auth_error(e);
119            if refresh_due {
120                self.refresh_failures
121                    .record_failure(&self.upstream.name, subject);
122                tracing::warn!(
123                    upstream = %self.upstream.name,
124                    provider = %self.oauth_provider_label(),
125                    subject,
126                    scope = %self.oauth_scope_label(),
127                    kind = mapped.kind(),
128                    elapsed_ms = started.elapsed().as_millis(),
129                    fallback = "reauthorization_required",
130                    "upstream oauth: token refresh failed"
131                );
132            }
133            mapped
134        })?;
135
136        self.refresh_failures.clear(&self.upstream.name, subject);
137        if refresh_due {
138            tracing::info!(
139                upstream = %self.upstream.name,
140                provider = %self.oauth_provider_label(),
141                subject,
142                scope = %self.oauth_scope_label(),
143                elapsed_ms = started.elapsed().as_millis(),
144                fallback = "none",
145                "upstream oauth: token refresh succeeded"
146            );
147        }
148
149        // See google.rs::GoogleProvider::new for why this call is needed
150        // under "rustls-no-provider" -- idempotent, safe to ignore Err.
151        drop(rustls::crypto::ring::default_provider().install_default());
152        Ok(AuthClient::new(reqwest::Client::new(), manager))
153    }
154
155    /// Build an `AuthClient<C>` wrapping the supplied HTTP client.
156    ///
157    /// Identical to `build_auth_client` except the caller provides the HTTP
158    /// transport, enabling `BodyCappedHttpClient` or any other
159    /// `StreamableHttpClient` to be used on the OAuth path.  The resulting
160    /// client is NOT cached — callers that need caching must do so themselves.
161    pub async fn build_auth_client_with<C>(
162        &self,
163        subject: &str,
164        http_client: C,
165    ) -> Result<AuthClient<C>, OauthError>
166    where
167        C: StreamableHttpClient,
168    {
169        let started = std::time::Instant::now();
170        let lock = self.locks.acquire(&self.upstream.name, subject);
171        let _guard = lock.lock().await;
172
173        let mut manager = self
174            .configured_authorization_manager(
175                subject,
176                DynamicClientRegistrationUse::StoredCredentials,
177            )
178            .await
179            .inspect_err(|e| {
180                tracing::warn!(
181                    upstream = %self.upstream.name,
182                    provider = %self.oauth_provider_label(),
183                    subject,
184                    scope = %self.oauth_scope_label(),
185                    kind = e.kind(),
186                    elapsed_ms = started.elapsed().as_millis(),
187                    fallback = "reauthorization_required",
188                    "upstream oauth: failed to build auth client manager (with_client)"
189                );
190            })?;
191        let initialized = manager.initialize_from_store().await.map_err(|e| {
192            tracing::warn!(
193                upstream = %self.upstream.name,
194                provider = %self.oauth_provider_label(),
195                subject,
196                scope = %self.oauth_scope_label(),
197                kind = "internal_error",
198                elapsed_ms = started.elapsed().as_millis(),
199                fallback = "reauthorization_required",
200                "upstream oauth: failed to initialize auth client from credential store (with_client)"
201            );
202            OauthError::Internal(format!("initialize from store: {e}"))
203        })?;
204
205        if !initialized {
206            tracing::warn!(
207                upstream = %self.upstream.name,
208                provider = %self.oauth_provider_label(),
209                subject,
210                scope = %self.oauth_scope_label(),
211                kind = "oauth_needs_reauth",
212                elapsed_ms = started.elapsed().as_millis(),
213                fallback = "reauthorization_required",
214                "upstream oauth: no stored credentials for auth client (with_client)"
215            );
216            return Err(OauthError::NeedsReauth(format!(
217                "no stored credentials for upstream '{}' subject '{subject}'",
218                self.upstream.name
219            )));
220        }
221
222        let credential_row = self.credential_row(subject).await?;
223        let refresh_state = credential_row
224            .as_ref()
225            .and_then(|row| TokenRefreshState::from_row(row, now_unix().ok()?));
226        let refresh_due = refresh_state
227            .as_ref()
228            .is_some_and(TokenRefreshState::refresh_due);
229        if let Some(state) = refresh_state.as_ref() {
230            self.log_expiring_token(subject, state, started.elapsed().as_millis());
231            self.log_refresh_attempt(subject, state, started.elapsed().as_millis());
232        }
233
234        if refresh_due
235            && self
236                .refresh_failures
237                .recently_failed(&self.upstream.name, subject)
238        {
239            tracing::warn!(
240                upstream = %self.upstream.name,
241                provider = %self.oauth_provider_label(),
242                subject,
243                scope = %self.oauth_scope_label(),
244                kind = "oauth_needs_reauth",
245                elapsed_ms = started.elapsed().as_millis(),
246                fallback = "reauthorization_required",
247                "upstream oauth: token refresh skipped, recently failed (with_client)"
248            );
249            return Err(OauthError::NeedsReauth(format!(
250                "upstream '{}' subject '{subject}' refresh failed recently; skipping retry until cooldown elapses",
251                self.upstream.name
252            )));
253        }
254
255        manager.get_access_token().await.map_err(|e| {
256            let mapped = super::map_auth_error(e);
257            if refresh_due {
258                self.refresh_failures
259                    .record_failure(&self.upstream.name, subject);
260                tracing::warn!(
261                    upstream = %self.upstream.name,
262                    provider = %self.oauth_provider_label(),
263                    subject,
264                    scope = %self.oauth_scope_label(),
265                    kind = mapped.kind(),
266                    elapsed_ms = started.elapsed().as_millis(),
267                    fallback = "reauthorization_required",
268                    "upstream oauth: token refresh failed (with_client)"
269                );
270            }
271            mapped
272        })?;
273
274        self.refresh_failures.clear(&self.upstream.name, subject);
275        if refresh_due {
276            tracing::info!(
277                upstream = %self.upstream.name,
278                provider = %self.oauth_provider_label(),
279                subject,
280                scope = %self.oauth_scope_label(),
281                elapsed_ms = started.elapsed().as_millis(),
282                fallback = "none",
283                "upstream oauth: token refresh succeeded (with_client)"
284            );
285        }
286
287        Ok(AuthClient::new(http_client, manager))
288    }
289
290    /// Force a refresh for stored credentials.
291    ///
292    /// `AuthorizationManager::get_access_token()` only refreshes inside rmcp's
293    /// short refresh buffer. Status checks need an explicit refresh so UI state
294    /// cannot report a stale credential row as connected.
295    pub async fn refresh_auth_client(&self, subject: &str) -> Result<(), OauthError> {
296        let started = std::time::Instant::now();
297        let lock = self.locks.acquire(&self.upstream.name, subject);
298        let _guard = lock.lock().await;
299
300        let mut manager = self
301            .configured_authorization_manager(
302                subject,
303                DynamicClientRegistrationUse::StoredCredentials,
304            )
305            .await
306            .inspect_err(|e| {
307                tracing::warn!(
308                    upstream = %self.upstream.name,
309                    provider = %self.oauth_provider_label(),
310                    subject,
311                    scope = %self.oauth_scope_label(),
312                    kind = e.kind(),
313                    elapsed_ms = started.elapsed().as_millis(),
314                    fallback = "reauthorization_required",
315                    "upstream oauth: failed to build refresh manager"
316                );
317            })?;
318        let initialized = manager.initialize_from_store().await.map_err(|e| {
319            tracing::warn!(
320                upstream = %self.upstream.name,
321                provider = %self.oauth_provider_label(),
322                subject,
323                scope = %self.oauth_scope_label(),
324                kind = "internal_error",
325                elapsed_ms = started.elapsed().as_millis(),
326                fallback = "reauthorization_required",
327                "upstream oauth: failed to initialize refresh manager from credential store"
328            );
329            OauthError::Internal(format!("initialize from store: {e}"))
330        })?;
331
332        if !initialized {
333            return Err(OauthError::NeedsReauth(format!(
334                "no stored credentials for upstream '{}' subject '{subject}'",
335                self.upstream.name
336            )));
337        }
338
339        // rmcp's `initialize_from_store()` reconfigures the OAuth client via
340        // `configure_client_id()`, which hardcodes `client_secret: None` and so
341        // discards the secret that `configured_authorization_manager` just set.
342        // Confidential upstreams (e.g. Google, which requires `client_secret` on
343        // the refresh_token grant) then fail refresh with "client_secret is
344        // missing". Re-apply the resolved client config — including the secret —
345        // now that stored credentials and metadata are loaded. `refresh_token()`
346        // reads the refresh token from the credential store, not the client
347        // config, so re-configuring the client does not disturb it. For public
348        // clients `resolve_client_config` yields no secret, so this is a no-op.
349        let scopes_owned = self.oauth_config()?.scopes.clone().unwrap_or_default();
350        let scopes: Vec<&str> = scopes_owned.iter().map(String::as_str).collect();
351        let client_cfg = self
352            .resolve_client_config(
353                &mut manager,
354                subject,
355                &scopes,
356                DynamicClientRegistrationUse::StoredCredentials,
357            )
358            .await?;
359        manager.configure_client(client_cfg).map_err(|e| {
360            OauthError::Internal(format!(
361                "re-configure client with credentials after store init: {e}"
362            ))
363        })?;
364
365        manager
366            .refresh_token()
367            .await
368            .map_err(super::map_auth_error)?;
369        tracing::info!(
370            upstream = %self.upstream.name,
371            provider = %self.oauth_provider_label(),
372            subject,
373            scope = %self.oauth_scope_label(),
374            elapsed_ms = started.elapsed().as_millis(),
375            "upstream oauth: status refresh succeeded"
376        );
377        Ok(())
378    }
379
380    pub(super) async fn resolve_client_config(
381        &self,
382        manager: &mut AuthorizationManager,
383        subject: &str,
384        scopes: &[&str],
385        dynamic_registration_use: DynamicClientRegistrationUse,
386    ) -> Result<OAuthClientConfig, OauthError> {
387        let oauth_cfg = self.oauth_config()?;
388        match &oauth_cfg.registration {
389            UpstreamOauthRegistration::Preregistered {
390                client_id,
391                client_secret_env,
392            } => {
393                let secret = match client_secret_env.as_deref() {
394                    None => None,
395                    Some(var) => {
396                        let val = std::env::var(var).unwrap_or_default();
397                        if val.is_empty() {
398                            return Err(OauthError::Internal(format!(
399                                "client_secret_env '{var}' is configured but env var '{var}' is not set or is empty"
400                            )));
401                        }
402                        Some(val)
403                    }
404                };
405
406                let mut cfg = OAuthClientConfig::new(client_id.clone(), self.redirect_uri.as_str());
407                if let Some(s) = secret {
408                    cfg = cfg.with_client_secret(s);
409                }
410                cfg = cfg.with_scopes(scopes.iter().map(|s| s.to_string()).collect());
411                Ok(cfg)
412            }
413            UpstreamOauthRegistration::Dynamic => {
414                // Dynamic registration (RFC 7591) has two different lifetimes:
415                //   1. Stored credentials are durable and remain authoritative after
416                //      a successful token exchange for normal MCP calls.
417                //   2. The dynamic registration row is only pending state between
418                //      begin_authorization and callback. It survives process restarts,
419                //      but must not be reused to start a new flow because upstream AS
420                //      state can be reset independently, leaving a stale client_id behind.
421
422                match dynamic_registration_use {
423                    DynamicClientRegistrationUse::StoredCredentials => {
424                        if let Some(row) = self
425                            .sqlite
426                            .find_upstream_oauth_credentials(&self.upstream.name, subject)
427                            .await
428                            .map_err(|e| OauthError::Internal(e.to_string()))?
429                        {
430                            let mut cfg =
431                                OAuthClientConfig::new(row.client_id, self.redirect_uri.as_str());
432                            cfg = cfg.with_scopes(scopes.iter().map(|s| s.to_string()).collect());
433                            return Ok(cfg);
434                        }
435
436                        return Err(OauthError::NeedsReauth(format!(
437                            "no stored credentials for upstream '{}' subject '{subject}'",
438                            self.upstream.name
439                        )));
440                    }
441                    DynamicClientRegistrationUse::CompleteAuthorization => {
442                        // Callback/token exchange path: use the client_id created
443                        // by the begin_authorization call. This keeps callbacks
444                        // valid across process restarts and lets an explicit
445                        // reauth flow replace stale stored credentials.
446                        if let Some(client_id) = self
447                            .sqlite
448                            .find_dynamic_client_registration(&self.upstream.name, subject)
449                            .await
450                            .map_err(|e| OauthError::Internal(e.to_string()))?
451                        {
452                            let mut cfg =
453                                OAuthClientConfig::new(client_id, self.redirect_uri.as_str());
454                            cfg = cfg.with_scopes(scopes.iter().map(|s| s.to_string()).collect());
455                            return Ok(cfg);
456                        }
457
458                        return Err(OauthError::NeedsReauth(format!(
459                            "no dynamic client registration for upstream '{}' subject '{subject}'",
460                            self.upstream.name
461                        )));
462                    }
463                    DynamicClientRegistrationUse::BeginAuthorization => {}
464                }
465
466                // Beginning a new flow: register with the AS every time there are
467                // no stored credentials. This self-heals when the upstream AS loses
468                // its dynamic-client DB while this process still has an old pending row.
469                let cfg = manager
470                    .register_client("soma", self.redirect_uri.as_str(), scopes)
471                    .await
472                    .map_err(|e| OauthError::Internal(format!("dynamic registration: {e}")))?;
473
474                self.sqlite
475                    .save_dynamic_client_registration(&self.upstream.name, subject, &cfg.client_id)
476                    .await
477                    .map_err(|e| OauthError::Internal(e.to_string()))?;
478
479                // Read back the persisted value to use the DB-canonical client_id.
480                let canonical_client_id = self
481                    .sqlite
482                    .find_dynamic_client_registration(&self.upstream.name, subject)
483                    .await
484                    .map_err(|e| OauthError::Internal(e.to_string()))?
485                    .ok_or_else(|| {
486                        OauthError::Internal(
487                            "dynamic registration saved but read-back returned nothing".to_string(),
488                        )
489                    })?;
490
491                let mut canonical_cfg =
492                    OAuthClientConfig::new(canonical_client_id, self.redirect_uri.as_str());
493                canonical_cfg =
494                    canonical_cfg.with_scopes(scopes.iter().map(|s| s.to_string()).collect());
495                Ok(canonical_cfg)
496            }
497            UpstreamOauthRegistration::ClientMetadataDocument { url } => {
498                // Client ID Metadata Document (CIMD): the metadata-document URL
499                // *is* the client identifier. No registration_endpoint call is
500                // issued — the AS fetches the document itself when it first sees
501                // the client_id. We construct the OAuth client locally.
502                let parsed = url::Url::parse(url).map_err(|e| {
503                    OauthError::Internal(format!("invalid client_metadata_document url: {e}"))
504                })?;
505                if parsed.scheme() != "https" {
506                    return Err(OauthError::Internal(format!(
507                        "client_metadata_document url must use https, got `{}`",
508                        parsed.scheme()
509                    )));
510                }
511                let cfg = OAuthClientConfig::new(url.clone(), self.redirect_uri.as_str())
512                    .with_scopes(scopes.iter().map(|s| s.to_string()).collect());
513                Ok(cfg)
514            }
515        }
516    }
517}