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::general_purpose::STANDARD;
67use base64::Engine;
68use http_body_util::Full;
69use hyper::body::Bytes;
70#[cfg(not(feature = "tls-rustls"))]
71use hyper_util::client::legacy::connect::HttpConnector;
72#[cfg(not(feature = "tls-rustls"))]
73use hyper_util::client::legacy::Client;
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 resp = tokio::time::timeout(self.request_timeout, self.client.request(req))
431 .await
432 .map_err(|_| ClientError::Timeout("token endpoint request timed out".into()))?
433 .map_err(|e| ClientError::Transport(format!("token endpoint request failed: {e}")))?;
434
435 let status = resp.status();
436 let body = crate::transport::collect_response_limited(
437 resp,
438 MAX_TOKEN_RESPONSE_SIZE,
439 self.request_timeout,
440 )
441 .await?;
442 if !status.is_success() {
443 return Err(token_error(status, &body));
444 }
445
446 let token_resp: TokenResponse = serde_json::from_slice(&body).map_err(|e| {
447 ClientError::Transport(format!("token endpoint returned invalid JSON: {e}"))
448 })?;
449 if let Some(ref tt) = token_resp.token_type {
450 if !tt.eq_ignore_ascii_case("bearer") {
451 return Err(ClientError::Transport(format!(
452 "token endpoint returned unsupported token_type {tt:?} (expected \"Bearer\")"
453 )));
454 }
455 }
456
457 let ttl = token_resp.expires_in.map_or(NO_EXPIRY_CACHE_TTL, |secs| {
458 Duration::from_secs(secs).saturating_sub(self.refresh_leeway)
459 });
460 *self
461 .cache
462 .write()
463 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(CachedToken {
464 token: token_resp.access_token.clone(),
465 refresh_after: Instant::now() + ttl,
466 });
467
468 Ok(token_resp.access_token)
469 }
470
471 fn build_token_request(&self) -> ClientResult<hyper::Request<Full<Bytes>>> {
473 let mut form: Vec<(String, String)> =
474 vec![("grant_type".to_owned(), "client_credentials".to_owned())];
475 if !self.scopes.is_empty() {
476 form.push(("scope".to_owned(), self.scopes.join(" ")));
477 }
478 if let Some(ref aud) = self.audience {
479 form.push(("audience".to_owned(), aud.clone()));
480 }
481 for (k, v) in &self.extra_params {
482 form.push((k.clone(), v.clone()));
483 }
484 if self.auth_style == TokenEndpointAuthStyle::Post {
485 form.push(("client_id".to_owned(), self.client_id.clone()));
486 form.push(("client_secret".to_owned(), self.client_secret.clone()));
487 }
488
489 let mut builder = hyper::Request::builder()
490 .method(hyper::Method::POST)
491 .uri(&self.token_url)
492 .header("content-type", "application/x-www-form-urlencoded")
493 .header("accept", "application/json");
494 if self.auth_style == TokenEndpointAuthStyle::Basic {
495 let credentials = format!(
497 "{}:{}",
498 form_urlencode(&self.client_id),
499 form_urlencode(&self.client_secret)
500 );
501 builder = builder.header(
502 "authorization",
503 format!("Basic {}", STANDARD.encode(credentials)),
504 );
505 }
506 builder
507 .body(Full::new(Bytes::from(encode_form(&form))))
508 .map_err(|e| ClientError::Transport(format!("token request build failed: {e}")))
509 }
510}
511
512fn token_error(status: hyper::StatusCode, body: &[u8]) -> ClientError {
515 let detail = serde_json::from_slice::<OAuth2ErrorBody>(body).map_or_else(
516 |_| String::from_utf8_lossy(&body[..body.len().min(256)]).into_owned(),
517 |e| match e.error_description {
518 Some(desc) => format!("{}: {desc}", e.error),
519 None => e.error,
520 },
521 );
522 ClientError::Transport(format!("token endpoint returned HTTP {status}: {detail}"))
523}
524
525impl TokenProvider for OAuth2ClientCredentials {
526 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
527 Box::pin(async move {
528 if let Some(token) = self.cached() {
529 return Ok(token);
530 }
531 let _guard = self.refresh_lock.lock().await;
533 if let Some(token) = self.cached() {
534 return Ok(token); }
536 self.refresh().await
537 })
538 }
539}
540
541#[derive(serde::Deserialize)]
543struct TokenResponse {
544 access_token: String,
545 #[serde(default)]
546 token_type: Option<String>,
547 #[serde(default)]
548 expires_in: Option<u64>,
549}
550
551#[derive(serde::Deserialize)]
553struct OAuth2ErrorBody {
554 error: String,
555 #[serde(default)]
556 error_description: Option<String>,
557}
558
559pub async fn discover_token_endpoint(issuer: &str) -> ClientResult<String> {
569 #[derive(serde::Deserialize)]
570 struct Discovery {
571 token_endpoint: Option<String>,
572 }
573 let url = format!(
574 "{}/.well-known/openid-configuration",
575 issuer.trim_end_matches('/')
576 );
577 check_endpoint_reachable(&url, "OIDC discovery")?;
578
579 let client = build_token_http_client();
580 let req = hyper::Request::builder()
581 .method(hyper::Method::GET)
582 .uri(&url)
583 .header("accept", "application/json")
584 .body(Full::new(Bytes::new()))
585 .map_err(|e| ClientError::Transport(format!("discovery request build failed: {e}")))?;
586
587 let resp = tokio::time::timeout(DEFAULT_TOKEN_REQUEST_TIMEOUT, client.request(req))
588 .await
589 .map_err(|_| ClientError::Timeout("OIDC discovery request timed out".into()))?
590 .map_err(|e| ClientError::Transport(format!("OIDC discovery request failed: {e}")))?;
591
592 let status = resp.status();
593 let body = crate::transport::collect_response_limited(
594 resp,
595 MAX_TOKEN_RESPONSE_SIZE,
596 DEFAULT_TOKEN_REQUEST_TIMEOUT,
597 )
598 .await?;
599 if !status.is_success() {
600 return Err(ClientError::Transport(format!(
601 "OIDC discovery returned HTTP {status}"
602 )));
603 }
604
605 let doc: Discovery = serde_json::from_slice(&body).map_err(|e| {
606 ClientError::Transport(format!("OIDC discovery returned invalid JSON: {e}"))
607 })?;
608 doc.token_endpoint.ok_or_else(|| {
609 ClientError::Transport("OIDC discovery document has no token_endpoint".into())
610 })
611}
612
613fn build_token_http_client() -> TokenHttpClient {
616 #[cfg(not(feature = "tls-rustls"))]
617 {
618 let mut connector = HttpConnector::new();
619 connector.set_connect_timeout(Some(Duration::from_secs(10)));
620 connector.set_nodelay(true);
621 Client::builder(TokioExecutor::new()).build(connector)
622 }
623 #[cfg(feature = "tls-rustls")]
624 {
625 crate::tls::build_https_client_with_connect_timeout(
626 crate::tls::default_tls_config(),
627 Duration::from_secs(10),
628 )
629 }
630}
631
632#[cfg_attr(
634 feature = "tls-rustls",
635 allow(
636 clippy::unnecessary_wraps,
637 unused_variables,
638 clippy::missing_const_for_fn
639 )
640)]
641fn check_endpoint_reachable(url: &str, what: &str) -> ClientResult<()> {
642 #[cfg(not(feature = "tls-rustls"))]
643 {
644 let is_https = url
645 .split_once("://")
646 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
647 if is_https {
648 return Err(ClientError::Transport(format!(
649 "{what} URL {url} is https:// but this build has no TLS; enable the \
650 `tls-rustls` feature (on by default)"
651 )));
652 }
653 }
654 Ok(())
655}
656
657fn form_urlencode(value: &str) -> String {
659 let mut out = String::with_capacity(value.len());
660 for byte in value.bytes() {
661 match byte {
662 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
663 out.push(byte as char);
664 }
665 b' ' => out.push('+'),
666 other => {
667 out.push('%');
668 out.push(
669 char::from_digit(u32::from(other >> 4), 16)
670 .unwrap_or('0')
671 .to_ascii_uppercase(),
672 );
673 out.push(
674 char::from_digit(u32::from(other & 0xf), 16)
675 .unwrap_or('0')
676 .to_ascii_uppercase(),
677 );
678 }
679 }
680 }
681 out
682}
683
684fn encode_form(pairs: &[(String, String)]) -> String {
686 pairs
687 .iter()
688 .map(|(k, v)| format!("{}={}", form_urlencode(k), form_urlencode(v)))
689 .collect::<Vec<_>>()
690 .join("&")
691}
692
693#[cfg(test)]
696mod tests {
697 use super::*;
698 use std::collections::HashMap;
699 use std::sync::atomic::{AtomicUsize, Ordering};
700
701 #[test]
704 fn form_urlencode_passes_unreserved() {
705 assert_eq!(form_urlencode("Abc-123._~"), "Abc-123._~");
706 }
707
708 #[test]
709 fn form_urlencode_escapes_reserved_and_space() {
710 assert_eq!(form_urlencode("a b&c=d%"), "a+b%26c%3Dd%25");
711 assert_eq!(form_urlencode("秘"), "%E7%A7%98");
712 }
713
714 #[test]
715 fn encode_form_joins_pairs() {
716 let pairs = vec![
717 ("grant_type".to_owned(), "client_credentials".to_owned()),
718 ("scope".to_owned(), "a b".to_owned()),
719 ];
720 assert_eq!(
721 encode_form(&pairs),
722 "grant_type=client_credentials&scope=a+b"
723 );
724 }
725
726 #[test]
729 fn debug_redacts_secrets() {
730 let p = StaticTokenProvider::new("super-secret");
731 let dbg = format!("{p:?}");
732 assert!(!dbg.contains("super-secret"), "secret leaked: {dbg}");
733 assert!(
738 dbg.contains("StaticTokenProvider"),
739 "Debug must name the type; empty output would pass the redaction \
740 check while telling a reader nothing: {dbg}"
741 );
742 assert!(
743 dbg.contains("<redacted>"),
744 "the token field must be present and redacted, not omitted: {dbg}"
745 );
746
747 let o = OAuth2ClientCredentials::new("http://localhost/token", "id", "very-secret");
748 let dbg = format!("{o:?}");
749 assert!(!dbg.contains("very-secret"), "secret leaked: {dbg}");
750 assert!(dbg.contains("id"), "client_id should be visible");
751 }
752
753 #[test]
756 fn bearer_interceptor_debug_names_the_type() {
757 let interceptor = BearerAuthInterceptor::new(Arc::new(StaticTokenProvider::new("t")));
758 let dbg = format!("{interceptor:?}");
759 assert!(
760 dbg.contains("BearerAuthInterceptor"),
761 "Debug must name the type, got: {dbg:?}"
762 );
763 }
764
765 #[test]
770 fn is_fresh_is_exclusive_at_the_deadline() {
771 let t = Instant::now();
772
773 assert!(
774 !is_fresh(t, t),
775 "at exactly the refresh deadline a token is due for refresh, not fresh"
776 );
777 assert!(
778 is_fresh(t, t + Duration::from_secs(1)),
779 "before the deadline the token is still usable"
780 );
781 assert!(
782 !is_fresh(t + Duration::from_secs(1), t),
783 "after the deadline the token is stale"
784 );
785 }
786
787 #[tokio::test]
790 async fn static_provider_returns_token() {
791 let p = StaticTokenProvider::new("tok-1");
792 assert_eq!(p.access_token().await.unwrap(), "tok-1");
793 }
794
795 #[tokio::test]
796 async fn bearer_interceptor_injects_header() {
797 let p: Arc<dyn TokenProvider> = Arc::new(StaticTokenProvider::new("tok-xyz"));
798 let interceptor = BearerAuthInterceptor::new(p);
799 let mut req = ClientRequest::new("message/send", serde_json::json!({}));
800 interceptor.before(&mut req).await.unwrap();
801 assert_eq!(
802 req.extra_headers.get("authorization").map(String::as_str),
803 Some("Bearer tok-xyz")
804 );
805 }
806
807 fn card_with_oauth2(
810 flows: a2a_protocol_types::security::OAuthFlows,
811 ) -> a2a_protocol_types::agent_card::AgentCard {
812 use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard};
813 use a2a_protocol_types::security::{OAuth2SecurityScheme, SecurityScheme};
814 let mut schemes = std::collections::HashMap::new();
815 schemes.insert(
816 "oauth".to_owned(),
817 SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
818 flows,
819 oauth2_metadata_url: None,
820 description: None,
821 })),
822 );
823 AgentCard {
824 name: "a".into(),
825 url: None,
826 description: "d".into(),
827 version: "1".into(),
828 supported_interfaces: vec![],
829 default_input_modes: vec![],
830 default_output_modes: vec![],
831 skills: vec![],
832 capabilities: AgentCapabilities::none(),
833 provider: None,
834 icon_url: None,
835 documentation_url: None,
836 security_schemes: Some(schemes),
837 security_requirements: None,
838 signatures: None,
839 }
840 }
841
842 #[test]
843 fn from_agent_card_reads_token_url() {
844 use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
845 let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
846 token_url: "https://auth.example.com/token".into(),
847 refresh_url: None,
848 scopes: HashMap::new(),
849 }));
850 let p = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec").unwrap();
851 assert_eq!(p.token_url, "https://auth.example.com/token");
852 }
853
854 #[test]
855 fn from_agent_card_missing_scheme_errors() {
856 use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
857 let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
858 token_url: "https://auth.example.com/token".into(),
859 refresh_url: None,
860 scopes: HashMap::new(),
861 }));
862 let err = OAuth2ClientCredentials::from_agent_card(&card, "nope", "id", "sec")
863 .expect_err("missing scheme");
864 assert!(err.to_string().contains("no security scheme"));
865 }
866
867 #[test]
868 fn from_agent_card_wrong_flow_errors() {
869 use a2a_protocol_types::security::{ImplicitFlow, OAuthFlows};
870 let card = card_with_oauth2(OAuthFlows::Implicit(ImplicitFlow {
871 authorization_url: "https://auth.example.com/authz".into(),
872 refresh_url: None,
873 scopes: HashMap::new(),
874 }));
875 let err = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec")
876 .expect_err("implicit flow is not client-credentials");
877 assert!(err.to_string().contains("client-credentials"));
878 }
879
880 async fn spawn_token_server(
885 responses: Vec<(u16, String)>,
886 captured: Arc<std::sync::Mutex<Vec<(String, String)>>>,
887 hits: Arc<AtomicUsize>,
888 ) -> std::net::SocketAddr {
889 use http_body_util::BodyExt;
890 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
891 let addr = listener.local_addr().unwrap();
892 tokio::spawn(async move {
893 loop {
894 let Ok((stream, _)) = listener.accept().await else {
895 break;
896 };
897 let responses = responses.clone();
898 let captured = Arc::clone(&captured);
899 let hits = Arc::clone(&hits);
900 tokio::spawn(async move {
901 let io = hyper_util::rt::TokioIo::new(stream);
902 let svc = hyper::service::service_fn(
903 move |req: hyper::Request<hyper::body::Incoming>| {
904 let responses = responses.clone();
905 let captured = Arc::clone(&captured);
906 let hits = Arc::clone(&hits);
907 async move {
908 let n = hits.fetch_add(1, Ordering::SeqCst);
909 let auth = req
910 .headers()
911 .get("authorization")
912 .and_then(|v| v.to_str().ok())
913 .unwrap_or("")
914 .to_owned();
915 let body = req.into_body().collect().await.unwrap().to_bytes();
916 captured
917 .lock()
918 .unwrap()
919 .push((auth, String::from_utf8_lossy(&body).into_owned()));
920 let (status, body) = responses
921 .get(n)
922 .or_else(|| responses.last())
923 .unwrap()
924 .clone();
925 Ok::<_, std::convert::Infallible>(
926 hyper::Response::builder()
927 .status(status)
928 .header("content-type", "application/json")
929 .body(Full::new(Bytes::from(body)))
930 .unwrap(),
931 )
932 }
933 },
934 );
935 let _ = hyper::server::conn::http1::Builder::new()
936 .serve_connection(io, svc)
937 .await;
938 });
939 }
940 });
941 addr
942 }
943
944 fn token_body(token: &str, expires_in: Option<u64>) -> String {
945 expires_in.map_or_else(
946 || format!(r#"{{"access_token":"{token}","token_type":"Bearer"}}"#),
947 |e| format!(r#"{{"access_token":"{token}","token_type":"Bearer","expires_in":{e}}}"#),
948 )
949 }
950
951 #[tokio::test]
952 async fn fetches_and_caches_token() {
953 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
954 let hits = Arc::new(AtomicUsize::new(0));
955 let addr = spawn_token_server(
956 vec![(200, token_body("tok-a", Some(3600)))],
957 Arc::clone(&captured),
958 Arc::clone(&hits),
959 )
960 .await;
961
962 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
963 .with_scopes(["read", "write"]);
964 assert_eq!(p.access_token().await.unwrap(), "tok-a");
965 assert_eq!(p.access_token().await.unwrap(), "tok-a");
966 assert_eq!(
967 hits.load(Ordering::SeqCst),
968 1,
969 "second call must be served from cache"
970 );
971
972 let (auth, body) = { captured.lock().unwrap()[0].clone() };
973 assert!(auth.starts_with("Basic "), "default auth style is Basic");
974 let decoded =
975 String::from_utf8(STANDARD.decode(auth.trim_start_matches("Basic ")).unwrap()).unwrap();
976 assert_eq!(decoded, "cid:csec");
977 assert!(body.contains("grant_type=client_credentials"), "{body}");
978 assert!(body.contains("scope=read+write"), "{body}");
979 assert!(
980 !body.contains("client_secret"),
981 "Basic style must not put the secret in the body: {body}"
982 );
983 }
984
985 #[tokio::test]
986 async fn post_auth_style_puts_credentials_in_body() {
987 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
988 let hits = Arc::new(AtomicUsize::new(0));
989 let addr = spawn_token_server(
990 vec![(200, token_body("tok-b", Some(3600)))],
991 Arc::clone(&captured),
992 Arc::clone(&hits),
993 )
994 .await;
995
996 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
997 .with_auth_style(TokenEndpointAuthStyle::Post);
998 assert_eq!(p.access_token().await.unwrap(), "tok-b");
999
1000 let (auth, body) = { captured.lock().unwrap()[0].clone() };
1001 assert!(auth.is_empty(), "no Authorization header in Post style");
1002 assert!(body.contains("client_id=cid"), "{body}");
1003 assert!(body.contains("client_secret=csec"), "{body}");
1004 }
1005
1006 #[tokio::test]
1007 async fn expired_token_is_refreshed() {
1008 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1009 let hits = Arc::new(AtomicUsize::new(0));
1010 let addr = spawn_token_server(
1011 vec![
1012 (200, token_body("tok-1", Some(1))),
1015 (200, token_body("tok-2", Some(3600))),
1016 ],
1017 Arc::clone(&captured),
1018 Arc::clone(&hits),
1019 )
1020 .await;
1021
1022 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1023 assert_eq!(p.access_token().await.unwrap(), "tok-1");
1024 assert_eq!(p.access_token().await.unwrap(), "tok-2");
1025 assert_eq!(hits.load(Ordering::SeqCst), 2);
1026 }
1027
1028 #[tokio::test]
1029 async fn concurrent_refreshes_single_flight() {
1030 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1031 let hits = Arc::new(AtomicUsize::new(0));
1032 let addr = spawn_token_server(
1033 vec![(200, token_body("tok-sf", Some(3600)))],
1034 Arc::clone(&captured),
1035 Arc::clone(&hits),
1036 )
1037 .await;
1038
1039 let p = Arc::new(OAuth2ClientCredentials::new(
1040 format!("http://{addr}/token"),
1041 "cid",
1042 "csec",
1043 ));
1044 let tasks: Vec<_> = (0..8)
1045 .map(|_| {
1046 let p = Arc::clone(&p);
1047 tokio::spawn(async move { p.access_token().await.unwrap() })
1048 })
1049 .collect();
1050 for t in tasks {
1051 assert_eq!(t.await.unwrap(), "tok-sf");
1052 }
1053 assert_eq!(
1054 hits.load(Ordering::SeqCst),
1055 1,
1056 "8 concurrent callers must produce exactly one token request"
1057 );
1058 }
1059
1060 #[tokio::test]
1061 async fn error_response_surfaces_rfc6749_error_without_secret() {
1062 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1063 let hits = Arc::new(AtomicUsize::new(0));
1064 let addr = spawn_token_server(
1065 vec![(
1066 400,
1067 r#"{"error":"invalid_client","error_description":"bad credentials"}"#.to_owned(),
1068 )],
1069 Arc::clone(&captured),
1070 Arc::clone(&hits),
1071 )
1072 .await;
1073
1074 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "super-secret");
1075 let err = p.access_token().await.expect_err("400 must fail");
1076 let msg = err.to_string();
1077 assert!(msg.contains("invalid_client"), "{msg}");
1078 assert!(msg.contains("bad credentials"), "{msg}");
1079 assert!(!msg.contains("super-secret"), "secret leaked: {msg}");
1080 }
1081
1082 #[tokio::test]
1083 async fn non_bearer_token_type_is_rejected() {
1084 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1085 let hits = Arc::new(AtomicUsize::new(0));
1086 let addr = spawn_token_server(
1087 vec![(200, r#"{"access_token":"t","token_type":"MAC"}"#.to_owned())],
1088 Arc::clone(&captured),
1089 Arc::clone(&hits),
1090 )
1091 .await;
1092
1093 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1094 let err = p.access_token().await.expect_err("MAC tokens unsupported");
1095 assert!(err.to_string().contains("token_type"));
1096 }
1097
1098 #[tokio::test]
1101 async fn oidc_discovery_finds_token_endpoint() {
1102 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1103 let hits = Arc::new(AtomicUsize::new(0));
1104 let addr = spawn_token_server(
1105 vec![(
1106 200,
1107 r#"{"issuer":"http://i","token_endpoint":"http://i/oauth/token"}"#.to_owned(),
1108 )],
1109 Arc::clone(&captured),
1110 Arc::clone(&hits),
1111 )
1112 .await;
1113
1114 let url = discover_token_endpoint(&format!("http://{addr}"))
1115 .await
1116 .unwrap();
1117 assert_eq!(url, "http://i/oauth/token");
1118
1119 let url = discover_token_endpoint(&format!("http://{addr}/"))
1121 .await
1122 .unwrap();
1123 assert_eq!(url, "http://i/oauth/token");
1124 }
1125
1126 #[tokio::test]
1127 async fn oidc_discovery_without_token_endpoint_errors() {
1128 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1129 let hits = Arc::new(AtomicUsize::new(0));
1130 let addr = spawn_token_server(
1131 vec![(200, r#"{"issuer":"http://i"}"#.to_owned())],
1132 Arc::clone(&captured),
1133 Arc::clone(&hits),
1134 )
1135 .await;
1136
1137 let err = discover_token_endpoint(&format!("http://{addr}"))
1138 .await
1139 .expect_err("no token_endpoint");
1140 assert!(err.to_string().contains("token_endpoint"));
1141 }
1142
1143 #[tokio::test]
1150 async fn token_response_larger_than_the_mutated_ceiling_is_accepted() {
1151 let filler = "s".repeat(4096);
1156 let body = format!(
1157 r#"{{"access_token":"tok-big","token_type":"Bearer","expires_in":3600,"scope":"{filler}"}}"#
1158 );
1159 assert!(
1160 body.len() > 1088 && body.len() < 64 * 1024,
1161 "body must sit strictly between the mutated and real ceilings to \
1162 discriminate; got {} bytes",
1163 body.len()
1164 );
1165
1166 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1167 let hits = Arc::new(AtomicUsize::new(0));
1168 let addr =
1169 spawn_token_server(vec![(200, body)], Arc::clone(&captured), Arc::clone(&hits)).await;
1170
1171 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1172 assert_eq!(
1173 p.access_token()
1174 .await
1175 .expect("a 4 KiB token response is well within the 64 KiB limit"),
1176 "tok-big"
1177 );
1178 }
1179}