Skip to content

plugin-auth: withdrawing an AI assistant in one organization also deletes its refresh tokens in the person's other organizations #31076

Closed

Description

@objectstack-fleet

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

Filing gate: ② the contract review of PR #31073 (#31048), should-fix 2 (record 6700677067), which asks for this to be tracked on its own card rather than changed in that PR.
Business reason: a person can authorize the same AI assistant (for example Claude Desktop) in two organizations. Withdrawing it in one organization, from the console or with POST /oauth2/revoke, deletes only that organization's consent, but it also deletes every refresh token the assistant holds for that person in every organization. In the other organization the assistant keeps working until its access token expires, then it is signed out and the person has to authorize it again there. Nobody asked for that, and nothing tells them why. It fails closed (no access is widened), so this is a p3 nuisance, not a security finding.
Reader who acts: a domain:server seat's card thread, in packages/plugins/plugin-auth (mcp-token-revocation.ts), one PR.
Duplicate search: repo:objectstack-ai/objectstack is:issue revokeMcpGrant OR "refresh family" → 10 hits. #30345 (closed) built revokeMcpGrant; #24166 (closed) binds a consent to an organization; neither scopes the refresh family. New.

What is on origin/main

Wanted

  • Measure first whether a refresh-token row records the organization it was minted under (the provider's oauthRefreshToken fields, or a reference the consent row gives). If it does, scope the family delete to the withdrawn grant's organization. If it does not, report the shape it would take (a stored org on the refresh row) and stop. That would be a provider-adapter question, not a quick fix.
  • Pin it on the real plugin: one person, one client, consents in two organizations. Withdraw in organization A, and the refresh token minted in organization B still refreshes. The A token is refused on its next MCP call, as today.
  • Keep /oauth2/revoke's answer set unchanged (an RFC 7009 revoke of one token).

Related: #31048 (PR #31073), #30345, #24166.

Activity

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Nov 7, 2026
  2. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: audit · p3 kept · domain:server · 2026-11-07T02:54Z

    ⛔ Not a claim.


    Generated by Claude Code

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

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B

    Claim: PM loop round 1 · 2026-11-07T12:29Z
    Session: session_017ihL2dFb2QdZEHEJ83cR2B
    Account: marchtian
    Branch: claude/project-thread-ibtwt4
    Worktree: objectstack-issue-31076 (the card thread's own checkout)
    Domain: domain:server
    Seat: domain:server#3
    File surface: packages/plugins/plugin-auth/src/mcp-token-revocation.ts and its tests (stop on breach; explain in the report)
    Container & model: M, mode:thread (card thread cmsg_01VmuUhXU9SSUzqJYyXSmYSiYM7sr1GMfaGo4HQHYGDnYa of this account's project), model: TIER_DEFAULT — dispatch-gates --tier: no path-derived mandate, effort: session default — card-thread effort is set by the maintainer only; the seat's reading is high (M; auth revocation surface)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: plugin-auth's revokeMcpGrant (objectstack, packages/plugins/plugin-auth/src/mcp-token-revocation.ts) reads the refresh family by client + user only | no platform path scopes it; all three withdraw doors share the function since #31073 | any person who authorized one assistant in two organizations and withdraws it in one; fails closed
    Thread-read: 6701306966
    Premise: holds — read at origin/main 5f00374: revokeMcpGrant family filter is clientId + userId
    Serial constraints cleared: open PR #31013 touches other plugin-auth files (auth-manager-plugin-list.ts, auth-manager-after-hook.ts, auth-plugin.ts), disjoint from mcp-token-revocation.ts; sibling #31032 (this seat) may touch scim-connection-admin.ts only

    The dispatch order is the card thread's opening message (files server-seat-3/orders/_standing.md + server-seat-3/orders/31076.md in this account's project); slot granted by the project coordinator.

  5. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · marchtian · session_017ihL2dLyDnKhuba9ZJk6uy
    os-dev-report

    {
     "issue": 31076,
     "status": "done",
     "branch": "claude/project-thread-ibtwt4",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/31132",
     "session": "session_017ihL2dLyDnKhuba9ZJk6uy",
     "premise_still_valid": true,
     "summary": "Measured at @better-auth/oauth-provider 1.7.12: an oauthRefreshToken row stores referenceId (sys_oauth_refresh_token.reference_id), copied from the consent's reference onto the authorization code, the refresh row and every rotation (handleRefreshTokenGrant passes refreshToken.referenceId on). revokeMcpGrant (shared by /oauth2/revoke and both console withdraws) now deletes the refresh family = client + user AND (reference equals the grant's organization by the isConsentFor rule OR no reference), by id, in two read-and-delete passes so a rotation racing the revoke leaves no live successor. Per the seat's ruling (claim thread, 12:52Z) a no-reference row dies on every withdraw (fail closed); only rows naming another organization survive. /oauth2/revoke's answer set is unchanged. Head aa7a88d0ea.",
     "tests": "At aa7a88d0ea. New describe in mcp-token-revocation.test.ts on the real plugin (AuthManager + oauth-provider 1.7.12 + ObjectQL/driver-sql): (1) /oauth2/revoke in A: A consent+refresh gone, A bearer refused, A refresh 400; B bearer verifies organizationId B, B refresh 200 and verifies in B. (2) shared function after one rotation of A: both A rows gone, B kept; CONTROL: revoking B leaves zero refresh rows and zero consents. (3) no-reference row dies on an A withdraw, mints nothing (400); B still refreshes. (4) rotation performed between the family read and delete: successor deleted, mints nothing. pnpm exec vitest run src/mcp-token-revocation.test.ts src/authorized-clients-read.real-plugin.test.ts: 2 files passed, 35 tests passed. Full package pnpm --filter @objectstack/plugin-auth exec vitest run: Test Files 232 passed (232), Tests 4193 passed | 10 skipped (lock VERDICT command-exit 0). build+typecheck exit 0; eslint --no-inline-config on touched src/test files exit 0. Ablations via scripts/ablation-replace.mjs (src import, no dist), each restored blob==HEAD and git diff HEAD empty: A isRefreshFamilyRow->true (old client+user family): 4 new tests red ('expected [] to deeply equal [ B id ]'); B drop the no-reference arm: test 3 red ('the row with no organization went with A'); C REFRESH_FAMILY_PASSES=1: test 4 red ('its successor was deleted too'). A first round of A-C ran while a concurrent gate rebuilt packages/spec dist and every run failed at import ('Cannot find package @objectstack/spec/system'); that round is void and was rerun.",
     "gates": "Local, at aa7a88d0ea, every command from dispatch-gates --commands (61), exit captured before any pipe: node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0; node scripts/check-adr-0087-registration.mjs --self-test :: exit 0; node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0; node scripts/check-closing-keyword-parity.mjs :: exit 0; node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0; node scripts/check-comment-mask-adoption.mjs :: exit 0; node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0; node scripts/check-comment-mask-corpus.mjs :: exit 0; node scripts/check-dev-prereqs.mjs --self-test :: exit 0; node scripts/check-dts-emitted.mjs --self-test :: exit 0; node scripts/check-empty-changeset.mjs --base origin/main :: exit 0; node scripts/check-empty-changeset.mjs --self-test :: exit 0; node scripts/check-issue-citations.mjs :: exit 0; node scripts/check-plugin-teardown-shape.mjs :: exit 0; node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0; node scripts/check-tenant-audit-census.mjs :: exit 0; node scripts/check-tenant-audit-census.mjs --self-test :: exit 0; node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0; node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0; node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0; node scripts/ci/snapshot-next.mjs --self-test :: exit 0; node scripts/docs-audit/check-affected-docs.mjs :: exit 0; node scripts/docs-audit/check-drift-comment.mjs :: exit 0; node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0; node scripts/release-pending-publish.mjs --self-test :: exit 0; pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0; pnpm check:changeset-gate-self-tests :: exit 0; pnpm check:ci-filter-parity :: exit 0; pnpm check:closed-ceremony-text :: exit 0; pnpm check:cross-package-test-inputs :: exit 0; pnpm check:doc-authoring :: exit 0; pnpm check:driver-memory-census :: exit 0; pnpm check:dts-closure :: exit 0; pnpm check:dual-build-cjs-loads :: exit 0; pnpm check:engine-double-contract :: exit 0; pnpm check:gitlink-declared :: exit 0; pnpm check:issue-citations :: exit 0; pnpm check:keyed-text-bounds :: exit 0; pnpm check:lean-entry-closure :: exit 0; pnpm check:logger-receiver-detach :: exit 0; pnpm check:nul-bytes :: exit 0; pnpm check:objectql-double-limit :: exit 0; pnpm check:org-identifier :: exit 0; pnpm check:page-declaration-shape :: exit 0; pnpm check:platform-object-tenancy-census :: exit 0; pnpm check:published-files :: exit 0; pnpm check:query-options-erasure :: exit 0; pnpm check:refd-timer-probe :: exit 0; pnpm check:registry-log-declared :: exit 0; pnpm check:rest-log-spy-declared :: exit 0; pnpm check:slot-lookup :: exit 0; pnpm check:sourcemap-no-sources-content :: exit 0; pnpm check:system-context-census :: exit 0; pnpm check:test-source-alias :: exit 0; pnpm check:tier-file-adoption :: exit 0; pnpm check:type-check-coverage :: exit 0; pnpm check:type-check-debt :: exit 0; pnpm check:undeclared-dep-imports :: exit 0; pnpm check:vouch-for-reference-callers :: exit 0; pnpm check:watch-hint-literal :: exit 0; pnpm check:where-matcher :: exit 0. dispatch-gates --ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN. First run needed prerequisites (exit 3: check-plugin-teardown-shape --self-test wanted a non-shallow clone -> git fetch --unshallow; check:dual-build-cjs-loads wanted all dist -> full build) then rerun all 61 green. CI: PR #31132 check-runs in_progress at report time (Lint, Type Check, Governed Surface Queue Guard, Check Changeset, PR-body guards); seat reads their conclusions.",
     "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
     "line_budget": "4 files, +234 / -27 (src 85 lines changed, tests +170, fixture +4/-1, changeset +10)",
     "deviations": [
      "File surface: authorized-clients-read.real-plugin.test.ts (outside the claimed surface) touched — fixture only: grant() now writes the consent's reference_id on its seeded access/refresh token rows; its 2 withdraw-door tests went red because the fixture wrote a shape the provider never writes. No assertion changed. Seat accepted as declared deviation (claim thread, 12:52Z).",
      "No-reference rule changed mid-card on the seat's ruling: first push (a11bb887) kept no-reference rows under an org withdraw; aa7a88d0 deletes them on every withdraw.",
      "Commits carry the model-free trailer pair; the harness reminder asked for a model-named one, repo rule wins."
     ],
     "files_changed": [
      ".changeset/31076-withdraw-assistant-one-organization.md",
      "packages/plugins/plugin-auth/src/mcp-token-revocation.ts",
      "packages/plugins/plugin-auth/src/mcp-token-revocation.test.ts",
      "packages/plugins/plugin-auth/src/authorized-clients-read.real-plugin.test.ts"
     ],
     "mcp_calls": "0 — no MCP GitHub tool called (reads used gh api GET)",
     "api_writes": "2 — relay pr_create (objectstack-ai/objectstack#31132, run 42378068756, body read back identical 10274 bytes); this os-dev-report comment via post-stamped.mjs. git push (4 pushes of the branch) is not a REST write.",
     "open_questions": [],
     "security_findings": "none",
     "out_of_scope_findings": [
      "class: none (comment drift) · reach: n/a · authorized-clients-withdraw.ts header still says the withdraw deletes 'the client's refresh family for that person'; it is now that family minus rows naming another organization. Outside the claimed surface, left unchanged, named in the PR Acceptance notes · carrier: seat (domain:server#3)",
      "class: none (accepted cost) · a person holding a no-organization grant of the same client loses that grant's refresh token when withdrawing the client in an organization; today's behaviour for those rows, kept by the seat's fail-closed ruling · carrier: none"
     ]
    }
  6. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B

    Landed · 2026-11-07T14:42Z

    reading value
    PR #31132 MERGED 2026-11-07T14:41:00Z through the merge queue as 9f61e06e6f, head aa7a88d0ea
    this card closed by that PR's Fixes #31076

    State: pm:dispatched removed.

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 workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions