#![cfg(all(unix, feature = "oauth"))]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc;
use std::time::Duration;
fn spawn_token_endpoint() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
for c in l.incoming().take(4).flatten() {
let mut s = c;
drain_request(&mut s);
let body = r#"{"access_token":"at-browser","token_type":"Bearer","expires_in":3600,"refresh_token":"rt-browser"}"#;
let _ = s.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
);
}
});
port
}
fn drain_request(s: &mut TcpStream) {
s.set_read_timeout(Some(Duration::from_secs(5))).ok();
let mut r = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
r.read_line(&mut line).ok();
let mut len = 0usize;
loop {
let mut h = String::new();
if r.read_line(&mut h).unwrap_or(0) == 0 || h.trim().is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':')
&& k.trim().eq_ignore_ascii_case("content-length")
{
len = v.trim().parse().unwrap_or(0);
}
}
let mut b = vec![0u8; len];
r.read_exact(&mut b).ok();
}
fn query_param(url: &str, key: &str) -> Option<String> {
let q = url.split_once('?')?.1;
for kv in q.split('&') {
if let Some((k, v)) = kv.split_once('=')
&& k == key
{
return Some(pct_decode(v));
}
}
None
}
fn pct_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let h = |c: u8| (c as char).to_digit(16);
if let (Some(x), Some(y)) = (h(b[i + 1]), h(b[i + 2])) {
out.push((x * 16 + y) as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[test]
fn browser_authorization_code_pkce_flow() {
let token_port = spawn_token_endpoint();
let params = agentd::auth::oauth2::OAuth2Params {
token_url: format!("http://127.0.0.1:{token_port}/token"),
device_authorization_url: None,
authorization_url: Some("https://as.example/authorize".into()),
client_id: "agentd".into(),
client_secret: None,
scopes: vec!["profile".into()],
audience: None,
};
let (tx, rx) = mpsc::channel::<String>();
let handle = std::thread::spawn(move || {
agentd::auth::browser::browser_login(
¶ms,
|url| tx.send(url.to_string()).unwrap(),
Duration::from_secs(10),
)
});
let url = rx
.recv_timeout(Duration::from_secs(5))
.expect("auth URL printed");
assert!(
url.contains("code_challenge="),
"PKCE challenge present: {url}"
);
assert!(url.contains("code_challenge_method=S256"), "{url}");
let redirect = query_param(&url, "redirect_uri").expect("redirect_uri");
let state = query_param(&url, "state").expect("state");
let port: u16 = redirect
.rsplit(':')
.next()
.and_then(|hp| hp.split('/').next())
.and_then(|p| p.parse().ok())
.expect("callback port");
let mut cb = TcpStream::connect(("127.0.0.1", port)).expect("connect callback");
let req = format!(
"GET /callback?code=test-code&state={state} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"
);
cb.write_all(req.as_bytes()).unwrap();
let mut resp = String::new();
cb.read_to_string(&mut resp).ok();
assert!(
resp.contains("authorized"),
"the callback page is served: {resp}"
);
let cred = handle.join().unwrap().expect("browser_login succeeds");
assert_eq!(cred.access_token, "at-browser");
assert_eq!(cred.refresh_token.as_deref(), Some("rt-browser"));
}