Skip to content

Commit 429e69a

Browse files
committed
feat(gateway): optional system-wide disable of inbound auth
Add a `gateway.auth_disabled` setting (default false — auth required) that, when on, lets inbound MCP clients connect without a Bearer token. This makes the upcoming one-click per-workspace install trivial: a client config needs only the URL + `X-Mcpmux-Workspace` header, no OAuth/access-key dance. The middleware is now lenient rather than all-or-nothing: a valid token is always honored when present, so flipping the setting never breaks an already-configured client. With auth disabled and no valid token, the connection is accepted as an anonymous client on the default Space; routing still prefers the workspace header → binding. The toggle lives in GatewayState (seeded from settings at startup, flipped live by set_gateway_auth_disabled so no restart is needed). Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent b5a9167 commit 429e69a

4 files changed

Lines changed: 183 additions & 53 deletions

File tree

apps/desktop/src-tauri/src/commands/gateway.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,23 @@ pub async fn start_gateway(
920920
let grant_service = server.grant_service();
921921
let session_roots = server.session_roots();
922922

923+
// Seed the system-wide inbound-auth toggle into the running gateway from
924+
// persisted settings (default: auth required). Live changes go through
925+
// `set_gateway_auth_disabled`.
926+
{
927+
let disabled = app_state
928+
.settings_repository
929+
.get(GATEWAY_AUTH_DISABLED_KEY)
930+
.await
931+
.ok()
932+
.flatten()
933+
.map(|v| v == "true")
934+
.unwrap_or(false);
935+
if disabled {
936+
gw_state.write().await.set_auth_disabled(true);
937+
}
938+
}
939+
923940
// Subscribe to OAuth completions BEFORE spawn so we don't miss early
924941
// events emitted during initial auto-connect.
925942
let oauth_completion_rx = pool_service.oauth_manager().subscribe();
@@ -1105,6 +1122,47 @@ pub async fn reset_gateway_port(app_state: State<'_, AppState>) -> Result<(), St
11051122
Ok(())
11061123
}
11071124

1125+
/// App-settings key for the system-wide inbound-auth toggle. Stored as
1126+
/// `"true"`/`"false"`; missing means auth is required (the secure default).
1127+
pub const GATEWAY_AUTH_DISABLED_KEY: &str = "gateway.auth_disabled";
1128+
1129+
/// Whether inbound MCP authentication is disabled — connections are accepted
1130+
/// without an access key (localhost-only convenience). Default **false** (auth
1131+
/// required).
1132+
#[tauri::command]
1133+
pub async fn get_gateway_auth_disabled(app_state: State<'_, AppState>) -> Result<bool, String> {
1134+
let stored = app_state
1135+
.settings_repository
1136+
.get(GATEWAY_AUTH_DISABLED_KEY)
1137+
.await
1138+
.map_err(|e| e.to_string())?;
1139+
Ok(stored.map(|v| v == "true").unwrap_or(false))
1140+
}
1141+
1142+
/// Enable/disable system-wide inbound auth. Persists the setting AND mirrors it
1143+
/// into the running gateway so the change takes effect immediately (no
1144+
/// restart). When the gateway isn't running it's a no-op beyond persistence —
1145+
/// `start_gateway` seeds the value on launch.
1146+
#[tauri::command]
1147+
pub async fn set_gateway_auth_disabled(
1148+
disabled: bool,
1149+
app_state: State<'_, AppState>,
1150+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
1151+
) -> Result<bool, String> {
1152+
app_state
1153+
.settings_repository
1154+
.set(GATEWAY_AUTH_DISABLED_KEY, &disabled.to_string())
1155+
.await
1156+
.map_err(|e| e.to_string())?;
1157+
1158+
let state = gateway_state.read().await;
1159+
if let Some(ref gw) = state.gateway_state {
1160+
gw.write().await.set_auth_disabled(disabled);
1161+
}
1162+
info!("[Gateway] Inbound auth disabled set to {}", disabled);
1163+
Ok(disabled)
1164+
}
1165+
11081166
/// Which port source a startup attempt would use.
11091167
///
11101168
/// Kept as a string-valued enum for clean JSON serialization to the UI.

apps/desktop/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,8 @@ pub fn run() {
945945
commands::get_gateway_port_settings,
946946
commands::set_gateway_port,
947947
commands::reset_gateway_port,
948+
commands::get_gateway_auth_disabled,
949+
commands::set_gateway_auth_disabled,
948950
commands::probe_gateway_start,
949951
commands::take_pending_port_conflict,
950952
commands::start_gateway,

crates/mcpmux-gateway/src/mcp/oauth_middleware.rs

Lines changed: 83 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ use crate::auth::validate_token;
1818
use crate::logging::TraceContext;
1919
use crate::server::ServiceContainer;
2020

21+
/// Synthetic client identity used when system-wide inbound auth is disabled and
22+
/// a connection arrives without a (valid) Bearer token. Routing still prefers
23+
/// the `X-Mcpmux-Workspace` header → binding; this id only feeds the rootless
24+
/// `client_grants` fallback (which finds none) → Space default.
25+
const ANONYMOUS_CLIENT_ID: &str = "mcpmux-anonymous";
26+
2127
/// OAuth middleware for MCP endpoints using rmcp
2228
///
2329
/// Extracts Bearer token → Verifies JWT → Resolves space → Injects OAuthContext
@@ -38,75 +44,99 @@ pub async fn mcp_oauth_middleware(
3844
.map(|ctx| ctx.trace_id.clone())
3945
.unwrap_or_else(|| "??????".to_string());
4046

41-
// Extract Authorization header
47+
// System-wide inbound auth can be disabled (localhost-only convenience):
48+
// when off, a connection is accepted without a Bearer token and routed by
49+
// the workspace header / default space. A valid token is still honored when
50+
// present, so flipping the setting never breaks an already-configured
51+
// client. Default is auth-required.
52+
let require_auth = !services.gateway_state.read().await.auth_disabled();
53+
4254
let auth_header = request
4355
.headers()
4456
.get("authorization")
45-
.and_then(|v| v.to_str().ok());
46-
47-
let Some(auth_value) = auth_header else {
48-
warn!(trace_id = %trace_id, "Missing Authorization header");
49-
return unauthorized_response("Missing Authorization header");
50-
};
51-
52-
// Extract Bearer token
53-
let token = match auth_value.strip_prefix("Bearer ") {
54-
Some(t) => t,
55-
None => {
56-
warn!(trace_id = %trace_id, "Authorization header must use Bearer scheme");
57-
return unauthorized_response("Authorization header must use Bearer scheme");
57+
.and_then(|v| v.to_str().ok())
58+
.map(str::to_owned);
59+
let token = auth_header
60+
.as_deref()
61+
.and_then(|v| v.strip_prefix("Bearer "));
62+
63+
// Verify the Bearer token whenever one is present.
64+
let claims = match token {
65+
Some(token) => {
66+
let jwt_secret = {
67+
let state = services.gateway_state.read().await;
68+
state.get_jwt_secret().map(|s| s.to_vec())
69+
};
70+
match jwt_secret {
71+
Some(secret) => validate_token(token, &secret),
72+
None => {
73+
warn!(trace_id = %trace_id, "JWT secret not configured");
74+
None
75+
}
76+
}
5877
}
78+
None => None,
5979
};
6080

61-
// Verify JWT and extract claims
62-
let jwt_secret = {
63-
let state = services.gateway_state.read().await;
64-
match state.get_jwt_secret() {
65-
Some(secret) => secret.to_vec(),
66-
None => {
67-
warn!(trace_id = %trace_id, "JWT secret not configured");
81+
// Resolve (client_id, space_id) from the token, or — when auth is disabled
82+
// — fall back to an anonymous identity on the default space.
83+
let (client_id, space_id) = if let Some(claims) = claims {
84+
match services
85+
.space_resolver_service
86+
.resolve_space_for_client(&claims.client_id)
87+
.await
88+
{
89+
Ok(id) => (claims.client_id, id),
90+
Err(e) => {
91+
warn!(
92+
trace_id = %trace_id,
93+
client_id = %claims.client_id,
94+
"Failed to resolve space: {}", e
95+
);
6896
return (
6997
StatusCode::INTERNAL_SERVER_ERROR,
70-
"Server not configured for authentication",
98+
format!("Failed to resolve space: {}", e),
7199
)
72100
.into_response();
73101
}
74102
}
75-
};
76-
77-
let claims = match validate_token(token, &jwt_secret) {
78-
Some(claims) => claims,
79-
None => {
80-
warn!(trace_id = %trace_id, "Token verification failed");
81-
return unauthorized_response("Invalid token");
82-
}
83-
};
84-
85-
// Resolve space for this client
86-
let space_id = match services
87-
.space_resolver_service
88-
.resolve_space_for_client(&claims.client_id)
89-
.await
90-
{
91-
Ok(id) => id,
92-
Err(e) => {
93-
warn!(
94-
trace_id = %trace_id,
95-
client_id = %claims.client_id,
96-
"Failed to resolve space: {}", e
97-
);
98-
return (
99-
StatusCode::INTERNAL_SERVER_ERROR,
100-
format!("Failed to resolve space: {}", e),
101-
)
102-
.into_response();
103+
} else if require_auth {
104+
// No valid token and auth is required → 401 with the specific reason.
105+
let msg = match auth_header.as_deref() {
106+
None => "Missing Authorization header",
107+
Some(v) if !v.starts_with("Bearer ") => "Authorization header must use Bearer scheme",
108+
_ => "Invalid token",
109+
};
110+
warn!(trace_id = %trace_id, "{}", msg);
111+
return unauthorized_response(msg);
112+
} else {
113+
// Auth disabled → accept anonymously on the default space. Routing
114+
// still prefers the workspace header (pinned below) → binding.
115+
match services.dependencies.space_repo.get_default().await {
116+
Ok(Some(space)) => (ANONYMOUS_CLIENT_ID.to_string(), space.id),
117+
Ok(None) => {
118+
warn!(trace_id = %trace_id, "Auth disabled but no default space configured");
119+
return (
120+
StatusCode::INTERNAL_SERVER_ERROR,
121+
"No default space configured",
122+
)
123+
.into_response();
124+
}
125+
Err(e) => {
126+
warn!(trace_id = %trace_id, "Failed to resolve default space: {}", e);
127+
return (
128+
StatusCode::INTERNAL_SERVER_ERROR,
129+
format!("Failed to resolve default space: {}", e),
130+
)
131+
.into_response();
132+
}
103133
}
104134
};
105135

106136
// Inject OAuth context via custom headers (rmcp will preserve these)
107137
request.headers_mut().insert(
108138
"x-mcpmux-client-id",
109-
claims.client_id.parse().expect("valid header value"),
139+
client_id.parse().expect("valid header value"),
110140
);
111141
request.headers_mut().insert(
112142
"x-mcpmux-space-id",
@@ -152,7 +182,7 @@ pub async fn mcp_oauth_middleware(
152182
// Log single consolidated entry line
153183
info!(
154184
trace_id = %trace_id,
155-
client = %&claims.client_id[..claims.client_id.len().min(12)],
185+
client = %&client_id[..client_id.len().min(12)],
156186
space = %&space_id.to_string()[..8],
157187
method = method.as_deref().unwrap_or("-"),
158188
"→ MCP"
@@ -185,7 +215,7 @@ pub async fn mcp_oauth_middleware(
185215
warn!(
186216
trace_id = %trace_id,
187217
status = %status,
188-
client = %claims.client_id,
218+
client = %client_id,
189219
method = mcp_method.as_deref().unwrap_or("-"),
190220
"← MCP error"
191221
);

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ pub struct GatewayState {
6161
client_metadata_service: Option<Arc<ClientMetadataService>>,
6262
/// Unified event broadcaster (UI subscribes to receive all domain events)
6363
domain_event_tx: broadcast::Sender<DomainEvent>,
64+
/// When true, inbound MCP connections are accepted WITHOUT a Bearer token
65+
/// (localhost-only convenience). Default false (auth required). Seeded from
66+
/// the `gateway.auth_disabled` app setting at startup and flipped live by
67+
/// the desktop toggle. A valid token is still honored when present.
68+
auth_disabled: bool,
6469
}
6570

6671
impl GatewayState {
@@ -77,6 +82,7 @@ impl GatewayState {
7782
inbound_client_repository: None,
7883
client_metadata_service: None,
7984
domain_event_tx,
85+
auth_disabled: false,
8086
}
8187
}
8288

@@ -86,6 +92,24 @@ impl GatewayState {
8692
self.base_url = base_url;
8793
}
8894

95+
/// Whether inbound MCP auth is disabled — connections may be accepted
96+
/// without a Bearer token. See [`Self::auth_disabled`] field docs.
97+
pub fn auth_disabled(&self) -> bool {
98+
self.auth_disabled
99+
}
100+
101+
/// Enable/disable system-wide inbound auth. Called at startup (seed from
102+
/// settings) and live from the desktop toggle.
103+
pub fn set_auth_disabled(&mut self, disabled: bool) {
104+
if self.auth_disabled != disabled {
105+
info!(
106+
"[State] Inbound auth {}",
107+
if disabled { "DISABLED" } else { "enabled" }
108+
);
109+
}
110+
self.auth_disabled = disabled;
111+
}
112+
89113
/// Subscribe to domain events (new unified channel)
90114
pub fn subscribe_domain_events(&self) -> broadcast::Receiver<DomainEvent> {
91115
self.domain_event_tx.subscribe()
@@ -265,3 +289,19 @@ impl Default for GatewayState {
265289
Self::new(domain_event_tx)
266290
}
267291
}
292+
293+
#[cfg(test)]
294+
mod tests {
295+
use super::*;
296+
297+
#[test]
298+
fn auth_disabled_defaults_off_and_toggles() {
299+
let mut state = GatewayState::default();
300+
// Secure default: auth is required (not disabled).
301+
assert!(!state.auth_disabled());
302+
state.set_auth_disabled(true);
303+
assert!(state.auth_disabled());
304+
state.set_auth_disabled(false);
305+
assert!(!state.auth_disabled());
306+
}
307+
}

0 commit comments

Comments
 (0)