1use 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
19fn should_use_client_metadata_document(
20 registration: &UpstreamOauthRegistration,
21 preference: Option<bool>,
22 server_supports_cimd: bool,
23) -> bool {
24 server_supports_cimd
25 && (matches!(registration, UpstreamOauthRegistration::Auto) || preference == Some(true))
26}
27
28fn dynamic_registration_bootstrap_config(redirect_uri: &str) -> OAuthClientConfig {
29 OAuthClientConfig::new("soma-registration-placeholder", redirect_uri)
30 .with_application_type("web")
31}
32
33fn generated_client_metadata_document_url(
34 redirect_uri: &str,
35 upstream_name: &str,
36) -> Result<Option<String>, OauthError> {
37 let mut client_id = url::Url::parse(redirect_uri).map_err(|error| {
38 OauthError::Internal(format!("invalid upstream OAuth redirect URI: {error}"))
39 })?;
40 if client_id.scheme() != "https" {
41 return Ok(None);
42 }
43 client_id.set_query(None);
44 client_id.set_fragment(None);
45 {
46 let mut segments = client_id.path_segments_mut().map_err(|_| {
47 OauthError::Internal(
48 "upstream OAuth redirect URI cannot host a client metadata document".to_string(),
49 )
50 })?;
51 segments
52 .clear()
53 .push("auth")
54 .push("upstream")
55 .push("client-metadata")
56 .push(upstream_name);
57 }
58 Ok(Some(client_id.to_string()))
59}
60
61impl UpstreamOauthManager {
62 pub async fn build_auth_client(
70 &self,
71 subject: &str,
72 ) -> Result<AuthClient<reqwest::Client>, OauthError> {
73 let started = std::time::Instant::now();
74 let lock = self.locks.acquire(&self.upstream.name, subject);
75 let _guard = lock.lock().await;
76
77 let mut manager = self
78 .configured_authorization_manager(
79 subject,
80 DynamicClientRegistrationUse::StoredCredentials,
81 )
82 .await
83 .inspect_err(|e| {
84 tracing::warn!(
85 upstream = %self.upstream.name,
86 provider = %self.oauth_provider_label(),
87 subject,
88 scope = %self.oauth_scope_label(),
89 kind = e.kind(),
90 elapsed_ms = started.elapsed().as_millis(),
91 fallback = "reauthorization_required",
92 "upstream oauth: failed to build auth client manager"
93 );
94 })?;
95 let initialized = manager.initialize_from_store().await.map_err(|e| {
96 tracing::warn!(
97 upstream = %self.upstream.name,
98 provider = %self.oauth_provider_label(),
99 subject,
100 scope = %self.oauth_scope_label(),
101 kind = "internal_error",
102 elapsed_ms = started.elapsed().as_millis(),
103 fallback = "reauthorization_required",
104 "upstream oauth: failed to initialize auth client from credential store"
105 );
106 OauthError::Internal(format!("initialize from store: {e}"))
107 })?;
108
109 if !initialized {
110 tracing::warn!(
111 upstream = %self.upstream.name,
112 provider = %self.oauth_provider_label(),
113 subject,
114 scope = %self.oauth_scope_label(),
115 kind = "oauth_needs_reauth",
116 elapsed_ms = started.elapsed().as_millis(),
117 fallback = "reauthorization_required",
118 "upstream oauth: no stored credentials for auth client"
119 );
120 return Err(OauthError::NeedsReauth(format!(
121 "no stored credentials for upstream '{}' subject '{subject}'",
122 self.upstream.name
123 )));
124 }
125
126 let credential_row = self.credential_row(subject).await?;
127 let refresh_state = credential_row
128 .as_ref()
129 .and_then(|row| TokenRefreshState::from_row(row, now_unix().ok()?));
130 let refresh_due = refresh_state
131 .as_ref()
132 .is_some_and(TokenRefreshState::refresh_due);
133 if let Some(state) = refresh_state.as_ref() {
134 self.log_expiring_token(subject, state, started.elapsed().as_millis());
135 self.log_refresh_attempt(subject, state, started.elapsed().as_millis());
136 }
137
138 if refresh_due
139 && self
140 .refresh_failures
141 .recently_failed(&self.upstream.name, subject)
142 {
143 tracing::warn!(
144 upstream = %self.upstream.name,
145 provider = %self.oauth_provider_label(),
146 subject,
147 scope = %self.oauth_scope_label(),
148 kind = "oauth_needs_reauth",
149 elapsed_ms = started.elapsed().as_millis(),
150 fallback = "reauthorization_required",
151 "upstream oauth: token refresh skipped, recently failed"
152 );
153 return Err(OauthError::NeedsReauth(format!(
154 "upstream '{}' subject '{subject}' refresh failed recently; skipping retry until cooldown elapses",
155 self.upstream.name
156 )));
157 }
158
159 manager.get_access_token().await.map_err(|e| {
160 let mapped = super::map_auth_error(e);
161 if refresh_due {
162 self.refresh_failures
163 .record_failure(&self.upstream.name, subject);
164 tracing::warn!(
165 upstream = %self.upstream.name,
166 provider = %self.oauth_provider_label(),
167 subject,
168 scope = %self.oauth_scope_label(),
169 kind = mapped.kind(),
170 elapsed_ms = started.elapsed().as_millis(),
171 fallback = "reauthorization_required",
172 "upstream oauth: token refresh failed"
173 );
174 }
175 mapped
176 })?;
177
178 self.refresh_failures.clear(&self.upstream.name, subject);
179 if refresh_due {
180 tracing::info!(
181 upstream = %self.upstream.name,
182 provider = %self.oauth_provider_label(),
183 subject,
184 scope = %self.oauth_scope_label(),
185 elapsed_ms = started.elapsed().as_millis(),
186 fallback = "none",
187 "upstream oauth: token refresh succeeded"
188 );
189 }
190
191 drop(rustls::crypto::ring::default_provider().install_default());
194 Ok(AuthClient::new(reqwest::Client::new(), manager))
195 }
196
197 pub async fn build_auth_client_with<C>(
204 &self,
205 subject: &str,
206 http_client: C,
207 ) -> Result<AuthClient<C>, OauthError>
208 where
209 C: StreamableHttpClient,
210 {
211 let started = std::time::Instant::now();
212 let lock = self.locks.acquire(&self.upstream.name, subject);
213 let _guard = lock.lock().await;
214
215 let mut manager = self
216 .configured_authorization_manager(
217 subject,
218 DynamicClientRegistrationUse::StoredCredentials,
219 )
220 .await
221 .inspect_err(|e| {
222 tracing::warn!(
223 upstream = %self.upstream.name,
224 provider = %self.oauth_provider_label(),
225 subject,
226 scope = %self.oauth_scope_label(),
227 kind = e.kind(),
228 elapsed_ms = started.elapsed().as_millis(),
229 fallback = "reauthorization_required",
230 "upstream oauth: failed to build auth client manager (with_client)"
231 );
232 })?;
233 let initialized = manager.initialize_from_store().await.map_err(|e| {
234 tracing::warn!(
235 upstream = %self.upstream.name,
236 provider = %self.oauth_provider_label(),
237 subject,
238 scope = %self.oauth_scope_label(),
239 kind = "internal_error",
240 elapsed_ms = started.elapsed().as_millis(),
241 fallback = "reauthorization_required",
242 "upstream oauth: failed to initialize auth client from credential store (with_client)"
243 );
244 OauthError::Internal(format!("initialize from store: {e}"))
245 })?;
246
247 if !initialized {
248 tracing::warn!(
249 upstream = %self.upstream.name,
250 provider = %self.oauth_provider_label(),
251 subject,
252 scope = %self.oauth_scope_label(),
253 kind = "oauth_needs_reauth",
254 elapsed_ms = started.elapsed().as_millis(),
255 fallback = "reauthorization_required",
256 "upstream oauth: no stored credentials for auth client (with_client)"
257 );
258 return Err(OauthError::NeedsReauth(format!(
259 "no stored credentials for upstream '{}' subject '{subject}'",
260 self.upstream.name
261 )));
262 }
263
264 let credential_row = self.credential_row(subject).await?;
265 let refresh_state = credential_row
266 .as_ref()
267 .and_then(|row| TokenRefreshState::from_row(row, now_unix().ok()?));
268 let refresh_due = refresh_state
269 .as_ref()
270 .is_some_and(TokenRefreshState::refresh_due);
271 if let Some(state) = refresh_state.as_ref() {
272 self.log_expiring_token(subject, state, started.elapsed().as_millis());
273 self.log_refresh_attempt(subject, state, started.elapsed().as_millis());
274 }
275
276 if refresh_due
277 && self
278 .refresh_failures
279 .recently_failed(&self.upstream.name, subject)
280 {
281 tracing::warn!(
282 upstream = %self.upstream.name,
283 provider = %self.oauth_provider_label(),
284 subject,
285 scope = %self.oauth_scope_label(),
286 kind = "oauth_needs_reauth",
287 elapsed_ms = started.elapsed().as_millis(),
288 fallback = "reauthorization_required",
289 "upstream oauth: token refresh skipped, recently failed (with_client)"
290 );
291 return Err(OauthError::NeedsReauth(format!(
292 "upstream '{}' subject '{subject}' refresh failed recently; skipping retry until cooldown elapses",
293 self.upstream.name
294 )));
295 }
296
297 manager.get_access_token().await.map_err(|e| {
298 let mapped = super::map_auth_error(e);
299 if refresh_due {
300 self.refresh_failures
301 .record_failure(&self.upstream.name, subject);
302 tracing::warn!(
303 upstream = %self.upstream.name,
304 provider = %self.oauth_provider_label(),
305 subject,
306 scope = %self.oauth_scope_label(),
307 kind = mapped.kind(),
308 elapsed_ms = started.elapsed().as_millis(),
309 fallback = "reauthorization_required",
310 "upstream oauth: token refresh failed (with_client)"
311 );
312 }
313 mapped
314 })?;
315
316 self.refresh_failures.clear(&self.upstream.name, subject);
317 if refresh_due {
318 tracing::info!(
319 upstream = %self.upstream.name,
320 provider = %self.oauth_provider_label(),
321 subject,
322 scope = %self.oauth_scope_label(),
323 elapsed_ms = started.elapsed().as_millis(),
324 fallback = "none",
325 "upstream oauth: token refresh succeeded (with_client)"
326 );
327 }
328
329 Ok(AuthClient::new(http_client, manager))
330 }
331
332 pub async fn refresh_auth_client(&self, subject: &str) -> Result<(), OauthError> {
338 let started = std::time::Instant::now();
339 let lock = self.locks.acquire(&self.upstream.name, subject);
340 let _guard = lock.lock().await;
341
342 let mut manager = self
343 .configured_authorization_manager(
344 subject,
345 DynamicClientRegistrationUse::StoredCredentials,
346 )
347 .await
348 .inspect_err(|e| {
349 tracing::warn!(
350 upstream = %self.upstream.name,
351 provider = %self.oauth_provider_label(),
352 subject,
353 scope = %self.oauth_scope_label(),
354 kind = e.kind(),
355 elapsed_ms = started.elapsed().as_millis(),
356 fallback = "reauthorization_required",
357 "upstream oauth: failed to build refresh manager"
358 );
359 })?;
360 let initialized = manager.initialize_from_store().await.map_err(|e| {
361 tracing::warn!(
362 upstream = %self.upstream.name,
363 provider = %self.oauth_provider_label(),
364 subject,
365 scope = %self.oauth_scope_label(),
366 kind = "internal_error",
367 elapsed_ms = started.elapsed().as_millis(),
368 fallback = "reauthorization_required",
369 "upstream oauth: failed to initialize refresh manager from credential store"
370 );
371 OauthError::Internal(format!("initialize from store: {e}"))
372 })?;
373
374 if !initialized {
375 return Err(OauthError::NeedsReauth(format!(
376 "no stored credentials for upstream '{}' subject '{subject}'",
377 self.upstream.name
378 )));
379 }
380
381 let scopes_owned = self.oauth_config()?.scopes.clone().unwrap_or_default();
392 let scopes: Vec<&str> = scopes_owned.iter().map(String::as_str).collect();
393 let issuer = self
394 .metadata_cache
395 .read()
396 .await
397 .as_ref()
398 .and_then(|metadata| metadata.issuer.as_deref())
399 .map(|issuer| issuer.trim_end_matches('/').to_string())
400 .ok_or_else(|| {
401 OauthError::IssuerMismatch("authorization server issuer is unavailable".to_string())
402 })?;
403 let client_cfg = self
404 .resolve_client_config(
405 &mut manager,
406 subject,
407 &scopes,
408 &issuer,
409 DynamicClientRegistrationUse::StoredCredentials,
410 )
411 .await?;
412 manager.configure_client(client_cfg).map_err(|e| {
413 OauthError::Internal(format!(
414 "re-configure client with credentials after store init: {e}"
415 ))
416 })?;
417
418 manager
419 .refresh_token()
420 .await
421 .map_err(super::map_auth_error)?;
422 tracing::info!(
423 upstream = %self.upstream.name,
424 provider = %self.oauth_provider_label(),
425 subject,
426 scope = %self.oauth_scope_label(),
427 elapsed_ms = started.elapsed().as_millis(),
428 "upstream oauth: status refresh succeeded"
429 );
430 Ok(())
431 }
432
433 pub(super) async fn resolve_client_config(
434 &self,
435 manager: &mut AuthorizationManager,
436 subject: &str,
437 scopes: &[&str],
438 issuer: &str,
439 dynamic_registration_use: DynamicClientRegistrationUse,
440 ) -> Result<OAuthClientConfig, OauthError> {
441 let oauth_cfg = self.oauth_config()?;
442 match &oauth_cfg.registration {
443 UpstreamOauthRegistration::Preregistered {
444 client_id,
445 client_secret_env,
446 } => {
447 let secret = match client_secret_env.as_deref() {
448 None => None,
449 Some(var) => {
450 let val = std::env::var(var).unwrap_or_default();
451 if val.is_empty() {
452 return Err(OauthError::Internal(format!(
453 "client_secret_env '{var}' is configured but env var '{var}' is not set or is empty"
454 )));
455 }
456 Some(val)
457 }
458 };
459
460 let mut cfg = OAuthClientConfig::new(client_id.clone(), self.redirect_uri.as_str());
461 if let Some(s) = secret {
462 cfg = cfg.with_client_secret(s);
463 }
464 cfg = cfg.with_scopes(scopes.iter().map(|s| s.to_string()).collect());
465 Ok(cfg)
466 }
467 UpstreamOauthRegistration::Auto | UpstreamOauthRegistration::Dynamic => {
468 let (supports_cimd, supports_dcr) = {
469 let metadata = self.metadata_cache.read().await;
470 let metadata = metadata.as_ref();
471 (
472 metadata
473 .and_then(|metadata| {
474 metadata
475 .additional_fields
476 .get("client_id_metadata_document_supported")
477 })
478 .and_then(serde_json::Value::as_bool)
479 .unwrap_or(false),
480 metadata
481 .and_then(|metadata| metadata.registration_endpoint.as_deref())
482 .is_some(),
483 )
484 };
485 if should_use_client_metadata_document(
486 &oauth_cfg.registration,
487 oauth_cfg.prefer_client_metadata_document,
488 supports_cimd,
489 ) && let Some(client_id) = generated_client_metadata_document_url(
490 self.redirect_uri.as_str(),
491 &self.upstream.name,
492 )? {
493 return Ok(
494 OAuthClientConfig::new(client_id, self.redirect_uri.as_str())
495 .with_scopes(scopes.iter().map(|scope| scope.to_string()).collect())
496 .with_application_type("web"),
497 );
498 }
499
500 match dynamic_registration_use {
509 DynamicClientRegistrationUse::StoredCredentials => {
510 if let Some(row) = self
511 .sqlite
512 .find_upstream_oauth_credentials(&self.upstream.name, subject)
513 .await
514 .map_err(|e| OauthError::Internal(e.to_string()))?
515 {
516 if row.issuer.is_empty() || row.issuer != issuer {
517 self.sqlite
518 .delete_upstream_oauth_credentials(&self.upstream.name, subject)
519 .await
520 .map_err(|e| OauthError::Internal(e.to_string()))?;
521 self.sqlite
522 .delete_dynamic_client_registration(
523 &self.upstream.name,
524 subject,
525 )
526 .await
527 .map_err(|e| OauthError::Internal(e.to_string()))?;
528 return Err(OauthError::NeedsReauth(format!(
529 "authorization server changed for upstream '{}'",
530 self.upstream.name
531 )));
532 }
533 let mut cfg =
534 OAuthClientConfig::new(row.client_id, self.redirect_uri.as_str());
535 cfg = cfg.with_scopes(scopes.iter().map(|s| s.to_string()).collect());
536 return Ok(cfg);
537 }
538
539 return Err(OauthError::NeedsReauth(format!(
540 "no stored credentials for upstream '{}' subject '{subject}'",
541 self.upstream.name
542 )));
543 }
544 DynamicClientRegistrationUse::CompleteAuthorization => {
545 if let Some(registration) = self
550 .sqlite
551 .find_dynamic_client_registration(&self.upstream.name, subject)
552 .await
553 .map_err(|e| OauthError::Internal(e.to_string()))?
554 {
555 if registration.issuer.is_empty() || registration.issuer != issuer {
556 self.sqlite
557 .delete_dynamic_client_registration(
558 &self.upstream.name,
559 subject,
560 )
561 .await
562 .map_err(|e| OauthError::Internal(e.to_string()))?;
563 return Err(OauthError::NeedsReauth(format!(
564 "authorization server changed for upstream '{}'",
565 self.upstream.name
566 )));
567 }
568 let mut cfg = OAuthClientConfig::new(
569 registration.client_id,
570 self.redirect_uri.as_str(),
571 );
572 cfg = cfg.with_scopes(scopes.iter().map(|s| s.to_string()).collect());
573 return Ok(cfg);
574 }
575
576 return Err(OauthError::NeedsReauth(format!(
577 "no dynamic client registration for upstream '{}' subject '{subject}'",
578 self.upstream.name
579 )));
580 }
581 DynamicClientRegistrationUse::BeginAuthorization => {}
582 }
583
584 if !supports_dcr {
585 return Err(OauthError::UnsupportedMethod(
586 "authorization server supports neither a usable Client ID Metadata Document nor Dynamic Client Registration"
587 .to_string(),
588 ));
589 }
590
591 manager
595 .configure_client(dynamic_registration_bootstrap_config(
596 self.redirect_uri.as_str(),
597 ))
598 .map_err(|e| {
599 OauthError::Internal(format!(
600 "configure dynamic registration application_type: {e}"
601 ))
602 })?;
603 let cfg = manager
604 .register_client("soma", self.redirect_uri.as_str(), scopes)
605 .await
606 .map_err(|e| OauthError::Internal(format!("dynamic registration: {e}")))?;
607
608 self.sqlite
609 .save_dynamic_client_registration(
610 &self.upstream.name,
611 subject,
612 &cfg.client_id,
613 issuer,
614 )
615 .await
616 .map_err(|e| OauthError::Internal(e.to_string()))?;
617
618 let canonical_registration = self
620 .sqlite
621 .find_dynamic_client_registration(&self.upstream.name, subject)
622 .await
623 .map_err(|e| OauthError::Internal(e.to_string()))?
624 .ok_or_else(|| {
625 OauthError::Internal(
626 "dynamic registration saved but read-back returned nothing".to_string(),
627 )
628 })?;
629
630 let mut canonical_cfg = OAuthClientConfig::new(
631 canonical_registration.client_id,
632 self.redirect_uri.as_str(),
633 );
634 canonical_cfg =
635 canonical_cfg.with_scopes(scopes.iter().map(|s| s.to_string()).collect());
636 Ok(canonical_cfg)
637 }
638 UpstreamOauthRegistration::ClientMetadataDocument { url } => {
639 let parsed = url::Url::parse(url).map_err(|e| {
644 OauthError::Internal(format!("invalid client_metadata_document url: {e}"))
645 })?;
646 if parsed.scheme() != "https" {
647 return Err(OauthError::Internal(format!(
648 "client_metadata_document url must use https, got `{}`",
649 parsed.scheme()
650 )));
651 }
652 let cfg = OAuthClientConfig::new(url.clone(), self.redirect_uri.as_str())
653 .with_scopes(scopes.iter().map(|s| s.to_string()).collect())
654 .with_application_type("web");
655 Ok(cfg)
656 }
657 }
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 #[test]
666 fn auto_prefers_cimd_only_when_the_server_advertises_it() {
667 assert!(should_use_client_metadata_document(
668 &UpstreamOauthRegistration::Auto,
669 None,
670 true,
671 ));
672 assert!(!should_use_client_metadata_document(
673 &UpstreamOauthRegistration::Auto,
674 None,
675 false,
676 ));
677 }
678
679 #[test]
680 fn explicit_dynamic_remains_dcr_unless_the_compatibility_override_is_enabled() {
681 assert!(!should_use_client_metadata_document(
682 &UpstreamOauthRegistration::Dynamic,
683 None,
684 true,
685 ));
686 assert!(should_use_client_metadata_document(
687 &UpstreamOauthRegistration::Dynamic,
688 Some(true),
689 true,
690 ));
691 }
692
693 #[test]
694 fn dynamic_registration_identifies_soma_as_a_web_client() {
695 let config =
696 dynamic_registration_bootstrap_config("https://soma.example/auth/upstream/callback");
697 assert_eq!(config.application_type.as_deref(), Some("web"));
698 }
699
700 #[test]
701 fn generated_cimd_url_uses_the_public_callback_origin() {
702 let url = generated_client_metadata_document_url(
703 "https://soma.example/auth/upstream/callback?ignored=yes",
704 "github",
705 )
706 .expect("metadata URL")
707 .expect("https callback should host CIMD");
708 assert_eq!(
709 url,
710 "https://soma.example/auth/upstream/client-metadata/github"
711 );
712 }
713
714 #[test]
715 fn generated_cimd_url_rejects_plain_http_so_auto_can_fall_back_to_dcr() {
716 let url = generated_client_metadata_document_url(
717 "http://127.0.0.1:40060/auth/upstream/callback",
718 "github",
719 )
720 .expect("valid callback URL");
721 assert!(url.is_none());
722 }
723}