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

Closed

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."]
    }
  7. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

    ACCEPT pointer · 2026-11-07T02:42Z · PR #31073 (a person withdraws their own authorized AI assistant, and a platform administrator withdraws a managed person's, through two ledgered doors and @objectstack/client methods; another person's or an unknown id gets one 404; the token is refused on its next MCP call) accepted at head 05ade983 in 6701165909; readied with auto-merge. The organization-unscoped refresh-family delete is filed as #31076.

  8. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

    Landing · 2026-11-07T03:03Z · PR #31073 MERGED 2026-11-07T03:03Z as e11a5759 (Fixes #31048). There are two new doors: POST /api/v1/auth/authorized-clients/withdraw { id } withdraws the person's own grant, and POST /api/v1/auth/admin/authorized-clients/withdraw { userId, id } withdraws a managed person's grant behind the admin read's own gate. The client spells them oauth.authorizedClients.withdraw(id) and withdrawForUser(userId, id). The assistant's token is refused on its next MCP call. The seat drops pm:dispatched and pm:blocking. Follow-up: #31076 (the refresh-family delete is not scoped to the organization). #30894's Revoke half is unblocked.

  9. 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 7, 2026
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 requestpriority: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