Skip to main content

aether_auth/
browser.rs

1use crate::error::OAuthError;
2use std::process::Command;
3use std::time::Duration;
4use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
5use tokio::net::TcpListener;
6use tokio::time::timeout;
7
8pub async fn authorize_in_browser(auth_url: &str, port: u16) -> Result<String, OAuthError> {
9    let (listener, _) = bind_loopback(port)?;
10    if let Err(error) = open_browser(auth_url) {
11        tracing::warn!("Failed to open browser: {error}");
12    }
13    accept_oauth_callback(&listener).await
14}
15
16pub fn bind_loopback(port: u16) -> std::io::Result<(TcpListener, u16)> {
17    let listener = std::net::TcpListener::bind(("127.0.0.1", port))?;
18    listener.set_nonblocking(true)?;
19    let port = listener.local_addr()?.port();
20    Ok((TcpListener::from_std(listener)?, port))
21}
22
23/// Accept an OAuth callback and return its absolute URL.
24pub async fn accept_oauth_callback(listener: &TcpListener) -> Result<String, OAuthError> {
25    loop {
26        let (mut socket, _) = listener.accept().await?;
27        let request_line = {
28            let mut reader = BufReader::new(&mut socket);
29            let mut line = String::new();
30            let bytes_read =
31                timeout(Duration::from_secs(2), reader.read_line(&mut line)).await.ok().and_then(Result::ok);
32            let Some(1..) = bytes_read else { continue };
33            line
34        };
35
36        match callback_url(&request_line) {
37            Ok(callback_url) => {
38                let _ = socket.write_all(success_response().as_bytes()).await;
39                return Ok(callback_url);
40            }
41            Err(error) if request_line.contains('?') => return Err(error),
42            Err(_) => {}
43        }
44    }
45}
46
47/// Open a URL in the default browser.
48fn open_browser(url: &str) -> Result<(), OAuthError> {
49    #[cfg(target_os = "macos")]
50    {
51        Command::new("open").arg(url).spawn().map_err(std::io::Error::other)?;
52    }
53
54    #[cfg(target_os = "linux")]
55    {
56        Command::new("xdg-open").arg(url).spawn().map_err(std::io::Error::other)?;
57    }
58
59    #[cfg(target_os = "windows")]
60    {
61        Command::new("cmd").args(["/C", "start", url]).spawn().map_err(std::io::Error::other)?;
62    }
63
64    Ok(())
65}
66
67fn callback_url(request_line: &str) -> Result<String, OAuthError> {
68    let path = request_line
69        .split_whitespace()
70        .nth(1)
71        .ok_or_else(|| OAuthError::InvalidCallback("Invalid HTTP request format".to_string()))?;
72    let url = url::Url::parse(&format!("http://localhost{path}"))
73        .map_err(|error| OAuthError::InvalidCallback(format!("Invalid callback URL: {error}")))?;
74    if url.query().is_none() {
75        return Err(OAuthError::InvalidCallback("No query parameters in callback".to_string()));
76    }
77    if url.query_pairs().any(|(key, _)| key == "error") {
78        return Err(OAuthError::InvalidCallback("OAuth authorization failed".to_string()));
79    }
80    Ok(url.to_string())
81}
82
83fn success_response() -> String {
84    let body = include_str!("oauth_success.html");
85    format!(
86        "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
87        body.len(),
88        body
89    )
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn callback_request_preserves_encoded_parameters() {
98        let callback = callback_url(
99            "GET /oauth2callback?code=caf%C3%A9&state=test+state&iss=https%3A%2F%2Fauth.example.com HTTP/1.1\r\n",
100        )
101        .unwrap();
102        let url = url::Url::parse(&callback).unwrap();
103        let params = url.query_pairs().collect::<std::collections::HashMap<_, _>>();
104        assert_eq!(params.get("code").map(AsRef::as_ref), Some("café"));
105        assert_eq!(params.get("state").map(AsRef::as_ref), Some("test state"));
106        assert_eq!(params.get("iss").map(AsRef::as_ref), Some("https://auth.example.com"));
107    }
108
109    #[test]
110    fn callback_error_is_sanitized() {
111        let error =
112            callback_url("GET /oauth2callback?error=access_denied&error_description=attacker+controlled HTTP/1.1\r\n")
113                .unwrap_err()
114                .to_string();
115        assert!(error.contains("OAuth authorization failed"));
116        assert!(!error.contains("attacker"));
117    }
118
119    #[tokio::test]
120    async fn callback_listener_skips_stale_requests() {
121        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
122        let port = listener.local_addr().unwrap().port();
123        let handle = tokio::spawn(async move { accept_oauth_callback(&listener).await });
124
125        let mut stale = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
126        stale.write_all(b"GET /favicon.ico HTTP/1.1\r\n").await.unwrap();
127        let mut callback = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
128        callback.write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n").await.unwrap();
129
130        assert!(handle.await.unwrap().unwrap().contains("code=abc&state=xyz"));
131    }
132}