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
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    /// Return an `AuthClient` ready for use, proactively refreshing if near expiry.
63    ///
64    /// Creates a fresh `AuthorizationManager` backed by stored credentials.  Uses
65    /// cached AS metadata to avoid an extra HTTP round-trip.
66    ///
67    /// Returns `OauthError::NeedsReauth` when no credentials are stored or the
68    /// refresh token has been revoked.
69    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        // See google.rs::GoogleProvider::new for why this call is needed
192        // under "rustls-no-provider" -- idempotent, safe to ignore Err.
193        drop(rustls::crypto::ring::default_provider().install_default());
194        Ok(AuthClient::new(reqwest::Client::new(), manager))
195    }
196
197    /// Build an `AuthClient<C>` wrapping the supplied HTTP client.
198    ///
199    /// Identical to `build_auth_client` except the caller provides the HTTP
200    /// transport, enabling `BodyCappedHttpClient` or any other
201    /// `StreamableHttpClient` to be used on the OAuth path.  The resulting
202    /// client is NOT cached — callers that need caching must do so themselves.
203    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    /// Force a refresh for stored credentials.
333    ///
334    /// `AuthorizationManager::get_access_token()` only refreshes inside rmcp's
335    /// short refresh buffer. Status checks need an explicit refresh so UI state
336    /// cannot report a stale credential row as connected.
337    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        // rmcp's `initialize_from_store()` reconfigures the OAuth client via
382        // `configure_client_id()`, which hardcodes `client_secret: None` and so
383        // discards the secret that `configured_authorization_manager` just set.
384        // Confidential upstreams (e.g. Google, which requires `client_secret` on
385        // the refresh_token grant) then fail refresh with "client_secret is
386        // missing". Re-apply the resolved client config — including the secret —
387        // now that stored credentials and metadata are loaded. `refresh_token()`
388        // reads the refresh token from the credential store, not the client
389        // config, so re-configuring the client does not disturb it. For public
390        // clients `resolve_client_config` yields no secret, so this is a no-op.
391        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                // Dynamic registration (RFC 7591) has two different lifetimes:
501                //   1. Stored credentials are durable and remain authoritative after
502                //      a successful token exchange for normal MCP calls.
503                //   2. The dynamic registration row is only pending state between
504                //      begin_authorization and callback. It survives process restarts,
505                //      but must not be reused to start a new flow because upstream AS
506                //      state can be reset independently, leaving a stale client_id behind.
507
508                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                        // Callback/token exchange path: use the client_id created
546                        // by the begin_authorization call. This keeps callbacks
547                        // valid across process restarts and lets an explicit
548                        // reauth flow replace stale stored credentials.
549                        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                // Beginning a new flow: register with the AS every time there are
592                // no stored credentials. This self-heals when the upstream AS loses
593                // its dynamic-client DB while this process still has an old pending row.
594                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                // Read back the persisted value to use the DB-canonical client_id.
619                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                // Client ID Metadata Document (CIMD): the metadata-document URL
640                // *is* the client identifier. No registration_endpoint call is
641                // issued — the AS fetches the document itself when it first sees
642                // the client_id. We construct the OAuth client locally.
643                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}