Skip to content

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

Open

Description

@objectstack-fleet

domain:ui#3 · os-tesla · session_01L9qibxW6qraU2Z3mwupUGp

Filing gate: ③ the maintainer's order that names this work: #30894 (「都同意,立卡」, 2026-11-06), whose Expected asks for "a Revoke action … An administrator can see and revoke them for a person they manage", and whose triage audit 6682129286 says the server door the page needs, if one is missing, is filed to domain:server first. The withdraw itself is the maintainer's cloud#3606 ruling A (「撤销或撤回时删掉这条记录 … 控制台的撤回也随之真正生效」), quoted in packages/plugins/plugin-auth/src/mcp-token-revocation.ts. ⛔ Not a claim. Triage sets the grade and the lane. Suggested grade: p1 (#30894's grade; it is the half its Revoke needs).

Business reason. The consent page promises 「您可以随时在账户设置中撤销访问权限。」. Since #30946 the console can list the assistants a person authorized (and an admin can list them for a person), but it has no ledgered door to withdraw one, so the list would show a lost laptop's Claude Desktop with no way to turn it off.

Reader. The domain:server seat (@objectstack/plugin-auth: the route ledger, the client, an admin door), then the domain:ui seat holding #30894.

Dedupe. delete-consent (86 hits; the auth ones are #30345 and #30918, both closed, neither ledgers the withdraw), withdraw consent (12: #30918, #30345), authorized-clients revoke (1: #30918). New.

What is on origin/main 115a43ad16

  • POST /api/v1/auth/oauth2/delete-consent is published by better-auth (BETTER_AUTH_MOUNTED_SURFACE, auth-route-ledger.ts ~L430) but has no AUTH_ROUTE_LEDGER row and no @objectstack/client method. Its neighbours oauth2/consent, create-client, delete-client, get-client(s) and public-client all carry disposition: 'sdk' rows (~L173-178).
  • mcp-token-revocation.ts already names it "the Console's withdraw": it deletes the consent row, and since fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 mcpConsentAdmits refuses the token on its next request. So the effect is there; the door the console may call is not.
  • The client's oauth.authorizedClients.list() doc says "Withdraw a grant with POST /oauth2/delete-consent and the row's id" (packages/client/src/index.ts ~L5006), pointing at an unledgered route.
  • An administrator: GET /api/v1/auth/admin/authorized-clients?userId= (feat(plugin-auth): read the AI assistants a person has authorized, and when each was last used #30946) lists a person's grants, but nothing withdraws one for them; delete-consent acts for the session user only.

Wanted

  • A ledgered, client-exposed withdraw of one of the session user's own authorized assistants, by the row id authorized-clients answers (the lane picks: ledger delete-consent as sdk, or an ObjectStack mount beside authorized-clients). After it, that assistant's token is refused on its next MCP call.
  • The same withdraw for a person a platform administrator manages, behind the same gate as admin/authorized-clients (security#1's rules on Auth: no ledgered read lists the AI assistants a person has authorized, records when one was last used, or lets an admin see a managed person's #30918), refused for anyone else with one indistinguishable answer.
  • Tests on the real plugin: a member withdraws only their own (another's id is refused and leaves it in place); an admin withdraws a managed person's; the withdrawn token is refused next call.

Unblocks: the Revoke half of #30894. Related: #30918 (PR #30946), #30345 (PR #30879), cloud#3606.

Activity

  1. added
    area:aiAI-native — agent / tool / skill metadata, and the MCP surface an agent drives
    domain:serverDomain lane — seat card indexed by label:pm:seat
    enhancementNew feature or request
    pm:queueReady for the PM dispatch loop
    priority:p1High: required for production / M2
    on Nov 6, 2026
  2. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: audit · p1 · domain:server · 2026-11-06T21:54Z

    ⛔ Not a claim.


    Generated by Claude Code

  3. added
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    on Nov 7, 2026
  4. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

    Claim: PM loop round 3 · 2026-11-07T00:27Z
    Session: session_01L9qibyvxbrLP4jm4PargoH
    Account: os-tesla (the card's assignee)
    Branch: claude/project-thread-h42d2s (the card thread's designated branch; dev session session_01L9qibxU7nr4BXM7e8qMfwq)
    Worktree: objectstack-issue-31048
    Domain: domain:server
    Seat: domain:server#2
    File surface: packages/plugins/plugin-auth/src/** (the route ledger, the authorized-clients read and a withdraw beside it, the auth-plugin.ts mount lines, their tests), packages/client/src/index.ts (the oauth.authorizedClients methods and the stale delete-consent doc line) and its test, packages/qa/dogfood/test/ (one HTTP pin plus the admin non-admin refusal list if it enumerates the route), the content/docs/** pages that list the authorized-clients doors, one changeset. ⛔ packages/spec/** (a new published key or error code stops and reports), ⛔ ui/** (#30894's half), ⛔ skills/**.
    Container & model: M, mode:thread, model: TIER_DEFAULT, effort: high
    Clause-②: yes (widening)
    Metadata-protocol: none
    Responsibility: the missing door is ours (plugin-auth + client, objectstack): POST /oauth2/delete-consent is mounted with no ledger row or client method, and no admin door withdraws for a managed person. It reaches every person whose authorized assistant must be turned off (cloud#3606 ruling A).
    Thread-read: 6696803075
    Premise: holds. On origin/main 23cf11a4 auth-route-ledger.ts lists POST /api/v1/auth/oauth2/delete-consent only in the mounted surface (L417); the ledger has rows for GET /authorized-clients and GET /admin/authorized-clients (L198–199) and none for a withdraw.
    Serial constraints cleared: none open; #30946 (#30918, the admin read and its gate) landed 6bd00d2a.

    Order: /mnt/project-files/server-seat-2/r2026-11-06/dispatch-31048.md (frame FRAME.md beside it). The ownership measure of better-auth's delete-consent comes first; a defect there routes to security#1 rule-level only.

  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 7, 2026
  6. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-tesla · session_01L9qibxU7nr4BXM7e8qMfwq

    os-dev-report

    {
     "issue": 31048,
     "status": "done",
     "branch": "claude/project-thread-h42d2s",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/31073",
     "session": "session_01L9qibxU7nr4BXM7e8qMfwq",
     "premise_still_valid": true,
     "summary": "Design (b): two ObjectStack mounts in the new plugin-auth authorized-clients-withdraw.ts. POST /api/v1/auth/authorized-clients/withdraw { id } withdraws a consent of the session user only; POST /api/v1/auth/admin/authorized-clients/withdraw { userId, id } runs the same gateAdmin as GET /admin/authorized-clients before any body read. Both revoke through revokeMcpGrant (the /oauth2/revoke owner: the consent row, then the client's refresh family, via better-auth's adapter, attributed to the withdrawer), so mcpConsentAdmits refuses the token on its next MCP call. Unknown and not-yours give one byte-identical 404 RESOURCE_NOT_FOUND. Ledgered sdk (oauth.authorizedClients.withdraw / withdrawForUser, added to @objectstack/client); better-auth's /oauth2/delete-consent ledgered server-only with the measurement. Measured on @better-auth/oauth-provider 1.7.12: delete-consent DOES check ownership (consent.userId !== session.user.id), so there is no security defect, but it answers unknown 404 and another's 401 (distinguishable) and leaves the refresh family, which is why (a) was not taken. Audit: grant and revoke write no explicit row; plugin-audit's generic writer records the consent create/delete (sys_oauth_consent not in SKIP_OBJECTS), and the withdraw inherits the same generic delete row with no new action value, measured in dogfood with user_id = the withdrawer.",
     "tests": "On head 1c32be06 (main 5210acfb merged): plugin-auth `pnpm --filter @objectstack/plugin-auth test` Test Files 232/232, Tests 4187 passed | 10 skipped; real-plugin + ledger conformance 26/26; client `pnpm --filter @objectstack/client test` 55/55 files, 712/712 (cdf571a0; merge touched no client file), touched client file 8/8 on the final head; plugin-auth + client typecheck exit 0; dogfood after a full pnpm build (119/119): authorized-client-withdraw 2/2, admin-route-nonadmin-refusal + admin-platform-admin-standing 15/15; eslint --no-inline-config on changed .ts exit 0. Ablations via scripts/ablation-replace.mjs against the committed fix, src-resolved suite (plugin-auth tests import ./auth-plugin and ./authorized-clients-withdraw source, no dist): (i) ownership check removed from findConsentOf -> 2 red ('another person's id is refused with the SAME bytes…' expected 200 to be 404; 'a consent that is not the named person's…' expected 200 to be 404), restored blob == HEAD 8835068a7d09, git diff HEAD empty; (ii) gateAdmin removed from the admin withdraw mount -> 1 red ('a member, an organization OWNER…' member: expected 200 to be 403), restored blob == HEAD 61ed5aefa975, git diff HEAD empty.",
     "gates": "node scripts/pm/dispatch-gates.mjs --commands on 1c32be06: 95 derived, 95 run, all exit 0, each with </dev/null and the exit code captured before any pipe; --ran reconciliation: 95 run, 0 NOT-MEASURED, 0 UNRUN (record /mnt/project-files/server-seat-2/r2026-11-06/out/31048-gates-ran-1c32be06.list). First sweep on 9bcc1b5e: check:doc-authoring exit 1 (cloud#3606 in a ledger note string; removed in cdf571a0), check:skill-examples and check:dual-build-cjs-loads exit 3 (no dist before the full build), check-plugin-teardown-shape --self-test exit 3 (shallow clone; pinned fixture commit fetched); all exit 0 on the final sweep. pnpm lint family: eslint --no-inline-config on the changed .ts files exit 0; the repo-wide pnpm lint is CI's. PR check-runs: in_progress at report time.",
     "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
     "mcp_calls": "3 — mcp__github__issue_read (get #31048, get_comments #31048 twice); 0 writes",
     "api_writes": "2 — fleet-write relay pr_create (objectstack-ai/objectstack#31073, read-back identical 11632 bytes); this os-dev-report comment via post-stamped. The harness's assignee/reviewer step was skipped on the frame's rule.",
     "line_budget": "15 files, 991 insertions / 18 deletions vs merge base 23cf11a4 (under 3000; no governed path)",
     "files_changed": "packages/plugins/plugin-auth/src/{authorized-clients-withdraw.ts (new), auth-plugin.ts, auth-route-ledger.ts, auth-route-ledger.conformance.test.ts, authorized-clients-read.ts, authorized-clients-read.real-plugin.test.ts, mcp-token-revocation.ts, auth-manager.ts}; packages/client/src/{index.ts, oauth-authorized-clients.test.ts}; packages/qa/dogfood/test/{authorized-client-withdraw.dogfood.test.ts (new), admin-route-nonadmin-refusal.dogfood.test.ts, admin-platform-admin-standing.dogfood.test.ts}; content/docs/ai/connect-mcp.mdx; .changeset/31048-withdraw-authorized-assistant.md",
     "docs_pages": "No content/docs page listed the authorized-clients doors before this change. One bullet added to content/docs/ai/connect-mcp.mdx, in The security model.",
     "answer_table": "own door: own id 200 { id } | another's id 404 RESOURCE_NOT_FOUND (same bytes as unknown; row stays) | unknown id 404 RESOURCE_NOT_FOUND | a platform admin on the own door with another's id 404 (keyed by session). admin door: platform admin with { userId, id } of that person 200 | id not that person's 404 (same bytes as unknown) | userId naming no user 404 with the admin read's message | non-admin (member, org owner, org admin, the person) 403 PERMISSION_DENIED, byte-identical for existing, missing and empty bodies. Anonymous 401 UNAUTHENTICATED on both. Shape: query param / foreign key / missing, blank or non-string value / non-object 400 VALIDATION_ERROR; unparseable JSON 400 INVALID_REQUEST.",
     "deviations": "Changeset and commit trailers are model-free (Co-authored-by: Claude) per AGENTS.md, which overrides the harness's model-named trailer. Ablations ran on the src-resolved unit suite, not dogfood. The refresh delete is not organization-scoped (inherited from revokeMcpGrant); see Acceptance notes.",
     "open_questions": [],
     "security_findings": "none",
     "out_of_scope_findings": ["carrier: none · noted, not filed — revokeMcpGrant (and so /oauth2/revoke and this withdraw) deletes the client's refresh family across organizations although oauthRefreshToken carries referenceId; a person with the same client granted in two organizations re-authorizes the other organization after a withdraw. Recorded in the PR's Acceptance notes."]
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:aiAI-native — agent / tool / skill metadata, and the MCP surface an agent drivesdomain:serverDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-setpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions