#![cfg(feature = "oauth")]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use agentd::mcp::oauth::{OAuthClient, OAuthConfig};
fn spawn_token_endpoint() -> (String, Arc<Mutex<Vec<String>>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let url = format!("http://127.0.0.1:{port}/token");
let bodies = Arc::new(Mutex::new(Vec::new()));
let bodies_thread = Arc::clone(&bodies);
thread::spawn(move || {
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
continue;
}
let mut content_length = 0usize;
loop {
let mut h = String::new();
if reader.read_line(&mut h).unwrap_or(0) == 0 {
break;
}
let h = h.trim_end();
if h.is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':')
&& k.trim().eq_ignore_ascii_case("content-length")
{
content_length = v.trim().parse().unwrap_or(0);
}
}
let mut buf = vec![0u8; content_length];
reader.read_exact(&mut buf).unwrap();
bodies_thread
.lock()
.unwrap()
.push(String::from_utf8_lossy(&buf).into_owned());
let payload = br#"{"access_token":"tok-1","token_type":"Bearer","expires_in":3600}"#;
let mut stream: TcpStream = stream;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(payload);
let _ = stream.flush();
}
});
(url, bodies)
}
#[test]
fn client_credentials_fetches_then_caches() {
unsafe { std::env::set_var("MCP_OAUTH_TEST_SECRET", "sh!hh/secret") };
let (token_url, bodies) = spawn_token_endpoint();
let client = OAuthClient::new(
OAuthConfig {
token_url,
client_id: "agentd-client".into(),
client_secret: "{{secret:MCP_OAUTH_TEST_SECRET}}".into(),
scope: Some("mcp:read mcp:write".into()),
},
Duration::from_secs(5),
);
assert_eq!(client.bearer().unwrap(), "tok-1");
assert_eq!(client.bearer().unwrap(), "tok-1");
let bodies = bodies.lock().unwrap();
assert_eq!(bodies.len(), 1, "the token is cached — exactly one fetch");
let form = &bodies[0];
assert!(form.contains("grant_type=client_credentials"), "{form}");
assert!(form.contains("client_id=agentd-client"), "{form}");
assert!(form.contains("client_secret=sh%21hh%2Fsecret"), "{form}");
assert!(form.contains("scope=mcp%3Aread%20mcp%3Awrite"), "{form}");
unsafe { std::env::remove_var("MCP_OAUTH_TEST_SECRET") };
}
#[test]
fn oauth_bearer_signer_injects_a_refreshing_authorization_header() {
use agentd::mcp::http::RequestSigner;
unsafe { std::env::set_var("MCP_OAUTH_SIGNER_SECRET", "sec") };
let (token_url, bodies) = spawn_token_endpoint();
let spec = agentd::config::McpOauthSpec {
token_url,
client_id: "agentd".into(),
client_secret: "{{secret:MCP_OAUTH_SIGNER_SECRET}}".into(),
scope: None,
};
let signer = agentd::mcp::oauth::OAuthBearerSigner::new(spec, Duration::from_secs(5));
let headers = signer.sign("POST", "mcp.example", "/mcp", b"{}");
assert_eq!(headers.len(), 1, "exactly the Authorization header");
assert_eq!(headers[0].0, "Authorization");
assert_eq!(headers[0].1, "Bearer tok-1");
let _ = signer.sign("POST", "mcp.example", "/mcp", b"{}");
assert_eq!(bodies.lock().unwrap().len(), 1, "the bearer is cached");
unsafe { std::env::remove_var("MCP_OAUTH_SIGNER_SECRET") };
}