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
94const NO_EXPIRY_CACHE_TTL: Duration = Duration::from_secs(60);
98
99pub trait TokenProvider: Send + Sync + 'static {
107 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>>;
114}
115
116pub struct StaticTokenProvider {
121 token: String,
122}
123
124impl StaticTokenProvider {
125 #[must_use]
127 pub fn new(token: impl Into<String>) -> Self {
128 Self {
129 token: token.into(),
130 }
131 }
132}
133
134impl fmt::Debug for StaticTokenProvider {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 f.debug_struct("StaticTokenProvider")
137 .field("token", &"<redacted>")
138 .finish()
139 }
140}
141
142impl TokenProvider for StaticTokenProvider {
143 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
144 Box::pin(async move { Ok(self.token.clone()) })
145 }
146}
147
148pub struct BearerAuthInterceptor {
161 provider: Arc<dyn TokenProvider>,
162}
163
164impl BearerAuthInterceptor {
165 #[must_use]
167 pub fn new(provider: Arc<dyn TokenProvider>) -> Self {
168 Self { provider }
169 }
170}
171
172impl fmt::Debug for BearerAuthInterceptor {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 f.debug_struct("BearerAuthInterceptor").finish()
175 }
176}
177
178impl CallInterceptor for BearerAuthInterceptor {
179 #[allow(clippy::manual_async_fn)]
180 fn before<'a>(
181 &'a self,
182 req: &'a mut ClientRequest,
183 ) -> impl Future<Output = ClientResult<()>> + Send + 'a {
184 async move {
185 let token = self.provider.access_token().await?;
186 req.extra_headers
187 .insert("authorization".to_owned(), format!("Bearer {token}"));
188 Ok(())
189 }
190 }
191
192 #[allow(clippy::manual_async_fn)]
193 fn after<'a>(
194 &'a self,
195 _resp: &'a ClientResponse,
196 ) -> impl Future<Output = ClientResult<()>> + Send + 'a {
197 async move { Ok(()) }
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum TokenEndpointAuthStyle {
206 Basic,
210 Post,
213}
214
215pub struct OAuth2ClientCredentials {
231 token_url: String,
232 client_id: String,
233 client_secret: String,
234 scopes: Vec<String>,
235 audience: Option<String>,
236 extra_params: Vec<(String, String)>,
237 auth_style: TokenEndpointAuthStyle,
238 refresh_leeway: Duration,
239 request_timeout: Duration,
240 client: TokenHttpClient,
241 cache: RwLock<Option<CachedToken>>,
242 refresh_lock: tokio::sync::Mutex<()>,
243}
244
245#[derive(Clone)]
246struct CachedToken {
247 token: String,
248 refresh_after: Instant,
249}
250
251impl fmt::Debug for OAuth2ClientCredentials {
252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253 f.debug_struct("OAuth2ClientCredentials")
254 .field("token_url", &self.token_url)
255 .field("client_id", &self.client_id)
256 .field("client_secret", &"<redacted>")
257 .field("scopes", &self.scopes)
258 .field("audience", &self.audience)
259 .field("auth_style", &self.auth_style)
260 .finish_non_exhaustive()
261 }
262}
263
264impl OAuth2ClientCredentials {
265 #[must_use]
267 pub fn new(
268 token_url: impl Into<String>,
269 client_id: impl Into<String>,
270 client_secret: impl Into<String>,
271 ) -> Self {
272 Self {
273 token_url: token_url.into(),
274 client_id: client_id.into(),
275 client_secret: client_secret.into(),
276 scopes: Vec::new(),
277 audience: None,
278 extra_params: Vec::new(),
279 auth_style: TokenEndpointAuthStyle::Basic,
280 refresh_leeway: DEFAULT_REFRESH_LEEWAY,
281 request_timeout: DEFAULT_TOKEN_REQUEST_TIMEOUT,
282 client: build_token_http_client(),
283 cache: RwLock::new(None),
284 refresh_lock: tokio::sync::Mutex::new(()),
285 }
286 }
287
288 pub fn from_agent_card(
301 card: &a2a_protocol_types::agent_card::AgentCard,
302 scheme_name: &str,
303 client_id: impl Into<String>,
304 client_secret: impl Into<String>,
305 ) -> ClientResult<Self> {
306 use a2a_protocol_types::security::{OAuthFlows, SecurityScheme};
307
308 let scheme = card
309 .security_schemes
310 .as_ref()
311 .and_then(|schemes| schemes.get(scheme_name))
312 .ok_or_else(|| {
313 ClientError::InvalidEndpoint(format!(
314 "agent card has no security scheme named {scheme_name:?}"
315 ))
316 })?;
317 let SecurityScheme::OAuth2(oauth2) = scheme else {
318 return Err(ClientError::InvalidEndpoint(format!(
319 "security scheme {scheme_name:?} is not an OAuth 2.0 scheme"
320 )));
321 };
322 let OAuthFlows::ClientCredentials(flow) = &oauth2.flows else {
323 return Err(ClientError::InvalidEndpoint(format!(
324 "security scheme {scheme_name:?} has no client-credentials flow \
325 (interactive flows need a custom TokenProvider)"
326 )));
327 };
328 Ok(Self::new(flow.token_url.clone(), client_id, client_secret))
329 }
330
331 pub async fn from_oidc_issuer(
340 issuer: &str,
341 client_id: impl Into<String>,
342 client_secret: impl Into<String>,
343 ) -> ClientResult<Self> {
344 let token_url = discover_token_endpoint(issuer).await?;
345 Ok(Self::new(token_url, client_id, client_secret))
346 }
347
348 #[must_use]
350 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
351 where
352 I: IntoIterator<Item = S>,
353 S: Into<String>,
354 {
355 self.scopes = scopes.into_iter().map(Into::into).collect();
356 self
357 }
358
359 #[must_use]
362 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
363 self.audience = Some(audience.into());
364 self
365 }
366
367 #[must_use]
369 pub fn with_extra_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
370 self.extra_params.push((key.into(), value.into()));
371 self
372 }
373
374 #[must_use]
377 pub const fn with_auth_style(mut self, style: TokenEndpointAuthStyle) -> Self {
378 self.auth_style = style;
379 self
380 }
381
382 #[must_use]
384 pub const fn with_refresh_leeway(mut self, leeway: Duration) -> Self {
385 self.refresh_leeway = leeway;
386 self
387 }
388
389 #[must_use]
391 pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
392 self.request_timeout = timeout;
393 self
394 }
395
396 fn cached(&self) -> Option<String> {
398 let guard = self
399 .cache
400 .read()
401 .unwrap_or_else(std::sync::PoisonError::into_inner);
402 guard.as_ref().and_then(|c| {
403 if Instant::now() < c.refresh_after {
404 Some(c.token.clone())
405 } else {
406 None
407 }
408 })
409 }
410
411 async fn refresh(&self) -> ClientResult<String> {
413 check_endpoint_reachable(&self.token_url, "token endpoint")?;
414
415 let req = self.build_token_request()?;
416 let resp = tokio::time::timeout(self.request_timeout, self.client.request(req))
417 .await
418 .map_err(|_| ClientError::Timeout("token endpoint request timed out".into()))?
419 .map_err(|e| ClientError::Transport(format!("token endpoint request failed: {e}")))?;
420
421 let status = resp.status();
422 let body = crate::transport::collect_response_limited(
423 resp,
424 MAX_TOKEN_RESPONSE_SIZE,
425 self.request_timeout,
426 )
427 .await?;
428 if !status.is_success() {
429 return Err(token_error(status, &body));
430 }
431
432 let token_resp: TokenResponse = serde_json::from_slice(&body).map_err(|e| {
433 ClientError::Transport(format!("token endpoint returned invalid JSON: {e}"))
434 })?;
435 if let Some(ref tt) = token_resp.token_type {
436 if !tt.eq_ignore_ascii_case("bearer") {
437 return Err(ClientError::Transport(format!(
438 "token endpoint returned unsupported token_type {tt:?} (expected \"Bearer\")"
439 )));
440 }
441 }
442
443 let ttl = token_resp.expires_in.map_or(NO_EXPIRY_CACHE_TTL, |secs| {
444 Duration::from_secs(secs).saturating_sub(self.refresh_leeway)
445 });
446 *self
447 .cache
448 .write()
449 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(CachedToken {
450 token: token_resp.access_token.clone(),
451 refresh_after: Instant::now() + ttl,
452 });
453
454 Ok(token_resp.access_token)
455 }
456
457 fn build_token_request(&self) -> ClientResult<hyper::Request<Full<Bytes>>> {
459 let mut form: Vec<(String, String)> =
460 vec![("grant_type".to_owned(), "client_credentials".to_owned())];
461 if !self.scopes.is_empty() {
462 form.push(("scope".to_owned(), self.scopes.join(" ")));
463 }
464 if let Some(ref aud) = self.audience {
465 form.push(("audience".to_owned(), aud.clone()));
466 }
467 for (k, v) in &self.extra_params {
468 form.push((k.clone(), v.clone()));
469 }
470 if self.auth_style == TokenEndpointAuthStyle::Post {
471 form.push(("client_id".to_owned(), self.client_id.clone()));
472 form.push(("client_secret".to_owned(), self.client_secret.clone()));
473 }
474
475 let mut builder = hyper::Request::builder()
476 .method(hyper::Method::POST)
477 .uri(&self.token_url)
478 .header("content-type", "application/x-www-form-urlencoded")
479 .header("accept", "application/json");
480 if self.auth_style == TokenEndpointAuthStyle::Basic {
481 let credentials = format!(
483 "{}:{}",
484 form_urlencode(&self.client_id),
485 form_urlencode(&self.client_secret)
486 );
487 builder = builder.header(
488 "authorization",
489 format!("Basic {}", STANDARD.encode(credentials)),
490 );
491 }
492 builder
493 .body(Full::new(Bytes::from(encode_form(&form))))
494 .map_err(|e| ClientError::Transport(format!("token request build failed: {e}")))
495 }
496}
497
498fn token_error(status: hyper::StatusCode, body: &[u8]) -> ClientError {
501 let detail = serde_json::from_slice::<OAuth2ErrorBody>(body).map_or_else(
502 |_| String::from_utf8_lossy(&body[..body.len().min(256)]).into_owned(),
503 |e| match e.error_description {
504 Some(desc) => format!("{}: {desc}", e.error),
505 None => e.error,
506 },
507 );
508 ClientError::Transport(format!("token endpoint returned HTTP {status}: {detail}"))
509}
510
511impl TokenProvider for OAuth2ClientCredentials {
512 fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
513 Box::pin(async move {
514 if let Some(token) = self.cached() {
515 return Ok(token);
516 }
517 let _guard = self.refresh_lock.lock().await;
519 if let Some(token) = self.cached() {
520 return Ok(token); }
522 self.refresh().await
523 })
524 }
525}
526
527#[derive(serde::Deserialize)]
529struct TokenResponse {
530 access_token: String,
531 #[serde(default)]
532 token_type: Option<String>,
533 #[serde(default)]
534 expires_in: Option<u64>,
535}
536
537#[derive(serde::Deserialize)]
539struct OAuth2ErrorBody {
540 error: String,
541 #[serde(default)]
542 error_description: Option<String>,
543}
544
545pub async fn discover_token_endpoint(issuer: &str) -> ClientResult<String> {
555 #[derive(serde::Deserialize)]
556 struct Discovery {
557 token_endpoint: Option<String>,
558 }
559 let url = format!(
560 "{}/.well-known/openid-configuration",
561 issuer.trim_end_matches('/')
562 );
563 check_endpoint_reachable(&url, "OIDC discovery")?;
564
565 let client = build_token_http_client();
566 let req = hyper::Request::builder()
567 .method(hyper::Method::GET)
568 .uri(&url)
569 .header("accept", "application/json")
570 .body(Full::new(Bytes::new()))
571 .map_err(|e| ClientError::Transport(format!("discovery request build failed: {e}")))?;
572
573 let resp = tokio::time::timeout(DEFAULT_TOKEN_REQUEST_TIMEOUT, client.request(req))
574 .await
575 .map_err(|_| ClientError::Timeout("OIDC discovery request timed out".into()))?
576 .map_err(|e| ClientError::Transport(format!("OIDC discovery request failed: {e}")))?;
577
578 let status = resp.status();
579 let body = crate::transport::collect_response_limited(
580 resp,
581 MAX_TOKEN_RESPONSE_SIZE,
582 DEFAULT_TOKEN_REQUEST_TIMEOUT,
583 )
584 .await?;
585 if !status.is_success() {
586 return Err(ClientError::Transport(format!(
587 "OIDC discovery returned HTTP {status}"
588 )));
589 }
590
591 let doc: Discovery = serde_json::from_slice(&body).map_err(|e| {
592 ClientError::Transport(format!("OIDC discovery returned invalid JSON: {e}"))
593 })?;
594 doc.token_endpoint.ok_or_else(|| {
595 ClientError::Transport("OIDC discovery document has no token_endpoint".into())
596 })
597}
598
599fn build_token_http_client() -> TokenHttpClient {
602 #[cfg(not(feature = "tls-rustls"))]
603 {
604 let mut connector = HttpConnector::new();
605 connector.set_connect_timeout(Some(Duration::from_secs(10)));
606 connector.set_nodelay(true);
607 Client::builder(TokioExecutor::new()).build(connector)
608 }
609 #[cfg(feature = "tls-rustls")]
610 {
611 crate::tls::build_https_client_with_connect_timeout(
612 crate::tls::default_tls_config(),
613 Duration::from_secs(10),
614 )
615 }
616}
617
618#[cfg_attr(
620 feature = "tls-rustls",
621 allow(
622 clippy::unnecessary_wraps,
623 unused_variables,
624 clippy::missing_const_for_fn
625 )
626)]
627fn check_endpoint_reachable(url: &str, what: &str) -> ClientResult<()> {
628 #[cfg(not(feature = "tls-rustls"))]
629 {
630 let is_https = url
631 .split_once("://")
632 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
633 if is_https {
634 return Err(ClientError::Transport(format!(
635 "{what} URL {url} is https:// but this build has no TLS; enable the \
636 `tls-rustls` feature (on by default)"
637 )));
638 }
639 }
640 Ok(())
641}
642
643fn form_urlencode(value: &str) -> String {
645 let mut out = String::with_capacity(value.len());
646 for byte in value.bytes() {
647 match byte {
648 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
649 out.push(byte as char);
650 }
651 b' ' => out.push('+'),
652 other => {
653 out.push('%');
654 out.push(
655 char::from_digit(u32::from(other >> 4), 16)
656 .unwrap_or('0')
657 .to_ascii_uppercase(),
658 );
659 out.push(
660 char::from_digit(u32::from(other & 0xf), 16)
661 .unwrap_or('0')
662 .to_ascii_uppercase(),
663 );
664 }
665 }
666 }
667 out
668}
669
670fn encode_form(pairs: &[(String, String)]) -> String {
672 pairs
673 .iter()
674 .map(|(k, v)| format!("{}={}", form_urlencode(k), form_urlencode(v)))
675 .collect::<Vec<_>>()
676 .join("&")
677}
678
679#[cfg(test)]
682mod tests {
683 use super::*;
684 use std::collections::HashMap;
685 use std::sync::atomic::{AtomicUsize, Ordering};
686
687 #[test]
690 fn form_urlencode_passes_unreserved() {
691 assert_eq!(form_urlencode("Abc-123._~"), "Abc-123._~");
692 }
693
694 #[test]
695 fn form_urlencode_escapes_reserved_and_space() {
696 assert_eq!(form_urlencode("a b&c=d%"), "a+b%26c%3Dd%25");
697 assert_eq!(form_urlencode("秘"), "%E7%A7%98");
698 }
699
700 #[test]
701 fn encode_form_joins_pairs() {
702 let pairs = vec![
703 ("grant_type".to_owned(), "client_credentials".to_owned()),
704 ("scope".to_owned(), "a b".to_owned()),
705 ];
706 assert_eq!(
707 encode_form(&pairs),
708 "grant_type=client_credentials&scope=a+b"
709 );
710 }
711
712 #[test]
715 fn debug_redacts_secrets() {
716 let p = StaticTokenProvider::new("super-secret");
717 assert!(!format!("{p:?}").contains("super-secret"));
718
719 let o = OAuth2ClientCredentials::new("http://localhost/token", "id", "very-secret");
720 let dbg = format!("{o:?}");
721 assert!(!dbg.contains("very-secret"), "secret leaked: {dbg}");
722 assert!(dbg.contains("id"), "client_id should be visible");
723 }
724
725 #[tokio::test]
728 async fn static_provider_returns_token() {
729 let p = StaticTokenProvider::new("tok-1");
730 assert_eq!(p.access_token().await.unwrap(), "tok-1");
731 }
732
733 #[tokio::test]
734 async fn bearer_interceptor_injects_header() {
735 let p: Arc<dyn TokenProvider> = Arc::new(StaticTokenProvider::new("tok-xyz"));
736 let interceptor = BearerAuthInterceptor::new(p);
737 let mut req = ClientRequest::new("message/send", serde_json::json!({}));
738 interceptor.before(&mut req).await.unwrap();
739 assert_eq!(
740 req.extra_headers.get("authorization").map(String::as_str),
741 Some("Bearer tok-xyz")
742 );
743 }
744
745 fn card_with_oauth2(
748 flows: a2a_protocol_types::security::OAuthFlows,
749 ) -> a2a_protocol_types::agent_card::AgentCard {
750 use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard};
751 use a2a_protocol_types::security::{OAuth2SecurityScheme, SecurityScheme};
752 let mut schemes = std::collections::HashMap::new();
753 schemes.insert(
754 "oauth".to_owned(),
755 SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
756 flows,
757 oauth2_metadata_url: None,
758 description: None,
759 })),
760 );
761 AgentCard {
762 name: "a".into(),
763 url: None,
764 description: "d".into(),
765 version: "1".into(),
766 supported_interfaces: vec![],
767 default_input_modes: vec![],
768 default_output_modes: vec![],
769 skills: vec![],
770 capabilities: AgentCapabilities::none(),
771 provider: None,
772 icon_url: None,
773 documentation_url: None,
774 security_schemes: Some(schemes),
775 security_requirements: None,
776 signatures: None,
777 }
778 }
779
780 #[test]
781 fn from_agent_card_reads_token_url() {
782 use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
783 let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
784 token_url: "https://auth.example.com/token".into(),
785 refresh_url: None,
786 scopes: HashMap::new(),
787 }));
788 let p = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec").unwrap();
789 assert_eq!(p.token_url, "https://auth.example.com/token");
790 }
791
792 #[test]
793 fn from_agent_card_missing_scheme_errors() {
794 use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
795 let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
796 token_url: "https://auth.example.com/token".into(),
797 refresh_url: None,
798 scopes: HashMap::new(),
799 }));
800 let err = OAuth2ClientCredentials::from_agent_card(&card, "nope", "id", "sec")
801 .expect_err("missing scheme");
802 assert!(err.to_string().contains("no security scheme"));
803 }
804
805 #[test]
806 fn from_agent_card_wrong_flow_errors() {
807 use a2a_protocol_types::security::{ImplicitFlow, OAuthFlows};
808 let card = card_with_oauth2(OAuthFlows::Implicit(ImplicitFlow {
809 authorization_url: "https://auth.example.com/authz".into(),
810 refresh_url: None,
811 scopes: HashMap::new(),
812 }));
813 let err = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec")
814 .expect_err("implicit flow is not client-credentials");
815 assert!(err.to_string().contains("client-credentials"));
816 }
817
818 async fn spawn_token_server(
823 responses: Vec<(u16, String)>,
824 captured: Arc<std::sync::Mutex<Vec<(String, String)>>>,
825 hits: Arc<AtomicUsize>,
826 ) -> std::net::SocketAddr {
827 use http_body_util::BodyExt;
828 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
829 let addr = listener.local_addr().unwrap();
830 tokio::spawn(async move {
831 loop {
832 let Ok((stream, _)) = listener.accept().await else {
833 break;
834 };
835 let responses = responses.clone();
836 let captured = Arc::clone(&captured);
837 let hits = Arc::clone(&hits);
838 tokio::spawn(async move {
839 let io = hyper_util::rt::TokioIo::new(stream);
840 let svc = hyper::service::service_fn(
841 move |req: hyper::Request<hyper::body::Incoming>| {
842 let responses = responses.clone();
843 let captured = Arc::clone(&captured);
844 let hits = Arc::clone(&hits);
845 async move {
846 let n = hits.fetch_add(1, Ordering::SeqCst);
847 let auth = req
848 .headers()
849 .get("authorization")
850 .and_then(|v| v.to_str().ok())
851 .unwrap_or("")
852 .to_owned();
853 let body = req.into_body().collect().await.unwrap().to_bytes();
854 captured
855 .lock()
856 .unwrap()
857 .push((auth, String::from_utf8_lossy(&body).into_owned()));
858 let (status, body) = responses
859 .get(n)
860 .or_else(|| responses.last())
861 .unwrap()
862 .clone();
863 Ok::<_, std::convert::Infallible>(
864 hyper::Response::builder()
865 .status(status)
866 .header("content-type", "application/json")
867 .body(Full::new(Bytes::from(body)))
868 .unwrap(),
869 )
870 }
871 },
872 );
873 let _ = hyper::server::conn::http1::Builder::new()
874 .serve_connection(io, svc)
875 .await;
876 });
877 }
878 });
879 addr
880 }
881
882 fn token_body(token: &str, expires_in: Option<u64>) -> String {
883 expires_in.map_or_else(
884 || format!(r#"{{"access_token":"{token}","token_type":"Bearer"}}"#),
885 |e| format!(r#"{{"access_token":"{token}","token_type":"Bearer","expires_in":{e}}}"#),
886 )
887 }
888
889 #[tokio::test]
890 async fn fetches_and_caches_token() {
891 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
892 let hits = Arc::new(AtomicUsize::new(0));
893 let addr = spawn_token_server(
894 vec![(200, token_body("tok-a", Some(3600)))],
895 Arc::clone(&captured),
896 Arc::clone(&hits),
897 )
898 .await;
899
900 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
901 .with_scopes(["read", "write"]);
902 assert_eq!(p.access_token().await.unwrap(), "tok-a");
903 assert_eq!(p.access_token().await.unwrap(), "tok-a");
904 assert_eq!(
905 hits.load(Ordering::SeqCst),
906 1,
907 "second call must be served from cache"
908 );
909
910 let (auth, body) = { captured.lock().unwrap()[0].clone() };
911 assert!(auth.starts_with("Basic "), "default auth style is Basic");
912 let decoded =
913 String::from_utf8(STANDARD.decode(auth.trim_start_matches("Basic ")).unwrap()).unwrap();
914 assert_eq!(decoded, "cid:csec");
915 assert!(body.contains("grant_type=client_credentials"), "{body}");
916 assert!(body.contains("scope=read+write"), "{body}");
917 assert!(
918 !body.contains("client_secret"),
919 "Basic style must not put the secret in the body: {body}"
920 );
921 }
922
923 #[tokio::test]
924 async fn post_auth_style_puts_credentials_in_body() {
925 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
926 let hits = Arc::new(AtomicUsize::new(0));
927 let addr = spawn_token_server(
928 vec![(200, token_body("tok-b", Some(3600)))],
929 Arc::clone(&captured),
930 Arc::clone(&hits),
931 )
932 .await;
933
934 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
935 .with_auth_style(TokenEndpointAuthStyle::Post);
936 assert_eq!(p.access_token().await.unwrap(), "tok-b");
937
938 let (auth, body) = { captured.lock().unwrap()[0].clone() };
939 assert!(auth.is_empty(), "no Authorization header in Post style");
940 assert!(body.contains("client_id=cid"), "{body}");
941 assert!(body.contains("client_secret=csec"), "{body}");
942 }
943
944 #[tokio::test]
945 async fn expired_token_is_refreshed() {
946 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
947 let hits = Arc::new(AtomicUsize::new(0));
948 let addr = spawn_token_server(
949 vec![
950 (200, token_body("tok-1", Some(1))),
953 (200, token_body("tok-2", Some(3600))),
954 ],
955 Arc::clone(&captured),
956 Arc::clone(&hits),
957 )
958 .await;
959
960 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
961 assert_eq!(p.access_token().await.unwrap(), "tok-1");
962 assert_eq!(p.access_token().await.unwrap(), "tok-2");
963 assert_eq!(hits.load(Ordering::SeqCst), 2);
964 }
965
966 #[tokio::test]
967 async fn concurrent_refreshes_single_flight() {
968 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
969 let hits = Arc::new(AtomicUsize::new(0));
970 let addr = spawn_token_server(
971 vec![(200, token_body("tok-sf", Some(3600)))],
972 Arc::clone(&captured),
973 Arc::clone(&hits),
974 )
975 .await;
976
977 let p = Arc::new(OAuth2ClientCredentials::new(
978 format!("http://{addr}/token"),
979 "cid",
980 "csec",
981 ));
982 let tasks: Vec<_> = (0..8)
983 .map(|_| {
984 let p = Arc::clone(&p);
985 tokio::spawn(async move { p.access_token().await.unwrap() })
986 })
987 .collect();
988 for t in tasks {
989 assert_eq!(t.await.unwrap(), "tok-sf");
990 }
991 assert_eq!(
992 hits.load(Ordering::SeqCst),
993 1,
994 "8 concurrent callers must produce exactly one token request"
995 );
996 }
997
998 #[tokio::test]
999 async fn error_response_surfaces_rfc6749_error_without_secret() {
1000 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1001 let hits = Arc::new(AtomicUsize::new(0));
1002 let addr = spawn_token_server(
1003 vec![(
1004 400,
1005 r#"{"error":"invalid_client","error_description":"bad credentials"}"#.to_owned(),
1006 )],
1007 Arc::clone(&captured),
1008 Arc::clone(&hits),
1009 )
1010 .await;
1011
1012 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "super-secret");
1013 let err = p.access_token().await.expect_err("400 must fail");
1014 let msg = err.to_string();
1015 assert!(msg.contains("invalid_client"), "{msg}");
1016 assert!(msg.contains("bad credentials"), "{msg}");
1017 assert!(!msg.contains("super-secret"), "secret leaked: {msg}");
1018 }
1019
1020 #[tokio::test]
1021 async fn non_bearer_token_type_is_rejected() {
1022 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1023 let hits = Arc::new(AtomicUsize::new(0));
1024 let addr = spawn_token_server(
1025 vec![(200, r#"{"access_token":"t","token_type":"MAC"}"#.to_owned())],
1026 Arc::clone(&captured),
1027 Arc::clone(&hits),
1028 )
1029 .await;
1030
1031 let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
1032 let err = p.access_token().await.expect_err("MAC tokens unsupported");
1033 assert!(err.to_string().contains("token_type"));
1034 }
1035
1036 #[tokio::test]
1039 async fn oidc_discovery_finds_token_endpoint() {
1040 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1041 let hits = Arc::new(AtomicUsize::new(0));
1042 let addr = spawn_token_server(
1043 vec![(
1044 200,
1045 r#"{"issuer":"http://i","token_endpoint":"http://i/oauth/token"}"#.to_owned(),
1046 )],
1047 Arc::clone(&captured),
1048 Arc::clone(&hits),
1049 )
1050 .await;
1051
1052 let url = discover_token_endpoint(&format!("http://{addr}"))
1053 .await
1054 .unwrap();
1055 assert_eq!(url, "http://i/oauth/token");
1056
1057 let url = discover_token_endpoint(&format!("http://{addr}/"))
1059 .await
1060 .unwrap();
1061 assert_eq!(url, "http://i/oauth/token");
1062 }
1063
1064 #[tokio::test]
1065 async fn oidc_discovery_without_token_endpoint_errors() {
1066 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1067 let hits = Arc::new(AtomicUsize::new(0));
1068 let addr = spawn_token_server(
1069 vec![(200, r#"{"issuer":"http://i"}"#.to_owned())],
1070 Arc::clone(&captured),
1071 Arc::clone(&hits),
1072 )
1073 .await;
1074
1075 let err = discover_token_endpoint(&format!("http://{addr}"))
1076 .await
1077 .expect_err("no token_endpoint");
1078 assert!(err.to_string().contains("token_endpoint"));
1079 }
1080}