1use std::fmt;
61use std::future::Future;
62use std::pin::Pin;
63use std::sync::Arc;
64use std::time::Duration;
65
66use base64::Engine;
67use base64::engine::general_purpose::STANDARD;
68use http_body_util::Full;
69use hyper::body::Bytes;
70#[cfg(not(feature = "tls-rustls"))]
71use hyper_util::client::legacy::Client;
72#[cfg(not(feature = "tls-rustls"))]
73use hyper_util::client::legacy::connect::HttpConnector;
74#[cfg(not(feature = "tls-rustls"))]
75use hyper_util::rt::TokioExecutor;
76
77use crate::error::{ClientError, ClientResult};
78use crate::interceptor::{CallInterceptor, ClientRequest, ClientResponse};
79
80#[cfg(not(feature = "tls-rustls"))]
81type TokenHttpClient = Client<HttpConnector, Full<Bytes>>;
82#[cfg(feature = "tls-rustls")]
83type TokenHttpClient = crate::tls::HttpsClient;
84
85const MAX_TOKEN_RESPONSE_SIZE: usize = 64 * 1024;
87
88const DEFAULT_TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
90
91const DEFAULT_REFRESH_LEEWAY: Duration = Duration::from_secs(30);
93
94const DEFAULT_FAILURE_BACKOFF: Duration = Duration::from_secs(1);
103
104mod flight;
105
106const NO_EXPIRY_CACHE_TTL: Duration = Duration::from_secs(60);
110
111fn cache_ttl(expires_in: Option<u64>, leeway: Duration) -> Duration {
120 expires_in.map_or(NO_EXPIRY_CACHE_TTL, |secs| {
121 let lifetime = Duration::from_secs(secs);
122 lifetime.saturating_sub(leeway.min(lifetime / 2))
123 })
124}
125
126pub trait TokenProvider: Send + Sync + 'static {
134 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>>;
141
142 fn invalidate(&self, _token: &str) {}
157}
158
159pub struct StaticTokenProvider {
164 token: String,
165}
166
167impl StaticTokenProvider {
168 #[must_use]
170 pub fn new(token: impl Into<String>) -> Self {
171 Self {
172 token: token.into(),
173 }
174 }
175}
176
177impl fmt::Debug for StaticTokenProvider {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.debug_struct("StaticTokenProvider")
180 .field("token", &"<redacted>")
181 .finish()
182 }
183}
184
185impl TokenProvider for StaticTokenProvider {
186 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
187 Box::pin(async move { Ok(self.token.clone()) })
188 }
189}
190
191pub struct BearerAuthInterceptor {
212 provider: Arc<dyn TokenProvider>,
213}
214
215impl BearerAuthInterceptor {
216 #[must_use]
218 pub fn new(provider: Arc<dyn TokenProvider>) -> Self {
219 Self { provider }
220 }
221}
222
223impl fmt::Debug for BearerAuthInterceptor {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 f.debug_struct("BearerAuthInterceptor").finish()
226 }
227}
228
229impl CallInterceptor for BearerAuthInterceptor {
230 #[allow(clippy::manual_async_fn)]
231 fn before<'a>(
232 &'a self,
233 req: &'a mut ClientRequest,
234 ) -> impl Future<Output = ClientResult<()>> + Send + 'a {
235 async move {
236 let token = self.provider.access_token().await?;
237 req.extra_headers
238 .insert("authorization".to_owned(), format!("Bearer {token}"));
239 Ok(())
240 }
241 }
242
243 #[allow(clippy::manual_async_fn)]
244 fn after<'a>(
245 &'a self,
246 _resp: &'a ClientResponse,
247 ) -> impl Future<Output = ClientResult<()>> + Send + 'a {
248 async move { Ok(()) }
249 }
250
251 fn on_error<'a>(
256 &'a self,
257 req: &'a ClientRequest,
258 err: &'a ClientError,
259 ) -> impl Future<Output = ()> + Send + 'a {
260 if matches!(err, ClientError::UnexpectedStatus { status: 401, .. })
261 && let Some(token) = req
262 .extra_headers
263 .get("authorization")
264 .and_then(|v| v.strip_prefix("Bearer "))
265 {
266 self.provider.invalidate(token);
267 }
268 std::future::ready(())
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum TokenEndpointAuthStyle {
277 Basic,
281 Post,
284}
285
286pub struct OAuth2ClientCredentials {
310 token_url: String,
311 client_id: String,
312 client_secret: String,
313 scopes: Vec<String>,
314 audience: Option<String>,
315 extra_params: Vec<(String, String)>,
316 auth_style: TokenEndpointAuthStyle,
317 refresh_leeway: Duration,
318 request_timeout: Duration,
319 failure_backoff: Duration,
320 client: TokenHttpClient,
321 tokens: flight::TokenCache,
322}
323
324impl fmt::Debug for OAuth2ClientCredentials {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 f.debug_struct("OAuth2ClientCredentials")
327 .field("token_url", &self.token_url)
328 .field("client_id", &self.client_id)
329 .field("client_secret", &"<redacted>")
330 .field("scopes", &self.scopes)
331 .field("audience", &self.audience)
332 .field("auth_style", &self.auth_style)
333 .finish_non_exhaustive()
334 }
335}
336
337impl OAuth2ClientCredentials {
338 #[must_use]
340 pub fn new(
341 token_url: impl Into<String>,
342 client_id: impl Into<String>,
343 client_secret: impl Into<String>,
344 ) -> Self {
345 Self {
346 token_url: token_url.into(),
347 client_id: client_id.into(),
348 client_secret: client_secret.into(),
349 scopes: Vec::new(),
350 audience: None,
351 extra_params: Vec::new(),
352 auth_style: TokenEndpointAuthStyle::Basic,
353 refresh_leeway: DEFAULT_REFRESH_LEEWAY,
354 request_timeout: DEFAULT_TOKEN_REQUEST_TIMEOUT,
355 failure_backoff: DEFAULT_FAILURE_BACKOFF,
356 client: build_token_http_client(),
357 tokens: flight::TokenCache::default(),
358 }
359 }
360
361 pub fn from_agent_card(
374 card: &a2a_protocol_types::agent_card::AgentCard,
375 scheme_name: &str,
376 client_id: impl Into<String>,
377 client_secret: impl Into<String>,
378 ) -> ClientResult<Self> {
379 use a2a_protocol_types::security::{OAuthFlows, SecurityScheme};
380
381 let scheme = card
382 .security_schemes
383 .as_ref()
384 .and_then(|schemes| schemes.get(scheme_name))
385 .ok_or_else(|| {
386 ClientError::InvalidEndpoint(format!(
387 "agent card has no security scheme named {scheme_name:?}"
388 ))
389 })?;
390 let SecurityScheme::OAuth2(oauth2) = scheme else {
391 return Err(ClientError::InvalidEndpoint(format!(
392 "security scheme {scheme_name:?} is not an OAuth 2.0 scheme"
393 )));
394 };
395 let OAuthFlows::ClientCredentials(flow) = &oauth2.flows else {
396 return Err(ClientError::InvalidEndpoint(format!(
397 "security scheme {scheme_name:?} has no client-credentials flow \
398 (interactive flows need a custom TokenProvider)"
399 )));
400 };
401 Ok(Self::new(flow.token_url.clone(), client_id, client_secret))
402 }
403
404 pub async fn from_oidc_issuer(
413 issuer: &str,
414 client_id: impl Into<String>,
415 client_secret: impl Into<String>,
416 ) -> ClientResult<Self> {
417 let token_url = discover_token_endpoint(issuer).await?;
418 Ok(Self::new(token_url, client_id, client_secret))
419 }
420
421 #[must_use]
423 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
424 where
425 I: IntoIterator<Item = S>,
426 S: Into<String>,
427 {
428 self.scopes = scopes.into_iter().map(Into::into).collect();
429 self
430 }
431
432 #[must_use]
435 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
436 self.audience = Some(audience.into());
437 self
438 }
439
440 #[must_use]
442 pub fn with_extra_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
443 self.extra_params.push((key.into(), value.into()));
444 self
445 }
446
447 #[must_use]
450 pub const fn with_auth_style(mut self, style: TokenEndpointAuthStyle) -> Self {
451 self.auth_style = style;
452 self
453 }
454
455 #[must_use]
460 pub const fn with_refresh_leeway(mut self, leeway: Duration) -> Self {
461 self.refresh_leeway = leeway;
462 self
463 }
464
465 #[must_use]
467 pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
468 self.request_timeout = timeout;
469 self
470 }
471
472 #[must_use]
479 pub const fn with_failure_backoff(mut self, backoff: Duration) -> Self {
480 self.failure_backoff = backoff;
481 self
482 }
483
484 async fn refresh(&self) -> ClientResult<(String, Duration)> {
487 check_endpoint_reachable(&self.token_url, "token endpoint")?;
488
489 let req = self.build_token_request()?;
490 let deadline = tokio::time::Instant::now() + self.request_timeout;
496 let resp = tokio::time::timeout_at(deadline, self.client.request(req))
497 .await
498 .map_err(|_| ClientError::Timeout("token endpoint request timed out".into()))?
499 .map_err(|e| ClientError::HttpClient(format!("token endpoint request failed: {e}")))?;
504
505 let status = resp.status();
506 let body = crate::transport::collect_response_limited(
507 resp,
508 MAX_TOKEN_RESPONSE_SIZE,
509 deadline.saturating_duration_since(tokio::time::Instant::now()),
510 )
511 .await?;
512 if !status.is_success() {
513 return Err(token_error(status, &body));
514 }
515
516 let token_resp: TokenResponse = serde_json::from_slice(&body).map_err(|e| {
517 ClientError::Transport(format!("token endpoint returned invalid JSON: {e}"))
518 })?;
519 if let Some(ref tt) = token_resp.token_type
520 && !tt.eq_ignore_ascii_case("bearer")
521 {
522 return Err(ClientError::Transport(format!(
523 "token endpoint returned unsupported token_type {tt:?} (expected \"Bearer\")"
524 )));
525 }
526
527 let ttl = cache_ttl(token_resp.expires_in, self.refresh_leeway);
528 Ok((token_resp.access_token, ttl))
529 }
530
531 fn build_token_request(&self) -> ClientResult<hyper::Request<Full<Bytes>>> {
533 let mut form: Vec<(String, String)> =
534 vec![("grant_type".to_owned(), "client_credentials".to_owned())];
535 if !self.scopes.is_empty() {
536 form.push(("scope".to_owned(), self.scopes.join(" ")));
537 }
538 if let Some(ref aud) = self.audience {
539 form.push(("audience".to_owned(), aud.clone()));
540 }
541 for (k, v) in &self.extra_params {
542 form.push((k.clone(), v.clone()));
543 }
544 if self.auth_style == TokenEndpointAuthStyle::Post {
545 form.push(("client_id".to_owned(), self.client_id.clone()));
546 form.push(("client_secret".to_owned(), self.client_secret.clone()));
547 }
548
549 let mut builder = hyper::Request::builder()
550 .method(hyper::Method::POST)
551 .uri(&self.token_url)
552 .header("content-type", "application/x-www-form-urlencoded")
553 .header("accept", "application/json");
554 if self.auth_style == TokenEndpointAuthStyle::Basic {
555 let credentials = format!(
557 "{}:{}",
558 form_urlencode(&self.client_id),
559 form_urlencode(&self.client_secret)
560 );
561 builder = builder.header(
562 "authorization",
563 format!("Basic {}", STANDARD.encode(credentials)),
564 );
565 }
566 builder
567 .body(Full::new(Bytes::from(encode_form(&form))))
568 .map_err(|e| ClientError::Transport(format!("token request build failed: {e}")))
569 }
570}
571
572fn token_error(status: hyper::StatusCode, body: &[u8]) -> ClientError {
575 let detail = serde_json::from_slice::<OAuth2ErrorBody>(body).map_or_else(
576 |_| String::from_utf8_lossy(&body[..body.len().min(256)]).into_owned(),
577 |e| match e.error_description {
578 Some(desc) => format!("{}: {desc}", e.error),
579 None => e.error,
580 },
581 );
582 ClientError::Transport(format!("token endpoint returned HTTP {status}: {detail}"))
583}
584
585impl TokenProvider for OAuth2ClientCredentials {
586 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
587 Box::pin(self.tokens.get(self.failure_backoff, || self.refresh()))
590 }
591
592 fn invalidate(&self, token: &str) {
593 self.tokens.invalidate(token);
594 }
595}
596
597#[derive(serde::Deserialize)]
599struct TokenResponse {
600 access_token: String,
601 #[serde(default)]
602 token_type: Option<String>,
603 #[serde(default)]
604 expires_in: Option<u64>,
605}
606
607#[derive(serde::Deserialize)]
609struct OAuth2ErrorBody {
610 error: String,
611 #[serde(default)]
612 error_description: Option<String>,
613}
614
615pub async fn discover_token_endpoint(issuer: &str) -> ClientResult<String> {
625 discover_token_endpoint_within(issuer, DEFAULT_TOKEN_REQUEST_TIMEOUT).await
626}
627
628pub(crate) async fn discover_token_endpoint_within(
634 issuer: &str,
635 budget: Duration,
636) -> ClientResult<String> {
637 #[derive(serde::Deserialize)]
638 struct Discovery {
639 token_endpoint: Option<String>,
640 }
641 let url = format!(
642 "{}/.well-known/openid-configuration",
643 issuer.trim_end_matches('/')
644 );
645 check_endpoint_reachable(&url, "OIDC discovery")?;
646
647 let client = build_token_http_client();
648 let req = hyper::Request::builder()
649 .method(hyper::Method::GET)
650 .uri(&url)
651 .header("accept", "application/json")
652 .body(Full::new(Bytes::new()))
653 .map_err(|e| ClientError::Transport(format!("discovery request build failed: {e}")))?;
654
655 let deadline = tokio::time::Instant::now() + budget;
656 let resp = tokio::time::timeout_at(deadline, client.request(req))
657 .await
658 .map_err(|_| ClientError::Timeout("OIDC discovery request timed out".into()))?
659 .map_err(|e| ClientError::HttpClient(format!("OIDC discovery request failed: {e}")))?;
660
661 let status = resp.status();
662 let body = crate::transport::collect_response_limited(
663 resp,
664 MAX_TOKEN_RESPONSE_SIZE,
665 deadline.saturating_duration_since(tokio::time::Instant::now()),
666 )
667 .await?;
668 if !status.is_success() {
669 return Err(ClientError::Transport(format!(
670 "OIDC discovery returned HTTP {status}"
671 )));
672 }
673
674 let doc: Discovery = serde_json::from_slice(&body).map_err(|e| {
675 ClientError::Transport(format!("OIDC discovery returned invalid JSON: {e}"))
676 })?;
677 doc.token_endpoint.ok_or_else(|| {
678 ClientError::Transport("OIDC discovery document has no token_endpoint".into())
679 })
680}
681
682fn build_token_http_client() -> TokenHttpClient {
685 #[cfg(not(feature = "tls-rustls"))]
686 {
687 let mut connector = HttpConnector::new();
688 connector.set_connect_timeout(Some(Duration::from_secs(10)));
689 connector.set_nodelay(true);
690 Client::builder(TokioExecutor::new()).build(connector)
691 }
692 #[cfg(feature = "tls-rustls")]
693 {
694 crate::tls::build_https_client_with_connect_timeout(
695 crate::tls::default_tls_config(),
696 Duration::from_secs(10),
697 )
698 }
699}
700
701#[cfg_attr(
703 feature = "tls-rustls",
704 allow(
705 clippy::unnecessary_wraps,
706 unused_variables,
707 clippy::missing_const_for_fn
708 )
709)]
710fn check_endpoint_reachable(url: &str, what: &str) -> ClientResult<()> {
711 #[cfg(not(feature = "tls-rustls"))]
712 {
713 let is_https = url
714 .split_once("://")
715 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
716 if is_https {
717 return Err(ClientError::Transport(format!(
718 "{what} URL {url} is https:// but this build has no TLS; enable the \
719 `tls-rustls` feature (on by default)"
720 )));
721 }
722 }
723 Ok(())
724}
725
726fn form_urlencode(value: &str) -> String {
728 let mut out = String::with_capacity(value.len());
729 for byte in value.bytes() {
730 match byte {
731 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
732 out.push(byte as char);
733 }
734 b' ' => out.push('+'),
735 other => {
736 out.push('%');
737 out.push(
738 char::from_digit(u32::from(other >> 4), 16)
739 .unwrap_or('0')
740 .to_ascii_uppercase(),
741 );
742 out.push(
743 char::from_digit(u32::from(other & 0xf), 16)
744 .unwrap_or('0')
745 .to_ascii_uppercase(),
746 );
747 }
748 }
749 }
750 out
751}
752
753fn encode_form(pairs: &[(String, String)]) -> String {
755 pairs
756 .iter()
757 .map(|(k, v)| format!("{}={}", form_urlencode(k), form_urlencode(v)))
758 .collect::<Vec<_>>()
759 .join("&")
760}
761
762#[cfg(test)]
765mod bearer_tests;
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use std::collections::HashMap;
771 use std::sync::atomic::{AtomicUsize, Ordering};
772 use std::time::Instant;
773
774 #[test]
777 fn form_urlencode_passes_unreserved() {
778 assert_eq!(form_urlencode("Abc-123._~"), "Abc-123._~");
779 }
780
781 #[test]
782 fn form_urlencode_escapes_reserved_and_space() {
783 assert_eq!(form_urlencode("a b&c=d%"), "a+b%26c%3Dd%25");
784 assert_eq!(form_urlencode("秘"), "%E7%A7%98");
785 }
786
787 #[test]
788 fn encode_form_joins_pairs() {
789 let pairs = vec![
790 ("grant_type".to_owned(), "client_credentials".to_owned()),
791 ("scope".to_owned(), "a b".to_owned()),
792 ];
793 assert_eq!(
794 encode_form(&pairs),
795 "grant_type=client_credentials&scope=a+b"
796 );
797 }
798
799 #[cfg(not(feature = "tls-rustls"))]
807 #[test]
808 fn https_endpoints_are_refused_by_a_build_without_tls() {
809 let err = check_endpoint_reachable("HTTPS://auth.example.com/token", "token endpoint")
810 .expect_err("no TLS in this build");
811 assert!(
812 matches!(&err, ClientError::Transport(m) if m.contains("tls-rustls")),
813 "{err:?}"
814 );
815 assert!(
816 check_endpoint_reachable("http://auth.example.com/token", "token endpoint").is_ok()
817 );
818 }
819
820 #[test]
823 fn debug_redacts_secrets() {
824 let p = StaticTokenProvider::new("super-secret");
825 let dbg = format!("{p:?}");
826 assert!(!dbg.contains("super-secret"), "secret leaked: {dbg}");
827 assert!(
832 dbg.contains("StaticTokenProvider"),
833 "Debug must name the type; empty output would pass the redaction \
834 check while telling a reader nothing: {dbg}"
835 );
836 assert!(
837 dbg.contains("<redacted>"),
838 "the token field must be present and redacted, not omitted: {dbg}"
839 );
840
841 let o = OAuth2ClientCredentials::new("http://localhost/token", "id", "very-secret");
842 let dbg = format!("{o:?}");
843 assert!(!dbg.contains("very-secret"), "secret leaked: {dbg}");
844 assert!(dbg.contains("id"), "client_id should be visible");
845 }
846
847 #[test]
850 fn bearer_interceptor_debug_names_the_type() {
851 let interceptor = BearerAuthInterceptor::new(Arc::new(StaticTokenProvider::new("t")));
852 let dbg = format!("{interceptor:?}");
853 assert!(
854 dbg.contains("BearerAuthInterceptor"),
855 "Debug must name the type, got: {dbg:?}"
856 );
857 }
858
859 #[tokio::test]
862 async fn static_provider_returns_token() {
863 let p = StaticTokenProvider::new("tok-1");
864 assert_eq!(p.access_token().await.unwrap(), "tok-1");
865 }
866
867 #[tokio::test]
868 async fn bearer_interceptor_injects_header() {
869 let p: Arc<dyn TokenProvider> = Arc::new(StaticTokenProvider::new("tok-xyz"));
870 let interceptor = BearerAuthInterceptor::new(p);
871 let mut req = ClientRequest::new("message/send", serde_json::json!({}));
872 interceptor.before(&mut req).await.unwrap();
873 assert_eq!(
874 req.extra_headers.get("authorization").map(String::as_str),
875 Some("Bearer tok-xyz")
876 );
877 }
878
879 fn card_with_oauth2(
882 flows: a2a_protocol_types::security::OAuthFlows,
883 ) -> a2a_protocol_types::agent_card::AgentCard {
884 use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard};
885 use a2a_protocol_types::security::{OAuth2SecurityScheme, SecurityScheme};
886 let mut schemes = std::collections::HashMap::new();
887 schemes.insert(
888 "oauth".to_owned(),
889 SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
890 flows,
891 oauth2_metadata_url: None,
892 description: None,
893 })),
894 );
895 AgentCard {
896 name: "a".into(),
897 url: None,
898 description: "d".into(),
899 version: "1".into(),
900 supported_interfaces: vec![],
901 default_input_modes: vec![],
902 default_output_modes: vec![],
903 skills: vec![],
904 capabilities: AgentCapabilities::none(),
905 provider: None,
906 icon_url: None,
907 documentation_url: None,
908 security_schemes: Some(schemes),
909 security_requirements: None,
910 signatures: None,
911 }
912 }
913
914 #[test]
915 fn from_agent_card_reads_token_url() {
916 use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
917 let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
918 token_url: "https://auth.example.com/token".into(),
919 refresh_url: None,
920 scopes: HashMap::new(),
921 }));
922 let p = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec").unwrap();
923 assert_eq!(p.token_url, "https://auth.example.com/token");
924 }
925
926 #[test]
927 fn from_agent_card_missing_scheme_errors() {
928 use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
929 let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
930 token_url: "https://auth.example.com/token".into(),
931 refresh_url: None,
932 scopes: HashMap::new(),
933 }));
934 let err = OAuth2ClientCredentials::from_agent_card(&card, "nope", "id", "sec")
935 .expect_err("missing scheme");
936 assert!(err.to_string().contains("no security scheme"));
937 }
938
939 #[test]
940 fn from_agent_card_wrong_flow_errors() {
941 use a2a_protocol_types::security::{ImplicitFlow, OAuthFlows};
942 let card = card_with_oauth2(OAuthFlows::Implicit(ImplicitFlow {
943 authorization_url: "https://auth.example.com/authz".into(),
944 refresh_url: None,
945 scopes: HashMap::new(),
946 }));
947 let err = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec")
948 .expect_err("implicit flow is not client-credentials");
949 assert!(err.to_string().contains("client-credentials"));
950 }
951
952 async fn spawn_token_server(
957 responses: Vec<(u16, String)>,
958 captured: Arc<std::sync::Mutex<Vec<(String, String)>>>,
959 hits: Arc<AtomicUsize>,
960 ) -> std::net::SocketAddr {
961 use http_body_util::BodyExt;
962 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
963 let addr = listener.local_addr().unwrap();
964 tokio::spawn(async move {
965 loop {
966 let Ok((stream, _)) = listener.accept().await else {
967 break;
968 };
969 let responses = responses.clone();
970 let captured = Arc::clone(&captured);
971 let hits = Arc::clone(&hits);
972 tokio::spawn(async move {
973 let io = hyper_util::rt::TokioIo::new(stream);
974 let svc = hyper::service::service_fn(
975 move |req: hyper::Request<hyper::body::Incoming>| {
976 let responses = responses.clone();
977 let captured = Arc::clone(&captured);
978 let hits = Arc::clone(&hits);
979 async move {
980 let n = hits.fetch_add(1, Ordering::SeqCst);
981 let auth = req
982 .headers()
983 .get("authorization")
984 .and_then(|v| v.to_str().ok())
985 .unwrap_or("")
986 .to_owned();
987 let body = req.into_body().collect().await.unwrap().to_bytes();
988 captured
989 .lock()
990 .unwrap()
991 .push((auth, String::from_utf8_lossy(&body).into_owned()));
992 let (status, body) = responses
993 .get(n)
994 .or_else(|| responses.last())
995 .unwrap()
996 .clone();
997 Ok::<_, std::convert::Infallible>(
998 hyper::Response::builder()
999 .status(status)
1000 .header("content-type", "application/json")
1001 .body(Full::new(Bytes::from(body)))
1002 .unwrap(),
1003 )
1004 }
1005 },
1006 );
1007 let _ = hyper::server::conn::http1::Builder::new()
1008 .serve_connection(io, svc)
1009 .await;
1010 });
1011 }
1012 });
1013 addr
1014 }
1015
1016 use crate::transport::test_support::spawn_stalling_server;
1017
1018 async fn timed_failure<T: std::fmt::Debug>(
1021 fut: impl std::future::Future<Output = ClientResult<T>>,
1022 ) -> (ClientError, Duration) {
1023 let started = Instant::now();
1024 let err = fut.await.expect_err("a stalled body must not succeed");
1025 (err, started.elapsed())
1026 }
1027
1028 #[tokio::test]
1034 async fn a_stalled_token_body_is_bounded_by_one_request_timeout() {
1035 let timeout = Duration::from_millis(600);
1036 let addr = spawn_stalling_server("HTTP/1.1 200 OK", timeout * 2 / 3).await;
1037 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
1038 .with_request_timeout(timeout);
1039
1040 let (err, elapsed) = timed_failure(p.refresh()).await;
1041 assert!(matches!(err, ClientError::Timeout(_)), "{err:?}");
1042 assert!(
1043 elapsed >= timeout && elapsed < timeout * 3 / 2,
1044 "one budget, not two: took {elapsed:?} against {timeout:?}"
1045 );
1046 }
1047
1048 #[tokio::test]
1051 async fn a_stalled_discovery_body_is_bounded_by_one_budget() {
1052 let budget = Duration::from_millis(600);
1053 let addr = spawn_stalling_server("HTTP/1.1 200 OK", budget * 2 / 3).await;
1054
1055 let (err, elapsed) = timed_failure(discover_token_endpoint_within(
1056 &format!("http://{addr}"),
1057 budget,
1058 ))
1059 .await;
1060 assert!(matches!(err, ClientError::Timeout(_)), "{err:?}");
1061 assert!(
1062 elapsed >= budget && elapsed < budget * 3 / 2,
1063 "one budget, not two: took {elapsed:?} against {budget:?}"
1064 );
1065 }
1066
1067 fn token_body(token: &str, expires_in: Option<u64>) -> String {
1068 expires_in.map_or_else(
1069 || format!(r#"{{"access_token":"{token}","token_type":"Bearer"}}"#),
1070 |e| format!(r#"{{"access_token":"{token}","token_type":"Bearer","expires_in":{e}}}"#),
1071 )
1072 }
1073
1074 #[tokio::test]
1075 async fn fetches_and_caches_token() {
1076 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1077 let hits = Arc::new(AtomicUsize::new(0));
1078 let addr = spawn_token_server(
1079 vec![(200, token_body("tok-a", Some(3600)))],
1080 Arc::clone(&captured),
1081 Arc::clone(&hits),
1082 )
1083 .await;
1084
1085 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
1086 .with_scopes(["read", "write"]);
1087 assert_eq!(p.access_token().await.unwrap(), "tok-a");
1088 assert_eq!(p.access_token().await.unwrap(), "tok-a");
1089 assert_eq!(
1090 hits.load(Ordering::SeqCst),
1091 1,
1092 "second call must be served from cache"
1093 );
1094
1095 let (auth, body) = { captured.lock().unwrap()[0].clone() };
1096 assert!(auth.starts_with("Basic "), "default auth style is Basic");
1097 let decoded =
1098 String::from_utf8(STANDARD.decode(auth.trim_start_matches("Basic ")).unwrap()).unwrap();
1099 assert_eq!(decoded, "cid:csec");
1100 assert!(body.contains("grant_type=client_credentials"), "{body}");
1101 assert!(body.contains("scope=read+write"), "{body}");
1102 assert!(
1103 !body.contains("client_secret"),
1104 "Basic style must not put the secret in the body: {body}"
1105 );
1106 }
1107
1108 #[tokio::test]
1109 async fn post_auth_style_puts_credentials_in_body() {
1110 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1111 let hits = Arc::new(AtomicUsize::new(0));
1112 let addr = spawn_token_server(
1113 vec![(200, token_body("tok-b", Some(3600)))],
1114 Arc::clone(&captured),
1115 Arc::clone(&hits),
1116 )
1117 .await;
1118
1119 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
1120 .with_auth_style(TokenEndpointAuthStyle::Post);
1121 assert_eq!(p.access_token().await.unwrap(), "tok-b");
1122
1123 let (auth, body) = { captured.lock().unwrap()[0].clone() };
1124 assert!(auth.is_empty(), "no Authorization header in Post style");
1125 assert!(body.contains("client_id=cid"), "{body}");
1126 assert!(body.contains("client_secret=csec"), "{body}");
1127 }
1128
1129 #[tokio::test]
1130 async fn expired_token_is_refreshed() {
1131 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1132 let hits = Arc::new(AtomicUsize::new(0));
1133 let addr = spawn_token_server(
1134 vec![
1135 (200, token_body("tok-1", Some(0))),
1138 (200, token_body("tok-2", Some(3600))),
1139 ],
1140 Arc::clone(&captured),
1141 Arc::clone(&hits),
1142 )
1143 .await;
1144
1145 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1146 assert_eq!(p.access_token().await.unwrap(), "tok-1");
1147 assert_eq!(p.access_token().await.unwrap(), "tok-2");
1148 assert_eq!(hits.load(Ordering::SeqCst), 2);
1149 }
1150
1151 #[tokio::test]
1155 async fn a_token_shorter_than_the_leeway_is_still_cached() {
1156 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1157 let hits = Arc::new(AtomicUsize::new(0));
1158 let addr = spawn_token_server(
1159 vec![(200, token_body("short", Some(20)))],
1160 Arc::clone(&captured),
1161 Arc::clone(&hits),
1162 )
1163 .await;
1164
1165 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1166 assert_eq!(p.access_token().await.unwrap(), "short");
1167 assert_eq!(p.access_token().await.unwrap(), "short");
1168 assert_eq!(
1169 hits.load(Ordering::SeqCst),
1170 1,
1171 "a 20 s token must not be refetched on the next call"
1172 );
1173 }
1174
1175 #[test]
1176 fn cache_ttl_keeps_at_least_half_the_lifetime() {
1177 let leeway = Duration::from_secs(30);
1178 let secs = Duration::from_secs;
1179 assert_eq!(cache_ttl(Some(3600), leeway), secs(3570));
1181 assert_eq!(cache_ttl(Some(61), leeway), secs(31));
1182 assert_eq!(cache_ttl(Some(60), leeway), secs(30));
1184 assert_eq!(cache_ttl(Some(40), leeway), secs(20));
1186 assert_eq!(cache_ttl(Some(30), leeway), secs(15));
1187 assert_eq!(cache_ttl(Some(20), leeway), secs(10));
1188 assert_eq!(cache_ttl(Some(1), leeway), Duration::from_millis(500));
1189 assert_eq!(cache_ttl(Some(0), leeway), Duration::ZERO);
1190 assert_eq!(cache_ttl(Some(100), Duration::ZERO), secs(100));
1192 assert_eq!(cache_ttl(None, leeway), NO_EXPIRY_CACHE_TTL);
1194 }
1195
1196 #[tokio::test]
1197 async fn concurrent_refreshes_single_flight() {
1198 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1199 let hits = Arc::new(AtomicUsize::new(0));
1200 let addr = spawn_token_server(
1201 vec![(200, token_body("tok-sf", Some(3600)))],
1202 Arc::clone(&captured),
1203 Arc::clone(&hits),
1204 )
1205 .await;
1206
1207 let p = Arc::new(OAuth2ClientCredentials::new(
1208 format!("http://{addr}/token"),
1209 "cid",
1210 "csec",
1211 ));
1212 let tasks: Vec<_> = (0..8)
1213 .map(|_| {
1214 let p = Arc::clone(&p);
1215 tokio::spawn(async move { p.access_token().await.unwrap() })
1216 })
1217 .collect();
1218 for t in tasks {
1219 assert_eq!(t.await.unwrap(), "tok-sf");
1220 }
1221 assert_eq!(
1222 hits.load(Ordering::SeqCst),
1223 1,
1224 "8 concurrent callers must produce exactly one token request"
1225 );
1226 }
1227
1228 #[tokio::test]
1232 async fn concurrent_failures_share_one_attempt() {
1233 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1234 let hits = Arc::new(AtomicUsize::new(0));
1235 let addr = spawn_token_server(
1236 vec![(500, r#"{"error":"temporarily_unavailable"}"#.to_owned())],
1237 Arc::clone(&captured),
1238 Arc::clone(&hits),
1239 )
1240 .await;
1241
1242 let p = Arc::new(OAuth2ClientCredentials::new(
1243 format!("http://{addr}/token"),
1244 "cid",
1245 "csec",
1246 ));
1247 let tasks: Vec<_> = (0..5)
1248 .map(|_| {
1249 let p = Arc::clone(&p);
1250 tokio::spawn(async move { p.access_token().await })
1251 })
1252 .collect();
1253 for t in tasks {
1254 let err = t.await.unwrap().expect_err("the endpoint only fails");
1255 assert!(err.to_string().contains("HTTP 500"), "{err}");
1256 }
1257 assert_eq!(
1258 hits.load(Ordering::SeqCst),
1259 1,
1260 "5 concurrent callers must share one failed token request"
1261 );
1262 }
1263
1264 #[tokio::test]
1267 async fn concurrent_timeouts_cost_one_timeout_not_one_each() {
1268 let timeout = Duration::from_millis(300);
1269 let addr = spawn_stalling_server("HTTP/1.1 200 OK", Duration::ZERO).await;
1270 let p = Arc::new(
1271 OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
1272 .with_request_timeout(timeout),
1273 );
1274
1275 let started = Instant::now();
1276 let tasks: Vec<_> = (0..5)
1277 .map(|_| {
1278 let p = Arc::clone(&p);
1279 tokio::spawn(async move { p.access_token().await })
1280 })
1281 .collect();
1282 for t in tasks {
1283 let err = t.await.unwrap().expect_err("a stalled endpoint must fail");
1284 assert!(matches!(err, ClientError::Timeout(_)), "{err:?}");
1285 }
1286 let elapsed = started.elapsed();
1287 assert!(
1288 elapsed < timeout * 2,
1289 "5 callers must wait out one shared timeout, took {elapsed:?} against {timeout:?}"
1290 );
1291 }
1292
1293 #[tokio::test]
1294 async fn error_response_surfaces_rfc6749_error_without_secret() {
1295 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1296 let hits = Arc::new(AtomicUsize::new(0));
1297 let addr = spawn_token_server(
1298 vec![(
1299 400,
1300 r#"{"error":"invalid_client","error_description":"bad credentials"}"#.to_owned(),
1301 )],
1302 Arc::clone(&captured),
1303 Arc::clone(&hits),
1304 )
1305 .await;
1306
1307 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "super-secret");
1308 let err = p.access_token().await.expect_err("400 must fail");
1309 let msg = err.to_string();
1310 assert!(msg.contains("invalid_client"), "{msg}");
1311 assert!(msg.contains("bad credentials"), "{msg}");
1312 assert!(!msg.contains("super-secret"), "secret leaked: {msg}");
1313 }
1314
1315 #[tokio::test]
1316 async fn non_bearer_token_type_is_rejected() {
1317 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1318 let hits = Arc::new(AtomicUsize::new(0));
1319 let addr = spawn_token_server(
1320 vec![(200, r#"{"access_token":"t","token_type":"MAC"}"#.to_owned())],
1321 Arc::clone(&captured),
1322 Arc::clone(&hits),
1323 )
1324 .await;
1325
1326 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1327 let err = p.access_token().await.expect_err("MAC tokens unsupported");
1328 assert!(err.to_string().contains("token_type"));
1329 }
1330
1331 #[tokio::test]
1334 async fn oidc_discovery_finds_token_endpoint() {
1335 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1336 let hits = Arc::new(AtomicUsize::new(0));
1337 let addr = spawn_token_server(
1338 vec![(
1339 200,
1340 r#"{"issuer":"http://i","token_endpoint":"http://i/oauth/token"}"#.to_owned(),
1341 )],
1342 Arc::clone(&captured),
1343 Arc::clone(&hits),
1344 )
1345 .await;
1346
1347 let url = discover_token_endpoint(&format!("http://{addr}"))
1348 .await
1349 .unwrap();
1350 assert_eq!(url, "http://i/oauth/token");
1351
1352 let url = discover_token_endpoint(&format!("http://{addr}/"))
1354 .await
1355 .unwrap();
1356 assert_eq!(url, "http://i/oauth/token");
1357 }
1358
1359 #[tokio::test]
1363 async fn from_oidc_issuer_targets_the_discovered_token_endpoint() {
1364 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1365 let hits = Arc::new(AtomicUsize::new(0));
1366 let addr = spawn_token_server(
1367 vec![(
1368 200,
1369 r#"{"issuer":"http://i","token_endpoint":"http://i/oauth/token"}"#.to_owned(),
1370 )],
1371 Arc::clone(&captured),
1372 Arc::clone(&hits),
1373 )
1374 .await;
1375
1376 let provider =
1377 OAuth2ClientCredentials::from_oidc_issuer(&format!("http://{addr}"), "cid", "csec")
1378 .await
1379 .expect("discovery succeeds");
1380 assert_eq!(provider.token_url, "http://i/oauth/token");
1381 assert_eq!(hits.load(Ordering::SeqCst), 1, "one discovery request");
1382 }
1383
1384 #[tokio::test]
1385 async fn oidc_discovery_without_token_endpoint_errors() {
1386 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1387 let hits = Arc::new(AtomicUsize::new(0));
1388 let addr = spawn_token_server(
1389 vec![(200, r#"{"issuer":"http://i"}"#.to_owned())],
1390 Arc::clone(&captured),
1391 Arc::clone(&hits),
1392 )
1393 .await;
1394
1395 let err = discover_token_endpoint(&format!("http://{addr}"))
1396 .await
1397 .expect_err("no token_endpoint");
1398 assert!(err.to_string().contains("token_endpoint"));
1399 }
1400
1401 #[tokio::test]
1408 async fn token_response_larger_than_the_mutated_ceiling_is_accepted() {
1409 let filler = "s".repeat(4096);
1414 let body = format!(
1415 r#"{{"access_token":"tok-big","token_type":"Bearer","expires_in":3600,"scope":"{filler}"}}"#
1416 );
1417 assert!(
1418 body.len() > 1088 && body.len() < 64 * 1024,
1419 "body must sit strictly between the mutated and real ceilings to \
1420 discriminate; got {} bytes",
1421 body.len()
1422 );
1423
1424 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1425 let hits = Arc::new(AtomicUsize::new(0));
1426 let addr =
1427 spawn_token_server(vec![(200, body)], Arc::clone(&captured), Arc::clone(&hits)).await;
1428
1429 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1430 assert_eq!(
1431 p.access_token()
1432 .await
1433 .expect("a 4 KiB token response is well within the 64 KiB limit"),
1434 "tok-big"
1435 );
1436 }
1437}