aether-auth 0.1.11

OAuth credential storage and authorization flows for the Aether AI agent framework
Documentation
#![cfg(feature = "mcp")]

use aether_auth::{
    FakeOAuthCredentialStore, OAuthCallback, OAuthCredential, OAuthCredentialStorage, OAuthError, OAuthHandler,
    create_auth_manager_from_store, perform_oauth_flow,
};
use futures::future::BoxFuture;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::test]
async fn configured_client_id_ignores_credentials_for_another_client() {
    let store = Arc::new(FakeOAuthCredentialStore::new().with_credential(
        "slack",
        OAuthCredential {
            client_id: "old-client".to_string(),
            access_token: "old-token".to_string(),
            refresh_token: None,
            expires_at: None,
            granted_scopes: Vec::new(),
        },
    ));

    let manager =
        create_auth_manager_from_store("slack", "https://mcp.slack.com/mcp", Some("configured-client"), store.clone())
            .await
            .unwrap();

    assert!(manager.is_none());
    let preserved = store.load_credential("slack").await.unwrap().expect("mismatched credential should be preserved");
    assert_eq!(preserved.client_id, "old-client");
}

struct FakeHandler {
    auth_url: Arc<Mutex<Option<String>>>,
}

impl OAuthHandler for FakeHandler {
    fn redirect_uri(&self) -> &'static str {
        "http://localhost:3118/"
    }

    fn authorize(&self, auth_url: &str) -> BoxFuture<'_, Result<OAuthCallback, OAuthError>> {
        *self.auth_url.lock().unwrap() = Some(auth_url.to_string());
        let state =
            url::Url::parse(auth_url).unwrap().query_pairs().find(|(name, _)| name == "state").unwrap().1.into_owned();
        Box::pin(async move { Ok(OAuthCallback { code: "test-code".to_string(), state }) })
    }
}

struct OAuthServer {
    base_url: String,
    requests: Arc<Mutex<Vec<String>>>,
    task: tokio::task::JoinHandle<()>,
}

impl OAuthServer {
    async fn bind() -> Self {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let origin = format!("http://{}", listener.local_addr().unwrap());
        let base_url = format!("{origin}/mcp");
        let requests = Arc::new(Mutex::new(Vec::new()));
        let captured_requests = Arc::clone(&requests);
        let task = tokio::spawn(async move {
            while let Ok((mut stream, _)) = listener.accept().await {
                let mut buffer = vec![0; 8192];
                let read = stream.read(&mut buffer).await.unwrap();
                let request = String::from_utf8_lossy(&buffer[..read]);
                let request_line = request.lines().next().unwrap().to_string();
                captured_requests.lock().unwrap().push(request_line.clone());
                let path = request_line.split_whitespace().nth(1).unwrap();

                let (status, headers, body) = if path == "/mcp" {
                    (
                        "401 Unauthorized",
                        format!(
                            "WWW-Authenticate: Bearer resource_metadata=\"{origin}/.well-known/oauth-protected-resource/mcp\"\r\n"
                        ),
                        String::new(),
                    )
                } else {
                    let body = if path.contains("oauth-protected-resource") {
                        serde_json::json!({
                            "resource": format!("{origin}/mcp"),
                            "authorization_servers": [&origin]
                        })
                    } else if path == "/token" {
                        serde_json::json!({
                            "access_token": "access-token",
                            "token_type": "Bearer",
                            "expires_in": 3600
                        })
                    } else if path == "/register" {
                        serde_json::json!({
                            "client_id": "registered-client",
                            "redirect_uris": ["http://localhost:3118/"]
                        })
                    } else {
                        serde_json::json!({
                            "issuer": origin,
                            "authorization_endpoint": format!("{origin}/authorize"),
                            "token_endpoint": format!("{origin}/token"),
                            "registration_endpoint": format!("{origin}/register"),
                            "response_types_supported": ["code"],
                            "code_challenge_methods_supported": ["S256"],
                            "scopes_supported": ["openid"]
                        })
                    }
                    .to_string();
                    ("200 OK", String::new(), body)
                };
                let response = format!(
                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{}",
                    body.len(),
                    body
                );
                stream.write_all(response.as_bytes()).await.unwrap();
            }
        });
        Self { base_url, requests, task }
    }
}

impl Drop for OAuthServer {
    fn drop(&mut self) {
        self.task.abort();
    }
}

#[tokio::test]
async fn no_client_id_uses_dynamic_registration() {
    let server = OAuthServer::bind().await;
    let auth_url = Arc::new(Mutex::new(None));
    let handler = FakeHandler { auth_url: Arc::clone(&auth_url) };

    perform_oauth_flow("slack", &server.base_url, &handler, None, None).await.unwrap();

    let auth_url = auth_url.lock().unwrap().clone().unwrap();
    let auth_url = url::Url::parse(&auth_url).unwrap();
    assert!(auth_url.query_pairs().any(|(name, value)| name == "client_id" && value == "registered-client"));
    assert!(server.requests.lock().unwrap().iter().any(|request| request.starts_with("POST /register")));
}

#[tokio::test]
async fn configured_client_id_skips_dynamic_registration() {
    let server = OAuthServer::bind().await;
    let auth_url = Arc::new(Mutex::new(None));
    let handler = FakeHandler { auth_url: Arc::clone(&auth_url) };

    perform_oauth_flow("slack", &server.base_url, &handler, Some("static-client"), None).await.unwrap();

    let auth_url = auth_url.lock().unwrap().clone().unwrap();
    let auth_url = url::Url::parse(&auth_url).unwrap();
    assert!(auth_url.query_pairs().any(|(name, value)| name == "client_id" && value == "static-client"));
    assert!(auth_url.query_pairs().any(|(name, value)| name == "redirect_uri" && value == "http://localhost:3118/"));
    assert!(!server.requests.lock().unwrap().iter().any(|request| request.starts_with("POST /register")));
}