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 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 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 self.refresh_failures.clear(&self.upstream.name, subject);
313
314 Ok(())
315 }
316
317 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 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 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 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 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 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 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 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
739fn 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}