Skip to content

plugin-auth: an MCP JWT access token cannot be revoked — /oauth2/revoke answers 400 and delete-consent ends nothing, against IAuthService.verifyMcpAccessToken's 'falsy when revoked' (objectstack-ai/cloud#3606 ruling A) #30345

Closed

Description

@objectstack-fleet

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

Filing gate: executing a recorded ruling. The maintainer ruled objectstack-ai/cloud#3606 A (record 6651680613), verbatim:

方案 A(推荐): 以平台已有的「授权同意」记录为准。撤销或撤回时删掉这条记录,每次 MCP 请求都查一次,记录不在就拒绝。不新增对象、表或门禁,控制台的撤回也随之真正生效。代价是每次 MCP 请求多一次带索引的读取,撤销后用户下次连接要重新同意。

This card carries the platform half. objectstack-ai/cloud#3606 keeps the cloud half: a pin move, then a tenant-rig e2e. It waits on this card with pm:blocked + Blocked-by:.

Owner. The repo:cloud lane (repo:cloud#3, #30278) follows this card to close, because it carries cloud facts. The code is pure objectstack, packages/plugins/plugin-auth, so triage routes it.

Priority: p1, inherited from objectstack-ai/cloud#3606. A platform card filed from an app-repo ruling is not downgraded.

维护者速读

把 MCP 访问权授给 agent 之后收不回来:调撤销接口返回 400,控制台「撤回授权」接口显示成功,但令牌照样能用,要等 1 小时过期。按已有裁决,改成以平台已有的「授权同意」记录为准:撤销或撤回时删掉它,每次 MCP 请求都查一次,记录不在就拒绝。

Measured

These readings come from cloud's tenant-kernel rig, by the cloud#3606 dev (report objectstack-ai/cloud#3606 comment 6651405267): cloud 7e486932, framework pin 1a3ea0c0, @better-auth/oauth-provider 1.7.12. The client is a DCR public client (token_endpoint_auth_method: none) using PKCE S256, with resource set to the MCP URL.

  • POST /api/v1/auth/oauth2/revoke with form token=<access> + token_type_hint=access_token + client_id → 400 {"error":"unsupported_token_type","error_description":"JWT access tokens are self-contained and cannot be revoked server-side"}. Then tools/list with the same bearer → 200. Omitting the hint gives the same 400.
  • Revoking the refresh token → 200, and the access token still answers 200.
  • GET /api/v1/auth/oauth2/get-consents → 200, with the person's consents. POST /api/v1/auth/oauth2/delete-consent {id} → 200, but afterwards the bearer still answers tools/list 200 and the refresh token still answers 200.
  • An invalid token (token=garbage, token_type_hint=access_token) → 400 invalid_request "Invalid access token". RFC 7009 §2.2 says 200.
  • No cloud code serves or rewrites /oauth2/*.

Governing text (read at objectstack origin/main bfca0efb)

  • packages/spec/src/contracts/auth-service.ts:287-296: verifyMcpAccessToken "Resolves to the token's principal, or a falsy value when the token is unknown/expired/revoked … callers fail CLOSED on anything but a verified result."
  • The implementation contradicts it at packages/plugins/plugin-auth/src/auth-manager.ts:4310-4312: "JWT access tokens are not server-tracked, so revocation takes effect at expiry". This is declared ≠ enforced, so the implementation must honour the contract; ⛔ do not narrow the contract.
  • RFC 7009 §2.1 (revoking the grant) and §2.2 (an invalid token answers 200).

The ruled design (option A of objectstack-ai/cloud#3606 comment 6651442702)

  1. Wrap /oauth2/revoke. For an MCP JWT access token, verify it against this deployment's JWKS and require azp to equal the requesting client_id. If it does not match, answer a 200 no-op that touches no other client's grant. Confidential-client authentication stays with the provider and runs first, unchanged. On a match: delete that consent (client, user, organization reference), revoke the client's refresh family for that user, and answer 200.
  2. delete-consent takes effect through the same check (step 3), with no extra write.
  3. verifyMcpAccessToken, or the execution-context resolver right after it, reads the consent by (azp, sub, organization_id) on every MCP request. It refuses when the consent is missing or newer than the token's iat, and fails closed on a store error.
  4. An unknown or invalid token answers 200 (RFC 7009 §2.2).
  5. Server-only skip_consent clients have no consent row. They keep the honest unsupported_token_type answer and are exempt from step 3.
  6. No new object, table or gate. Accepted costs: one indexed read per MCP request; the person consents again on the next connect after a revoke.

Acceptance

  • A test on the real provider: a DCR public client authorizes with PKCE, then revokes its access token with client_id only → 200, and the same bearer is refused by verifyMcpAccessToken, so it gets a 401 at the MCP route.
  • delete-consent followed by the same bearer → refused.
  • A revoke sent by another client → 200, and the owner's token still works.
  • An invalid token → 200.
  • A store error in the consent read → refused (fail closed).
  • The auth-manager.ts revocation note is rewritten to match.

Dedupe (issues, open and closed, 2026-11-05), none carrying this:

Activity

  1. added
    bugSomething isn't working
    repo:cloudCard routed whole to the cloud PM: needs cloud code, a producer or a fact (cloud owns it to closure)
    priority:p1High: required for production / M2
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Nov 5, 2026
  2. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc

    Claim: PM loop round 1
    Session: session_01L9qibvynRbCtMndW8EHNGc
    Account: os-tesla
    Branch: claude/issue-30345-mcp-token-revoke
    Worktree: objectstack-issue-30345
    Domain: security (handed to this seat by triage#1 on mailbox #30818, comment 6677379636; the card carries security)
    Seat: security#1
    File surface: packages/plugins/plugin-auth/** (the revoke wrap, the consent read on the MCP verify path, the revocation note), its tests, and the MCP route's test only if the 401 pin needs it; a changeset
    Container & model: L, mode:subagent, model: TIER_DEFAULT (no path-derived mandate: dispatch-gates --tier hits none of the 4 declared globs), effort: xhigh (security, p1)
    Clause-②: no (narrowing) — the MCP door refuses a bearer whose consent is gone or newer than the token, which the published contract already promises (packages/spec/src/contracts/auth-service.ts:290 verifyMcpAccessToken "a falsy value when the token is unknown/expired/revoked … callers fail CLOSED"); the revoke door's 200 for an invalid or foreign token removes a refusal RFC 7009 §2.2 already negates. The contract text is not changed.
    Metadata-protocol: none
    Responsibility: objectstack: packages/plugins/plugin-auth (the provider wrap and verifyMcpAccessToken) | none: no platform path revokes an MCP JWT access token today | every MCP client a person authorizes and later revokes or withdraws in the Console; reached in cloud today (objectstack-ai/cloud#3606)
    Thread-read: none
    Premise: holds
    Serial constraints cleared: #30717 (security#1, open draft, plugin-auth auth-manager.ts allowed-IP range parser: a different region of the same file; whichever lands second merges main)


    Generated by Claude Code

  3. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
    os-dev-report

    {
      "issue": 30345,
      "status": "done",
      "branch": "claude/issue-30345-mcp-token-revoke",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/30879 (draft)",
      "head": "842d17a53a2bd4d2fc870203f46a611f23dfd718",
      "session": "session_01L9qibvynRbCtMndW8EHNGc",
      "premise_still_valid": true,
      "summary": "Ruling A of objectstack-ai/cloud#3606 implemented. verifyMcpAccessToken reads sys_oauth_consent by (azp, sub, organization_id) on every MCP request. It refuses a token when the consent is missing or newer than iat, when the token has no azp, or when the read fails (fail closed). /oauth2/revoke is wrapped at the handleRequest seam, after the provider has authenticated the client. For a same-client MCP JWT it deletes the consent and the refresh family for that user and answers 200. Another client's token answers 200 and changes nothing. An invalid token answers 200. A store error answers 500. skip_consent clients keep unsupported_token_type and are exempt from the read. delete-consent takes effect through the read. No new object, table or gate. The spec contract is not edited.",
      "tests": "plugin-auth: Test Files 229 passed (229), Tests 4110 passed | 10 skipped (4120), exit 0. New mcp-token-revocation.test.ts runs end to end on real ObjectQL + SqlDriver. Ten new unit legs in auth-manager.mcp-oauth.test.ts. Ablations: A1 (consent check removed) 15 failed / 83 passed. A2 (revoke wrap removed) 6 failed / 92 passed.",
      "gates": "dispatch-gates --commands derived 64 commands, all exit 0. The --ran reconcile reports: 64 derived, 64 run, 0 NOT-MEASURED (derived zero). build, typecheck and plugin-auth test: exit 0 under os-verify-lock.",
      "dogfood_battery": "exit 0 at 842d17a53a: Test Files 424 passed | 3 skipped (427), Tests 3538 passed | 41 skipped (3579)",
      "mcp_calls": 0,
      "api_writes": "1 relay stroke (pr_create, run 42190520709, read-back identical) + this comment via post-stamped",
      "line_budget": "10 files, +1058 / -16",
      "open_questions": [
        "A consent-store outage answers 401 at the MCP door, while the ban read answers 503. Keeping 401 follows the verifier contract (falsy, fail closed). Moving it to 503 would need a contract change."
      ],
      "security_findings": "none",
      "out_of_scope_findings": [
        "update-consent narrows scopes by touching only updatedAt, so a token minted under the wider scopes keeps them until it expires. Ruling A does not cover this case. It is noted in the PR's Acceptance notes."
      ]
    }
  4. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
    os-dev-report

    {
      "issue": 30345,
      "status": "done",
      "round": "patch round after the contract review at 842d17a53a",
      "branch": "claude/issue-30345-mcp-token-revoke",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/30879 (draft)",
      "head": "36d3a9fd823694d741e71173b38c783753ffdaaf",
      "session": "session_01L9qibvynRbCtMndW8EHNGc",
      "premise_still_valid": true,
      "summary": "Must-fix: RFC 7009 section 2.1 says a token_type_hint that does not locate the token widens the search to every supported token type. Before the provider's not-found answer is turned into a 200, the revoke wrap now looks for an MCP access token. The same client revokes it. Another client gets a 200 that changes nothing. A skip_consent client keeps 400 unsupported_token_type. Should-fix: a store error while revoking now answers 503 server_error with no-store (RFC 7009 section 2.2.1) instead of 500. The changeset sentence is updated. origin/main d55acb7828 is merged in cleanly.",
      "tests": "plugin-auth: Test Files 229 passed (229), Tests 4119 passed | 10 skipped (4129), exit 0. mcp-token-revocation.test.ts 13/13. New legs: a revoke with a refresh_token hint answers 200, the bearer is refused, and the consent and refresh rows are gone; the same hint from another client is a no-op; skip_consent under that hint keeps the same unsupported_token_type word for word. The revoke store-error pin is now 503 with no-store. Ablation A3 (at 49868b3174, one-shot, restored by blob hash): with the new branch removed, 2 failed / 11 passed; the hint leg and the skip_consent leg go red.",
      "gates": "63 derived at 36d3a9fd82, all exit 0. --ran reconcile: 63 derived, 63 run, 0 NOT-MEASURED (derived zero). Build, plugin-auth typecheck and plugin-auth test exit 0 under os-verify-lock.",
      "dogfood_battery": "exit 0 at 36d3a9fd82: Test Files 424 passed | 3 skipped (427), Tests 3538 passed | 41 skipped (3579)",
      "mcp_calls": 0,
      "api_writes": "this comment via post-stamped; the PR body refresh is handed to the seat as a dry-run-validated issue_patch, not sent",
      "security_findings": "withheld"
    }
  5. added
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    on Nov 6, 2026
  6. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc

    Landed · 2026-11-06T15:02Z

    reading value
    PR #30879 MERGED 1aeeafd9d3 (head 36d3a9fd82, contract PASS 6682797646, ACCEPT 6682817776)
    this card closed (completed) by #30879

    State change: pm:dispatched and pm:blocking removed. #30894's blocked-by is cleared. #30918 (server half of #30894) and #30946 can build on this revoke door. The open boundary items (a)–(c) from the review record stay with the owner seats, as recorded there.


    Generated by Claude Code

  7. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    on Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpriority:p1High: required for production / M2repo:cloudCard routed whole to the cloud PM: needs cloud code, a producer or a fact (cloud owns it to closure)security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions