diff --git a/Cargo.lock b/Cargo.lock index 4d046e1..df9d606 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -246,12 +255,41 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "6.0.0" @@ -382,6 +420,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -772,6 +820,7 @@ dependencies = [ "anyhow", "assert_cmd", "async-trait", + "base64", "bytes", "chrono", "clap", @@ -788,6 +837,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tempfile", "thiserror", "tokio", @@ -1192,6 +1242,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1455,6 +1516,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uncased" version = "0.9.10" diff --git a/Cargo.toml b/Cargo.toml index 57b8d8a..216c529 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ opt-level = 3 [dependencies] anyhow = "1.0.98" async-trait = "0.1.89" +base64 = "0.22" bytes = "1.10.1" chrono = { version = "0.4.42", features = ["clock", "serde"] } clap = { version = "4.5.38", features = ["derive", "string"] } @@ -55,6 +56,7 @@ hyper-rustls = { version = "0.27", default-features = false, features = ["http1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" serde_yaml = "0.9.34" +sha2 = "0.10" thiserror = "2.0.12" tokio = { version = "1.49.0", features = ["full"] } tracing = "0.1.41" @@ -66,4 +68,4 @@ uuid = { version = "1.18.1", features = ["serde", "v4"] } [dev-dependencies] assert_cmd = "2.0" predicates = "3.1" -tempfile = "3.20.0" \ No newline at end of file +tempfile = "3.20.0" diff --git a/docs/features/authentication.md b/docs/features/authentication.md index a737f57..8f3a321 100644 --- a/docs/features/authentication.md +++ b/docs/features/authentication.md @@ -7,10 +7,13 @@ Manage server authentication with token persistence, interactive login, and auto ## Commands ```bash -# Login — reads the bearer token from stdin, then falls back to a prompt +# Login — starts browser OAuth for HTTP servers that advertise it +email auth login + +# Login with an existing bearer token echo "$TOKEN" | email auth login -# Login non-interactively (fails fast instead of prompting if no token is piped) +# Login non-interactively with an existing bearer token echo "$TOKEN" | email auth login --non-interactive # Login with a structured payload @@ -23,13 +26,13 @@ email auth status email auth logout ``` -`auth login` obtains the bearer token from one of three sources, in order: +`auth login` resolves credentials in this order: 1. **Piped stdin** — `echo "$TOKEN" | email auth login`. 2. **`--input-json`** — a JSON object with the schema below. -3. **Interactive prompt** — used only when neither of the above provides a token. +3. **Browser OAuth** — for streamable-HTTP configs, mcp2cli discovers the server's OAuth metadata, dynamically registers a loopback client, starts authorization-code + PKCE, and stores the returned access token. -Pass `--non-interactive` to skip the prompt and fail fast (useful in CI and scripts) when no token is supplied via stdin or `--input-json`. +Pass `--non-interactive` to fail fast when no token is supplied via stdin or `--input-json`. ### `--input-json` schema @@ -53,12 +56,18 @@ Pass `--non-interactive` to skip the prompt and fail fast (useful in CI and scri sequenceDiagram participant User participant CLI as mcp2cli + participant Auth as OAuth Server participant Store as Token Store participant Server as MCP Server User->>CLI: email auth login - CLI->>User: Enter token: - User->>CLI: sk-abc123 + CLI->>Server: Discover protected resource metadata + CLI->>Auth: Register loopback OAuth client + CLI->>User: Open authorization URL + User->>Auth: Approve login in browser + Auth->>CLI: Redirect to loopback callback with code + CLI->>Auth: Exchange code + PKCE verifier + Auth-->>CLI: Bearer access token CLI->>Store: Store token for "email" CLI-->>User: Authenticated ✓ @@ -149,7 +158,8 @@ server: ## Browser-Based OAuth -For servers that support OAuth browser flows: +For streamable-HTTP servers that implement MCP OAuth discovery and dynamic +client registration: ```yaml auth: @@ -157,7 +167,13 @@ auth: # browser_open_command: "open" # macOS ``` -When the server sends an `elicitation/create` with a URL during auth, mcp2cli opens the browser automatically. +`auth login` discovers `/.well-known/oauth-protected-resource`, reads the +authorization server metadata, dynamically registers a loopback redirect URI, +opens the authorization URL, and stores the resulting bearer token. The +authorization request includes the MCP `resource` parameter and uses PKCE S256. + +If you already have a bearer token, pipe it or pass `--input-json`; this bypasses +browser OAuth and stores the token directly. --- diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 83ed535..5433aba 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -324,7 +324,7 @@ See [`docs/features/transports.md`](features/transports.md) and [`docs/features/ ## Known gaps - **Pagination cursors.** Spec-defined `nextCursor` on `*/list` responses is not yet consumed — mcp2cli issues a single `list` request per primitive and treats the first page as the full inventory. Will matter for servers with very large tool/resource catalogs. -- **Authorization (OAuth 2.1) flows.** `auth login` supports bearer-token capture and the stored-token lifecycle; end-to-end OAuth authorization-code with PKCE is partial — see [`docs/features/authentication.md`](features/authentication.md) for the current matrix. +- **Authorization (OAuth 2.1) flows.** `auth login` supports bearer-token capture plus authorization-code + PKCE with dynamic client registration for streamable-HTTP servers. Remaining gaps include client ID metadata documents, pre-registered client config, refresh-token rotation, and runtime step-up authorization — see [`docs/features/authentication.md`](features/authentication.md) for the current matrix. - **Multi-root `notifications/roots/list_changed` debouncing.** Clients may spam the server if root config is hot-reloaded in a tight loop; there is no built-in debounce window. Found a gap not listed here? File an issue — the intent is to track spec coverage accurately. diff --git a/docs/reference/cli-reference.md b/docs/reference/cli-reference.md index b2afcd7..f1fa015 100644 --- a/docs/reference/cli-reference.md +++ b/docs/reference/cli-reference.md @@ -303,15 +303,16 @@ email get mail://thread/123 ## Auth Commands ```bash -email auth login # Prompt for a bearer token (or read it from stdin) +email auth login # Start browser OAuth, or store a supplied bearer token email auth logout # Clear stored credentials email auth status # Show current auth state ``` -`auth login` reads a bearer token interactively, from a piped stdin -(`echo "$TOKEN" | email auth login`), or from +For streamable-HTTP configs, `auth login` starts OAuth authorization-code + PKCE +when no token is supplied. Existing bearer-token workflows still work via piped +stdin (`echo "$TOKEN" | email auth login`) or `--input-json '{"bearer_token": ""}'`. With `--non-interactive` and no -token available it fails fast instead of prompting. +token available it fails fast. --- diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 6974345..e5e939f 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -509,10 +509,13 @@ credentials. email auth login ``` -For **real servers** (non-demo), this prompts for a bearer token via stdin: +For streamable-HTTP servers that advertise MCP OAuth metadata, this starts a +browser authorization-code + PKCE flow and stores the returned bearer token. -```text -enter bearer token for email: +If you already have a bearer token, pipe it instead: + +```bash +echo "$TOKEN" | email auth login ``` The token is persisted in a file-backed token store at diff --git a/src/apps/bridge.rs b/src/apps/bridge.rs index 3f85d4b..e67dfe8 100644 --- a/src/apps/bridge.rs +++ b/src/apps/bridge.rs @@ -26,7 +26,7 @@ //! every MCP call, so timeouts, events, and telemetry behave //! identically to the dynamic path. -use std::{ffi::OsString, io::IsTerminal, path::Path}; +use std::{ffi::OsString, io::IsTerminal, path::Path, time::Duration}; use anyhow::{Result, anyhow}; use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, error::ErrorKind}; @@ -1453,7 +1453,71 @@ async fn auth_login(context: &AppContext) -> Result { return auth_login_demo(context).await; } - let (token, account) = resolve_login_credentials(context)?; + let (token, account, summary, mut lines, details) = if let Some((token, account)) = + resolve_login_credentials(context)? + { + ( + token, + account, + "bearer token stored".to_owned(), + vec![ + "status: authenticated".to_owned(), + format!("server: {}", context.config.server.display_name), + ], + json!({ + "method": "bearer_token", + }), + ) + } else { + if context.config.server.transport != TransportKind::StreamableHttp { + return Err(anyhow!( + "browser OAuth login requires streamable_http transport; pipe a bearer token for this server" + )); + } + let endpoint = context + .config + .server + .endpoint + .clone() + .ok_or_else(|| anyhow!("server.endpoint must be set for browser OAuth login"))?; + context + .services + .event_broker + .emit(RuntimeEvent::AuthPrompt { + app_id: context.config_name.clone(), + message: "open browser to complete OAuth login".to_owned(), + }); + let oauth = crate::auth::oauth::login(crate::auth::oauth::OAuthLoginOptions { + endpoint, + browser_open_command: context.config.auth.browser_open_command.clone(), + timeout: Duration::from_secs( + context + .timeout_override + .unwrap_or(context.config.defaults.timeout_seconds) + .max(1), + ), + client_name: format!("mcp2cli {}", context.config_name), + })?; + let mut lines = vec![ + "status: authenticated".to_owned(), + format!("server: {}", context.config.server.display_name), + format!("issuer: {}", oauth.issuer), + ]; + if let Some(expires_in) = oauth.expires_in { + lines.push(format!("expires_in: {}s", expires_in)); + } + ( + oauth.access_token, + oauth.account, + "OAuth login completed".to_owned(), + lines, + json!({ + "method": "oauth_authorization_code_pkce", + "issuer": oauth.issuer, + "expires_in": oauth.expires_in, + }), + ) + }; let stored = StoredToken { bearer_token: token, @@ -1479,34 +1543,31 @@ async fn auth_login(context: &AppContext) -> Result { .upsert_auth_session(record) .await?; - let mut lines = vec![ - "status: authenticated".to_owned(), - format!("server: {}", context.config.server.display_name), - ]; if let Some(account) = &account { lines.push(format!("account: {}", account)); } Ok(CommandOutput::new( &context.config_name, "auth login", - "bearer token stored".to_owned(), + summary, lines, json!({ "status": "authenticated", "server": context.config.server.display_name, "account": account, + "details": details, }), )) } /// Resolve the bearer token (and optional account) for `auth login`, honoring -/// the CI-mode flags. Resolution order: +/// CI-mode flags. Resolution order: /// /// 1. `--input-json '{"bearer_token": "...", "account": "..."}'` (account optional). /// 2. A bearer token piped on stdin (non-TTY), e.g. `echo "$TOKEN" | … auth login`. /// 3. `--non-interactive` with no token available → fail fast (no prompt). -/// 4. Otherwise prompt interactively on the terminal. -fn resolve_login_credentials(context: &AppContext) -> Result<(String, Option)> { +/// 4. Otherwise return `None` so HTTP configs can start browser OAuth. +fn resolve_login_credentials(context: &AppContext) -> Result)>> { // 1. Explicit JSON payload. if let Some(input) = &context.input_json { let object = input.as_object().ok_or_else(|| { @@ -1530,7 +1591,7 @@ fn resolve_login_credentials(context: &AppContext) -> Result<(String, Option Result<(String, Option\"}}'" )); } - return Ok((token, None)); + return Ok(Some((token, None))); } // 3. Non-interactive with nothing to read — fail rather than block. @@ -1554,21 +1615,9 @@ fn resolve_login_credentials(context: &AppContext) -> Result<(String, Option Result { diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..26ea844 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,3 @@ +//! OAuth helpers for `auth login`. + +pub mod oauth; diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs new file mode 100644 index 0000000..79c52ea --- /dev/null +++ b/src/auth/oauth.rs @@ -0,0 +1,790 @@ +use std::{ + io::{Read, Write}, + net::TcpListener, + process::Command, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, anyhow}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use url::{Url, form_urlencoded}; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct OAuthLoginOptions { + pub endpoint: String, + pub browser_open_command: Option, + pub timeout: Duration, + pub client_name: String, +} + +#[derive(Debug, Clone)] +pub struct OAuthLoginResult { + pub access_token: String, + pub account: Option, + pub issuer: String, + pub expires_in: Option, +} + +#[derive(Debug, Deserialize)] +struct ProtectedResourceMetadata { + #[serde(default)] + resource: Option, + #[serde(default)] + authorization_servers: Vec, + #[serde(default)] + scopes_supported: Vec, +} + +#[derive(Debug, Deserialize)] +struct AuthorizationServerMetadata { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + #[serde(default)] + registration_endpoint: Option, + #[serde(default)] + scopes_supported: Vec, + #[serde(default)] + code_challenge_methods_supported: Vec, +} + +#[derive(Debug, Serialize)] +struct ClientRegistrationRequest { + redirect_uris: Vec, + client_name: String, + grant_types: Vec, + response_types: Vec, + token_endpoint_auth_method: String, +} + +#[derive(Debug, Deserialize)] +struct ClientRegistrationResponse { + client_id: String, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + token_type: String, + #[serde(default)] + expires_in: Option, +} + +struct CallbackData { + code: String, +} + +pub fn login(options: OAuthLoginOptions) -> Result { + let endpoint_url = Url::parse(&options.endpoint) + .with_context(|| format!("invalid MCP endpoint URL: {}", options.endpoint))?; + let agent = ureq::AgentBuilder::new().timeout(options.timeout).build(); + + let protected = discover_protected_resource(&agent, &endpoint_url)?; + let auth_server_url = protected.authorization_servers.first().ok_or_else(|| { + anyhow!("protected resource metadata did not include authorization_servers") + })?; + let metadata = discover_authorization_server(&agent, auth_server_url)?; + + if !metadata + .code_challenge_methods_supported + .iter() + .any(|method| method == "S256") + { + return Err(anyhow!( + "authorization server does not advertise PKCE S256 support" + )); + } + + let registration_endpoint = metadata.registration_endpoint.as_ref().ok_or_else(|| { + anyhow!("authorization server does not advertise a dynamic registration endpoint") + })?; + + let listener = + TcpListener::bind("127.0.0.1:0").context("failed to bind local OAuth callback listener")?; + let redirect_uri = format!( + "http://127.0.0.1:{}/callback", + listener.local_addr()?.port() + ); + + let client = register_client( + &agent, + registration_endpoint, + &redirect_uri, + &options.client_name, + )?; + + let verifier = pkce_verifier(); + let challenge = pkce_challenge(&verifier); + let state = Uuid::new_v4().to_string(); + let resource = protected + .resource + .clone() + .unwrap_or_else(|| options.endpoint.clone()); + let scopes = select_scopes(&protected, &metadata); + let authorization_url = authorization_url( + &metadata.authorization_endpoint, + &client.client_id, + &redirect_uri, + &challenge, + &state, + &resource, + scopes.as_deref(), + )?; + + eprintln!("Open this URL to authorize {}:", options.client_name); + eprintln!("{}", authorization_url); + if let Err(error) = open_browser(&authorization_url, options.browser_open_command.as_deref()) { + eprintln!("Could not open browser automatically: {error}"); + eprintln!("Open the URL above manually, then complete the login in your browser."); + } + + let callback = wait_for_callback(listener, &state, options.timeout)?; + let token = exchange_code( + &agent, + &metadata.token_endpoint, + &client.client_id, + &redirect_uri, + &callback.code, + &verifier, + &resource, + )?; + + if !token.token_type.eq_ignore_ascii_case("bearer") { + return Err(anyhow!( + "authorization server returned unsupported token_type {}", + token.token_type + )); + } + + Ok(OAuthLoginResult { + access_token: token.access_token, + account: Some(format!("oauth:{}", metadata.issuer)), + issuer: metadata.issuer, + expires_in: token.expires_in, + }) +} + +fn discover_protected_resource( + agent: &ureq::Agent, + endpoint_url: &Url, +) -> Result { + if let Some(metadata_url) = metadata_url_from_challenge(agent, endpoint_url.as_str())? { + return get_json(agent, &metadata_url).with_context(|| { + format!("failed to fetch protected resource metadata from {metadata_url}") + }); + } + + let mut last_error = None; + for candidate in protected_resource_metadata_candidates(endpoint_url) { + match get_json(agent, &candidate) { + Ok(metadata) => return Ok(metadata), + Err(error) => last_error = Some(error), + } + } + + Err(anyhow!( + "failed to discover protected resource metadata{}", + last_error + .map(|error| format!(": {error}")) + .unwrap_or_default() + )) +} + +fn metadata_url_from_challenge(agent: &ureq::Agent, endpoint: &str) -> Result> { + match agent.get(endpoint).call() { + Ok(_) => Ok(None), + Err(ureq::Error::Status(401, response)) => Ok(response + .header("www-authenticate") + .and_then(|header| parse_www_authenticate_param(header, "resource_metadata"))), + Err(ureq::Error::Status(_, response)) => Ok(response + .header("www-authenticate") + .and_then(|header| parse_www_authenticate_param(header, "resource_metadata"))), + Err(error) => Err(anyhow!( + "failed to probe MCP endpoint for auth challenge: {error}" + )), + } +} + +fn discover_authorization_server( + agent: &ureq::Agent, + auth_server_url: &str, +) -> Result { + let auth_url = Url::parse(auth_server_url) + .with_context(|| format!("invalid authorization server URL: {auth_server_url}"))?; + let mut last_error = None; + for candidate in authorization_server_metadata_candidates(&auth_url) { + match get_json(agent, &candidate) { + Ok(metadata) => return Ok(metadata), + Err(error) => last_error = Some(error), + } + } + + Err(anyhow!( + "failed to discover authorization server metadata{}", + last_error + .map(|error| format!(": {error}")) + .unwrap_or_default() + )) +} + +fn register_client( + agent: &ureq::Agent, + registration_endpoint: &str, + redirect_uri: &str, + client_name: &str, +) -> Result { + let request = ClientRegistrationRequest { + redirect_uris: vec![redirect_uri.to_owned()], + client_name: client_name.to_owned(), + grant_types: vec!["authorization_code".to_owned()], + response_types: vec!["code".to_owned()], + token_endpoint_auth_method: "none".to_owned(), + }; + agent + .post(registration_endpoint) + .send_json(serde_json::to_value(request)?) + .map_err(ureq_error) + .and_then(|response| { + response + .into_json() + .context("failed to parse dynamic client registration response") + }) +} + +fn authorization_url( + authorization_endpoint: &str, + client_id: &str, + redirect_uri: &str, + code_challenge: &str, + state: &str, + resource: &str, + scope: Option<&str>, +) -> Result { + let mut url = Url::parse(authorization_endpoint) + .with_context(|| format!("invalid authorization endpoint: {authorization_endpoint}"))?; + { + let mut pairs = url.query_pairs_mut(); + pairs.append_pair("response_type", "code"); + pairs.append_pair("client_id", client_id); + pairs.append_pair("redirect_uri", redirect_uri); + pairs.append_pair("code_challenge", code_challenge); + pairs.append_pair("code_challenge_method", "S256"); + pairs.append_pair("state", state); + pairs.append_pair("resource", resource); + if let Some(scope) = scope.filter(|scope| !scope.trim().is_empty()) { + pairs.append_pair("scope", scope); + } + } + Ok(url.to_string()) +} + +fn exchange_code( + agent: &ureq::Agent, + token_endpoint: &str, + client_id: &str, + redirect_uri: &str, + code: &str, + code_verifier: &str, + resource: &str, +) -> Result { + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "authorization_code") + .append_pair("code", code) + .append_pair("redirect_uri", redirect_uri) + .append_pair("client_id", client_id) + .append_pair("code_verifier", code_verifier) + .append_pair("resource", resource) + .finish(); + + agent + .post(token_endpoint) + .set("content-type", "application/x-www-form-urlencoded") + .send_string(&body) + .map_err(ureq_error) + .and_then(|response| { + response + .into_json() + .context("failed to parse token response") + }) +} + +fn wait_for_callback( + listener: TcpListener, + expected_state: &str, + timeout: Duration, +) -> Result { + listener + .set_nonblocking(true) + .context("failed to set OAuth callback listener nonblocking")?; + let deadline = Instant::now() + timeout; + + loop { + match listener.accept() { + Ok((mut stream, _addr)) => { + let request_path = read_request_path(&mut stream)?; + let callback_url = Url::parse(&format!("http://127.0.0.1{request_path}")) + .context("failed to parse OAuth callback request")?; + let params: std::collections::HashMap<_, _> = + callback_url.query_pairs().into_owned().collect(); + + if let Some(error) = params.get("error") { + let description = params + .get("error_description") + .map(String::as_str) + .unwrap_or(""); + write_callback_response(&mut stream, false)?; + return Err(anyhow!( + "authorization server returned error: {error} {description}" + )); + } + + let state = params + .get("state") + .ok_or_else(|| anyhow!("OAuth callback missing state"))?; + if state != expected_state { + write_callback_response(&mut stream, false)?; + return Err(anyhow!("OAuth callback state did not match")); + } + + let code = params + .get("code") + .filter(|code| !code.is_empty()) + .ok_or_else(|| anyhow!("OAuth callback missing code"))? + .to_owned(); + write_callback_response(&mut stream, true)?; + return Ok(CallbackData { code }); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(anyhow!("timed out waiting for OAuth browser callback")); + } + thread::sleep(Duration::from_millis(100)); + } + Err(error) => return Err(anyhow!("failed to accept OAuth callback: {error}")), + } + } +} + +fn read_request_path(stream: &mut std::net::TcpStream) -> Result { + let mut buf = [0_u8; 8192]; + let n = stream + .read(&mut buf) + .context("failed to read OAuth callback request")?; + let request = String::from_utf8_lossy(&buf[..n]); + let first_line = request + .lines() + .next() + .ok_or_else(|| anyhow!("OAuth callback request was empty"))?; + let mut parts = first_line.split_whitespace(); + let method = parts.next().unwrap_or_default(); + let path = parts.next().unwrap_or_default(); + if method != "GET" || !path.starts_with('/') { + return Err(anyhow!( + "unexpected OAuth callback request line: {first_line}" + )); + } + Ok(path.to_owned()) +} + +fn write_callback_response(stream: &mut std::net::TcpStream, ok: bool) -> Result<()> { + let (status, body) = if ok { + ( + "200 OK", + "OAuth login completed. You can close this browser tab and return to the terminal.", + ) + } else { + ( + "400 Bad Request", + "OAuth login failed. Return to the terminal for details.", + ) + }; + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .context("failed to write OAuth callback response") +} + +fn get_json Deserialize<'de>>(agent: &ureq::Agent, url: &str) -> Result { + agent + .get(url) + .call() + .map_err(ureq_error) + .and_then(|response| { + response + .into_json() + .with_context(|| format!("failed to parse JSON from {url}")) + }) +} + +fn ureq_error(error: ureq::Error) -> anyhow::Error { + match error { + ureq::Error::Status(status, response) => { + let message = response + .into_string() + .unwrap_or_else(|_| "".to_owned()); + anyhow!("HTTP {status}: {message}") + } + other => anyhow!("{other}"), + } +} + +fn select_scopes( + protected: &ProtectedResourceMetadata, + metadata: &AuthorizationServerMetadata, +) -> Option { + if !protected.scopes_supported.is_empty() { + return Some(protected.scopes_supported.join(" ")); + } + if !metadata.scopes_supported.is_empty() { + return Some(metadata.scopes_supported.join(" ")); + } + None +} + +fn protected_resource_metadata_candidates(endpoint_url: &Url) -> Vec { + let origin = url_origin(endpoint_url); + let mut candidates = Vec::new(); + let path = endpoint_url + .path() + .trim_start_matches('/') + .trim_end_matches('/'); + if !path.is_empty() { + candidates.push(format!( + "{origin}/.well-known/oauth-protected-resource/{path}" + )); + } + candidates.push(format!("{origin}/.well-known/oauth-protected-resource")); + candidates +} + +fn authorization_server_metadata_candidates(auth_url: &Url) -> Vec { + let origin = url_origin(auth_url); + let path = auth_url + .path() + .trim_start_matches('/') + .trim_end_matches('/'); + let mut candidates = Vec::new(); + if !path.is_empty() { + candidates.push(format!( + "{origin}/.well-known/oauth-authorization-server/{path}" + )); + candidates.push(format!("{origin}/.well-known/openid-configuration/{path}")); + candidates.push(format!( + "{}/.well-known/openid-configuration", + auth_url.as_str().trim_end_matches('/') + )); + } else { + candidates.push(format!("{origin}/.well-known/oauth-authorization-server")); + candidates.push(format!("{origin}/.well-known/openid-configuration")); + } + candidates +} + +fn url_origin(url: &Url) -> String { + let host = url.host_str().unwrap_or_default(); + match url.port() { + Some(port) => format!("{}://{}:{}", url.scheme(), host, port), + None => format!("{}://{}", url.scheme(), host), + } +} + +fn parse_www_authenticate_param(header: &str, key: &str) -> Option { + for part in header.split(',') { + let trimmed = part.trim(); + let (_, value) = trimmed.split_once('=')?; + let name = trimmed.split_once('=')?.0.trim(); + let name = name.strip_prefix("Bearer ").unwrap_or(name).trim(); + if name.eq_ignore_ascii_case(key) { + return Some(value.trim().trim_matches('"').to_owned()); + } + } + None +} + +fn pkce_verifier() -> String { + format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +fn open_browser(url: &str, configured_command: Option<&str>) -> Result<()> { + let mut parts: Vec<&str> = configured_command + .map(str::split_whitespace) + .map(Iterator::collect) + .unwrap_or_default(); + if parts.is_empty() { + parts.push(default_browser_command()?); + } + let command = parts.remove(0); + Command::new(command) + .args(parts) + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .with_context(|| format!("failed to launch browser command {command}"))?; + Ok(()) +} + +fn default_browser_command() -> Result<&'static str> { + #[cfg(target_os = "macos")] + { + Ok("open") + } + #[cfg(target_os = "linux")] + { + Ok("xdg-open") + } + #[cfg(target_os = "windows")] + { + Ok("cmd") + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + Err(anyhow!("no default browser command for this platform")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + #[test] + fn pkce_challenge_matches_rfc7636_vector() { + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + pkce_challenge(verifier), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn protected_resource_candidates_include_logdeck_root_fallback() { + let endpoint = Url::parse("http://localhost:8080/mcp").unwrap(); + assert_eq!( + protected_resource_metadata_candidates(&endpoint), + vec![ + "http://localhost:8080/.well-known/oauth-protected-resource/mcp", + "http://localhost:8080/.well-known/oauth-protected-resource", + ] + ); + } + + #[test] + fn parses_resource_metadata_from_bearer_challenge() { + let header = r#"Bearer resource_metadata="https://logdeck.example/.well-known/oauth-protected-resource", scope="mcp:read""#; + assert_eq!( + parse_www_authenticate_param(header, "resource_metadata").as_deref(), + Some("https://logdeck.example/.well-known/oauth-protected-resource") + ); + } + + #[cfg(unix)] + #[test] + fn login_completes_against_logdeck_shaped_oauth_server() { + use std::os::unix::fs::PermissionsExt; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let (token_body_tx, token_body_rx) = mpsc::channel(); + let server_base = base_url.clone(); + let server = thread::spawn(move || { + for _ in 0..8 { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_test_request(&mut stream); + let first_line = request.lines().next().unwrap_or_default(); + let path = first_line.split_whitespace().nth(1).unwrap_or("/"); + + match path.split('?').next().unwrap_or(path) { + "/mcp" => write_response( + &mut stream, + "401 Unauthorized", + &[( + "www-authenticate", + &format!( + "Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource\", scope=\"mcp:read\"", + server_base + ), + )], + "", + ), + "/.well-known/oauth-protected-resource" => write_response( + &mut stream, + "200 OK", + &[("content-type", "application/json")], + &format!( + r#"{{"resource":"{}/mcp","authorization_servers":["{}"],"scopes_supported":["mcp:read"]}}"#, + server_base, server_base + ), + ), + "/.well-known/oauth-authorization-server" => write_response( + &mut stream, + "200 OK", + &[("content-type", "application/json")], + &format!( + r#"{{"issuer":"{}","authorization_endpoint":"{}/oauth/authorize","token_endpoint":"{}/oauth/token","registration_endpoint":"{}/oauth/register","response_types_supported":["code"],"grant_types_supported":["authorization_code"],"token_endpoint_auth_methods_supported":["none"],"code_challenge_methods_supported":["S256"],"scopes_supported":["mcp:read"]}}"#, + server_base, server_base, server_base, server_base + ), + ), + "/oauth/register" => write_response( + &mut stream, + "201 Created", + &[("content-type", "application/json")], + r#"{"client_id":"test-client","redirect_uris":["http://127.0.0.1/callback"]}"#, + ), + "/oauth/authorize" => { + let url = Url::parse(&format!("{}{}", server_base, path)).unwrap(); + let params: std::collections::HashMap<_, _> = + url.query_pairs().into_owned().collect(); + assert_eq!( + params.get("client_id").map(String::as_str), + Some("test-client") + ); + assert_eq!( + params.get("resource").map(String::as_str), + Some(format!("{}/mcp", server_base).as_str()) + ); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + let redirect_uri = params.get("redirect_uri").unwrap(); + let state = params.get("state").unwrap(); + let location = format!("{redirect_uri}?code=test-code&state={state}"); + write_response(&mut stream, "302 Found", &[("location", &location)], ""); + } + "/oauth/token" => { + token_body_tx + .send(request.split("\r\n\r\n").nth(1).unwrap_or("").to_owned()) + .unwrap(); + write_response( + &mut stream, + "200 OK", + &[("content-type", "application/json")], + r#"{"access_token":"logdeck-token","token_type":"Bearer","expires_in":86400}"#, + ); + break; + } + _ => write_response(&mut stream, "404 Not Found", &[], ""), + } + } + }); + + let dir = tempfile::TempDir::new().unwrap(); + let opened_url_path = dir.path().join("opened-url"); + let browser_script = dir.path().join("open-url.sh"); + std::fs::write( + &browser_script, + format!( + "#!/bin/sh\nprintf '%s' \"$1\" > {}\n", + opened_url_path.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&browser_script, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let login_base = base_url.clone(); + let browser_command = browser_script.to_string_lossy().to_string(); + let login_thread = thread::spawn(move || { + login(OAuthLoginOptions { + endpoint: format!("{login_base}/mcp"), + browser_open_command: Some(browser_command), + timeout: Duration::from_secs(5), + client_name: "mcp2cli test".to_owned(), + }) + }); + + let deadline = Instant::now() + Duration::from_secs(5); + let opened_url = loop { + if let Ok(value) = std::fs::read_to_string(&opened_url_path) { + if !value.is_empty() { + break value; + } + } + assert!( + Instant::now() < deadline, + "browser command did not receive URL" + ); + thread::sleep(Duration::from_millis(25)); + }; + ureq::get(opened_url.trim()).call().unwrap(); + + let result = login_thread.join().unwrap().unwrap(); + assert_eq!(result.access_token, "logdeck-token"); + assert_eq!(result.issuer, base_url); + assert_eq!(result.expires_in, Some(86400)); + assert!(token_body_rx.recv().unwrap().contains("resource=")); + server.join().unwrap(); + } + + #[cfg(unix)] + fn write_response( + stream: &mut std::net::TcpStream, + status: &str, + headers: &[(&str, &str)], + body: &str, + ) { + let mut response = format!( + "HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n", + body.len() + ); + for (name, value) in headers { + response.push_str(name); + response.push_str(": "); + response.push_str(value); + response.push_str("\r\n"); + } + response.push_str("\r\n"); + response.push_str(body); + stream.write_all(response.as_bytes()).unwrap(); + } + + #[cfg(unix)] + fn read_test_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut request = Vec::new(); + let mut buf = [0_u8; 4096]; + loop { + let n = stream.read(&mut buf).unwrap_or(0); + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + if has_complete_http_request(&request) { + break; + } + } + String::from_utf8_lossy(&request).to_string() + } + + #[cfg(unix)] + fn has_complete_http_request(request: &[u8]) -> bool { + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + request.len() >= header_end + 4 + content_length + } +} diff --git a/src/lib.rs b/src/lib.rs index 8341651..132faac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,6 +64,7 @@ pub mod app; pub mod apps; +pub mod auth; pub mod cli; pub mod config; pub mod dispatch;