use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use http_body_util::Full;
use hyper::body::Bytes;
#[cfg(not(feature = "tls-rustls"))]
use hyper_util::client::legacy::connect::HttpConnector;
#[cfg(not(feature = "tls-rustls"))]
use hyper_util::client::legacy::Client;
#[cfg(not(feature = "tls-rustls"))]
use hyper_util::rt::TokioExecutor;
use crate::error::{ClientError, ClientResult};
use crate::interceptor::{CallInterceptor, ClientRequest, ClientResponse};
#[cfg(not(feature = "tls-rustls"))]
type TokenHttpClient = Client<HttpConnector, Full<Bytes>>;
#[cfg(feature = "tls-rustls")]
type TokenHttpClient = crate::tls::HttpsClient;
const MAX_TOKEN_RESPONSE_SIZE: usize = 64 * 1024;
const DEFAULT_TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_REFRESH_LEEWAY: Duration = Duration::from_secs(30);
fn is_fresh(now: Instant, refresh_after: Instant) -> bool {
now < refresh_after
}
const NO_EXPIRY_CACHE_TTL: Duration = Duration::from_secs(60);
pub trait TokenProvider: Send + Sync + 'static {
fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>>;
}
pub struct StaticTokenProvider {
token: String,
}
impl StaticTokenProvider {
#[must_use]
pub fn new(token: impl Into<String>) -> Self {
Self {
token: token.into(),
}
}
}
impl fmt::Debug for StaticTokenProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StaticTokenProvider")
.field("token", &"<redacted>")
.finish()
}
}
impl TokenProvider for StaticTokenProvider {
fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
Box::pin(async move { Ok(self.token.clone()) })
}
}
pub struct BearerAuthInterceptor {
provider: Arc<dyn TokenProvider>,
}
impl BearerAuthInterceptor {
#[must_use]
pub fn new(provider: Arc<dyn TokenProvider>) -> Self {
Self { provider }
}
}
impl fmt::Debug for BearerAuthInterceptor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BearerAuthInterceptor").finish()
}
}
impl CallInterceptor for BearerAuthInterceptor {
#[allow(clippy::manual_async_fn)]
fn before<'a>(
&'a self,
req: &'a mut ClientRequest,
) -> impl Future<Output = ClientResult<()>> + Send + 'a {
async move {
let token = self.provider.access_token().await?;
req.extra_headers
.insert("authorization".to_owned(), format!("Bearer {token}"));
Ok(())
}
}
#[allow(clippy::manual_async_fn)]
fn after<'a>(
&'a self,
_resp: &'a ClientResponse,
) -> impl Future<Output = ClientResult<()>> + Send + 'a {
async move { Ok(()) }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenEndpointAuthStyle {
Basic,
Post,
}
pub struct OAuth2ClientCredentials {
token_url: String,
client_id: String,
client_secret: String,
scopes: Vec<String>,
audience: Option<String>,
extra_params: Vec<(String, String)>,
auth_style: TokenEndpointAuthStyle,
refresh_leeway: Duration,
request_timeout: Duration,
client: TokenHttpClient,
cache: RwLock<Option<CachedToken>>,
refresh_lock: tokio::sync::Mutex<()>,
}
#[derive(Clone)]
struct CachedToken {
token: String,
refresh_after: Instant,
}
impl fmt::Debug for OAuth2ClientCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuth2ClientCredentials")
.field("token_url", &self.token_url)
.field("client_id", &self.client_id)
.field("client_secret", &"<redacted>")
.field("scopes", &self.scopes)
.field("audience", &self.audience)
.field("auth_style", &self.auth_style)
.finish_non_exhaustive()
}
}
impl OAuth2ClientCredentials {
#[must_use]
pub fn new(
token_url: impl Into<String>,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
Self {
token_url: token_url.into(),
client_id: client_id.into(),
client_secret: client_secret.into(),
scopes: Vec::new(),
audience: None,
extra_params: Vec::new(),
auth_style: TokenEndpointAuthStyle::Basic,
refresh_leeway: DEFAULT_REFRESH_LEEWAY,
request_timeout: DEFAULT_TOKEN_REQUEST_TIMEOUT,
client: build_token_http_client(),
cache: RwLock::new(None),
refresh_lock: tokio::sync::Mutex::new(()),
}
}
pub fn from_agent_card(
card: &a2a_protocol_types::agent_card::AgentCard,
scheme_name: &str,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> ClientResult<Self> {
use a2a_protocol_types::security::{OAuthFlows, SecurityScheme};
let scheme = card
.security_schemes
.as_ref()
.and_then(|schemes| schemes.get(scheme_name))
.ok_or_else(|| {
ClientError::InvalidEndpoint(format!(
"agent card has no security scheme named {scheme_name:?}"
))
})?;
let SecurityScheme::OAuth2(oauth2) = scheme else {
return Err(ClientError::InvalidEndpoint(format!(
"security scheme {scheme_name:?} is not an OAuth 2.0 scheme"
)));
};
let OAuthFlows::ClientCredentials(flow) = &oauth2.flows else {
return Err(ClientError::InvalidEndpoint(format!(
"security scheme {scheme_name:?} has no client-credentials flow \
(interactive flows need a custom TokenProvider)"
)));
};
Ok(Self::new(flow.token_url.clone(), client_id, client_secret))
}
pub async fn from_oidc_issuer(
issuer: &str,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> ClientResult<Self> {
let token_url = discover_token_endpoint(issuer).await?;
Ok(Self::new(token_url, client_id, client_secret))
}
#[must_use]
pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes = scopes.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
self.audience = Some(audience.into());
self
}
#[must_use]
pub fn with_extra_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_params.push((key.into(), value.into()));
self
}
#[must_use]
pub const fn with_auth_style(mut self, style: TokenEndpointAuthStyle) -> Self {
self.auth_style = style;
self
}
#[must_use]
pub const fn with_refresh_leeway(mut self, leeway: Duration) -> Self {
self.refresh_leeway = leeway;
self
}
#[must_use]
pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
fn cached(&self) -> Option<String> {
let guard = self
.cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.as_ref().and_then(|c| {
if is_fresh(Instant::now(), c.refresh_after) {
Some(c.token.clone())
} else {
None
}
})
}
async fn refresh(&self) -> ClientResult<String> {
check_endpoint_reachable(&self.token_url, "token endpoint")?;
let req = self.build_token_request()?;
let resp = tokio::time::timeout(self.request_timeout, self.client.request(req))
.await
.map_err(|_| ClientError::Timeout("token endpoint request timed out".into()))?
.map_err(|e| ClientError::Transport(format!("token endpoint request failed: {e}")))?;
let status = resp.status();
let body = crate::transport::collect_response_limited(
resp,
MAX_TOKEN_RESPONSE_SIZE,
self.request_timeout,
)
.await?;
if !status.is_success() {
return Err(token_error(status, &body));
}
let token_resp: TokenResponse = serde_json::from_slice(&body).map_err(|e| {
ClientError::Transport(format!("token endpoint returned invalid JSON: {e}"))
})?;
if let Some(ref tt) = token_resp.token_type {
if !tt.eq_ignore_ascii_case("bearer") {
return Err(ClientError::Transport(format!(
"token endpoint returned unsupported token_type {tt:?} (expected \"Bearer\")"
)));
}
}
let ttl = token_resp.expires_in.map_or(NO_EXPIRY_CACHE_TTL, |secs| {
Duration::from_secs(secs).saturating_sub(self.refresh_leeway)
});
*self
.cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(CachedToken {
token: token_resp.access_token.clone(),
refresh_after: Instant::now() + ttl,
});
Ok(token_resp.access_token)
}
fn build_token_request(&self) -> ClientResult<hyper::Request<Full<Bytes>>> {
let mut form: Vec<(String, String)> =
vec![("grant_type".to_owned(), "client_credentials".to_owned())];
if !self.scopes.is_empty() {
form.push(("scope".to_owned(), self.scopes.join(" ")));
}
if let Some(ref aud) = self.audience {
form.push(("audience".to_owned(), aud.clone()));
}
for (k, v) in &self.extra_params {
form.push((k.clone(), v.clone()));
}
if self.auth_style == TokenEndpointAuthStyle::Post {
form.push(("client_id".to_owned(), self.client_id.clone()));
form.push(("client_secret".to_owned(), self.client_secret.clone()));
}
let mut builder = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(&self.token_url)
.header("content-type", "application/x-www-form-urlencoded")
.header("accept", "application/json");
if self.auth_style == TokenEndpointAuthStyle::Basic {
let credentials = format!(
"{}:{}",
form_urlencode(&self.client_id),
form_urlencode(&self.client_secret)
);
builder = builder.header(
"authorization",
format!("Basic {}", STANDARD.encode(credentials)),
);
}
builder
.body(Full::new(Bytes::from(encode_form(&form))))
.map_err(|e| ClientError::Transport(format!("token request build failed: {e}")))
}
}
fn token_error(status: hyper::StatusCode, body: &[u8]) -> ClientError {
let detail = serde_json::from_slice::<OAuth2ErrorBody>(body).map_or_else(
|_| String::from_utf8_lossy(&body[..body.len().min(256)]).into_owned(),
|e| match e.error_description {
Some(desc) => format!("{}: {desc}", e.error),
None => e.error,
},
);
ClientError::Transport(format!("token endpoint returned HTTP {status}: {detail}"))
}
impl TokenProvider for OAuth2ClientCredentials {
fn access_token(&self) -> Pin<Box<dyn Future<Output = ClientResult<String>> + Send + '_>> {
Box::pin(async move {
if let Some(token) = self.cached() {
return Ok(token);
}
let _guard = self.refresh_lock.lock().await;
if let Some(token) = self.cached() {
return Ok(token); }
self.refresh().await
})
}
}
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
token_type: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
#[derive(serde::Deserialize)]
struct OAuth2ErrorBody {
error: String,
#[serde(default)]
error_description: Option<String>,
}
pub async fn discover_token_endpoint(issuer: &str) -> ClientResult<String> {
#[derive(serde::Deserialize)]
struct Discovery {
token_endpoint: Option<String>,
}
let url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
check_endpoint_reachable(&url, "OIDC discovery")?;
let client = build_token_http_client();
let req = hyper::Request::builder()
.method(hyper::Method::GET)
.uri(&url)
.header("accept", "application/json")
.body(Full::new(Bytes::new()))
.map_err(|e| ClientError::Transport(format!("discovery request build failed: {e}")))?;
let resp = tokio::time::timeout(DEFAULT_TOKEN_REQUEST_TIMEOUT, client.request(req))
.await
.map_err(|_| ClientError::Timeout("OIDC discovery request timed out".into()))?
.map_err(|e| ClientError::Transport(format!("OIDC discovery request failed: {e}")))?;
let status = resp.status();
let body = crate::transport::collect_response_limited(
resp,
MAX_TOKEN_RESPONSE_SIZE,
DEFAULT_TOKEN_REQUEST_TIMEOUT,
)
.await?;
if !status.is_success() {
return Err(ClientError::Transport(format!(
"OIDC discovery returned HTTP {status}"
)));
}
let doc: Discovery = serde_json::from_slice(&body).map_err(|e| {
ClientError::Transport(format!("OIDC discovery returned invalid JSON: {e}"))
})?;
doc.token_endpoint.ok_or_else(|| {
ClientError::Transport("OIDC discovery document has no token_endpoint".into())
})
}
fn build_token_http_client() -> TokenHttpClient {
#[cfg(not(feature = "tls-rustls"))]
{
let mut connector = HttpConnector::new();
connector.set_connect_timeout(Some(Duration::from_secs(10)));
connector.set_nodelay(true);
Client::builder(TokioExecutor::new()).build(connector)
}
#[cfg(feature = "tls-rustls")]
{
crate::tls::build_https_client_with_connect_timeout(
crate::tls::default_tls_config(),
Duration::from_secs(10),
)
}
}
#[cfg_attr(
feature = "tls-rustls",
allow(
clippy::unnecessary_wraps,
unused_variables,
clippy::missing_const_for_fn
)
)]
fn check_endpoint_reachable(url: &str, what: &str) -> ClientResult<()> {
#[cfg(not(feature = "tls-rustls"))]
{
let is_https = url
.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
if is_https {
return Err(ClientError::Transport(format!(
"{what} URL {url} is https:// but this build has no TLS; enable the \
`tls-rustls` feature (on by default)"
)));
}
}
Ok(())
}
fn form_urlencode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char);
}
b' ' => out.push('+'),
other => {
out.push('%');
out.push(
char::from_digit(u32::from(other >> 4), 16)
.unwrap_or('0')
.to_ascii_uppercase(),
);
out.push(
char::from_digit(u32::from(other & 0xf), 16)
.unwrap_or('0')
.to_ascii_uppercase(),
);
}
}
}
out
}
fn encode_form(pairs: &[(String, String)]) -> String {
pairs
.iter()
.map(|(k, v)| format!("{}={}", form_urlencode(k), form_urlencode(v)))
.collect::<Vec<_>>()
.join("&")
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn form_urlencode_passes_unreserved() {
assert_eq!(form_urlencode("Abc-123._~"), "Abc-123._~");
}
#[test]
fn form_urlencode_escapes_reserved_and_space() {
assert_eq!(form_urlencode("a b&c=d%"), "a+b%26c%3Dd%25");
assert_eq!(form_urlencode("秘"), "%E7%A7%98");
}
#[test]
fn encode_form_joins_pairs() {
let pairs = vec![
("grant_type".to_owned(), "client_credentials".to_owned()),
("scope".to_owned(), "a b".to_owned()),
];
assert_eq!(
encode_form(&pairs),
"grant_type=client_credentials&scope=a+b"
);
}
#[test]
fn debug_redacts_secrets() {
let p = StaticTokenProvider::new("super-secret");
let dbg = format!("{p:?}");
assert!(!dbg.contains("super-secret"), "secret leaked: {dbg}");
assert!(
dbg.contains("StaticTokenProvider"),
"Debug must name the type; empty output would pass the redaction \
check while telling a reader nothing: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"the token field must be present and redacted, not omitted: {dbg}"
);
let o = OAuth2ClientCredentials::new("http://localhost/token", "id", "very-secret");
let dbg = format!("{o:?}");
assert!(!dbg.contains("very-secret"), "secret leaked: {dbg}");
assert!(dbg.contains("id"), "client_id should be visible");
}
#[test]
fn bearer_interceptor_debug_names_the_type() {
let interceptor = BearerAuthInterceptor::new(Arc::new(StaticTokenProvider::new("t")));
let dbg = format!("{interceptor:?}");
assert!(
dbg.contains("BearerAuthInterceptor"),
"Debug must name the type, got: {dbg:?}"
);
}
#[test]
fn is_fresh_is_exclusive_at_the_deadline() {
let t = Instant::now();
assert!(
!is_fresh(t, t),
"at exactly the refresh deadline a token is due for refresh, not fresh"
);
assert!(
is_fresh(t, t + Duration::from_secs(1)),
"before the deadline the token is still usable"
);
assert!(
!is_fresh(t + Duration::from_secs(1), t),
"after the deadline the token is stale"
);
}
#[tokio::test]
async fn static_provider_returns_token() {
let p = StaticTokenProvider::new("tok-1");
assert_eq!(p.access_token().await.unwrap(), "tok-1");
}
#[tokio::test]
async fn bearer_interceptor_injects_header() {
let p: Arc<dyn TokenProvider> = Arc::new(StaticTokenProvider::new("tok-xyz"));
let interceptor = BearerAuthInterceptor::new(p);
let mut req = ClientRequest::new("message/send", serde_json::json!({}));
interceptor.before(&mut req).await.unwrap();
assert_eq!(
req.extra_headers.get("authorization").map(String::as_str),
Some("Bearer tok-xyz")
);
}
fn card_with_oauth2(
flows: a2a_protocol_types::security::OAuthFlows,
) -> a2a_protocol_types::agent_card::AgentCard {
use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard};
use a2a_protocol_types::security::{OAuth2SecurityScheme, SecurityScheme};
let mut schemes = std::collections::HashMap::new();
schemes.insert(
"oauth".to_owned(),
SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
flows,
oauth2_metadata_url: None,
description: None,
})),
);
AgentCard {
name: "a".into(),
url: None,
description: "d".into(),
version: "1".into(),
supported_interfaces: vec![],
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
capabilities: AgentCapabilities::none(),
provider: None,
icon_url: None,
documentation_url: None,
security_schemes: Some(schemes),
security_requirements: None,
signatures: None,
}
}
#[test]
fn from_agent_card_reads_token_url() {
use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
token_url: "https://auth.example.com/token".into(),
refresh_url: None,
scopes: HashMap::new(),
}));
let p = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec").unwrap();
assert_eq!(p.token_url, "https://auth.example.com/token");
}
#[test]
fn from_agent_card_missing_scheme_errors() {
use a2a_protocol_types::security::{ClientCredentialsFlow, OAuthFlows};
let card = card_with_oauth2(OAuthFlows::ClientCredentials(ClientCredentialsFlow {
token_url: "https://auth.example.com/token".into(),
refresh_url: None,
scopes: HashMap::new(),
}));
let err = OAuth2ClientCredentials::from_agent_card(&card, "nope", "id", "sec")
.expect_err("missing scheme");
assert!(err.to_string().contains("no security scheme"));
}
#[test]
fn from_agent_card_wrong_flow_errors() {
use a2a_protocol_types::security::{ImplicitFlow, OAuthFlows};
let card = card_with_oauth2(OAuthFlows::Implicit(ImplicitFlow {
authorization_url: "https://auth.example.com/authz".into(),
refresh_url: None,
scopes: HashMap::new(),
}));
let err = OAuth2ClientCredentials::from_agent_card(&card, "oauth", "id", "sec")
.expect_err("implicit flow is not client-credentials");
assert!(err.to_string().contains("client-credentials"));
}
async fn spawn_token_server(
responses: Vec<(u16, String)>,
captured: Arc<std::sync::Mutex<Vec<(String, String)>>>,
hits: Arc<AtomicUsize>,
) -> std::net::SocketAddr {
use http_body_util::BodyExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let responses = responses.clone();
let captured = Arc::clone(&captured);
let hits = Arc::clone(&hits);
tokio::spawn(async move {
let io = hyper_util::rt::TokioIo::new(stream);
let svc = hyper::service::service_fn(
move |req: hyper::Request<hyper::body::Incoming>| {
let responses = responses.clone();
let captured = Arc::clone(&captured);
let hits = Arc::clone(&hits);
async move {
let n = hits.fetch_add(1, Ordering::SeqCst);
let auth = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
let body = req.into_body().collect().await.unwrap().to_bytes();
captured
.lock()
.unwrap()
.push((auth, String::from_utf8_lossy(&body).into_owned()));
let (status, body) = responses
.get(n)
.or_else(|| responses.last())
.unwrap()
.clone();
Ok::<_, std::convert::Infallible>(
hyper::Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Full::new(Bytes::from(body)))
.unwrap(),
)
}
},
);
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(io, svc)
.await;
});
}
});
addr
}
fn token_body(token: &str, expires_in: Option<u64>) -> String {
expires_in.map_or_else(
|| format!(r#"{{"access_token":"{token}","token_type":"Bearer"}}"#),
|e| format!(r#"{{"access_token":"{token}","token_type":"Bearer","expires_in":{e}}}"#),
)
}
#[tokio::test]
async fn fetches_and_caches_token() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr = spawn_token_server(
vec![(200, token_body("tok-a", Some(3600)))],
Arc::clone(&captured),
Arc::clone(&hits),
)
.await;
let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
.with_scopes(["read", "write"]);
assert_eq!(p.access_token().await.unwrap(), "tok-a");
assert_eq!(p.access_token().await.unwrap(), "tok-a");
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"second call must be served from cache"
);
let (auth, body) = { captured.lock().unwrap()[0].clone() };
assert!(auth.starts_with("Basic "), "default auth style is Basic");
let decoded =
String::from_utf8(STANDARD.decode(auth.trim_start_matches("Basic ")).unwrap()).unwrap();
assert_eq!(decoded, "cid:csec");
assert!(body.contains("grant_type=client_credentials"), "{body}");
assert!(body.contains("scope=read+write"), "{body}");
assert!(
!body.contains("client_secret"),
"Basic style must not put the secret in the body: {body}"
);
}
#[tokio::test]
async fn post_auth_style_puts_credentials_in_body() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr = spawn_token_server(
vec![(200, token_body("tok-b", Some(3600)))],
Arc::clone(&captured),
Arc::clone(&hits),
)
.await;
let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec")
.with_auth_style(TokenEndpointAuthStyle::Post);
assert_eq!(p.access_token().await.unwrap(), "tok-b");
let (auth, body) = { captured.lock().unwrap()[0].clone() };
assert!(auth.is_empty(), "no Authorization header in Post style");
assert!(body.contains("client_id=cid"), "{body}");
assert!(body.contains("client_secret=csec"), "{body}");
}
#[tokio::test]
async fn expired_token_is_refreshed() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr = spawn_token_server(
vec![
(200, token_body("tok-1", Some(1))),
(200, token_body("tok-2", Some(3600))),
],
Arc::clone(&captured),
Arc::clone(&hits),
)
.await;
let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
assert_eq!(p.access_token().await.unwrap(), "tok-1");
assert_eq!(p.access_token().await.unwrap(), "tok-2");
assert_eq!(hits.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn concurrent_refreshes_single_flight() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr = spawn_token_server(
vec![(200, token_body("tok-sf", Some(3600)))],
Arc::clone(&captured),
Arc::clone(&hits),
)
.await;
let p = Arc::new(OAuth2ClientCredentials::new(
format!("http://{addr}/token"),
"cid",
"csec",
));
let tasks: Vec<_> = (0..8)
.map(|_| {
let p = Arc::clone(&p);
tokio::spawn(async move { p.access_token().await.unwrap() })
})
.collect();
for t in tasks {
assert_eq!(t.await.unwrap(), "tok-sf");
}
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"8 concurrent callers must produce exactly one token request"
);
}
#[tokio::test]
async fn error_response_surfaces_rfc6749_error_without_secret() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr = spawn_token_server(
vec![(
400,
r#"{"error":"invalid_client","error_description":"bad credentials"}"#.to_owned(),
)],
Arc::clone(&captured),
Arc::clone(&hits),
)
.await;
let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "super-secret");
let err = p.access_token().await.expect_err("400 must fail");
let msg = err.to_string();
assert!(msg.contains("invalid_client"), "{msg}");
assert!(msg.contains("bad credentials"), "{msg}");
assert!(!msg.contains("super-secret"), "secret leaked: {msg}");
}
#[tokio::test]
async fn non_bearer_token_type_is_rejected() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr = spawn_token_server(
vec![(200, r#"{"access_token":"t","token_type":"MAC"}"#.to_owned())],
Arc::clone(&captured),
Arc::clone(&hits),
)
.await;
let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
let err = p.access_token().await.expect_err("MAC tokens unsupported");
assert!(err.to_string().contains("token_type"));
}
#[tokio::test]
async fn oidc_discovery_finds_token_endpoint() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr = spawn_token_server(
vec![(
200,
r#"{"issuer":"http://i","token_endpoint":"http://i/oauth/token"}"#.to_owned(),
)],
Arc::clone(&captured),
Arc::clone(&hits),
)
.await;
let url = discover_token_endpoint(&format!("http://{addr}"))
.await
.unwrap();
assert_eq!(url, "http://i/oauth/token");
let url = discover_token_endpoint(&format!("http://{addr}/"))
.await
.unwrap();
assert_eq!(url, "http://i/oauth/token");
}
#[tokio::test]
async fn oidc_discovery_without_token_endpoint_errors() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr = spawn_token_server(
vec![(200, r#"{"issuer":"http://i"}"#.to_owned())],
Arc::clone(&captured),
Arc::clone(&hits),
)
.await;
let err = discover_token_endpoint(&format!("http://{addr}"))
.await
.expect_err("no token_endpoint");
assert!(err.to_string().contains("token_endpoint"));
}
#[tokio::test]
async fn token_response_larger_than_the_mutated_ceiling_is_accepted() {
let filler = "s".repeat(4096);
let body = format!(
r#"{{"access_token":"tok-big","token_type":"Bearer","expires_in":3600,"scope":"{filler}"}}"#
);
assert!(
body.len() > 1088 && body.len() < 64 * 1024,
"body must sit strictly between the mutated and real ceilings to \
discriminate; got {} bytes",
body.len()
);
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let hits = Arc::new(AtomicUsize::new(0));
let addr =
spawn_token_server(vec![(200, body)], Arc::clone(&captured), Arc::clone(&hits)).await;
let p = OAuth2ClientCredentials::new(format!("http://{addr}/token"), "cid", "csec");
assert_eq!(
p.access_token()
.await
.expect("a 4 KiB token response is well within the 64 KiB limit"),
"tok-big"
);
}
}