Repository navigation
Description
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/revokewith formtoken=<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"}. Thentools/listwith 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 answerstools/list200 and the refresh token still answers 200.- An invalid token (
token=garbage,token_type_hint=access_token) → 400invalid_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)
- Wrap
/oauth2/revoke. For an MCP JWT access token, verify it against this deployment's JWKS and requireazpto equal the requestingclient_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. delete-consenttakes effect through the same check (step 3), with no extra write.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'siat, and fails closed on a store error.- An unknown or invalid token answers 200 (RFC 7009 §2.2).
- Server-only
skip_consentclients have no consent row. They keep the honestunsupported_token_typeanswer and are exempt from step 3. - 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_idonly → 200, and the same bearer is refused byverifyMcpAccessToken, so it gets a 401 at the MCP route. delete-consentfollowed 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.tsrevocation note is rewritten to match.
Dedupe (issues, open and closed, 2026-11-05), none carrying this:
repo:objectstack-ai/objectstack verifyMcpAccessToken revoked is:issue: 3 hits (finding(mcp/oauth): an OAuth-token MCP agent acting for an ordinary user is denied every object operation — the D10 delegator is resolved with no active organization, so the user's organization-scoped grants do not count #24137, every authenticated request pays 11 queries in 7 serial round trips before its handler: in-process getSession runs the jwt after-hook (two sys_jwks reads, a discarded signature), and me/* resolve the grants twice #24061, print page ③ of #8346: a render service with one headless-Chromium driver renders a print page under the requesting user's principal and archives the PDF as a sys_file (M2) #22269).oauth2 revoke JWT access token MCP is:issue: 3 hits (QA run · area:identity-auth (23/23) · 2034975a · 2026-10-16 · 18 PASS / 5 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #23943, deps(plugin-auth): lift the better-auth family from 1.7.3 to 1.7.12 — security fixes published after the 2026-10-01 deferral #25096, [PM seat] triage (objectstack-wide) — 🟢 Routine · session_01AavokzJ5DndAwitDXvKy4U · trig_01NcnCtMS2tH46nUg1a5TaMJ (hourly) · state = body + the round records newer than it #6015).delete-consent MCP is:issue: 2 hits (A sixth client-SDK erasure spelling, larger than the other five combined: 43 exported methodsreturn res.json()directly, whose lib.dom type isPromise< any >#12104, [decision] after #22307 a cold boot refuses any environment row over a package-held permission set or position, so the 2026-08-24 legacy-overlay remedies (boot overlay reading, drift overlay_shadow, Discard Overlay) find nothing on v18: keep or retire? #22371).
Activity
objectstack-fleet commentedon Nov 6, 2026
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
Claim: PM loop round 1
Session:
session_01L9qibvynRbCtMndW8EHNGcAccount:
os-teslaBranch:
claude/issue-30345-mcp-token-revokeWorktree:
objectstack-issue-30345Domain:
security(handed to this seat by triage#1 on mailbox #30818, comment 6677379636; the card carriessecurity)Seat:
security#1File 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 changesetContainer & 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:290verifyMcpAccessToken"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
objectstack-fleet commentedon Nov 6, 2026
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." ] }objectstack-fleet commentedon Nov 6, 2026
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" }objectstack-fleet commentedon Nov 6, 2026
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
Landed · 2026-11-06T15:02Z
1aeeafd9d3(head36d3a9fd82, contract PASS 6682797646, ACCEPT 6682817776)State change:
pm:dispatchedandpm:blockingremoved. #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