Skip to content
Merged
4 changes: 2 additions & 2 deletions crates/aether-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ keyring = [
"dep:windows-native-keyring-store",
"dep:dbus-secret-service-keyring-store",
]
mcp = ["dep:rmcp", "dep:url"]
mcp = ["dep:rmcp"]

[dependencies]
age = { workspace = true }
Expand All @@ -41,7 +41,7 @@ keyring-core = { workspace = true, optional = true }
rmcp = { workspace = true, optional = true, features = ["auth", "client"] }
oauth2 = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true, optional = true }
url = { workspace = true }

[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { workspace = true, optional = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/aether-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ let loaded = store.load_credential("anthropic").await?;
# }
```

For MCP servers that require OAuth, the `mcp` feature provides `perform_oauth_flow`, which orchestrates the full authorization-code flow (browser launch, callback capture, token exchange, credential storage) and `create_auth_manager_from_store`, which builds an `rmcp::transport::auth::AuthorizationManager` from stored credentials with automatic token refresh.
For MCP servers that require OAuth, the `mcp` feature integrates Aether's browser and callback UI with `rmcp`'s authorization state machine.

## Feature Flags

Expand Down
234 changes: 54 additions & 180 deletions crates/aether-auth/src/browser.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::error::OAuthError;
use crate::handler::{OAuthCallback, OAuthHandler};
use crate::handler::OAuthHandler;
use futures::future::BoxFuture;
use std::process::Command;
use std::time::Duration;
Expand All @@ -24,9 +24,6 @@ impl BrowserOAuthHandler {
}

/// Create a handler bound to a specific port with a custom redirect URI.
///
/// Use this when the OAuth provider has a fixed redirect URI registered
/// (e.g. `http://localhost:1455/auth/callback` for Codex).
pub fn with_redirect_uri(redirect_uri: impl Into<String>, port: u16) -> Result<Self, std::io::Error> {
let std_listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))?;
std_listener.set_nonblocking(true)?;
Expand All @@ -40,24 +37,19 @@ impl OAuthHandler for BrowserOAuthHandler {
&self.redirect_uri
}

fn authorize(&self, auth_url: &str) -> BoxFuture<'_, Result<OAuthCallback, OAuthError>> {
fn authorize(&self, auth_url: &str) -> BoxFuture<'_, Result<String, OAuthError>> {
let auth_url = auth_url.to_string();
Box::pin(async move {
if let Err(e) = open_browser(&auth_url) {
tracing::warn!("Failed to open browser: {e}. Open manually: {auth_url}");
if let Err(error) = open_browser(&auth_url) {
tracing::warn!("Failed to open browser: {error}");
}

accept_oauth_callback(&self.listener).await
})
}
}

/// Accept an OAuth callback on an already-bound listener.
///
/// Loops until it receives a valid OAuth callback, skipping stale connections
/// left over from previous flows (e.g. browser favicon requests, preconnect
/// sockets, or closed connections).
pub async fn accept_oauth_callback(listener: &TcpListener) -> Result<OAuthCallback, OAuthError> {
/// Accept an OAuth callback and return its absolute URL.
pub async fn accept_oauth_callback(listener: &TcpListener) -> Result<String, OAuthError> {
loop {
let (mut socket, _) = listener.accept().await?;
let request_line = {
Expand All @@ -69,31 +61,24 @@ pub async fn accept_oauth_callback(listener: &TcpListener) -> Result<OAuthCallba
line
};

match parse_callback_from_request(&request_line) {
Ok(callback) => {
let _ = socket.write_all(create_success_response().as_bytes()).await;
return Ok(callback);
}
Err(e) => {
if request_line.contains('?') {
return Err(e);
}
match callback_url(&request_line) {
Ok(callback_url) => {
let _ = socket.write_all(success_response().as_bytes()).await;
return Ok(callback_url);
}
Err(error) if request_line.contains('?') => return Err(error),
Err(_) => {}
}
}
}

/// Start a local callback server to capture the OAuth authorization code and state
///
/// Listens on the specified port and waits for the OAuth redirect.
/// Returns the authorization code and state (CSRF token) from the callback URL.
pub async fn wait_for_callback(port: u16) -> Result<OAuthCallback, OAuthError> {
let addr = format!("127.0.0.1:{port}");
let listener = TcpListener::bind(&addr).await?;
/// Start a local callback server and return the OAuth callback URL.
pub async fn wait_for_callback(port: u16) -> Result<String, OAuthError> {
let listener = TcpListener::bind(format!("127.0.0.1:{port}")).await?;
accept_oauth_callback(&listener).await
}

/// Open a URL in the default browser
/// Open a URL in the default browser.
pub fn open_browser(url: &str) -> Result<(), OAuthError> {
#[cfg(target_os = "macos")]
{
Expand All @@ -113,83 +98,24 @@ pub fn open_browser(url: &str) -> Result<(), OAuthError> {
Ok(())
}

/// Parse the authorization code and state from the HTTP request line
fn parse_callback_from_request(request_line: &str) -> Result<OAuthCallback, OAuthError> {
// Request format: GET /oauth2callback?code=XXX&state=YYY HTTP/1.1
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
return Err(OAuthError::InvalidCallback("Invalid HTTP request format".to_string()));
}

let path = parts[1];
let query_start =
path.find('?').ok_or_else(|| OAuthError::InvalidCallback("No query parameters in callback".to_string()))?;

let query = &path[query_start + 1..];

// Check for error in callback
for param in query.split('&') {
if let Some((key, value)) = param.split_once('=')
&& key == "error"
{
let error_desc = query
.split('&')
.find_map(|p| {
p.split_once('=').filter(|(k, _)| *k == "error_description").map(|(_, v)| urlencoding_decode(v))
})
.unwrap_or_else(|| value.to_string());
return Err(OAuthError::InvalidCallback(format!("OAuth error: {error_desc}")));
}
fn callback_url(request_line: &str) -> Result<String, OAuthError> {
let path = request_line
.split_whitespace()
.nth(1)
.ok_or_else(|| OAuthError::InvalidCallback("Invalid HTTP request format".to_string()))?;
let url = url::Url::parse(&format!("http://localhost{path}"))
.map_err(|error| OAuthError::InvalidCallback(format!("Invalid callback URL: {error}")))?;
if url.query().is_none() {
return Err(OAuthError::InvalidCallback("No query parameters in callback".to_string()));
}

// Extract code and state
let mut code = None;
let mut state = None;

for param in query.split('&') {
if let Some((key, value)) = param.split_once('=') {
match key {
"code" => code = Some(urlencoding_decode(value)),
"state" => state = Some(urlencoding_decode(value)),
_ => {}
}
}
}

let code = code.ok_or_else(|| OAuthError::InvalidCallback("No authorization code in callback".into()))?;
let state = state.ok_or_else(|| OAuthError::InvalidCallback("No state parameter in callback".into()))?;

Ok(OAuthCallback { code, state })
}

/// Simple URL decoding (handles %XX escapes)
fn urlencoding_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();

while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex);
}
} else if c == '+' {
result.push(' ');
} else {
result.push(c);
}
if url.query_pairs().any(|(key, _)| key == "error") {
return Err(OAuthError::InvalidCallback("OAuth authorization failed".to_string()));
}

result
Ok(url.to_string())
}

/// Create an HTML success response
fn create_success_response() -> String {
fn success_response() -> String {
let body = include_str!("oauth_success.html");

format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
Expand All @@ -202,97 +128,45 @@ mod tests {
use super::*;

#[test]
fn parse_callback_from_valid_request() {
let request = "GET /oauth2callback?code=4%2F0AYWS-abc123&state=verifier HTTP/1.1\r\n";
let callback = parse_callback_from_request(request).unwrap();
assert_eq!(callback.code, "4/0AYWS-abc123");
assert_eq!(callback.state, "verifier");
}

#[test]
fn parse_callback_handles_plus_encoding() {
let request = "GET /oauth2callback?code=hello+world&state=test+state HTTP/1.1\r\n";
let callback = parse_callback_from_request(request).unwrap();
assert_eq!(callback.code, "hello world");
assert_eq!(callback.state, "test state");
}

#[test]
fn parse_callback_returns_error_for_oauth_error() {
let request = "GET /oauth2callback?error=access_denied&error_description=User+denied HTTP/1.1\r\n";
let result = parse_callback_from_request(request);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("User denied"));
}

#[test]
fn parse_callback_returns_error_for_missing_code() {
let request = "GET /oauth2callback?state=verifier HTTP/1.1\r\n";
let result = parse_callback_from_request(request);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No authorization code"));
}

#[test]
fn parse_callback_returns_error_for_missing_state() {
let request = "GET /oauth2callback?code=abc123 HTTP/1.1\r\n";
let result = parse_callback_from_request(request);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No state parameter"));
}

#[tokio::test]
async fn with_redirect_uri_binds_to_specified_port() {
let handler = BrowserOAuthHandler::with_redirect_uri("http://localhost:9999/callback", 0).unwrap();
assert_eq!(handler.redirect_uri(), "http://localhost:9999/callback");
}

#[test]
fn urlencoding_decode_handles_percent() {
assert_eq!(urlencoding_decode("hello%20world"), "hello world");
assert_eq!(urlencoding_decode("a%2Fb"), "a/b");
fn callback_request_preserves_encoded_parameters() {
let callback = callback_url(
"GET /oauth2callback?code=caf%C3%A9&state=test+state&iss=https%3A%2F%2Fauth.example.com HTTP/1.1\r\n",
)
.unwrap();
let url = url::Url::parse(&callback).unwrap();
let params = url.query_pairs().collect::<std::collections::HashMap<_, _>>();
assert_eq!(params.get("code").map(AsRef::as_ref), Some("café"));
assert_eq!(params.get("state").map(AsRef::as_ref), Some("test state"));
assert_eq!(params.get("iss").map(AsRef::as_ref), Some("https://auth.example.com"));
}

#[test]
fn urlencoding_decode_handles_plus() {
assert_eq!(urlencoding_decode("hello+world"), "hello world");
fn callback_error_is_sanitized() {
let error =
callback_url("GET /oauth2callback?error=access_denied&error_description=attacker+controlled HTTP/1.1\r\n")
.unwrap_err()
.to_string();
assert!(error.contains("OAuth authorization failed"));
assert!(!error.contains("attacker"));
}

#[tokio::test]
async fn accept_oauth_callback_skips_stale_favicon_request() {
use tokio::io::AsyncWriteExt;

async fn callback_listener_skips_stale_requests() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move { accept_oauth_callback(&listener).await });

let mut stale = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
let mut stale = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
stale.write_all(b"GET /favicon.ico HTTP/1.1\r\n").await.unwrap();
let mut callback = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
callback.write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n").await.unwrap();

let mut real = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
real.write_all(b"GET /oauth2callback?code=abc&state=xyz HTTP/1.1\r\n").await.unwrap();

let callback = handle.await.unwrap().unwrap();
assert_eq!(callback.code, "abc");
assert_eq!(callback.state, "xyz");
assert!(handle.await.unwrap().unwrap().contains("code=abc&state=xyz"));
}

#[tokio::test]
async fn accept_oauth_callback_skips_closed_connection() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move { accept_oauth_callback(&listener).await });

{
let _stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
}

let mut real = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
real.write_all(b"GET /oauth2callback?code=def&state=uvw HTTP/1.1\r\n").await.unwrap();

let callback = handle.await.unwrap().unwrap();
assert_eq!(callback.code, "def");
assert_eq!(callback.state, "uvw");
async fn custom_redirect_uri_is_retained() {
let handler = BrowserOAuthHandler::with_redirect_uri("http://localhost:9999/callback", 0).unwrap();
assert_eq!(handler.redirect_uri(), "http://localhost:9999/callback");
}
}
20 changes: 4 additions & 16 deletions crates/aether-auth/src/handler.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
use crate::error::OAuthError;
use futures::future::BoxFuture;

/// OAuth callback data containing both the authorization code and state (CSRF token)
#[derive(Debug, Clone)]
pub struct OAuthCallback {
pub code: String,
pub state: String,
}

/// Trait that consuming applications implement to handle OAuth UI/UX.
///
/// Uses `BoxFuture` instead of `async fn` to support `dyn OAuthHandler`
/// (required for `Arc<dyn OAuthHandler>` in `McpManager`).
/// UI boundary for interactive OAuth authorization.
pub trait OAuthHandler: Send + Sync {
/// The redirect URI the OAuth provider should send the user back to,
/// e.g. `http://127.0.0.1:<port>/oauth2callback`.
/// The redirect URI the OAuth provider should send the user back to.
fn redirect_uri(&self) -> &str;

/// Called when user needs to authorize. App should open browser to `auth_url`
/// and return the authorization code and state (CSRF token) from the callback.
fn authorize(&self, auth_url: &str) -> BoxFuture<'_, Result<OAuthCallback, OAuthError>>;
/// Present `auth_url` and return the absolute callback URL.
fn authorize(&self, auth_url: &str) -> BoxFuture<'_, Result<String, OAuthError>>;
}
6 changes: 4 additions & 2 deletions crates/aether-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub use credential::{OAuthCredential, OAuthCredentialStorage, oauth_http_client}
pub use encrypted_file::EncryptedFileOAuthCredentialStorage;
pub use error::OAuthError;
pub use fake::FakeOAuthCredentialStore;
pub use handler::{OAuthCallback, OAuthHandler};
pub use handler::OAuthHandler;

#[cfg(feature = "keyring")]
pub mod keyring;
Expand All @@ -22,4 +22,6 @@ pub use keyring::OsKeyringStore;
#[cfg(feature = "mcp")]
pub mod mcp;
#[cfg(feature = "mcp")]
pub use mcp::{McpCredentialStore, create_auth_manager_from_store, perform_oauth_flow};
pub use mcp::{
McpCredentialStore, OAuthClientRegistration, OAuthFlowOptions, create_auth_manager_from_store, perform_oauth_flow,
};
Loading