Skip to main content

soma_auth/upstream/
manager.rs

1//! Upstream OAuth lifecycle manager.
2//!
3//! `UpstreamOauthManager` orchestrates the full outbound `authorization_code` + PKCE
4//! flow for one configured upstream MCP server.  It is per-upstream (constructed once
5//! per `UpstreamConfig` that has an `oauth` block) and is `Clone` / `Send + Sync`.
6//!
7//! ## Subject
8//!
9//! All public methods take a `subject: &str` identifying the user initiating the
10//! flow.  Credentials are stored and refreshed independently per `(upstream, subject)`.
11//!
12//! ## Two-phase authorization
13//!
14//! ```text
15//! begin_authorization(subject)
16//!   ↓  generates PKCE + CSRF, stores state in SQLite, returns redirect URL
17//! browser → AS → callback
18//!   ↓
19//! complete_authorization_callback(subject, code, csrf)
20//!   ↓  exchanges code, stores encrypted tokens in SQLite
21//! build_auth_client(subject)
22//!   ↓  loads stored credentials, proactively refreshes if stale
23//! AuthClient<reqwest::Client>  → used by the consumer's connection pool for MCP calls
24//! ```
25//!
26//! ## AS metadata caching
27//!
28//! Authorization server metadata is fetched once per upstream (not per-subject) and
29//! cached to avoid an HTTP round-trip on every `build_auth_client` call.
30
31mod client;
32
33use std::sync::Arc;
34
35use rmcp::transport::AuthorizationManager;
36use rmcp::transport::auth::AuthorizationMetadata;
37use rmcp_client as rmcp;
38use serde::Deserialize;
39use tokio::sync::RwLock;
40use tracing::info;
41
42use crate::sqlite::SqliteStore;
43use crate::types::UpstreamOauthCredentialRow;
44use crate::upstream::config::UpstreamConfig;
45use crate::upstream::encryption::EncryptionKey;
46use crate::upstream::refresh::{RefreshFailureCache, RefreshLocks};
47use crate::upstream::store::{SqliteCredentialStore, SqliteStateStore};
48use crate::upstream::types::{BeginAuthorization, OauthError};
49
50const TOKEN_EXPIRY_WARNING_SECS: i64 = 300;
51const PROACTIVE_REFRESH_WINDOW_SECS: i64 = 30;
52
53/// Upstream OAuth manager for a single upstream MCP server.
54///
55/// Cheap to clone — all mutable state is behind `Arc`.
56#[derive(Clone)]
57pub struct UpstreamOauthManager {
58    sqlite: SqliteStore,
59    key: EncryptionKey,
60    upstream: UpstreamConfig,
61    redirect_uri: Arc<String>,
62    locks: Arc<RefreshLocks>,
63    /// Tracks recent refresh failures so a known-dead credential fails fast
64    /// instead of hitting the authorization server on every request.
65    refresh_failures: Arc<RefreshFailureCache>,
66    /// Cached AS metadata (fetched once per upstream, shared across subjects).
67    metadata_cache: Arc<RwLock<Option<AuthorizationMetadata>>>,
68}
69
70impl UpstreamOauthManager {
71    /// Create a new manager for `upstream`.
72    ///
73    /// `redirect_uri` is the absolute URL of the OAuth callback endpoint that will
74    /// receive the authorization code (e.g.
75    /// `https://soma.example/v1/upstream-oauth/{name}/callback`).
76    pub fn new(
77        sqlite: SqliteStore,
78        key: EncryptionKey,
79        upstream: UpstreamConfig,
80        redirect_uri: String,
81    ) -> Self {
82        Self {
83            sqlite,
84            key,
85            upstream,
86            redirect_uri: Arc::new(redirect_uri),
87            locks: Arc::new(RefreshLocks::new()),
88            refresh_failures: Arc::new(RefreshFailureCache::new()),
89            metadata_cache: Arc::new(RwLock::new(None)),
90        }
91    }
92
93    /// Return the `UpstreamConfig` this manager was constructed with.
94    ///
95    /// Used to persist transient (probe-created) managers back into the
96    /// consumer's config when authorization completes for the first time.
97    pub fn upstream_config(&self) -> &UpstreamConfig {
98        &self.upstream
99    }
100
101    /// Return `true` if persisted credentials exist for `subject`.
102    ///
103    /// Does not check whether the credentials are still valid.
104    #[allow(dead_code)]
105    pub async fn has_credentials(&self, subject: &str) -> Result<bool, OauthError> {
106        self.sqlite
107            .find_upstream_oauth_credentials(&self.upstream.name, subject)
108            .await
109            .map(|opt| opt.is_some())
110            .map_err(|e| OauthError::Internal(e.to_string()))
111    }
112
113    /// Begin the authorization flow.
114    ///
115    /// Discovers (or uses cached) AS metadata, registers or configures the OAuth
116    /// client, generates a PKCE challenge, saves the pending state to SQLite, and
117    /// returns the authorization URL to redirect the operator's browser to.
118    ///
119    /// Enforces S256 PKCE — returns `OauthError::UnsupportedMethod` if the AS does
120    /// not advertise S256 in `code_challenge_methods_supported`.
121    pub async fn begin_authorization(
122        &self,
123        subject: &str,
124    ) -> Result<BeginAuthorization, OauthError> {
125        let started = std::time::Instant::now();
126        let oauth_cfg = self.oauth_config()?;
127        let upstream_url = self.upstream_url()?;
128
129        // rmcp's AuthorizationManager builds its own internal reqwest client.
130        // See google.rs::GoogleProvider::new for why this call is needed
131        // under "rustls-no-provider" -- idempotent, safe to ignore Err.
132        drop(rustls::crypto::ring::default_provider().install_default());
133        let mut manager = AuthorizationManager::new(upstream_url.as_str())
134            .await
135            .map_err(|e| {
136                tracing::warn!(
137                    upstream = %self.upstream.name,
138                    subject,
139                    kind = "internal_error",
140                    error = %e,
141                    "upstream oauth: failed to create authorization manager"
142                );
143                OauthError::Internal(format!("create auth manager: {e}"))
144            })?;
145
146        let state_store = SqliteStateStore::new(self.sqlite.clone(), &self.upstream.name, subject);
147        manager.set_state_store(state_store);
148
149        let metadata = self
150            .get_or_discover_metadata(&mut manager)
151            .await
152            .map_err(|e| {
153                tracing::warn!(
154                    upstream = %self.upstream.name,
155                    subject,
156                    kind = e.kind(),
157                    error = %e,
158                    "upstream oauth: AS metadata discovery failed"
159                );
160                e
161            })?;
162
163        info!(
164            upstream = %self.upstream.name,
165            subject,
166            issuer = metadata.issuer.as_deref().unwrap_or("<none>"),
167            "upstream oauth: AS metadata ready"
168        );
169
170        let issuer = metadata
171            .issuer
172            .as_deref()
173            .expect("issuer binding verification requires issuer")
174            .trim_end_matches('/')
175            .to_string();
176        let cred_store = SqliteCredentialStore::new(
177            self.sqlite.clone(),
178            self.key.clone(),
179            &self.upstream.name,
180            subject,
181            issuer.clone(),
182        );
183        manager.set_credential_store(cred_store);
184
185        self.verify_s256(&metadata.code_challenge_methods_supported)
186            .inspect_err(|e| {
187                tracing::warn!(
188                    upstream = %self.upstream.name,
189                    subject,
190                    kind = e.kind(),
191                    "upstream oauth: S256 PKCE verification failed"
192                );
193            })?;
194        manager.set_metadata(metadata);
195
196        let scopes: Vec<&str> = oauth_cfg
197            .scopes
198            .as_deref()
199            .unwrap_or(&[])
200            .iter()
201            .map(String::as_str)
202            .collect();
203
204        let client_cfg = self
205            .resolve_client_config(
206                &mut manager,
207                subject,
208                &scopes,
209                &issuer,
210                DynamicClientRegistrationUse::BeginAuthorization,
211            )
212            .await
213            .map_err(|e| {
214                tracing::warn!(
215                    upstream = %self.upstream.name,
216                    subject,
217                    kind = e.kind(),
218                    error = %e,
219                    "upstream oauth: client config resolution failed"
220                );
221                e
222            })?;
223
224        manager.configure_client(client_cfg).map_err(|e| {
225            tracing::warn!(
226                upstream = %self.upstream.name,
227                subject,
228                kind = "internal_error",
229                error = %e,
230                "upstream oauth: client configuration failed"
231            );
232            OauthError::Internal(format!("configure client: {e}"))
233        })?;
234
235        let authorization_url = manager.get_authorization_url(&scopes).await.map_err(|e| {
236            tracing::warn!(
237                upstream = %self.upstream.name,
238                subject,
239                kind = "internal_error",
240                error = %e,
241                "upstream oauth: authorization URL generation failed"
242            );
243            OauthError::Internal(format!("get authorization url: {e}"))
244        })?;
245        let authorization_url = google_offline_access_url(&authorization_url)?;
246
247        let _csrf = extract_state_param(&authorization_url).ok_or_else(|| {
248            tracing::warn!(
249                upstream = %self.upstream.name,
250                subject,
251                kind = "internal_error",
252                "upstream oauth: authorization URL missing state parameter"
253            );
254            OauthError::Internal("authorization url missing required state parameter".to_string())
255        })?;
256
257        info!(
258            upstream = %self.upstream.name,
259            subject,
260            elapsed_ms = started.elapsed().as_millis(),
261            "upstream oauth: authorization started"
262        );
263
264        Ok(BeginAuthorization { authorization_url })
265    }
266
267    /// Complete the authorization callback.
268    ///
269    /// Exchanges the authorization code for tokens and persists the encrypted
270    /// credentials. Completion is reconstructed from persisted PKCE state rather
271    /// than an in-memory pending map, so callbacks remain valid across restarts.
272    pub async fn complete_authorization_callback(
273        &self,
274        subject: &str,
275        code: &str,
276        csrf_token: &str,
277        received_issuer: Option<&str>,
278    ) -> Result<(), OauthError> {
279        let started = std::time::Instant::now();
280
281        let auth_manager = self
282            .configured_authorization_manager(
283                subject,
284                DynamicClientRegistrationUse::CompleteAuthorization,
285            )
286            .await
287            .map_err(|e| {
288                tracing::warn!(
289                    upstream = %self.upstream.name,
290                    subject,
291                    kind = e.kind(),
292                    error = %e,
293                    "upstream oauth: failed to build configured authorization manager for token exchange"
294                );
295                e
296            })?;
297
298        auth_manager
299            .exchange_code_for_token_with_issuer(code, csrf_token, received_issuer)
300            .await
301            .map_err(|e| {
302                let mapped = map_auth_error(e);
303                tracing::warn!(
304                    upstream = %self.upstream.name,
305                    subject,
306                    kind = mapped.kind(),
307                    elapsed_ms = started.elapsed().as_millis(),
308                    "upstream oauth: token exchange failed"
309                );
310                mapped
311            })?;
312
313        info!(
314            upstream = %self.upstream.name,
315            subject,
316            elapsed_ms = started.elapsed().as_millis(),
317            "upstream oauth: authorization completed, tokens stored"
318        );
319
320        // A fresh grant supersedes whatever was failing before -- don't make
321        // the caller wait out the circuit-breaker cooldown after fixing it.
322        self.refresh_failures.clear(&self.upstream.name, subject);
323
324        Ok(())
325    }
326
327    /// Delete all stored credentials for `subject` and evict any cached state.
328    pub async fn clear_credentials(&self, subject: &str) -> Result<(), OauthError> {
329        self.refresh_failures.clear(&self.upstream.name, subject);
330        self.sqlite
331            .delete_upstream_oauth_credentials(&self.upstream.name, subject)
332            .await
333            .map_err(|e| {
334                tracing::warn!(
335                    upstream = %self.upstream.name,
336                    subject,
337                    kind = "internal_error",
338                    error = %e,
339                    "upstream oauth: failed to delete credentials from store"
340                );
341                OauthError::Internal(e.to_string())
342            })?;
343
344        self.sqlite
345            .delete_dynamic_client_registration(&self.upstream.name, subject)
346            .await
347            .map_err(|e| {
348                tracing::warn!(
349                    upstream = %self.upstream.name,
350                    subject,
351                    kind = "internal_error",
352                    error = %e,
353                    "upstream oauth: failed to delete dynamic client registration"
354                );
355                OauthError::Internal(e.to_string())
356            })?;
357
358        info!(
359            upstream = %self.upstream.name,
360            subject,
361            "upstream oauth: credentials and dynamic registration cleared"
362        );
363
364        Ok(())
365    }
366
367    pub async fn credential_row(
368        &self,
369        subject: &str,
370    ) -> Result<Option<UpstreamOauthCredentialRow>, OauthError> {
371        self.sqlite
372            .find_upstream_oauth_credentials(&self.upstream.name, subject)
373            .await
374            .map_err(|e| OauthError::Internal(e.to_string()))
375    }
376
377    #[allow(dead_code)]
378    pub async fn subject_for_state(&self, csrf_token: &str) -> Result<Option<String>, OauthError> {
379        let now = std::time::SystemTime::now()
380            .duration_since(std::time::UNIX_EPOCH)
381            .map_err(|error| OauthError::Internal(format!("system clock error: {error}")))?
382            .as_secs() as i64;
383        self.sqlite
384            .find_upstream_oauth_state_subject(&self.upstream.name, csrf_token, now)
385            .await
386            .map_err(|e| OauthError::Internal(e.to_string()))
387    }
388
389    /// Look up the stored dynamic `client_id` for `subject`, if any.
390    ///
391    /// Returns `None` when the upstream is not `Dynamic` or when no registration
392    /// has been persisted yet. Used by `OauthClientCache` to include the
393    /// per-subject `client_id` in the fingerprint so a re-registration is
394    /// detected and the stale `AuthClient` is evicted.
395    pub async fn stored_dynamic_client_id(
396        &self,
397        subject: &str,
398    ) -> Result<Option<String>, OauthError> {
399        self.sqlite
400            .find_dynamic_client_registration(&self.upstream.name, subject)
401            .await
402            .map(|row| row.map(|row| row.client_id))
403            .map_err(|e| OauthError::Internal(e.to_string()))
404    }
405
406    // ---- private helpers ----
407
408    async fn configured_authorization_manager(
409        &self,
410        subject: &str,
411        dynamic_registration_use: DynamicClientRegistrationUse,
412    ) -> Result<AuthorizationManager, OauthError> {
413        let upstream_url = self.upstream_url()?;
414        let oauth_cfg = self.oauth_config()?;
415
416        // See begin_authorization above for why this call is needed under
417        // "rustls-no-provider" -- idempotent, safe to ignore Err.
418        drop(rustls::crypto::ring::default_provider().install_default());
419        let mut manager = AuthorizationManager::new(upstream_url.as_str())
420            .await
421            .map_err(|e| OauthError::Internal(format!("create auth manager: {e}")))?;
422
423        let state_store = SqliteStateStore::new(self.sqlite.clone(), &self.upstream.name, subject);
424        manager.set_state_store(state_store);
425
426        let metadata = self.get_or_discover_metadata(&mut manager).await?;
427        let issuer = metadata
428            .issuer
429            .as_deref()
430            .expect("issuer binding verification requires issuer")
431            .trim_end_matches('/')
432            .to_string();
433        let cred_store = SqliteCredentialStore::new(
434            self.sqlite.clone(),
435            self.key.clone(),
436            &self.upstream.name,
437            subject,
438            issuer.clone(),
439        );
440        manager.set_credential_store(cred_store);
441        self.verify_s256(&metadata.code_challenge_methods_supported)?;
442        manager.set_metadata(metadata);
443
444        let scopes: Vec<&str> = oauth_cfg
445            .scopes
446            .as_deref()
447            .unwrap_or(&[])
448            .iter()
449            .map(String::as_str)
450            .collect();
451        let client_cfg = self
452            .resolve_client_config(
453                &mut manager,
454                subject,
455                &scopes,
456                &issuer,
457                dynamic_registration_use,
458            )
459            .await?;
460        manager
461            .configure_client(client_cfg)
462            .map_err(|e| OauthError::Internal(format!("configure client: {e}")))?;
463        Ok(manager)
464    }
465
466    fn oauth_config(&self) -> Result<&crate::upstream::config::UpstreamOauthConfig, OauthError> {
467        self.upstream
468            .oauth
469            .as_ref()
470            .ok_or_else(|| OauthError::Internal("upstream has no oauth config".to_string()))
471    }
472
473    fn oauth_scope_label(&self) -> String {
474        self.upstream
475            .oauth
476            .as_ref()
477            .and_then(|cfg| cfg.scopes.as_ref())
478            .filter(|scopes| !scopes.is_empty())
479            .map(|scopes| scopes.join(" "))
480            .unwrap_or_else(|| "<none>".to_string())
481    }
482
483    fn oauth_provider_label(&self) -> String {
484        self.upstream.name.clone()
485    }
486
487    fn log_expiring_token(&self, subject: &str, state: &TokenRefreshState, elapsed_ms: u128) {
488        if state.seconds_until_expiry <= TOKEN_EXPIRY_WARNING_SECS {
489            tracing::warn!(
490                upstream = %self.upstream.name,
491                provider = %self.oauth_provider_label(),
492                subject,
493                scope = %self.oauth_scope_label(),
494                seconds_until_expiry = state.seconds_until_expiry,
495                refresh_token_present = state.refresh_token_present,
496                elapsed_ms,
497                "upstream oauth: access token nearing expiry"
498            );
499        }
500    }
501
502    fn log_refresh_attempt(&self, subject: &str, state: &TokenRefreshState, elapsed_ms: u128) {
503        if !state.refresh_due() {
504            return;
505        }
506
507        if state.refresh_token_present {
508            tracing::info!(
509                upstream = %self.upstream.name,
510                provider = %self.oauth_provider_label(),
511                subject,
512                scope = %self.oauth_scope_label(),
513                seconds_until_expiry = state.seconds_until_expiry,
514                elapsed_ms,
515                "upstream oauth: token refresh attempt"
516            );
517        } else {
518            tracing::warn!(
519                upstream = %self.upstream.name,
520                provider = %self.oauth_provider_label(),
521                subject,
522                scope = %self.oauth_scope_label(),
523                seconds_until_expiry = state.seconds_until_expiry,
524                kind = "oauth_needs_reauth",
525                elapsed_ms,
526                fallback = "reauthorization_required",
527                "upstream oauth: access token expired or near expiry without refresh token"
528            );
529        }
530    }
531
532    fn upstream_url(&self) -> Result<Arc<String>, OauthError> {
533        let canonical = self
534            .upstream
535            .canonical_url()
536            .ok_or_else(|| OauthError::Internal("upstream has no url".to_string()))?
537            .map_err(|e| OauthError::Internal(format!("invalid upstream url: {e}")))?;
538        Ok(Arc::new(canonical))
539    }
540
541    /// Fetch AS metadata, caching the result for subsequent calls.
542    ///
543    /// Enforces issuer binding per RFC 8414: `issuer` MUST be present and the
544    /// `authorization_endpoint` + `token_endpoint` MUST share its origin. Rejects
545    /// silent issuer drift between the first and subsequent discovery calls.
546    ///
547    /// Uses a single write-lock acquisition to avoid a TOCTOU race between a
548    /// read-lock check and a subsequent write-lock cache update.
549    async fn get_or_discover_metadata(
550        &self,
551        manager: &mut AuthorizationManager,
552    ) -> Result<AuthorizationMetadata, OauthError> {
553        let mut cache = self.metadata_cache.write().await;
554        if let Some(meta) = cache.clone() {
555            return Ok(meta);
556        }
557
558        let metadata = match manager.resolve_metadata().await {
559            Ok(resolution) => resolution.metadata,
560            Err(error) => {
561                match discover_metadata_via_protected_resource(self.upstream_url()?.as_str())
562                    .await?
563                {
564                    Some(metadata) => metadata,
565                    None => {
566                        return Err(OauthError::Internal(format!("discover metadata: {error}")));
567                    }
568                }
569            }
570        };
571
572        self.verify_issuer_binding(&metadata)?;
573
574        *cache = Some(metadata.clone());
575        Ok(metadata)
576    }
577
578    /// RFC 8414 §3 issuer binding: `issuer` must be present, and every
579    /// non-jwks endpoint origin (scheme + host + port) must match the
580    /// issuer origin. This is stricter than a host-only check: it rejects
581    /// endpoints served over a different scheme (e.g. http vs https) or
582    /// on a different port, which a host-only comparison would miss.
583    fn verify_issuer_binding(&self, metadata: &AuthorizationMetadata) -> Result<(), OauthError> {
584        let issuer_raw = metadata.issuer.as_deref().ok_or_else(|| {
585            OauthError::IssuerMismatch(format!(
586                "AS metadata for upstream '{}' is missing required `issuer` claim",
587                self.upstream.name
588            ))
589        })?;
590        // Normalize the issuer: strip trailing slashes for a canonical form.
591        let issuer_normalized = issuer_raw.trim_end_matches('/');
592        let issuer_origin = url_origin(issuer_normalized).ok_or_else(|| {
593            OauthError::IssuerMismatch(format!("issuer `{issuer_raw}` is not a valid URL"))
594        })?;
595        for (label, endpoint) in [
596            (
597                "authorization_endpoint",
598                Some(metadata.authorization_endpoint.as_str()),
599            ),
600            ("token_endpoint", Some(metadata.token_endpoint.as_str())),
601            (
602                "registration_endpoint",
603                metadata.registration_endpoint.as_deref(),
604            ),
605        ] {
606            let Some(endpoint) = endpoint else { continue };
607            let Some(origin) = url_origin(endpoint) else {
608                return Err(OauthError::IssuerMismatch(format!(
609                    "{label} `{endpoint}` is not a valid URL"
610                )));
611            };
612            if origin != issuer_origin
613                && !is_known_split_endpoint_origin(issuer_origin.as_str(), origin.as_str())
614            {
615                return Err(OauthError::IssuerMismatch(format!(
616                    "{label} origin `{origin}` does not match issuer origin `{issuer_origin}`"
617                )));
618            }
619        }
620        Ok(())
621    }
622
623    fn verify_s256(&self, methods: &Option<Vec<String>>) -> Result<(), OauthError> {
624        match methods {
625            Some(methods) if methods.iter().any(|m| m == "S256") => Ok(()),
626            Some(methods) => Err(OauthError::UnsupportedMethod(format!(
627                "AS does not advertise S256 PKCE; advertised methods: {methods:?}"
628            ))),
629            None => Err(OauthError::UnsupportedMethod(
630                "AS did not advertise code_challenge_methods_supported; S256 is required"
631                    .to_string(),
632            )),
633        }
634    }
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
638enum DynamicClientRegistrationUse {
639    BeginAuthorization,
640    CompleteAuthorization,
641    StoredCredentials,
642}
643
644#[derive(Debug, Deserialize)]
645struct ProtectedResourceMetadata {
646    #[serde(default)]
647    authorization_server: Option<String>,
648    #[serde(default)]
649    authorization_servers: Option<Vec<String>>,
650}
651
652async fn discover_metadata_via_protected_resource(
653    upstream_url: &str,
654) -> Result<Option<AuthorizationMetadata>, OauthError> {
655    let upstream = url::Url::parse(upstream_url)
656        .map_err(|error| OauthError::Internal(format!("invalid upstream url: {error}")))?;
657    // See google.rs::GoogleProvider::new for why this call is needed
658    // under "rustls-no-provider" -- idempotent, safe to ignore Err.
659    drop(rustls::crypto::ring::default_provider().install_default());
660    let client = reqwest::Client::builder()
661        .timeout(std::time::Duration::from_secs(30))
662        .build()
663        .map_err(|error| OauthError::Internal(format!("build oauth metadata client: {error}")))?;
664
665    for metadata_url in protected_resource_metadata_candidates(&upstream) {
666        let response = match client.get(metadata_url.clone()).send().await {
667            Ok(response) => response,
668            Err(_) => continue,
669        };
670        if !response.status().is_success() {
671            continue;
672        }
673        let Ok(resource_metadata) = response.json::<ProtectedResourceMetadata>().await else {
674            continue;
675        };
676
677        let mut authorization_servers = Vec::new();
678        if let Some(server) = resource_metadata.authorization_server {
679            authorization_servers.push(server);
680        }
681        if let Some(servers) = resource_metadata.authorization_servers {
682            authorization_servers.extend(servers);
683        }
684
685        for authorization_server in authorization_servers {
686            let Ok(server_url) =
687                resolve_authorization_server_url(&metadata_url, authorization_server.trim())
688            else {
689                continue;
690            };
691            for authorization_metadata_url in authorization_metadata_candidates(&server_url) {
692                let response = match client.get(authorization_metadata_url).send().await {
693                    Ok(response) => response,
694                    Err(_) => continue,
695                };
696                if !response.status().is_success() {
697                    continue;
698                }
699                if let Ok(metadata) = response.json::<AuthorizationMetadata>().await {
700                    return Ok(Some(metadata));
701                }
702            }
703        }
704    }
705
706    Ok(None)
707}
708
709fn protected_resource_metadata_candidates(upstream: &url::Url) -> Vec<url::Url> {
710    let trimmed = upstream
711        .path()
712        .trim_start_matches('/')
713        .trim_end_matches('/');
714    let paths = if trimmed.is_empty() {
715        vec!["/.well-known/oauth-protected-resource".to_string()]
716    } else {
717        vec![
718            format!("/.well-known/oauth-protected-resource/{trimmed}"),
719            format!("/{trimmed}/.well-known/oauth-protected-resource"),
720            "/.well-known/oauth-protected-resource".to_string(),
721        ]
722    };
723
724    paths
725        .into_iter()
726        .map(|path| {
727            let mut candidate = upstream.clone();
728            candidate.set_query(None);
729            candidate.set_fragment(None);
730            candidate.set_path(&path);
731            candidate
732        })
733        .collect()
734}
735
736fn authorization_metadata_candidates(server: &url::Url) -> Vec<url::Url> {
737    if server.path().contains("/.well-known/") {
738        return vec![server.clone()];
739    }
740
741    [
742        "/.well-known/oauth-authorization-server",
743        "/.well-known/openid-configuration",
744    ]
745    .into_iter()
746    .map(|path| {
747        let mut candidate = server.clone();
748        candidate.set_query(None);
749        candidate.set_fragment(None);
750        candidate.set_path(path);
751        candidate
752    })
753    .collect()
754}
755
756fn resolve_authorization_server_url(
757    metadata_url: &url::Url,
758    authorization_server: &str,
759) -> Result<url::Url, url::ParseError> {
760    url::Url::parse(authorization_server).or_else(|_| metadata_url.join(authorization_server))
761}
762
763/// Return the normalized origin (scheme + "://" + lowercased host + optional explicit port)
764/// of a URL, or `None` if the URL is invalid or has no host.
765///
766/// This is stricter than a host-only comparison: it rejects URLs that share a host
767/// but differ in scheme or port (e.g. http vs https, or :80 vs :8080).
768fn url_origin(s: &str) -> Option<String> {
769    let u = url::Url::parse(s).ok()?;
770    let host = u.host_str()?.to_ascii_lowercase();
771    let scheme = u.scheme();
772    match u.port() {
773        Some(port) => Some(format!("{scheme}://{host}:{port}")),
774        None => Some(format!("{scheme}://{host}")),
775    }
776}
777
778fn is_known_split_endpoint_origin(issuer_origin: &str, endpoint_origin: &str) -> bool {
779    issuer_origin == "https://accounts.google.com"
780        && endpoint_origin == "https://oauth2.googleapis.com"
781}
782
783fn extract_state_param(url: &str) -> Option<String> {
784    let parsed = url::Url::parse(url).ok()?;
785    parsed
786        .query_pairs()
787        .find(|(k, _)| k == "state")
788        .map(|(_, v)| v.into_owned())
789}
790
791fn google_offline_access_url(url: &str) -> Result<String, OauthError> {
792    let mut parsed = url::Url::parse(url).map_err(|error| {
793        OauthError::Internal(format!("authorization url generated invalid URL: {error}"))
794    })?;
795    let is_google_authorize = parsed
796        .host_str()
797        .is_some_and(|host| host.eq_ignore_ascii_case("accounts.google.com"));
798    if !is_google_authorize {
799        return Ok(url.to_string());
800    }
801
802    let existing: std::collections::HashSet<String> = parsed
803        .query_pairs()
804        .map(|(key, _)| key.into_owned())
805        .collect();
806    {
807        let mut query = parsed.query_pairs_mut();
808        if !existing.contains("access_type") {
809            query.append_pair("access_type", "offline");
810        }
811        if !existing.contains("prompt") {
812            query.append_pair("prompt", "consent");
813        }
814        if !existing.contains("include_granted_scopes") {
815            query.append_pair("include_granted_scopes", "true");
816        }
817    }
818    Ok(parsed.into())
819}
820
821struct TokenRefreshState {
822    seconds_until_expiry: i64,
823    refresh_token_present: bool,
824}
825
826impl TokenRefreshState {
827    fn from_row(row: &UpstreamOauthCredentialRow, now: i64) -> Option<Self> {
828        if row.access_token_expires_at <= 0 {
829            return None;
830        }
831        Some(Self {
832            seconds_until_expiry: row.access_token_expires_at.saturating_sub(now),
833            refresh_token_present: row.refresh_token_present,
834        })
835    }
836
837    fn refresh_due(&self) -> bool {
838        self.seconds_until_expiry <= PROACTIVE_REFRESH_WINDOW_SECS
839    }
840}
841
842fn now_unix() -> Result<i64, OauthError> {
843    std::time::SystemTime::now()
844        .duration_since(std::time::UNIX_EPOCH)
845        .map_err(|error| OauthError::Internal(format!("system clock error: {error}")))
846        .map(|duration| duration.as_secs() as i64)
847}
848
849fn map_auth_error(e: rmcp::transport::AuthError) -> OauthError {
850    match e {
851        rmcp::transport::AuthError::AuthorizationRequired => {
852            OauthError::NeedsReauth("authorization required".to_string())
853        }
854        rmcp::transport::AuthError::TokenExchangeFailed(msg) => OauthError::Internal(msg),
855        rmcp::transport::AuthError::TokenRefreshFailed(msg) => {
856            OauthError::NeedsReauth(format!("token refresh failed: {msg}"))
857        }
858        other => OauthError::Internal(other.to_string()),
859    }
860}
861
862#[cfg(test)]
863mod url_tests {
864    use super::google_offline_access_url;
865
866    #[test]
867    fn google_authorization_url_requests_offline_consent() {
868        let url = "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&state=abc";
869        let updated = google_offline_access_url(url).expect("url");
870        let parsed = url::Url::parse(&updated).expect("updated url parses");
871        let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
872
873        assert_eq!(
874            params.get("access_type").map(|v| v.as_ref()),
875            Some("offline")
876        );
877        assert_eq!(params.get("prompt").map(|v| v.as_ref()), Some("consent"));
878        assert_eq!(
879            params.get("include_granted_scopes").map(|v| v.as_ref()),
880            Some("true")
881        );
882        assert_eq!(params.get("state").map(|v| v.as_ref()), Some("abc"));
883    }
884
885    #[test]
886    fn non_google_authorization_url_is_unchanged() {
887        let url = "https://auth.example.test/authorize?response_type=code&state=abc";
888        let updated = google_offline_access_url(url).expect("url");
889        assert_eq!(updated, url);
890    }
891
892    #[test]
893    fn existing_google_authorization_params_are_preserved() {
894        let url = "https://accounts.google.com/o/oauth2/v2/auth?access_type=online&prompt=select_account&include_granted_scopes=false";
895        let updated = google_offline_access_url(url).expect("url");
896        let parsed = url::Url::parse(&updated).expect("updated url parses");
897        let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
898
899        assert_eq!(
900            params.get("access_type").map(|v| v.as_ref()),
901            Some("online")
902        );
903        assert_eq!(
904            params.get("prompt").map(|v| v.as_ref()),
905            Some("select_account")
906        );
907        assert_eq!(
908            params.get("include_granted_scopes").map(|v| v.as_ref()),
909            Some("false")
910        );
911    }
912}