Repository navigation
Refuse refresh grants for servers of another workspace (#1609) - #1632
Merged
Merged
Conversation
A token for a server whose workspace the user is not in was refused by the MCP endpoint with 401, but the refresh grant checked no membership and answered 200, so clients refreshed, retried and looped without ever reaching the consent page. The refresh middleware now answers invalid_grant when the refresh names such a server in its RFC 8707 resource, or, when it names none, when the endpoint refused this user and OAuth client in the last 15 minutes. The reachability rule (primary org or active membership) is extracted into one helper shared by the consent page, the MCP endpoint and the refresh middleware.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Since #1613 the consent page refuses a new authorization for an MCP server whose workspace the signed-in account is not a member of. Tokens issued before that still loop: the per-server endpoint answers 401
invalid_token, the client refreshes,POST /tokenanswers 200 because the refresh grant never looks at membership, the client retries, gets 401 again, and starts over in the next conversation. One user was refused 970 times in three days this way. The client never runs/authorizeagain, so it never reaches the consent page that explains the problem.Clients do stop after
invalid_granton a refresh (58 cases measured this week, at most 2 refusals per user, no loop). This PR makes the refresh grant answerinvalid_grantfor these tokens. The tokens cannot name a server themselves (their resource is the instance-wide/mcp), so the server comes from:resourcethe client sends with the refresh, when it names/mcp/<serverId>; orThe client's input is only ever used to refuse, never to grant. An unknown or deleted server, or a lookup error, falls through as before.
Auth change approved by Matteo on 2026-11-11.
Changes
auth/server-reachability.util.ts: the one rule (primary org, or an activeorganization_membersrow). The consent page (login.controller.ts), the per-server MCP endpoint and the refresh middleware all use it now. Behaviour of consent and endpoint is unchanged; an API key still only reaches its own workspace.auth/cross-workspace-refusal.store.ts: marker (user, client) -> serverId, 15 min. Redis throughRedisServicewhen connected, otherwise an in-memory map with expiry and a 10,000-entry cap. Never throws. Provided and exported by the globalAuthModule.mcp-endpoint.controller.ts: on the JWT non-member 401, awaits the marker write before answering (OAuth tokens only: needsazp; API keys keep the 403 and never mark), and addserror_descriptiontoWWW-Authenticate.mcp-www-authenticate.util.ts: optionalerror_description, only next toerror="invalid_token", left out unless it is RFC 6750-safe ASCII.refresh-token-revocation.middleware.ts: after the existing subject / deleted / watermark checks (which keep their order and text), the cross-workspace check. Refusal is 400{"error":"invalid_grant","error_description":"This sign-in is not a member of the workspace that owns this MCP server. Connect again and sign in with an account of that workspace, or ask its admin to invite you."}, same shape and headers as the revoked case. The marker is kept after a refusal so a retry of the same refresh token is refused again; it is cleared when the user can reach the server again. A refresh naming/mcpor a plugin endpoint (/mcp/file-search,/mcp/chatgpt-app) skips the marker. Audit metadata getsserverIdandvia, with noorganizationId, so the event does not show up in the other workspace's audit log.Refresh grant resource: server|global|plugin|none|other (client …abc123), at most once per client and kind per hour. It shows whether clients sendresourceon refresh, which tells us whether (b) is needed. The refusal line readsRefused refresh_token for <userId> (wrong_workspace via resource|marker, server …abc123). No tokens are logged, and client and server ids are shortened.No Prisma schema change, nothing under
ee/, no change to@rekog/mcp-nest-auth.Cloud and self-hosted
Same code path, no
isCloud()branch. It only applies withMCP_AUTH_MODE=oauth2|both, where the middleware is mounted. Withnone/legacythe middleware is not mounted, and a marker written by the endpoint expires unused. On a self-hosted install with one workspace everyone is a member, so the check never refuses. Multi-workspace self-hosted installs behave like Cloud. WithoutREDIS_URLthe marker lives in process memory, which is correct for a single backend process. With several replicas and no Redis, only (a) is reliable.Type
Testing
New and extended specs:
refresh-token-revocation.middleware.spec.ts: refused with resource; members of non-primary workspaces pass; a switched active workspace passes; primary workspace passes with no membership query (also the self-hosted single-workspace case);/mcp,/mcp/file-searchand/mcp/chatgpt-apppass even with a marker; malformed resource passes when there is no marker; unknown server and Prisma errors fall through; deactivated-only membership is refused; the watermark (revoked) anduser_deletedkeep their reason and text and are checked first; marker refusal with no resource and with an unparseable one; retry refused again; another client's marker is ignored; an expired marker is ignored; a user who became a member passes and the marker is cleared; a body resource wins over the marker; works without the store; the temporary log is throttled and never contains the token;refreshResourceKindtable.server-reachability.util.spec.ts,cross-workspace-refusal.store.spec.ts,mcp-www-authenticate.util.spec.ts(new).mcp-endpoint.controller.spec.ts: the marker is written before the 401; still 401 when the write fails; no marker for app JWTs without a client, for API keys (403 stays) or for members; header now carrieserror_description.login.controller.wrong-workspace.spec.ts: unchanged, passes on the extracted helper.Commands run in
packages/backendon macOS, Node from the shared checkout'snode_modules, Prisma client generated with a dummyDATABASE_URL:npm run lint: 0 errors, 11 warnings, all in files this PR does not touch.--fixrewrote nothing.npx tsc --noEmit -p tsconfig.json: no errors in changed files. The only errors are missing local type packages (@types/picomatch,@aws-sdk/client-s3,basic-ftp,ssh2) infile-searchandee/file-search. These are a localnode_modulesgap that CI does not have.npx jest --maxWorkers=4 src/auth src/mcp-server/mcp-endpoint.controller.spec.ts: allsrc/authsuites pass (583 tests) exceptjwt-or-management-key.guard.spec.ts, which needs a live Postgres (baseline, unrelated).mcp-endpoint.controller.spec.tscannot load locally because of the missing SDKs above. Run with ts diagnostics off and those SDKs stubbed, it passes: 60/60.jest --maxWorkers=4, diagnostics off, missing SDKs stubbed): 1072 suites passed, 2 failed. Both failures are environmental:jwt-or-management-key.guard.spec.tsneeds a DB, andee/file-search/sources/sources.spec.tsexercises the stubbed SDKs.Checklist
npm test), apart from the local environment gaps listed above (CI is authoritative)Related Issues
Relates to #1609 (follow-up to #1613).