Skip to content

Refuse refresh grants for servers of another workspace (#1609) - #1632

Merged
helpcodeai merged 2 commits into
mainfrom
fix/refresh-refuses-cross-workspace
Nov 11, 2026
Merged

helpcodeai merged 2 commits into
mainfrom
fix/refresh-refuses-cross-workspace

Conversation

@helpcodeai

Copy link
Copy Markdown
Contributor

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 /token answers 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 /authorize again, so it never reaches the consent page that explains the problem.

Clients do stop after invalid_grant on a refresh (58 cases measured this week, at most 2 refusals per user, no loop). This PR makes the refresh grant answer invalid_grant for these tokens. The tokens cannot name a server themselves (their resource is the instance-wide /mcp), so the server comes from:

  • (a) the RFC 8707 resource the client sends with the refresh, when it names /mcp/<serverId>; or
  • (b) when the refresh names no resource (or one that cannot be parsed), a 15-minute marker the MCP endpoint leaves for (user, OAuth client) when it refuses a JWT caller as a non-member. The refresh follows the 401 within a second, from the same client.

The 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

  • New auth/server-reachability.util.ts: the one rule (primary org, or an active organization_members row). 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.
  • New auth/cross-workspace-refusal.store.ts: marker (user, client) -> serverId, 15 min. Redis through RedisService when connected, otherwise an in-memory map with expiry and a 10,000-entry cap. Never throws. Provided and exported by the global AuthModule.
  • mcp-endpoint.controller.ts: on the JWT non-member 401, awaits the marker write before answering (OAuth tokens only: needs azp; API keys keep the 403 and never mark), and adds error_description to WWW-Authenticate.
  • mcp-www-authenticate.util.ts: optional error_description, only next to error="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 /mcp or a plugin endpoint (/mcp/file-search, /mcp/chatgpt-app) skips the marker. Audit metadata gets serverId and via, with no organizationId, so the event does not show up in the other workspace's audit log.
  • Consent message: one more sentence, "If you do not need this connection, remove it from your AI client."
  • Temporary log, to be removed about a week after deploy: Refresh grant resource: server|global|plugin|none|other (client …abc123), at most once per client and kind per hour. It shows whether clients send resource on refresh, which tells us whether (b) is needed. The refusal line reads Refused 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 with MCP_AUTH_MODE=oauth2|both, where the middleware is mounted. With none/legacy the 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. Without REDIS_URL the marker lives in process memory, which is correct for a single backend process. With several replicas and no Redis, only (a) is reliable.

Type

  • Bug fix

Testing

  • Added/updated unit tests

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-search and /mcp/chatgpt-app pass 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) and user_deleted keep 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; refreshResourceKind table.
  • 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 carries error_description.
  • login.controller.wrong-workspace.spec.ts: unchanged, passes on the extracted helper.

Commands run in packages/backend on macOS, Node from the shared checkout's node_modules, Prisma client generated with a dummy DATABASE_URL:

  • npm run lint: 0 errors, 11 warnings, all in files this PR does not touch. --fix rewrote 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) in file-search and ee/file-search. These are a local node_modules gap that CI does not have.
  • npx jest --maxWorkers=4 src/auth src/mcp-server/mcp-endpoint.controller.spec.ts: all src/auth suites pass (583 tests) except jwt-or-management-key.guard.spec.ts, which needs a live Postgres (baseline, unrelated). mcp-endpoint.controller.spec.ts cannot load locally because of the missing SDKs above. Run with ts diagnostics off and those SDKs stubbed, it passes: 60/60.
  • Full backend suite with the same local workaround (jest --maxWorkers=4, diagnostics off, missing SDKs stubbed): 1072 suites passed, 2 failed. Both failures are environmental: jwt-or-management-key.guard.spec.ts needs a DB, and ee/file-search/sources/sources.spec.ts exercises the stubbed SDKs.

Checklist

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation (if applicable): no user-facing docs change; the refresh behaviour is internal
  • All existing tests pass (npm test), apart from the local environment gaps listed above (CI is authoritative)
  • This PR has a descriptive title

Related Issues

Relates to #1609 (follow-up to #1613).

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.
@helpcodeai
helpcodeai merged commit 77f98a6 into main Nov 11, 2026
16 checks passed
@helpcodeai
helpcodeai deleted the fix/refresh-refuses-cross-workspace branch November 11, 2026 11:08
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant