Skip to content

Commit a55081e

Browse files
committed
gateway: reject revoked clients immediately instead of on token expiry
- oauth_middleware: check the client still exists in inbound_clients on every request, not just at JWT-signature-verify time. A deleted/revoked client now gets a clean 401 on its next call instead of continuing to work until the JWT naturally expires. - mcp_notifier: drop a deleted client's live session bookkeeping immediately on ClientDeleted instead of waiting for the lazy is_transport_closed() GC pass, so it stops receiving notification fanout right away. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 53d28df commit a55081e

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

crates/mcpmux-gateway/src/consumers/mcp_notifier.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,41 @@ impl MCPNotifier {
330330
);
331331
}
332332

333+
/// Drop bookkeeping for every live session belonging to a deleted client.
334+
///
335+
/// rmcp doesn't expose a way to forcibly close a `Peer`'s underlying
336+
/// transport from here (only `RunningService`, which we don't hold, can
337+
/// do that) — so this can't hang up the socket. What it *can* do is stop
338+
/// fanning out further notifications to it immediately, instead of
339+
/// waiting on the lazy `is_transport_closed()` GC pass. The actual
340+
/// connection gets cut on the client's next request via the DB-backed
341+
/// revocation check in `oauth_middleware`.
342+
fn drop_sessions_for_client(&self, client_id: &str) {
343+
let removed: Vec<String> = {
344+
let mut sessions = self.sessions.write();
345+
let dead: Vec<String> = sessions
346+
.iter()
347+
.filter(|(_, e)| e.client_id == client_id)
348+
.map(|(sid, _)| sid.clone())
349+
.collect();
350+
for sid in &dead {
351+
sessions.remove(sid);
352+
}
353+
dead
354+
};
355+
if removed.is_empty() {
356+
return;
357+
}
358+
for sid in &removed {
359+
self.feature_set_resolver.session_roots().remove(sid);
360+
}
361+
info!(
362+
%client_id,
363+
removed = removed.len(),
364+
"[MCPNotifier] 🗑️ Dropped sessions for deleted client"
365+
);
366+
}
367+
333368
/// Unregister a session.
334369
///
335370
/// Called when a client disconnects or the session closes.
@@ -523,6 +558,14 @@ impl MCPNotifier {
523558
/// This is enterprise-grade: consumers interpret events based on their context,
524559
/// not producers dictating what to do.
525560
async fn handle_event(&self, event: DomainEvent) {
561+
// Revocation teardown runs ahead of (and regardless of) the
562+
// capability-change filter below: a deleted client never implies a
563+
// list_changed notification, but we still want its bookkeeping gone
564+
// immediately rather than waiting for the next lazy GC pass.
565+
if let DomainEvent::ClientDeleted { ref client_id } = event {
566+
self.drop_sessions_for_client(client_id);
567+
}
568+
526569
// Only handle events that affect MCP capabilities
527570
if !event.affects_mcp_capabilities() {
528571
trace!(

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,33 @@ pub async fn mcp_oauth_middleware(
123123
// key); when auth is disabled, fall back to an anonymous identity on the
124124
// default space.
125125
let authed_client_id = claims.map(|c| c.client_id).or(api_key_client_id);
126+
127+
// Revocation check: a JWT access token is a stateless, self-contained
128+
// credential that stays cryptographically valid until it expires —
129+
// deleting the client in the UI doesn't invalidate tokens it already
130+
// issued. Confirm the client row still exists on every request so a
131+
// revoked client is rejected on its very next call instead of continuing
132+
// to work until natural token expiry.
133+
let authed_client_id = match authed_client_id {
134+
Some(cid) => match services
135+
.dependencies
136+
.inbound_client_repo
137+
.get_client(&cid)
138+
.await
139+
{
140+
Ok(Some(_)) => Some(cid),
141+
Ok(None) => {
142+
warn!(trace_id = %trace_id, client_id = %cid, "Client no longer registered (revoked) — rejecting");
143+
None
144+
}
145+
Err(e) => {
146+
warn!(trace_id = %trace_id, client_id = %cid, "Client lookup failed: {}", e);
147+
None
148+
}
149+
},
150+
None => None,
151+
};
152+
126153
let (client_id, space_id) = if let Some(cid) = authed_client_id {
127154
match services
128155
.space_resolver_service

0 commit comments

Comments
 (0)