From 14be45dfb79517529d2982cd6492ddb4843f0143 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 25 Jun 2026 11:57:17 +0800 Subject: [PATCH] fix(gateway): don't advertise OAuth when inbound auth is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `gateway.auth_disabled` on, the handshake already returns 200 for a tokenless client (no 401), but the OAuth-discovery endpoints still served metadata — so MCP clients that probe `.well-known/oauth-protected-resource` (per the MCP authorization spec) started an OAuth flow against a gateway that accepts them without a token. Gate the discovery handlers: when auth is disabled, `oauth_metadata` and `resource_metadata` return 404. Combined with the tokenless-200 handshake, this is the spec's "no auth" path — no 401 challenge and no resource metadata, so the client connects without OAuth. Tests: extend the authless integration harness to assert the discovery endpoints 404 when auth is disabled and 200 when it's required (alongside the existing tokenless-handshake 200 / 401 assertions). Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- crates/mcpmux-gateway/src/server/handlers.rs | 24 +++++-- crates/mcpmux-gateway/src/server/mod.rs | 5 +- .../tests/streamable_http/auth_disable.rs | 67 ++++++++++++++++++- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs index 4155b63e..faaff8fc 100644 --- a/crates/mcpmux-gateway/src/server/handlers.rs +++ b/crates/mcpmux-gateway/src/server/handlers.rs @@ -69,10 +69,17 @@ pub struct OAuthServerMetadata { /// OAuth metadata endpoint (RFC 8414) pub async fn oauth_metadata( axum::extract::State(app_state): axum::extract::State, -) -> Json { +) -> Result, StatusCode> { + // When inbound auth is disabled, don't advertise an authorization server — + // otherwise MCP clients that probe discovery start an OAuth flow even + // though `/mcp` accepts them without a token. 404 makes them connect + // tokenlessly. + if app_state.gateway_state.read().await.auth_disabled() { + return Err(StatusCode::NOT_FOUND); + } info!("[Gateway] OAuth metadata request - serving authorization server metadata"); let base = &app_state.base_url; - Json(OAuthServerMetadata { + Ok(Json(OAuthServerMetadata { issuer: base.to_string(), authorization_endpoint: format!("{}/oauth/authorize", base), token_endpoint: format!("{}/oauth/token", base), @@ -88,7 +95,7 @@ pub async fn oauth_metadata( // MCP spec 2025-11-25: Advertise CIMD support client_id_metadata_document_supported: Some(true), - }) + })) } /// OAuth Protected Resource Metadata (RFC 9728) @@ -104,14 +111,19 @@ pub struct ProtectedResourceMetadata { /// This tells MCP clients where to find the authorization server pub async fn resource_metadata( axum::extract::State(app_state): axum::extract::State, -) -> Json { +) -> Result, StatusCode> { + // See `oauth_metadata`: stay silent about auth when it's disabled so clients + // don't kick off OAuth against a gateway that accepts them tokenlessly. + if app_state.gateway_state.read().await.auth_disabled() { + return Err(StatusCode::NOT_FOUND); + } info!("[Gateway] Protected resource metadata request"); let base = &app_state.base_url; - Json(ProtectedResourceMetadata { + Ok(Json(ProtectedResourceMetadata { resource: format!("{}/mcp", base), authorization_servers: vec![base.to_string()], scopes_supported: Some(vec!["mcp".to_string(), "offline_access".to_string()]), - }) + })) } /// OAuth authorization query params diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 4bf01dfc..f537e4dc 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -12,7 +12,10 @@ mod service_container; mod startup; mod state; -use handlers::AppState; // Import AppState +// Exposed for integration tests that mount these routes against a real +// ServiceContainer (e.g. asserting the OAuth-discovery endpoints 404 when +// inbound auth is disabled). AppState is also used throughout this module. +pub use handlers::{oauth_metadata, resource_metadata, AppState}; pub use dependencies::{DependenciesBuilder, GatewayDependencies}; pub use handlers::PendingAuthorization; diff --git a/tests/rust/tests/streamable_http/auth_disable.rs b/tests/rust/tests/streamable_http/auth_disable.rs index 711513e8..b3347c32 100644 --- a/tests/rust/tests/streamable_http/auth_disable.rs +++ b/tests/rust/tests/streamable_http/auth_disable.rs @@ -13,13 +13,16 @@ use axum::{ http::{Request, StatusCode}, middleware, response::{IntoResponse, Response}, - routing::post, + routing::{get, post}, Router, }; use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager}; use mcpmux_gateway::{ mcp::mcp_oauth_middleware, - server::{DependenciesBuilder, GatewayDependencies, GatewayState, ServiceContainer}, + server::{ + oauth_metadata, resource_metadata, AppState, DependenciesBuilder, GatewayDependencies, + GatewayState, ServiceContainer, + }, }; use mcpmux_storage::SqliteSpaceRepository; use std::sync::Arc; @@ -44,6 +47,7 @@ async fn echo_client_id(req: Request) -> Response { struct Harness { url: String, + base: String, ct: CancellationToken, } @@ -112,10 +116,30 @@ impl Harness { gateway_state, )); - let router = Router::new().route("/mcp", post(echo_client_id)).layer( + let mcp_router = Router::new().route("/mcp", post(echo_client_id)).layer( middleware::from_fn_with_state(services.clone(), mcp_oauth_middleware), ); + // Mount the OAuth-discovery endpoints so we can assert they 404 when + // inbound auth is disabled (don't advertise auth the gateway won't ask + // for). + let app_state = AppState { + gateway_state: services.gateway_state.clone(), + services: services.clone(), + base_url: "http://127.0.0.1:0".to_string(), + }; + let discovery_router = Router::new() + .route( + "/.well-known/oauth-protected-resource", + get(resource_metadata), + ) + .route( + "/.well-known/oauth-authorization-server", + get(oauth_metadata), + ) + .with_state(app_state); + let router = mcp_router.merge(discovery_router); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); @@ -131,6 +155,7 @@ impl Harness { Self { url: format!("http://127.0.0.1:{port}/mcp"), + base: format!("http://127.0.0.1:{port}"), ct, } } @@ -178,3 +203,39 @@ async fn auth_required_gateway_rejects_request_without_token() { "default gateway must reject a tokenless request" ); } + +#[tokio::test] +async fn authless_gateway_does_not_advertise_oauth_discovery() { + // With inbound auth disabled, the OAuth-discovery endpoints must 404 so MCP + // clients don't start an OAuth flow against a gateway that accepts them + // without a token. + let h = Harness::start(true).await; + let client = reqwest::Client::new(); + for path in [ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-authorization-server", + ] { + let resp = client + .get(format!("{}{path}", h.base)) + .send() + .await + .expect("request"); + assert_eq!( + resp.status(), + reqwest::StatusCode::NOT_FOUND, + "{path} must 404 when auth is disabled" + ); + } +} + +#[tokio::test] +async fn auth_required_gateway_advertises_oauth_discovery() { + // The default (auth required) still serves discovery so real OAuth works. + let h = Harness::start(false).await; + let resp = reqwest::Client::new() + .get(format!("{}/.well-known/oauth-protected-resource", h.base)) + .send() + .await + .expect("request"); + assert_eq!(resp.status(), reqwest::StatusCode::OK); +}