Skip to content

Commit 14be45d

Browse files
committed
fix(gateway): don't advertise OAuth when inbound auth is disabled
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 <maa.ashik00@gmail.com>
1 parent 608a841 commit 14be45d

3 files changed

Lines changed: 86 additions & 10 deletions

File tree

crates/mcpmux-gateway/src/server/handlers.rs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,17 @@ pub struct OAuthServerMetadata {
6969
/// OAuth metadata endpoint (RFC 8414)
7070
pub async fn oauth_metadata(
7171
axum::extract::State(app_state): axum::extract::State<AppState>,
72-
) -> Json<OAuthServerMetadata> {
72+
) -> Result<Json<OAuthServerMetadata>, StatusCode> {
73+
// When inbound auth is disabled, don't advertise an authorization server —
74+
// otherwise MCP clients that probe discovery start an OAuth flow even
75+
// though `/mcp` accepts them without a token. 404 makes them connect
76+
// tokenlessly.
77+
if app_state.gateway_state.read().await.auth_disabled() {
78+
return Err(StatusCode::NOT_FOUND);
79+
}
7380
info!("[Gateway] OAuth metadata request - serving authorization server metadata");
7481
let base = &app_state.base_url;
75-
Json(OAuthServerMetadata {
82+
Ok(Json(OAuthServerMetadata {
7683
issuer: base.to_string(),
7784
authorization_endpoint: format!("{}/oauth/authorize", base),
7885
token_endpoint: format!("{}/oauth/token", base),
@@ -88,7 +95,7 @@ pub async fn oauth_metadata(
8895

8996
// MCP spec 2025-11-25: Advertise CIMD support
9097
client_id_metadata_document_supported: Some(true),
91-
})
98+
}))
9299
}
93100

94101
/// OAuth Protected Resource Metadata (RFC 9728)
@@ -104,14 +111,19 @@ pub struct ProtectedResourceMetadata {
104111
/// This tells MCP clients where to find the authorization server
105112
pub async fn resource_metadata(
106113
axum::extract::State(app_state): axum::extract::State<AppState>,
107-
) -> Json<ProtectedResourceMetadata> {
114+
) -> Result<Json<ProtectedResourceMetadata>, StatusCode> {
115+
// See `oauth_metadata`: stay silent about auth when it's disabled so clients
116+
// don't kick off OAuth against a gateway that accepts them tokenlessly.
117+
if app_state.gateway_state.read().await.auth_disabled() {
118+
return Err(StatusCode::NOT_FOUND);
119+
}
108120
info!("[Gateway] Protected resource metadata request");
109121
let base = &app_state.base_url;
110-
Json(ProtectedResourceMetadata {
122+
Ok(Json(ProtectedResourceMetadata {
111123
resource: format!("{}/mcp", base),
112124
authorization_servers: vec![base.to_string()],
113125
scopes_supported: Some(vec!["mcp".to_string(), "offline_access".to_string()]),
114-
})
126+
}))
115127
}
116128

117129
/// OAuth authorization query params

crates/mcpmux-gateway/src/server/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ mod service_container;
1212
mod startup;
1313
mod state;
1414

15-
use handlers::AppState; // Import AppState
15+
// Exposed for integration tests that mount these routes against a real
16+
// ServiceContainer (e.g. asserting the OAuth-discovery endpoints 404 when
17+
// inbound auth is disabled). AppState is also used throughout this module.
18+
pub use handlers::{oauth_metadata, resource_metadata, AppState};
1619

1720
pub use dependencies::{DependenciesBuilder, GatewayDependencies};
1821
pub use handlers::PendingAuthorization;

tests/rust/tests/streamable_http/auth_disable.rs

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@ use axum::{
1313
http::{Request, StatusCode},
1414
middleware,
1515
response::{IntoResponse, Response},
16-
routing::post,
16+
routing::{get, post},
1717
Router,
1818
};
1919
use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager};
2020
use mcpmux_gateway::{
2121
mcp::mcp_oauth_middleware,
22-
server::{DependenciesBuilder, GatewayDependencies, GatewayState, ServiceContainer},
22+
server::{
23+
oauth_metadata, resource_metadata, AppState, DependenciesBuilder, GatewayDependencies,
24+
GatewayState, ServiceContainer,
25+
},
2326
};
2427
use mcpmux_storage::SqliteSpaceRepository;
2528
use std::sync::Arc;
@@ -44,6 +47,7 @@ async fn echo_client_id(req: Request<Body>) -> Response {
4447

4548
struct Harness {
4649
url: String,
50+
base: String,
4751
ct: CancellationToken,
4852
}
4953

@@ -112,10 +116,30 @@ impl Harness {
112116
gateway_state,
113117
));
114118

115-
let router = Router::new().route("/mcp", post(echo_client_id)).layer(
119+
let mcp_router = Router::new().route("/mcp", post(echo_client_id)).layer(
116120
middleware::from_fn_with_state(services.clone(), mcp_oauth_middleware),
117121
);
118122

123+
// Mount the OAuth-discovery endpoints so we can assert they 404 when
124+
// inbound auth is disabled (don't advertise auth the gateway won't ask
125+
// for).
126+
let app_state = AppState {
127+
gateway_state: services.gateway_state.clone(),
128+
services: services.clone(),
129+
base_url: "http://127.0.0.1:0".to_string(),
130+
};
131+
let discovery_router = Router::new()
132+
.route(
133+
"/.well-known/oauth-protected-resource",
134+
get(resource_metadata),
135+
)
136+
.route(
137+
"/.well-known/oauth-authorization-server",
138+
get(oauth_metadata),
139+
)
140+
.with_state(app_state);
141+
let router = mcp_router.merge(discovery_router);
142+
119143
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
120144
.await
121145
.expect("bind");
@@ -131,6 +155,7 @@ impl Harness {
131155

132156
Self {
133157
url: format!("http://127.0.0.1:{port}/mcp"),
158+
base: format!("http://127.0.0.1:{port}"),
134159
ct,
135160
}
136161
}
@@ -178,3 +203,39 @@ async fn auth_required_gateway_rejects_request_without_token() {
178203
"default gateway must reject a tokenless request"
179204
);
180205
}
206+
207+
#[tokio::test]
208+
async fn authless_gateway_does_not_advertise_oauth_discovery() {
209+
// With inbound auth disabled, the OAuth-discovery endpoints must 404 so MCP
210+
// clients don't start an OAuth flow against a gateway that accepts them
211+
// without a token.
212+
let h = Harness::start(true).await;
213+
let client = reqwest::Client::new();
214+
for path in [
215+
"/.well-known/oauth-protected-resource",
216+
"/.well-known/oauth-authorization-server",
217+
] {
218+
let resp = client
219+
.get(format!("{}{path}", h.base))
220+
.send()
221+
.await
222+
.expect("request");
223+
assert_eq!(
224+
resp.status(),
225+
reqwest::StatusCode::NOT_FOUND,
226+
"{path} must 404 when auth is disabled"
227+
);
228+
}
229+
}
230+
231+
#[tokio::test]
232+
async fn auth_required_gateway_advertises_oauth_discovery() {
233+
// The default (auth required) still serves discovery so real OAuth works.
234+
let h = Harness::start(false).await;
235+
let resp = reqwest::Client::new()
236+
.get(format!("{}/.well-known/oauth-protected-resource", h.base))
237+
.send()
238+
.await
239+
.expect("request");
240+
assert_eq!(resp.status(), reqwest::StatusCode::OK);
241+
}

0 commit comments

Comments
 (0)