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