1mod 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#[derive(Clone)]
57pub struct UpstreamOauthManager {
58 sqlite: SqliteStore,
59 key: EncryptionKey,
60 upstream: UpstreamConfig,
61 redirect_uri: Arc<String>,
62 locks: Arc<RefreshLocks>,
63 refresh_failures: Arc<RefreshFailureCache>,
66 metadata_cache: Arc<RwLock<Option<AuthorizationMetadata>>>,
68}
69
70impl UpstreamOauthManager {
71 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 pub fn upstream_config(&self) -> &UpstreamConfig {
98 &self.upstream
99 }
100
101 #[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 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 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 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 self.refresh_failures.clear(&self.upstream.name, subject);
323
324 Ok(())
325 }
326
327 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 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 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 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 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 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 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 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
763fn 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}