From 6ffa106699e081fe55e13569be5c70327ea614f4 Mon Sep 17 00:00:00 2001 From: Vladimir Kuznichenkov Date: Thu, 9 Jul 2026 16:06:52 +0300 Subject: [PATCH] Add OAuth PKCE login for HTTP MCP servers MCP servers such as LogDeck advertise OAuth metadata and expect clients to obtain their own bearer token instead of pasting one manually. The CLI only had a token-capture path, so a fresh configured HTTP server could not complete the standard authorization-code flow from auth login. Add protected-resource discovery, dynamic client registration, loopback callback handling, PKCE S256, resource-bound token exchange, and storage through the existing token store. Keep the explicit bearer-token paths intact for CI and servers that issue tokens out of band. Update the auth docs and protocol coverage so users can distinguish browser OAuth from direct bearer-token login, and document the remaining refresh/client-metadata gaps separately. --- Cargo.lock | 67 +++ Cargo.toml | 4 +- docs/features/authentication.md | 34 +- docs/protocol-coverage.md | 2 +- docs/reference/cli-reference.md | 9 +- docs/usage-guide.md | 9 +- src/apps/bridge.rs | 103 +++-- src/auth.rs | 3 + src/auth/oauth.rs | 790 ++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 10 files changed, 977 insertions(+), 45 deletions(-) create mode 100644 src/auth.rs create mode 100644 src/auth/oauth.rs 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;