use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time;
use crate::error::{PepError, Result};
#[derive(Debug, Clone)]
pub struct AuthorizationCode {
pub code: String,
pub state: Option<String>,
}
pub struct CallbackServer {
port: u16,
timeout: Duration,
}
impl CallbackServer {
pub fn new(port: u16) -> Self {
Self {
port,
timeout: Duration::from_secs(120),
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn redirect_uri(&self) -> String {
format!("http://localhost:{}/callback", self.port)
}
pub async fn wait_for_code(&self) -> Result<AuthorizationCode> {
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
.await
.map_err(|e| {
PepError::BadRequest(format!(
"Failed to bind callback server on port {}: {}",
self.port, e
))
})?;
tracing::debug!(
"OAuth callback server listening on 127.0.0.1:{}",
self.port
);
let (mut socket, addr) = tokio::select! {
result = listener.accept() => {
tracing::debug!("Callback connection from {}", result.as_ref().map(|(_, a)| a).map(|a| a.to_string()).unwrap_or_default());
result.map_err(|e| PepError::BadRequest(format!("Failed to accept callback connection: {}", e)))?
}
_ = time::sleep(self.timeout) => {
return Err(PepError::BadRequest(
"OAuth callback timed out — no redirect received within the timeout period".to_string(),
));
}
};
let mut buf = vec![0u8; 4096];
let n = socket
.read(&mut buf)
.await
.map_err(|e| PepError::BadRequest(format!("Failed to read callback request: {}", e)))?;
let request = String::from_utf8_lossy(&buf[..n]).to_string();
tracing::debug!("Callback request from {} ({} bytes)", addr, n);
let (query_params, is_error) = parse_callback_request(&request);
let response_body = if is_error {
build_error_page()
} else {
build_success_page()
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
socket
.write_all(response.as_bytes())
.await
.map_err(|e| PepError::BadRequest(format!("Failed to write callback response: {}", e)))?;
let _ = socket.flush().await;
if is_error {
let error = query_params
.get("error")
.cloned()
.unwrap_or_else(|| "unknown".to_string());
let error_desc = query_params
.get("error_description")
.cloned()
.unwrap_or_default();
return Err(PepError::BadRequest(format!(
"OAuth provider returned error: {} — {}",
error, error_desc
)));
}
let code = query_params
.get("code")
.ok_or_else(|| PepError::BadRequest("No 'code' parameter in OAuth callback".to_string()))?
.clone();
let state = query_params.get("state").cloned();
Ok(AuthorizationCode { code, state })
}
}
fn parse_callback_request(request: &str) -> (std::collections::HashMap<String, String>, bool) {
let mut params = std::collections::HashMap::new();
let request_line = request.lines().next().unwrap_or("");
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
return (params, false);
}
let path = parts[1];
if let Some(query_start) = path.find('?') {
let query = &path[query_start + 1..];
for pair in query.split('&') {
if let Some(eq_pos) = pair.find('=') {
let key = percent_decode(&pair[..eq_pos]);
let value = percent_decode(&pair[eq_pos + 1..]);
params.insert(key, value);
} else {
params.insert(percent_decode(pair), String::new());
}
}
}
let is_error = params.contains_key("error");
(params, is_error)
}
fn percent_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'%' => {
let h1 = chars.next();
let h2 = chars.next();
if let (Some(h1), Some(h2)) = (h1, h2) {
if let Ok(byte) = u8::from_str_radix(&format!("{}{}", h1, h2), 16) {
result.push(byte as char);
} else {
result.push('%');
result.push(h1);
result.push(h2);
}
} else {
result.push('%');
}
}
'+' => result.push(' '),
_ => result.push(c),
}
}
result
}
fn build_success_page() -> String {
r#"<html>
<head><title>Authentication Successful</title></head>
<body style="font-family: sans-serif; text-align: center; margin-top: 50px;">
<h2>✅ Authentication successful</h2>
<p>You can close this browser tab now.</p>
</body>
</html>"#
.to_string()
}
fn build_error_page() -> String {
r#"<html>
<head><title>Authentication Failed</title></head>
<body style="font-family: sans-serif; text-align: center; margin-top: 50px;">
<h2>❌ Authentication failed</h2>
<p>Please try again from the terminal.</p>
</body>
</html>"#
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_callback_request_success() {
let request = "GET /callback?code=abc123&state=xyz789 HTTP/1.1\r\nHost: localhost:8765\r\n\r\n";
let (params, is_error) = parse_callback_request(request);
assert!(!is_error);
assert_eq!(params.get("code").unwrap(), "abc123");
assert_eq!(params.get("state").unwrap(), "xyz789");
}
#[test]
fn test_parse_callback_request_error() {
let request = "GET /callback?error=access_denied&error_description=User+cancelled HTTP/1.1\r\nHost: localhost:8765\r\n\r\n";
let (params, is_error) = parse_callback_request(request);
assert!(is_error);
assert_eq!(params.get("error").unwrap(), "access_denied");
assert_eq!(params.get("error_description").unwrap(), "User cancelled");
}
#[test]
fn test_parse_callback_request_percent_encoded() {
let request = "GET /callback?code=ab%2Fcd&state=x%26y HTTP/1.1\r\n\r\n";
let (params, _) = parse_callback_request(request);
assert_eq!(params.get("code").unwrap(), "ab/cd");
assert_eq!(params.get("state").unwrap(), "x&y");
}
#[test]
fn test_parse_callback_request_no_query() {
let request = "GET /callback HTTP/1.1\r\n\r\n";
let (params, is_error) = parse_callback_request(request);
assert!(params.is_empty());
assert!(!is_error);
}
#[test]
fn test_parse_callback_request_malformed() {
let (params, is_error) = parse_callback_request("garbage");
assert!(params.is_empty());
assert!(!is_error);
}
#[test]
fn test_percent_decode() {
assert_eq!(percent_decode("hello"), "hello");
assert_eq!(percent_decode("hello+world"), "hello world");
assert_eq!(percent_decode("a%2Fb"), "a/b");
assert_eq!(percent_decode("100%25"), "100%");
}
#[test]
fn test_redirect_uri() {
let server = CallbackServer::new(8765);
assert_eq!(server.redirect_uri(), "http://localhost:8765/callback");
}
#[tokio::test]
async fn test_callback_server_receives_code() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let server = CallbackServer::new(port).with_timeout(Duration::from_secs(5));
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
.await
.unwrap();
let request = "GET /callback?code=test_code_123&state=test_state HTTP/1.1\r\nHost: localhost\r\n\r\n";
stream.write_all(request.as_bytes()).await.unwrap();
let _ = stream.flush().await;
let mut buf = vec![0u8; 1024];
let _ = stream.read(&mut buf).await;
});
let result = server.wait_for_code().await.unwrap();
assert_eq!(result.code, "test_code_123");
assert_eq!(result.state.as_deref(), Some("test_state"));
}
#[tokio::test]
async fn test_callback_server_error_param() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let server = CallbackServer::new(port).with_timeout(Duration::from_secs(5));
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
.await
.unwrap();
let request = "GET /callback?error=access_denied HTTP/1.1\r\nHost: localhost\r\n\r\n";
stream.write_all(request.as_bytes()).await.unwrap();
let _ = stream.flush().await;
let mut buf = vec![0u8; 1024];
let _ = stream.read(&mut buf).await;
});
let result = server.wait_for_code().await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("access_denied"));
}
#[tokio::test]
async fn test_callback_server_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let server = CallbackServer::new(port).with_timeout(Duration::from_millis(100));
let result = server.wait_for_code().await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("timed out"));
}
}