Skip to content

feat(auth): withdraw an authorized AI assistant, for the person and for a platform administrator - #31073

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-h42d2s
Nov 7, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-h42d2s

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Clause-②: yes (widening)
Metadata-protocol: none

Fixes #31048

维护者速读

  • 改了什么:新增两个撤回入口:用户撤回自己授权过的 AI 助手(POST /api/v1/auth/authorized-clients/withdraw),平台管理员替所管理的人撤回(POST /api/v1/auth/admin/authorized-clients/withdraw);SDK 增加 oauth.authorizedClients.withdraw() / withdrawForUser()。
  • 为什么改:授权页承诺「您可以随时在账户设置中撤销访问权限」,控制台已能列出授权过的助手,但没有可调用的撤回入口;The OAuth consent page promises you can revoke access from your account settings, but no page lists the AI assistants you authorized #30894 的撤回按钮等的就是它。
  • 风险与回滚:只新增路由,现有路由不变。撤回与 /oauth2/revoke 共用同一段撤销代码(删授权记录 + 该客户端的刷新令牌),助手的令牌在下一次 MCP 调用即被拒。别人的 id 和不存在的 id 回同一个 404,探不出 id 是否存在。回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-auth/src/authorized-clients-withdraw.ts(findConsentOf 的归属判断);auth-plugin.ts 里管理员入口先跑 gateAdmin。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/authorized-clients-read.real-plugin.test.ts -t 31048,预期 6 条通过。
  • 需要你做的:无,席位落地。

What changes

Two ObjectStack mounts beside the authorized-clients reads, both keyed by the consent id the reads answer on every row:

Route Body Who
POST /api/v1/auth/authorized-clients/withdraw { id } the signed-in person, their own grant only
POST /api/v1/auth/admin/authorized-clients/withdraw { userId, id } a platform administrator, behind the same gateAdmin as GET /admin/authorized-clients

A withdraw revokes the grant through revokeMcpGrant (mcp-token-revocation.ts), the function /oauth2/revoke already uses: the consent row, then the client's refresh family, all through better-auth's own storage adapter. mcpConsentAdmits refuses the token on its next MCP call, and because the refresh family goes too, a refresh token the client still holds can't come back if the person authorizes the same assistant again later. The write is credited to whoever withdrew (runAttributedToUser, attribution only).

  • auth-route-ledger.ts: two sdk rows (oauth.authorizedClients.withdraw / withdrawForUser), and a server-only row for better-auth's POST /oauth2/delete-consent that records the measurement below and names the new door. The read row's note now names the withdraw as its key's consumer.
  • @objectstack/client: oauth.authorizedClients.withdraw(id) and withdrawForUser(userId, id), both answering AuthorizedClientWithdrawal ({ id }). The stale list() doc line that pointed at /oauth2/delete-consent is corrected.
  • content/docs/ai/connect-mcp.mdx: one bullet in The security model (no docs page listed the authorized-clients doors before; this is the page that covers OAuth assistants).
  • Doc comments that named /oauth2/delete-consent as "the Console's withdraw" (authorized-clients-read.ts, mcp-token-revocation.ts, auth-manager.ts) now name the new module.

Design choice: (b), an ObjectStack mount

Measured on @better-auth/oauth-provider 1.7.12 (deleteConsentEndpoint): the vendor route does check ownership (consent.userId !== session.user.id → UNAUTHORIZED), so option (a) is not a security defect and nothing went to security#1. But it answers an unknown id 404 not_found and another person's id 401 UNAUTHORIZED, so a caller can tell them apart; it reads the row by id alone, and it deletes only the consent row, leaving the refresh family. Ledgering it as sdk would have pinned a two-answer, probe-able door with a 401 that means "not yours" rather than "not signed in". The mount gives one closed answer set:

Caller → door Unknown id Another person's id Own id
member → own 404 RESOURCE_NOT_FOUND 404 RESOURCE_NOT_FOUND (same bytes; row stays) 200 { id }
platform admin → own 404 404 (same bytes; the own door is keyed by the session) 200
platform admin → admin {userId, id} 404 (consent message) 404 (same bytes when id isn't userId's; row stays) 200
platform admin → admin, userId naming no user 404 RESOURCE_NOT_FOUND with the admin read's message
member / org owner / org admin / the person → admin 403 PERMISSION_DENIED 403 (same bytes, also for {}) 403
anonymous → either 401 UNAUTHENTICATED 401 401

Shape refusals on both doors: a query parameter, a foreign body key, a missing, blank or non-string id/userId, a non-object body → 400 VALIDATION_ERROR; JSON declared and unparseable → 400 INVALID_REQUEST (readJsonRequestBody). A held session on the own door → 403 with the posture's own code. All codes come from the existing catalog; no spec key or error code is added.

Audit

The grant and the revoke paths write no explicit sys_audit_log row. Both go through better-auth's adapter into the engine, and sys_oauth_consent is not in plugin-audit's SKIP_OBJECTS, so the generic writer records the grant as create and the revoke as delete. The withdraw goes through the same adapter, so it gets the same generic delete row with no new action value. The dogfood pin measures both rows on the real stack with plugin-audit installed. The delete row is credited to whoever withdrew: the person on the own door, the administrator on the admin door (user_id). An own withdraw made from an impersonation session hands runAttributedToUser { id, impersonatedBy }, read off the session as gateAdmin and the auth before-hook read it. The revoke's writes therefore carry the attribution-only impersonated_by beside the person.

Tests

  • plugin-auth authorized-clients-read.real-plugin.test.ts (the shared harness: real AuthManager, real better-auth, real AuthPlugin routes on Hono): six new cases in two #31048 describes. They cover a member withdrawing their own grant (row and refresh family gone, that grant's MCP token refused, the person's other grants and tokens untouched, a second withdraw is the not-found answer); another person's id from a member and from a platform admin on the own door (byte-identical to an unknown id, row stays, token still admitted); the shape refusals; an admin withdrawing a managed person's grant (token refused, list shrinks); a mismatched userId/id and an unknown user; and the non-admin matrix (member, org owner, org admin, the person themself: one 403 for existing, missing and empty bodies; anonymous 401; nothing moves). One more #31048 describe (2 cases) reads the credited actor from inside the revoke. An impersonation session yields { userId, impersonatedBy }. An ordinary session or a blank impersonatedBy yields { userId } alone.
  • auth-route-ledger.conformance.test.ts: the hand-written objectstack-sourced roster gains both routes, each proven absent from better-auth's live table.
  • client oauth-authorized-clients.test.ts: URL, method and exact body of both withdraws, throw on 404/403, return types.
  • dogfood authorized-client-withdraw.dogfood.test.ts (new): over HTTP on the showcase stack with the MCP server and plugin-audit. It drives a real DCR, consent and code-exchange grant. Another member's withdraw is the same 404 as an unknown id and the assistant keeps working. The owner's withdraw makes initialize answer 401 and the refresh grant 400 invalid_grant, with audit rows as described above. On the admin door a member gets 403, and the admin's withdraw refuses the token. Its generic delete row is credited to the administrator, not the person.
  • dogfood admin-route-nonadmin-refusal / admin-platform-admin-standing: the new admin route classified in both derived sweeps (objectstack-gate; ADMITTED), each with a payload that reaches the gate.

Acceptance notes

  • Refresh family scope. revokeMcpGrant deletes the client's refresh tokens for the person across organizations (keyed by client and user, as /oauth2/revoke already does), while the consent it deletes is the one organization's. If a person granted the same client in two organizations, withdrawing one makes the other organization's session re-authorize when its access token expires (its consent stays, so no new consent prompt). This keeps one owner for "revoke a grant"; narrowing the refresh delete to reference_id would change /oauth2/revoke too and isn't in this card. Tracked in plugin-auth: withdrawing an AI assistant in one organization also deletes its refresh tokens in the person's other organizations #31076.
  • POST /oauth2/delete-consent stays mounted by better-auth's catch-all, unchanged, now ledgered server-only with the measurement. Shadowing or refusing it is not in this card.
  • The route-count prose in admin-route-nonadmin-refusal.dogfood.test.ts's header (11 + 24 = 31) was already stale before this change and is left alone, as that header itself says.

Verification

Ablations (negative pins, each restored blob == HEAD)

(i) Ownership check removed from findConsentOf (consent read by id alone, its own userId returned):

× another person's id is refused with the SAME bytes as an id naming no grant, and their grant stays in place
AssertionError: alice@authorized-clients.example.com: {"success":true,"data":{"id":"pOyAoR48grgEuTwXiONA0OSBScnR0oYa"}}: expected 200 to be 404
× a consent that is not the named person's is the same 404 as none, and stays; a userId naming no user is the read's 404
AssertionError: {"success":true,"data":{"id":"swvjR3Xx9jMnIdcQOi1n4vBOmS1k1g66"}}: expected 200 to be 404
Tests  2 failed | 4 passed | 10 skipped (16)
ablation-replace: ok restored: blob == HEAD (8835068a7d09) and `git diff HEAD` is empty

(ii) gateAdmin removed from the admin withdraw mount:

× a member, an organization OWNER, an organization ADMIN and the person themself are refused with ONE answer, whether or not the person or the grant exists, and nothing moves
AssertionError: member: {"success":true,"data":{"id":"zboAeJt35osPRynfvVdv7dOyFEXU10Qx"}}: expected 200 to be 403
Tests  1 failed | 5 passed | 10 skipped (16)
ablation-replace: ok restored: blob == HEAD (61ed5aefa975) and `git diff HEAD` is empty

(iii) impersonatedBy dropped from the own withdraw's actor (const actor = { id: subject };):

× an impersonation session: the person, with the impersonating administrator beside them
AssertionError: expected { userId: 'usr_person_31048' } to deeply equal { userId: 'usr_person_31048', …(1) }
-   "impersonatedBy": "usr_admin_31048",
Tests  1 failed | 1 passed | 16 skipped (18)
ablation-replace: restored: blob == HEAD (488794e1591e) and `git diff HEAD` is empty
Suites and gates on head 05ade98 (main 3f61e4f merged)
  • pnpm --filter @objectstack/plugin-auth test: Test Files 232 passed (232), Tests 4189 passed | 10 skipped (4199).
  • pnpm --filter @objectstack/client test: Test Files 55 passed (55), Tests 712 passed (712).
  • pnpm --filter @objectstack/plugin-auth typecheck, pnpm --filter @objectstack/client typecheck: exit 0.
  • dogfood, on the final head with a full pnpm build (119/119 tasks): authorized-client-withdraw, admin-route-nonadmin-refusal and admin-platform-admin-standing 17/17 (3 files).
  • eslint --no-inline-config on every changed .ts file: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands: 95 families derived, 95 run, all exit 0. The --ran reconciliation shows 95 run, 0 NOT-MEASURED, 0 UNRUN. Record: 31073-gates-ran-05ade983.list in the seat's out folder.
  • Full dogfood battery: n/a. The title carries no ! and Clause-② has no (narrowing) arm.
Earlier: suites and gates on head 1c32be0 (main 5210acf merged)
  • pnpm --filter @objectstack/plugin-auth test: Test Files 232 passed (232), Tests 4187 passed | 10 skipped (4197).
  • pnpm --filter @objectstack/client test: Test Files 55 passed (55), Tests 712 passed (712) (run on cdf571a; the merge touched no client file). Touched client file on the final head: 8/8.
  • pnpm --filter @objectstack/plugin-auth typecheck, pnpm --filter @objectstack/client typecheck: exit 0.
  • Touched plugin-auth files on the final head (real-plugin + ledger conformance): 26/26.
  • dogfood, on the final head with a full pnpm build (119/119 tasks): authorized-client-withdraw 2/2, admin-route-nonadmin-refusal + admin-platform-admin-standing 15/15 (3 files, 17 tests).
  • eslint --no-inline-config on every changed .ts file: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands: 95 families derived, 95 run, all exit 0; --ran reconciliation: 95 run, 0 NOT-MEASURED, 0 UNRUN. Record: 31048-gates-ran-1c32be06.list in the seat's out folder. On the first sweep (before the full build and the ledger-note fix), check:doc-authoring caught a tracker id in a ledger note, now removed. check:skill-examples and check:dual-build-cjs-loads reported exit 3 because there was no dist yet, and check-plugin-teardown-shape --self-test reported exit 3 because the clone is shallow (its pinned fixture commit was fetched). All are green on the final sweep.
  • Full dogfood battery: n/a. The title carries no ! and Clause-② has no (narrowing) arm.

POST /api/v1/auth/authorized-clients/withdraw { id } withdraws one of the
session user's own OAuth consents; POST /api/v1/auth/admin/authorized-clients/withdraw
{ userId, id } does the same for a platform administrator behind the admin
read's gate. Both revoke the grant the way /oauth2/revoke does (consent row,
then the client's refresh family), so the assistant's token is refused on its
next MCP call. Unknown and not-yours answer one 404. Ledgered as sdk with
oauth.authorizedClients.withdraw / withdrawForUser on the client.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxU7nr4BXM7e8qMfwq
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/client, @objectstack/plugin-auth, touching 53 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/mcp-token-revocation.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

49 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 3f61e4f8fb567160f839c3269ee95aa89ee4f1db.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/mcp-token-revocation.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: userId (5 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 49 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3f61e4f8fb567160f839c3269ee95aa89ee4f1db → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9e3738d4e2b90350e8d97a476749543391d03f00 — the merge of head 05ade9835ce438093cafe180ec8ea415ff816641 into base 3f61e4f8fb567160f839c3269ee95aa89ee4f1db, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9e3738d4e2b90350e8d97a476749543391d03f00 && git checkout 9e3738d4e2b90350e8d97a476749543391d03f00
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3f61e4f8fb567160f839c3269ee95aa89ee4f1db 05ade9835ce438093cafe180ec8ea415ff816641 && git checkout -B drift-repro 3f61e4f8fb567160f839c3269ee95aa89ee4f1db && git merge --no-ff 05ade9835ce438093cafe180ec8ea415ff816641

node scripts/docs-audit/affected-docs.mjs --json 3f61e4f8fb567160f839c3269ee95aa89ee4f1db

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3f61e4f8fb567160f839c3269ee95aa89ee4f1db → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1c32be0682768ddccefc88e23862819eb0799830
Local-runs: none

  • Read-only review in a detached worktree at head 1c32be06 (merge base with origin/main 5210acfb); no test or gate was run locally (the worktree carries no node_modules, and a src-resolved run against another worktree's install would not be a reading of this tree). The vendor measurement was re-done by reading @better-auth/oauth-provider 1.7.12's built dist/authorize-D_jy3s-1.mjs (deleteConsentEndpoint, L3141–3161) from an installed copy of the lockfile's pinned version.

Scope: draft PR #31073 (claude/project-thread-h42d2s, 15 files, +991/−18, no governed, packages/spec/**, ui/** or skills/** path) for card #31048 (triage 6696803075; claim 6699583095; os-dev-report 6700553383), against the order /mnt/project-files/server-seat-2/r2026-11-06/dispatch-31048.md + FRAME.md, and the landed precedents #30946 (admin/authorized-clients read, gateAdmin/judgePlatformAdmin, maintainer's decision A on #30918) and #30879 (mcpConsentAdmits / revokeMcpGrant). Judged: ownership and enumeration, the admin gate, the effect on the token and refresh family, ledger and client, error codes, audit, declarations, tests and ablations, CI.

1. Ownership and enumeration

  • Design (b) verified. The vendor route (deleteConsentEndpoint, oauth-provider 1.7.12 dist L3141–3161) reads the consent by id alone (getConsent(ctx, opts, id)), throws NOT_FOUND for no row and UNAUTHORIZED when consent.userId !== session.user.id, then deletes only the oauthConsent row. The dev's three measurements (ownership checked; 404 vs 401 distinguishable; refresh family left) are exactly what the source says, so option (a) is not a security defect and the choice of (b) is sound and its rationale honest.
  • Own door keyed by the session subject. authorized-clients-withdraw.ts:247–260 runOwnAuthorizedClientWithdraw reads the session (readCallerSession, L156–159), refuses 401 UNAUTHENTICATED with no subject, judgeHeldSession next (L256), reads the body last, then withdrawConsentOf(deps, subject, subject, id) — the userId the lookup is keyed by is the session's, never the body's. findConsentOf (L160–173) issues ONE findOne on oauthConsent with both id and userId predicates (the ObjectQL adapter ANDs clauses: objectql-adapter.ts:367–420 assigns one filter[field] per clause) and re-checks both fields on the row it gets back (L170), so a store that ignored a predicate still cannot hand back another person's row.
  • 404 bodies byte-identical. Unknown id and another person's id take the same path: one findOne → null → notFound() (L194–195, via L241), one frozen message (AUTHORIZED_CLIENT_WITHDRAW_NOT_FOUND_MESSAGE, L97–98), Cache-Control: no-store. No second query and no write happens on the refusal path, so timing leakage is limited to the store's own index miss in both cases and there is no side-effect leak. Pinned on the real plugin: authorized-clients-read.real-plugin.test.ts:641–662 asserts others.text === unknown.text for a member AND for a platform admin on the own door, that the row and refresh token stay, and that Bob's token still verifies; the dogfood pin repeats it over HTTP (authorized-client-withdraw.dogfood.test.ts:198–213).
  • Closed body set. readWithdrawRequest (L202–232): a query parameter is refused via the read's own unknownQueryParamRefusalMessage(queries, []) (closed, empty set); JSON declared and unparseable → 400 INVALID_REQUEST (readJsonRequestBody); non-object/array body, any key outside ['id'] / ['id','userId'], a missing, blank or non-string value → 400 VALIDATION_ERROR. Pinned L664–692 (own) and L744–753 (admin). A repeated key is not detectable after JSON.parse (last wins); that is inherent to a JSON body and no door in this package detects it — accepted, not a finding.

2. The admin gate

  • Same gate code, not a copy. auth-plugin.ts:3905–3908: const actor = await gateAdmin(c); if (actor instanceof Response) return actor; as the handler's first statement, before runAdminAuthorizedClientWithdraw reads the body. gateAdmin is the one closure at auth-plugin.ts:3669–3683 that GET /admin/authorized-clients (L3883–3885) and every other ObjectStack /admin/* mount call; it runs judgePlatformAdmin(session) (platform-admin-gate.ts:176–225).
  • Held session refused (ADR-0069 D3). judgePlatformAdmin calls judgeHeldSession(user) pathless between the 401 and the 403 (platform-admin-gate.ts:198–199; the judge itself L148–155), so a held platform admin is refused 403 with the posture's own code before the body is read. The own door applies the same judge (authorized-clients-withdraw.ts:256–257), matching the own read (authorized-clients-read.ts:330–331).
  • Impersonation. An impersonation session is judged on the impersonated user's standing (unchanged from every admin mount) and gateAdmin carries impersonatedBy onto the actor for attribution (auth-plugin.ts:3679–3682); the admin door passes that actor through runAttributedToUser(actor, …) (authorized-clients-withdraw.ts:242, via L280). The OWN door does not — see should-fix 1.
  • Org owner/admin refused with one answer. isPlatformAdminUser reads only isPlatformAdmin / legacy role === 'admin' (platform-admin-gate.ts:127–133), so an organization owner or admin is 403 PERMISSION_DENIED before any body read, whether or not the person or row exists. Pinned L758–793: member, org OWNER, org ADMIN and the person themself each get byte-identical 403 text for an existing {userId,id}, a non-existent pair, and {}; anonymous 401; consentIds unchanged; Bob's token still admits. The dogfood sweeps classify the route objectstack-gate / ADMITTED with a body that reaches the gate (admin-route-nonadmin-refusal.dogfood.test.ts:293–297, admin-platform-admin-standing.dogfood.test.ts:249–255, 737–739); the refusal sweep enumerates admin routes off the live stack, so the row was required, not optional.
  • The admin path binds id to userId. runAdminAuthorizedClientWithdraw (L269–281): userExists(userId) else the read's own 404 message (L277–279), then withdrawConsentOf(deps, actor, userId, id) (L280) → the same findConsentOf(userId, id) conjunction. A consent that belongs to someone other than userId reads the same 404 as none (pinned L718–756, mismatched.text === unknown.text, rows unchanged). The distinguishable "no such user" 404 is the admin read's declared behaviour (ledger L198) and is behind the gate, so it enumerates nothing to a non-admin.

3. Effect

  • Token refused on the next MCP call. revokeGrant → revokeMcpGrant(store, grant) (mcp-token-revocation.ts:215–234), the /oauth2/revoke owner: deletes the consent(s) for (client, user, organization) first, then the client's oauthAccessToken rows by refreshId in [...] and the oauthRefreshToken family by (client, user). mcpConsentAdmits (L189–205) re-reads the consent on every request, so the JWT fails closed. Pinned on the real plugin: verifyMcpAccessToken(deskToken) goes from non-null to null after the withdraw while the person's other grant's token still verifies (L609–639); over HTTP: initialize 200 → 401 after the withdraw (dogfood L222), and the refresh grant answers 400 invalid_grant (L226), so no new access token can be minted. The admin door is pinned the same way (real-plugin L695–716; dogfood L233–250).
  • Refresh-family scope. sys_oauth_refresh_token carries reference_id (platform-objects/src/identity/sys-oauth-refresh-token.object.ts:100), but revokeMcpGrant deletes by (clientId, userId) only (mcp-token-revocation.ts:219–222). So a person who granted the same client in two organizations and withdraws one row keeps the other organization's consent (the consentsOf filter is organization-exact, isConsentFor L148–150 / consentsOf L152–162, and the pin at L631–633 shows the other grant's token still admits) but loses that organization's refresh tokens: its access token works until expiry, then the assistant must re-run the authorization flow (no consent prompt, the consent row stands). Judgement: acceptable here, not a defect of this PR — it fails closed, it is the behaviour /oauth2/revoke already has since fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879, narrowing it would change that landed owner, and the card asks for one owner for "revoke a grant". The body declares it in Acceptance notes and the dev report lists it under out_of_scope_findings as "noted, not filed". It is a reproducible behavioural gap on a landed path, so it should carry a card (should-fix 2), not just a note.

4. Ledger and client

  • auth-route-ledger.ts:176: POST /oauth2/delete-consent now ledgered server-only with the 1.7.12 measurement and a pointer to the new door — honest (it stays mounted by the catch-all, nothing shadows it; BETTER_AUTH_MOUNTED_SURFACE L433 unchanged). L198 (admin read) unchanged; L199 (admin/authorized-clients/withdraw, sdk, oauth.authorizedClients.withdrawForUser) and L201 (authorized-clients/withdraw, sdk, oauth.authorizedClients.withdraw) describe the gate order, the one-404 rule, the closed body/query sets and the revoke accurately against the code; L200 (own read) now names the withdraw as the id's consumer. No tracker id in any note string (the first-sweep check:doc-authoring red was fixed in cdf571a0).
  • auth-route-ledger.conformance.test.ts:251–259, 285: both routes added to the objectstack-sourced roster proven absent from better-auth's live endpoint table; the client-side half (packages/client/src/auth-route-ledger-coverage.test.ts) resolves every sdk row's client name on a real client, and both methods exist: packages/client/src/index.ts:5047–5073 (withdraw(id) → POST …/authorized-clients/withdraw body {id}; withdrawForUser(userId, id) → POST …/admin/authorized-clients/withdraw body {userId,id}), typed AuthorizedClientWithdrawal (L1272–1276). The stale L~5006 doc line now reads "Withdraw a grant with withdraw()" (L5012), and AuthorizedClient.id's doc (L1243) no longer points at /oauth2/delete-consent. oauth-authorized-clients.test.ts:74–117 pins URL, method, exact body, throw on 404/403 and the return types. The dev reports the conformance file green (26/26 real-plugin + ledger); CI's Test Core shards carry it and were still running at the last read (§9).

5. Error codes

RESOURCE_NOT_FOUND (404), VALIDATION_ERROR (400), INVALID_REQUEST (400), UNAUTHENTICATED (401), PERMISSION_DENIED (403) — every code the module emits (authorized-clients-withdraw.ts:188–195, 206–230, 254–257, 277–279; the 403 is gateAdmin's) is an existing entry of content/docs/api/error-catalog.mdx (L60, L400, L439, L495, L728/775) at the status used; the held-session 403 reuses the posture's own AuthGateCodeSchema code as the precedent does. No packages/spec, ui/ or skills/ file in the diff; nothing added to the vocabulary.

6. Audit

  • No explicit sys_audit_log write exists on the grant path (better-auth's consent endpoint) or on /oauth2/revoke (mcp-token-revocation.ts writes none), so writing one here would have needed a new action value; the dev correctly did not.
  • Generic coverage verified: sys_oauth_consent is not in plugin-audit's SKIP_OBJECTS (audit-writers.ts:257–323), and every adapter write goes through withSystemContext → authSystemWriteContext() (objectql-adapter.ts:748–762), which puts attributedUserId (and impersonatedBy / performedBy when the scope carries them) on the context; the writer credits user_id = session.userId ?? provenance.attributedUserId (audit-writers.ts:2067–2072). withdrawConsentOf opens the scope with runAttributedToUser(actor, …) (authorized-clients-withdraw.ts:242), so the delete row names the withdrawer, not the system. Measured over HTTP: deletes[0].user_id === ownerId (dogfood L228–230) and a create row for the grant (L203). The admin door's crediting (the admin's id) is structural through the same call with the gate's actor and is not measured in dogfood (nit 4).

7. Declarations

  • PR body: Clause-②: yes (widening) and Metadata-protocol: none at line start; Fixes #31048 in the body; ## 维护者速读 six lines; Acceptance notes; verification record; session-URL footer. Changeset .changeset/31048-withdraw-authorized-assistant.md: patch for @objectstack/plugin-auth and @objectstack/client (correct: non-protocol widening ships patch), both declaration lines, no model name.
  • Commits a6e995a0, 9bcc1b5e, cdf571a0: Co-authored-by: Claude <noreply@anthropic.com> + Claude-Session: https://claude.ai/code/session_01L9qibxU7nr4BXM7e8qMfwq; no model identifier, no Fixes. The merge commit 1c32be06 carries the default message with no trailer pair (nit 3).
  • Docs: on the merge base no content/docs page named authorized-clients at all (git grep on 5210acfb → none), so "pages listing the doors" was the empty set; the PR adds one bullet to content/docs/ai/connect-mcp.mdx:379–392 covering list/withdraw for both roles and the SDK spellings. No new page, so no meta.json change is owed. One imprecision (nit 1).

8. Tests and ablations

  • On the real plugin: the harness in authorized-clients-read.real-plugin.test.ts boots a real AuthManager, real better-auth and the real AuthPlugin routes on Hono over a memory engine; the six new cases live there as two top-level describes (the shared-harness rule), not in a new file. Each card bullet has a pin: own withdraw + token refused + other grants untouched + second withdraw 404 (L609–639); another's id same bytes + row stays (L641–662); shape refusals (L664–692); admin withdraw + token refused + list shrinks (L695–716); mismatched/unknown/no-user (L718–756); the non-admin matrix (L758–793). The dogfood file drives real DCR → consent → code exchange → MCP initialize → refresh on the showcase stack with MCPServerPlugin and AuditPlugin.
  • Ablations ran on the committed fix with scripts/ablation-replace.mjs, blob == HEAD after each (8835068a7d09 = the withdraw module's blob at head, 61ed5aefa975 = auth-plugin.ts's blob at head — both match git diff index lines in this tree). (i) ownership removed → the two cross-user pins red with expected 200 to be 404: that is precisely the card's "another's id is refused and leaves it in place". (ii) gateAdmin removed → the non-admin matrix red with member: expected 200 to be 403: precisely the card's "refused for anyone else". Both pin what the card asks.

9. CI (head 1c32be06, last read 2026-11-07T01:54Z)

62 check-runs: 30 completed/success, 8 completed/skipped, 24 in_progress, 0 failures. Of the required contexts: Lint & Repo Gates success, Governed Surface Queue Guard success, Build Core success, Build & E2E UI success, Live E2E UI success, Lint UI skipped (no ui/ path); still in_progress: Test Core shards 1/8, 2/8, 3/8, 4/8, 6/8, 8/8 (5/8 and 7/8 success), Type Check · workspace, Type Check · consumer gates, Type Check UI, Test UI ×16. No red at any of four reads over ~8 minutes. The only PR comment is the advisory docs-drift bot (6700554314); no reviews.

Findings

Must-fix

  • none. No security defect found; nothing to route to security#1. The vendor delete-consent ownership check the dev measured is confirmed in the installed 1.7.12 source.

Should-fix

  1. Own door drops impersonatedBy from the audit attribution. runOwnAuthorizedClientWithdraw passes the bare subject string as the actor (authorized-clients-withdraw.ts:260 → runAttributedToUser(actor, …) at L242), so a withdraw made from an impersonation session records user_id = the impersonated user and no impersonated_by, whereas the same session's write through any better-auth door carries it (auth-manager-before-hook.ts:137–140) and the admin door carries it via gateAdmin (auth-plugin.ts:3679–3682). Attribution only, no authorization effect. Fix: read session?.session?.impersonatedBy in runOwnAuthorizedClientWithdraw and pass { id: subject, impersonatedBy } (the runAttributedToUser object form), plus one pin. Two lines; the seat may take it on this branch (delta-only re-read) or file it — it does not block the card's contract.
  2. File a card for the organization-unscoped refresh-family delete (revokeMcpGrant, mcp-token-revocation.ts:215–234, inherited by /oauth2/revoke and both withdraw doors): the body's Acceptance note is accurate, but a reproducible behavioural gap on a landed path belongs on a card (Prime Directive 10), not only in a note. Not this PR's scope; domain:server, p3.

Nits

  1. content/docs/ai/connect-mcp.mdx:390 says "anyone else is refused 403" for the admin door; anonymous is 401 (the changeset and ledger say so correctly).
  2. Stale prose after this PR: mcp-token-revocation.test.ts:23, 253 and mcp-client-last-use.real-engine.test.ts:181 still call /oauth2/delete-consent "the Console's withdraw"; the Console's withdraw is now the new door.
  3. Merge commit 1c32be06 carries no Co-authored-by / Claude-Session trailer pair (the three authored commits do).
  4. The admin door's audit crediting (user_id = the administrator) is structural, not measured; the dogfood admin case asserts only the 403 / 200 / 401 chain.

VERDICT: PASS — landing waits on: the required contexts still in_progress at the last read (Test Core ×6 of 8, Type Check · workspace/· consumer gates, Type Check UI, Test UI ×16) completing with conclusion: success on 1c32be06, and the seat's call on should-fix 1 (take it on this branch with a delta-only re-read, or file it); should-fix 2 is a card to file, not a landing condition.

An own withdraw made from an impersonation session now hands
runAttributedToUser { id, impersonatedBy } read off the session, as
gateAdmin and the auth before-hook do, so the revoke's writes carry the
attribution-only impersonated_by. Two pins read the credited actor from
inside the revoke.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxU7nr4BXM7e8qMfwq
- connect-mcp: the admin withdraw refuses no session 401, anyone else 403.
- Test prose no longer calls better-auth's delete-consent the Console's.
- dogfood: the admin door's generic delete row is credited to the
  administrator who withdrew the grant.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxU7nr4BXM7e8qMfwq
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

维护者速读

席位意见:可以落地。

席位核对

ACCEPT · seat domain:server#2 · head 05ade983 · 2026-11-07T02:41Z

  1. Shape: Fixes #31048, draft, base main, 17 files, +1055/−21 (1,076 changed lines; check-governed-merges --pr 31073: not governed, under the human-merge threshold). Clause-②: yes (widening) (two ledgered doors, two client methods), Metadata-protocol: none. One patch changeset. No packages/spec, ui/ or skills/ edit. Claim 6699583095.
  2. Premise: measured in the PR. POST /oauth2/delete-consent was mounted with no ledger row and no client method, and no door withdrew for a managed person. better-auth 1.7.12's route does check ownership, so there is no security defect and nothing went to security#1. Its answer set (404 vs 401) is distinguishable and it leaves the refresh family alive, which is why the PR takes design (b).
  3. Review: CONTRACT_REVIEW_TIER review 6700677067 (PASS on 1c32be06, 0 must-fix).
    • Should-fix 1 (an impersonation session's own withdraw dropped impersonatedBy from the attribution) was fixed in 7123f12a, with one pin and an ablation red once.
    • Should-fix 2 (the organization-unscoped refresh-family delete, inherited from revokeMcpGrant) was filed as plugin-auth: withdrawing an AI assistant in one organization also deletes its refresh tokens in the person's other organizations #31076 and is linked from Acceptance notes.
    • The nits are in 87d2f1c6.
    • The seat's delta read: the tree of 05ade983 differs from a clean git merge-tree of 1c32be06 and main 3f61e4f8 only by those two commits (6 files, +69/−8): the actor shape, the docs line (401 for no session, 403 otherwise), three prose lines and the admin-door audit credit measured in dogfood.
  4. Gates / check-runs: on head 05ade983: 57 success, 8 skipped, 0 failed. Dev: plugin-auth 4189 passed (10 skipped), client 712/712, typechecks exit 0, the touched dogfood 17/17, eslint exit 0, dispatch-gates --commands 95/95 exit 0, --ran 95/0/0. Ablations: ownership removed gives 2 red, the gate removed gives 1 red, impersonatedBy dropped gives 1 red; each restored to blob == HEAD.
  5. Accepted deviations / follow-ups: POST /oauth2/delete-consent stays mounted by better-auth's catch-all, ledgered server-only with the measurement. Shadowing it is not in this card. The refresh-family scope is plugin-auth: withdrawing an AI assistant in one organization also deletes its refresh tokens in the person's other organizations #31076. The OAuth consent page promises you can revoke access from your account settings, but no page lists the AI assistants you authorized #30894's Revoke half (domain:ui) is unblocked by this landing.
  6. Landing: not governed. The seat readies the PR and arms auto-merge on the maintainer's word in the seat thread 「你应该使用 pm-dispatch 技能审核和落地pr」(2026-11-06T16:49Z).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 7, 2026 02:42
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 7, 2026 02:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit e11a575 Nov 7, 2026
70 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-h42d2s branch November 7, 2026 03:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auth: the console cannot withdraw an authorized AI assistant: /oauth2/delete-consent has no ledger row or client method, and no admin door

1 participant