1use std::fmt;
61use std::future::Future;
62use std::pin::Pin;
63use std::sync::{Arc, RwLock};
64use std::time::{Duration, Instant};
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
94fn is_fresh(now: Instant, refresh_after: Instant) -> bool {
105 now < refresh_after
106}
107
108const NO_EXPIRY_CACHE_TTL: Duration = Duration::from_secs(60);
112
113pub trait TokenProvider: Send + Sync + 'static {
121 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>>;
128}
129
130pub struct StaticTokenProvider {
135 token: String,
136}
137
138impl StaticTokenProvider {
139 #[must_use]
141 pub fn new(token: impl Into<String>) -> Self {
142 Self {
143 token: token.into(),
144 }
145 }
146}
147
148impl fmt::Debug for StaticTokenProvider {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 f.debug_struct("StaticTokenProvider")
151 .field("token", &"<redacted>")
152 .finish()
153 }
154}
155
156impl TokenProvider for StaticTokenProvider {
157 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
158 Box::pin(async move { Ok(self.token.clone()) })
159 }
160}
161
162pub struct BearerAuthInterceptor {
175 provider: Arc<dyn TokenProvider>,
176}
177
178impl BearerAuthInterceptor {
179 #[must_use]
181 pub fn new(provider: Arc<dyn TokenProvider>) -> Self {
182 Self { provider }
183 }
184}
185
186impl fmt::Debug for BearerAuthInterceptor {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 f.debug_struct("BearerAuthInterceptor").finish()
189 }
190}
191
192impl CallInterceptor for BearerAuthInterceptor {
193 #[allow(clippy::manual_async_fn)]
194 fn before<'a>(
195 &'a self,
196 req: &'a mut ClientRequest,
197 ) -> impl Future<Output = ClientResult<()>> + Send + 'a {
198 async move {
199 let token = self.provider.access_token().await?;
200 req.extra_headers
201 .insert("authorization".to_owned(), format!("Bearer {token}"));
202 Ok(())
203 }
204 }
205
206 #[allow(clippy::manual_async_fn)]
207 fn after<'a>(
208 &'a self,
209 _resp: &'a ClientResponse,
210 ) -> impl Future<Output = ClientResult<()>> + Send + 'a {
211 async move { Ok(()) }
212 }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum TokenEndpointAuthStyle {
220 Basic,
224 Post,
227}
228
229pub struct OAuth2ClientCredentials {
245 token_url: String,
246 client_id: String,
247 client_secret: String,
248 scopes: Vec<String>,
249 audience: Option<String>,
250 extra_params: Vec<(String, String)>,
251 auth_style: TokenEndpointAuthStyle,
252 refresh_leeway: Duration,
253 request_timeout: Duration,
254 client: TokenHttpClient,
255 cache: RwLock<Option<CachedToken>>,
256 refresh_lock: tokio::sync::Mutex<()>,
257}
258
259#[derive(Clone)]
260struct CachedToken {
261 token: String,
262 refresh_after: Instant,
263}
264
265impl fmt::Debug for OAuth2ClientCredentials {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 f.debug_struct("OAuth2ClientCredentials")
268 .field("token_url", &self.token_url)
269 .field("client_id", &self.client_id)
270 .field("client_secret", &"<redacted>")
271 .field("scopes", &self.scopes)
272 .field("audience", &self.audience)
273 .field("auth_style", &self.auth_style)
274 .finish_non_exhaustive()
275 }
276}
277
278impl OAuth2ClientCredentials {
279 #[must_use]
281 pub fn new(
282 token_url: impl Into<String>,
283 client_id: impl Into<String>,
284 client_secret: impl Into<String>,
285 ) -> Self {
286 Self {
287 token_url: token_url.into(),
288 client_id: client_id.into(),
289 client_secret: client_secret.into(),
290 scopes: Vec::new(),
291 audience: None,
292 extra_params: Vec::new(),
293 auth_style: TokenEndpointAuthStyle::Basic,
294 refresh_leeway: DEFAULT_REFRESH_LEEWAY,
295 request_timeout: DEFAULT_TOKEN_REQUEST_TIMEOUT,
296 client: build_token_http_client(),
297 cache: RwLock::new(None),
298 refresh_lock: tokio::sync::Mutex::new(()),
299 }
300 }
301
302 pub fn from_agent_card(
315 card: &a2a_protocol_types::agent_card::AgentCard,
316 scheme_name: &str,
317 client_id: impl Into<String>,
318 client_secret: impl Into<String>,
319 ) -> ClientResult<Self> {
320 use a2a_protocol_types::security::{OAuthFlows, SecurityScheme};
321
322 let scheme = card
323 .security_schemes
324 .as_ref()
325 .and_then(|schemes| schemes.get(scheme_name))
326 .ok_or_else(|| {
327 ClientError::InvalidEndpoint(format!(
328 "agent card has no security scheme named {scheme_name:?}"
329 ))
330 })?;
331 let SecurityScheme::OAuth2(oauth2) = scheme else {
332 return Err(ClientError::InvalidEndpoint(format!(
333 "security scheme {scheme_name:?} is not an OAuth 2.0 scheme"
334 )));
335 };
336 let OAuthFlows::ClientCredentials(flow) = &oauth2.flows else {
337 return Err(ClientError::InvalidEndpoint(format!(
338 "security scheme {scheme_name:?} has no client-credentials flow \
339 (interactive flows need a custom TokenProvider)"
340 )));
341 };
342 Ok(Self::new(flow.token_url.clone(), client_id, client_secret))
343 }
344
345 pub async fn from_oidc_issuer(
354 issuer: &str,
355 client_id: impl Into<String>,
356 client_secret: impl Into<String>,
357 ) -> ClientResult<Self> {
358 let token_url = discover_token_endpoint(issuer).await?;
359 Ok(Self::new(token_url, client_id, client_secret))
360 }
361
362 #[must_use]
364 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
365 where
366 I: IntoIterator<Item = S>,
367 S: Into<String>,
368 {
369 self.scopes = scopes.into_iter().map(Into::into).collect();
370 self
371 }
372
373 #[must_use]
376 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
377 self.audience = Some(audience.into());
378 self
379 }
380
381 #[must_use]
383 pub fn with_extra_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
384 self.extra_params.push((key.into(), value.into()));
385 self
386 }
387
388 #[must_use]
391 pub const fn with_auth_style(mut self, style: TokenEndpointAuthStyle) -> Self {
392 self.auth_style = style;
393 self
394 }
395
396 #[must_use]
398 pub const fn with_refresh_leeway(mut self, leeway: Duration) -> Self {
399 self.refresh_leeway = leeway;
400 self
401 }
402
403 #[must_use]
405 pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
406 self.request_timeout = timeout;
407 self
408 }
409
410 fn cached(&self) -> Option<String> {
412 let guard = self
413 .cache
414 .read()
415 .unwrap_or_else(std::sync::PoisonError::into_inner);
416 guard.as_ref().and_then(|c| {
417 if is_fresh(Instant::now(), c.refresh_after) {
418 Some(c.token.clone())
419 } else {
420 None
421 }
422 })
423 }
424
425 async fn refresh(&self) -> ClientResult<String> {
427 check_endpoint_reachable(&self.token_url, "token endpoint")?;
428
429 let req = self.build_token_request()?;
430 let deadline = tokio::time::Instant::now() + self.request_timeout;
436 let resp = tokio::time::timeout_at(deadline, self.client.request(req))
437 .await
438 .map_err(|_| ClientError::Timeout("token endpoint request timed out".into()))?
439 .map_err(|e| ClientError::Transport(format!("token endpoint request failed: {e}")))?;
440
441 let status = resp.status();
442 let body = crate::transport::collect_response_limited(
443 resp,
444 MAX_TOKEN_RESPONSE_SIZE,
445 deadline.saturating_duration_since(tokio::time::Instant::now()),
446 )
447 .await?;
448 if !status.is_success() {
449 return Err(token_error(status, &body));
450 }
451
452 let token_resp: TokenResponse = serde_json::from_slice(&body).map_err(|e| {
453 ClientError::Transport(format!("token endpoint returned invalid JSON: {e}"))
454 })?;
455 if let Some(ref tt) = token_resp.token_type
456 && !tt.eq_ignore_ascii_case("bearer")
457 {
458 return Err(ClientError::Transport(format!(
459 "token endpoint returned unsupported token_type {tt:?} (expected \"Bearer\")"
460 )));
461 }
462
463 let ttl = token_resp.expires_in.map_or(NO_EXPIRY_CACHE_TTL, |secs| {
464 Duration::from_secs(secs).saturating_sub(self.refresh_leeway)
465 });
466 *self
467 .cache
468 .write()
469 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(CachedToken {
470 token: token_resp.access_token.clone(),
471 refresh_after: Instant::now() + ttl,
472 });
473
474 Ok(token_resp.access_token)
475 }
476
477 fn build_token_request(&self) -> ClientResult<hyper::Request<Full<Bytes>>> {
479 let mut form: Vec<(String, String)> =
480 vec![("grant_type".to_owned(), "client_credentials".to_owned())];
481 if !self.scopes.is_empty() {
482 form.push(("scope".to_owned(), self.scopes.join(" ")));
483 }
484 if let Some(ref aud) = self.audience {
485 form.push(("audience".to_owned(), aud.clone()));
486 }
487 for (k, v) in &self.extra_params {
488 form.push((k.clone(), v.clone()));
489 }
490 if self.auth_style == TokenEndpointAuthStyle::Post {
491 form.push(("client_id".to_owned(), self.client_id.clone()));
492 form.push(("client_secret".to_owned(), self.client_secret.clone()));
493 }
494
495 let mut builder = hyper::Request::builder()
496 .method(hyper::Method::POST)
497 .uri(&self.token_url)
498 .header("content-type", "application/x-www-form-urlencoded")
499 .header("accept", "application/json");
500 if self.auth_style == TokenEndpointAuthStyle::Basic {
501 let credentials = format!(
503 "{}:{}",
504 form_urlencode(&self.client_id),
505 form_urlencode(&self.client_secret)
506 );
507 builder = builder.header(
508 "authorization",
509 format!("Basic {}", STANDARD.encode(credentials)),
510 );
511 }
512 builder
513 .body(Full::new(Bytes::from(encode_form(&form))))
514 .map_err(|e| ClientError::Transport(format!("token request build failed: {e}")))
515 }
516}
517
518fn token_error(status: hyper::StatusCode, body: &[u8]) -> ClientError {
521 let detail = serde_json::from_slice::<OAuth2ErrorBody>(body).map_or_else(
522 |_| String::from_utf8_lossy(&body[..body.len().min(256)]).into_owned(),
523 |e| match e.error_description {
524 Some(desc) => format!("{}: {desc}", e.error),
525 None => e.error,
526 },
527 );
528 ClientError::Transport(format!("token endpoint returned HTTP {status}: {detail}"))
529}
530
531impl TokenProvider for OAuth2ClientCredentials {
532 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
533 Box::pin(async move {
534 if let Some(token) = self.cached() {
535 return Ok(token);
536 }
537 let _guard = self.refresh_lock.lock().await;
539 if let Some(token) = self.cached() {
540 return Ok(token); }
542 self.refresh().await
543 })
544 }
545}
546
547#[derive(serde::Deserialize)]
549struct TokenResponse {
550 access_token: String,
551 #[serde(default)]
552 token_type: Option<String>,
553 #[serde(default)]
554 expires_in: Option<u64>,
555}
556
557#[derive(serde::Deserialize)]
559struct OAuth2ErrorBody {
560 error: String,
561 #[serde(default)]
562 error_description: Option<String>,
563}
564
565pub async fn discover_token_endpoint(issuer: &str) -> ClientResult<String> {
575 discover_token_endpoint_within(issuer, DEFAULT_TOKEN_REQUEST_TIMEOUT).await
576}
577
578pub(crate) async fn discover_token_endpoint_within(
584 issuer: &str,
585 budget: Duration,
586) -> ClientResult<String> {
587 #[derive(serde::Deserialize)]
588 struct Discovery {
589 token_endpoint: Option<String>,
590 }
591 let url = format!(
592 "{}/.well-known/openid-configuration",
593 issuer.trim_end_matches('/')
594 );
595 check_endpoint_reachable(&url, "OIDC discovery")?;
596
597 let client = build_token_http_client();
598 let req = hyper::Request::builder()
599 .method(hyper::Method::GET)
600 .uri(&url)
601 .header("accept", "application/json")
602 .body(Full::new(Bytes::new()))
603 .map_err(|e| ClientError::Transport(format!("discovery request build failed: {e}")))?;
604
605 let deadline = tokio::time::Instant::now() + budget;
606 let resp = tokio::time::timeout_at(deadline, client.request(req))
607 .await
608 .map_err(|_| ClientError::Timeout("OIDC discovery request timed out".into()))?
609 .map_err(|e| ClientError::Transport(format!("OIDC discovery request failed: {e}")))?;
610
611 let status = resp.status();
612 let body = crate::transport::collect_response_limited(
613 resp,
614 MAX_TOKEN_RESPONSE_SIZE,
615 deadline.saturating_duration_since(tokio::time::Instant::now()),
616 )
617 .await?;
618 if !status.is_success() {
619 return Err(ClientError::Transport(format!(
620 "OIDC discovery returned HTTP {status}"
621 )));
622 }
623
624 let doc: Discovery = serde_json::from_slice(&body).map_err(|e| {
625 ClientError::Transport(format!("OIDC discovery returned invalid JSON: {e}"))
626 })?;
627 doc.token_endpoint.ok_or_else(|| {
628 ClientError::Transport("OIDC discovery document has no token_endpoint".into())
629 })
630}
631
632fn build_token_http_client() -> TokenHttpClient {
635 #[cfg(not(feature = "tls-rustls"))]
636 {
637 let mut connector = HttpConnector::new();
638 connector.set_connect_timeout(Some(Duration::from_secs(10)));
639 connector.set_nodelay(true);
640 Client::builder(TokioExecutor::new()).build(connector)
641 }
642 #[cfg(feature = "tls-rustls")]
643 {
644 crate::tls::build_https_client_with_connect_timeout(
645 crate::tls::default_tls_config(),
646 Duration::from_secs(10),
647 )
648 }
649}
650
651#[cfg_attr(
653 feature = "tls-rustls",
654 allow(
655 clippy::unnecessary_wraps,
656 unused_variables,
657 clippy::missing_const_for_fn
658 )
659)]
660fn check_endpoint_reachable(url: &str, what: &str) -> ClientResult<()> {
661 #[cfg(not(feature = "tls-rustls"))]
662 {
663 let is_https = url
664 .split_once("://")
665 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
666 if is_https {
667 return Err(ClientError::Transport(format!(
668 "{what} URL {url} is https:// but this build has no TLS; enable the \
669 `tls-rustls` feature (on by default)"
670 )));
671 }
672 }
673 Ok(())
674}
675
676fn form_urlencode(value: &str) -> String {
678 let mut out = String::with_capacity(value.len());
679 for byte in value.bytes() {
680 match byte {
681 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
682 out.push(byte as char);
683 }
684 b' ' => out.push('+'),
685 other => {
686 out.push('%');
687 out.push(
688 char::from_digit(u32::from(other >> 4), 16)
689 .unwrap_or('0')
690 .to_ascii_uppercase(),
691 );
692 out.push(
693 char::from_digit(u32::from(other & 0xf), 16)
694 .unwrap_or('0')
695 .to_ascii_uppercase(),
696 );
697 }
698 }
699 }
700 out
701}
702
703fn encode_form(pairs: &[(String, String)]) -> String {
705 pairs
706 .iter()
707 .map(|(k, v)| format!("{}={}", form_urlencode(k), form_urlencode(v)))
708 .collect::<Vec<_>>()
709 .join("&")
710}
711
712#[cfg(test)]
715mod tests {
716 use super::*;
717 use std::collections::HashMap;
718 use std::sync::atomic::{AtomicUsize, Ordering};
719
720 #[test]
723 fn form_urlencode_passes_unreserved() {
724 assert_eq!(form_urlencode("Abc-123._~"), "Abc-123._~");
725 }
726
727 #[test]
728 fn form_urlencode_escapes_reserved_and_space() {
729 assert_eq!(form_urlencode("a b&c=d%"), "a+b%26c%3Dd%25");
730 assert_eq!(form_urlencode("秘"), "%E7%A7%98");
731 }
732
733 #[test]
734 fn encode_form_joins_pairs() {
735 let pairs = vec![
736 ("grant_type".to_owned(), "client_credentials".to_owned()),
737 ("scope".to_owned(), "a b".to_owned()),
738 ];
739 assert_eq!(
740 encode_form(&pairs),
741 "grant_type=client_credentials&scope=a+b"
742 );
743 }
744
745 #[test]
748 fn debug_redacts_secrets() {
749 let p = StaticTokenProvider::new("super-secret");
750 let dbg = format!("{p:?}");
751 assert!(!dbg.contains("super-secret"), "secret leaked: {dbg}");
752 assert!(
757 dbg.contains("StaticTokenProvider"),
758 "Debug must name the type; empty output would pass the redaction \
759 check while telling a reader nothing: {dbg}"
760 );
761 assert!(
762 dbg.contains("<redacted>"),
763 "the token field must be present and redacted, not omitted: {dbg}"
764 );
765
766 let o = OAuth2ClientCredentials::new("http://localhost/token", "id", "very-secret");
767 let dbg = format!("{o:?}");
768 assert!(!dbg.contains("very-secret"), "secret leaked: {dbg}");
769 assert!(dbg.contains("id"), "client_id should be visible");
770 }
771
772 #[test]
775 fn bearer_interceptor_debug_names_the_type() {
776 let interceptor = BearerAuthInterceptor::new(Arc::new(StaticTokenProvider::new("t")));
777 let dbg = format!("{interceptor:?}");
778 assert!(
779 dbg.contains("BearerAuthInterceptor"),
780 "Debug must name the type, got: {dbg:?}"
781 );
782 }
783
784 #[test]
789 fn is_fresh_is_exclusive_at_the_deadline() {
790 let t = Instant::now();
791
792 assert!(
793 !is_fresh(t, t),
794 "at exactly the refresh deadline a token is due for refresh, not fresh"
795 );
796 assert!(
797 is_fresh(t, t + Duration::from_secs(1)),
798 "before the deadline the token is still usable"
799 );
800 assert!(
801 !is_fresh(t + Duration::from_secs(1), t),
802 "after the deadline the token is stale"
803 );
804 }
805
806 #[tokio::test]
809 async fn static_provider_returns_token() {
810 let p = StaticTokenProvider::new("tok-1");
811 assert_eq!(p.access_token().await.unwrap(), "tok-1");
812 }
813
814 #[tokio::test]
815 async fn bearer_interceptor_injects_header() {
816 let p: Arc<dyn TokenProvider> = Arc::new(StaticTokenProvider::new("tok-xyz"));
817 let interceptor = BearerAuthInterceptor::new(p);
818 let mut req = ClientRequest::new("message/send", serde_json::json!({}));
819 interceptor.before(&mut req).await.unwrap();
820 assert_eq!(
821 req.extra_headers.get("authorization").map(String::as_str),
822 Some("Bearer tok-xyz")
823 );
824 }
825
826 fn card_with_oauth2(
829 flows: a2a_protocol_types::security::OAuthFlows,
830 ) -> a2a_protocol_types::agent_card::AgentCard {
831 use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard};
832 use a2a_protocol_types::security::{OAuth2SecurityScheme, SecurityScheme};
833 let mut schemes = std::collections::HashMap::new();
834 schemes.insert(
835 "oauth".to_owned(),
836 SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
837 flows,
838 oauth2_metadata_url: None,
839 description: None,
840 })),
841 );
842 AgentCard {
843 name: "a".into(),
844 url: None,
845 description: "d".into(),
846 version: "1".into(),
847 supported_interfaces: vec![],
848 default_input_modes: vec![],
849 default_output_modes: vec![],
850 skills: vec![],
851 capabilities: AgentCapabilities::none(),
852 provider: None,
853 icon_url: None,
854 documentation_url: None,
855 security_schemes: Some(schemes),
856 security_requirements: None,
857 signatures: None,
858 }
859 }
860
861 #[test]
862 fn from_agent_card_reads_token_url() {
863 use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
864 let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
865 token_url: "https://auth.example.com/token".into(),
866 refresh_url: None,
867 scopes: HashMap::new(),
868 }));
869 let p = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec").unwrap();
870 assert_eq!(p.token_url, "https://auth.example.com/token");
871 }
872
873 #[test]
874 fn from_agent_card_missing_scheme_errors() {
875 use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
876 let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
877 token_url: "https://auth.example.com/token".into(),
878 refresh_url: None,
879 scopes: HashMap::new(),
880 }));
881 let err = OAuth2ClientCredentials::from_agent_card(&card, "nope", "id", "sec")
882 .expect_err("missing scheme");
883 assert!(err.to_string().contains("no security scheme"));
884 }
885
886 #[test]
887 fn from_agent_card_wrong_flow_errors() {
888 use a2a_protocol_types::security::{ImplicitFlow, OAuthFlows};
889 let card = card_with_oauth2(OAuthFlows::Implicit(ImplicitFlow {
890 authorization_url: "https://auth.example.com/authz".into(),
891 refresh_url: None,
892 scopes: HashMap::new(),
893 }));
894 let err = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec")
895 .expect_err("implicit flow is not client-credentials");
896 assert!(err.to_string().contains("client-credentials"));
897 }
898
899 async fn spawn_token_server(
904 responses: Vec<(u16, String)>,
905 captured: Arc<std::sync::Mutex<Vec<(String, String)>>>,
906 hits: Arc<AtomicUsize>,
907 ) -> std::net::SocketAddr {
908 use http_body_util::BodyExt;
909 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
910 let addr = listener.local_addr().unwrap();
911 tokio::spawn(async move {
912 loop {
913 let Ok((stream, _)) = listener.accept().await else {
914 break;
915 };
916 let responses = responses.clone();
917 let captured = Arc::clone(&captured);
918 let hits = Arc::clone(&hits);
919 tokio::spawn(async move {
920 let io = hyper_util::rt::TokioIo::new(stream);
921 let svc = hyper::service::service_fn(
922 move |req: hyper::Request<hyper::body::Incoming>| {
923 let responses = responses.clone();
924 let captured = Arc::clone(&captured);
925 let hits = Arc::clone(&hits);
926 async move {
927 let n = hits.fetch_add(1, Ordering::SeqCst);
928 let auth = req
929 .headers()
930 .get("authorization")
931 .and_then(|v| v.to_str().ok())
932 .unwrap_or("")
933 .to_owned();
934 let body = req.into_body().collect().await.unwrap().to_bytes();
935 captured
936 .lock()
937 .unwrap()
938 .push((auth, String::from_utf8_lossy(&body).into_owned()));
939 let (status, body) = responses
940 .get(n)
941 .or_else(|| responses.last())
942 .unwrap()
943 .clone();
944 Ok::<_, std::convert::Infallible>(
945 hyper::Response::builder()
946 .status(status)
947 .header("content-type", "application/json")
948 .body(Full::new(Bytes::from(body)))
949 .unwrap(),
950 )
951 }
952 },
953 );
954 let _ = hyper::server::conn::http1::Builder::new()
955 .serve_connection(io, svc)
956 .await;
957 });
958 }
959 });
960 addr
961 }
962
963 use crate::transport::test_support::spawn_stalling_server;
964
965 async fn timed_failure<T: std::fmt::Debug>(
968 fut: impl std::future::Future<Output = ClientResult<T>>,
969 ) -> (ClientError, Duration) {
970 let started = Instant::now();
971 let err = fut.await.expect_err("a stalled body must not succeed");
972 (err, started.elapsed())
973 }
974
975 #[tokio::test]
981 async fn a_stalled_token_body_is_bounded_by_one_request_timeout() {
982 let timeout = Duration::from_millis(600);
983 let addr = spawn_stalling_server("HTTP/1.1 200 OK", timeout * 2 / 3).await;
984 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
985 .with_request_timeout(timeout);
986
987 let (err, elapsed) = timed_failure(p.refresh()).await;
988 assert!(matches!(err, ClientError::Timeout(_)), "{err:?}");
989 assert!(
990 elapsed >= timeout && elapsed < timeout * 3 / 2,
991 "one budget, not two: took {elapsed:?} against {timeout:?}"
992 );
993 }
994
995 #[tokio::test]
998 async fn a_stalled_discovery_body_is_bounded_by_one_budget() {
999 let budget = Duration::from_millis(600);
1000 let addr = spawn_stalling_server("HTTP/1.1 200 OK", budget * 2 / 3).await;
1001
1002 let (err, elapsed) = timed_failure(discover_token_endpoint_within(
1003 &format!("http://{addr}"),
1004 budget,
1005 ))
1006 .await;
1007 assert!(matches!(err, ClientError::Timeout(_)), "{err:?}");
1008 assert!(
1009 elapsed >= budget && elapsed < budget * 3 / 2,
1010 "one budget, not two: took {elapsed:?} against {budget:?}"
1011 );
1012 }
1013
1014 fn token_body(token: &str, expires_in: Option<u64>) -> String {
1015 expires_in.map_or_else(
1016 || format!(r#"{{"access_token":"{token}","token_type":"Bearer"}}"#),
1017 |e| format!(r#"{{"access_token":"{token}","token_type":"Bearer","expires_in":{e}}}"#),
1018 )
1019 }
1020
1021 #[tokio::test]
1022 async fn fetches_and_caches_token() {
1023 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1024 let hits = Arc::new(AtomicUsize::new(0));
1025 let addr = spawn_token_server(
1026 vec![(200, token_body("tok-a", Some(3600)))],
1027 Arc::clone(&captured),
1028 Arc::clone(&hits),
1029 )
1030 .await;
1031
1032 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
1033 .with_scopes(["read", "write"]);
1034 assert_eq!(p.access_token().await.unwrap(), "tok-a");
1035 assert_eq!(p.access_token().await.unwrap(), "tok-a");
1036 assert_eq!(
1037 hits.load(Ordering::SeqCst),
1038 1,
1039 "second call must be served from cache"
1040 );
1041
1042 let (auth, body) = { captured.lock().unwrap()[0].clone() };
1043 assert!(auth.starts_with("Basic "), "default auth style is Basic");
1044 let decoded =
1045 String::from_utf8(STANDARD.decode(auth.trim_start_matches("Basic ")).unwrap()).unwrap();
1046 assert_eq!(decoded, "cid:csec");
1047 assert!(body.contains("grant_type=client_credentials"), "{body}");
1048 assert!(body.contains("scope=read+write"), "{body}");
1049 assert!(
1050 !body.contains("client_secret"),
1051 "Basic style must not put the secret in the body: {body}"
1052 );
1053 }
1054
1055 #[tokio::test]
1056 async fn post_auth_style_puts_credentials_in_body() {
1057 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1058 let hits = Arc::new(AtomicUsize::new(0));
1059 let addr = spawn_token_server(
1060 vec![(200, token_body("tok-b", Some(3600)))],
1061 Arc::clone(&captured),
1062 Arc::clone(&hits),
1063 )
1064 .await;
1065
1066 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
1067 .with_auth_style(TokenEndpointAuthStyle::Post);
1068 assert_eq!(p.access_token().await.unwrap(), "tok-b");
1069
1070 let (auth, body) = { captured.lock().unwrap()[0].clone() };
1071 assert!(auth.is_empty(), "no Authorization header in Post style");
1072 assert!(body.contains("client_id=cid"), "{body}");
1073 assert!(body.contains("client_secret=csec"), "{body}");
1074 }
1075
1076 #[tokio::test]
1077 async fn expired_token_is_refreshed() {
1078 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1079 let hits = Arc::new(AtomicUsize::new(0));
1080 let addr = spawn_token_server(
1081 vec![
1082 (200, token_body("tok-1", Some(1))),
1085 (200, token_body("tok-2", Some(3600))),
1086 ],
1087 Arc::clone(&captured),
1088 Arc::clone(&hits),
1089 )
1090 .await;
1091
1092 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1093 assert_eq!(p.access_token().await.unwrap(), "tok-1");
1094 assert_eq!(p.access_token().await.unwrap(), "tok-2");
1095 assert_eq!(hits.load(Ordering::SeqCst), 2);
1096 }
1097
1098 #[tokio::test]
1099 async fn concurrent_refreshes_single_flight() {
1100 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1101 let hits = Arc::new(AtomicUsize::new(0));
1102 let addr = spawn_token_server(
1103 vec![(200, token_body("tok-sf", Some(3600)))],
1104 Arc::clone(&captured),
1105 Arc::clone(&hits),
1106 )
1107 .await;
1108
1109 let p = Arc::new(OAuth2ClientCredentials::new(
1110 format!("http://{addr}/token"),
1111 "cid",
1112 "csec",
1113 ));
1114 let tasks: Vec<_> = (0..8)
1115 .map(|_| {
1116 let p = Arc::clone(&p);
1117 tokio::spawn(async move { p.access_token().await.unwrap() })
1118 })
1119 .collect();
1120 for t in tasks {
1121 assert_eq!(t.await.unwrap(), "tok-sf");
1122 }
1123 assert_eq!(
1124 hits.load(Ordering::SeqCst),
1125 1,
1126 "8 concurrent callers must produce exactly one token request"
1127 );
1128 }
1129
1130 #[tokio::test]
1131 async fn error_response_surfaces_rfc6749_error_without_secret() {
1132 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1133 let hits = Arc::new(AtomicUsize::new(0));
1134 let addr = spawn_token_server(
1135 vec![(
1136 400,
1137 r#"{"error":"invalid_client","error_description":"bad credentials"}"#.to_owned(),
1138 )],
1139 Arc::clone(&captured),
1140 Arc::clone(&hits),
1141 )
1142 .await;
1143
1144 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "super-secret");
1145 let err = p.access_token().await.expect_err("400 must fail");
1146 let msg = err.to_string();
1147 assert!(msg.contains("invalid_client"), "{msg}");
1148 assert!(msg.contains("bad credentials"), "{msg}");
1149 assert!(!msg.contains("super-secret"), "secret leaked: {msg}");
1150 }
1151
1152 #[tokio::test]
1153 async fn non_bearer_token_type_is_rejected() {
1154 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1155 let hits = Arc::new(AtomicUsize::new(0));
1156 let addr = spawn_token_server(
1157 vec![(200, r#"{"access_token":"t","token_type":"MAC"}"#.to_owned())],
1158 Arc::clone(&captured),
1159 Arc::clone(&hits),
1160 )
1161 .await;
1162
1163 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1164 let err = p.access_token().await.expect_err("MAC tokens unsupported");
1165 assert!(err.to_string().contains("token_type"));
1166 }
1167
1168 #[tokio::test]
1171 async fn oidc_discovery_finds_token_endpoint() {
1172 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1173 let hits = Arc::new(AtomicUsize::new(0));
1174 let addr = spawn_token_server(
1175 vec![(
1176 200,
1177 r#"{"issuer":"http://i","token_endpoint":"http://i/oauth/token"}"#.to_owned(),
1178 )],
1179 Arc::clone(&captured),
1180 Arc::clone(&hits),
1181 )
1182 .await;
1183
1184 let url = discover_token_endpoint(&format!("http://{addr}"))
1185 .await
1186 .unwrap();
1187 assert_eq!(url, "http://i/oauth/token");
1188
1189 let url = discover_token_endpoint(&format!("http://{addr}/"))
1191 .await
1192 .unwrap();
1193 assert_eq!(url, "http://i/oauth/token");
1194 }
1195
1196 #[tokio::test]
1197 async fn oidc_discovery_without_token_endpoint_errors() {
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, r#"{"issuer":"http://i"}"#.to_owned())],
1202 Arc::clone(&captured),
1203 Arc::clone(&hits),
1204 )
1205 .await;
1206
1207 let err = discover_token_endpoint(&format!("http://{addr}"))
1208 .await
1209 .expect_err("no token_endpoint");
1210 assert!(err.to_string().contains("token_endpoint"));
1211 }
1212
1213 #[tokio::test]
1220 async fn token_response_larger_than_the_mutated_ceiling_is_accepted() {
1221 let filler = "s".repeat(4096);
1226 let body = format!(
1227 r#"{{"access_token":"tok-big","token_type":"Bearer","expires_in":3600,"scope":"{filler}"}}"#
1228 );
1229 assert!(
1230 body.len() > 1088 && body.len() < 64 * 1024,
1231 "body must sit strictly between the mutated and real ceilings to \
1232 discriminate; got {} bytes",
1233 body.len()
1234 );
1235
1236 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1237 let hits = Arc::new(AtomicUsize::new(0));
1238 let addr =
1239 spawn_token_server(vec![(200, body)], Arc::clone(&captured), Arc::clone(&hits)).await;
1240
1241 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1242 assert_eq!(
1243 p.access_token()
1244 .await
1245 .expect("a 4 KiB token response is well within the 64 KiB limit"),
1246 "tok-big"
1247 );
1248 }
1249}