Repository navigation
fix(runtime,plugin-auth)!: a banned user's OAuth access tokens and refresh grants are refused while the ban holds - #30360
objectstack-fleet[bot] wants to merge 7 commits into
Conversation
…resh grants are refused while the ban holds Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…double Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…the ban holds Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…aude/sec-hardening-r5 Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…ponse as coded; the ban double is a bounded table filter Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…plement Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 42 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 74bfd1dc55b68cf5edf2602aa119604743821b65 && git checkout 74bfd1dc55b68cf5edf2602aa119604743821b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f064a0e4180e2d9c4f3d5a98302552a615d3a6f7 903250364727741ab558df18aec54f3cce5d5477 && git checkout -B drift-repro f064a0e4180e2d9c4f3d5a98302552a615d3a6f7 && git merge --no-ff 903250364727741ab558df18aec54f3cce5d5477
node scripts/docs-audit/affected-docs.mjs --json f064a0e4180e2d9c4f3d5a98302552a615d3a6f7
|
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: follow-up owed — content/docs/permissions/authentication.mdx, content/docs/permissions/administrator-guide.mdx, packages/platform-objects/src/identity/sys-user.object.ts (the Ban User confirmation) (each names what a ban refuses; OAuth access tokens and refresh grants join that list)
维护者速读
pnpm --filter @objectstack/dogfood exec vitest run test/oauth-banned-subject.dogfood.test.ts,预期 4/4 通过(封禁中 MCP 三次调用与 refresh 均与无效凭据应答一致,解封与到期后同一凭据恢复)。What changes
A ban holds every credential of the user. While a user's ban holds:
401body andWWW-Authenticateheader. An agent forward credential acting for a banned user resolves as one that does not redeem. The ban is read where the credential becomes its user (resolveExecutionContext→isOAuthSubjectBanned,packages/runtime/src/security/oauth-subject-ban.ts), with the shared predicateisUserBanInForcefrom@objectstack/core. The verifier (verifyMcpAccessToken) stays the local, I/O-free signature check.refresh_token,authorization_code, any extension grant that issues through the provider) is answered as an invalid grant of its type:400 invalid_grantwith the provider's own description (invalid refresh token,invalid code). The refusal runs in the provider's pre-write callbackcustomTokenResponseFields, which receives the user row the provider already read, so it adds no read and leaves every row as it was (packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.ts; its header records why this hook and not a before-hook or a database hook).sys_user(id,banned,ban_expires), as the system, per OAuth credential the MCP door resolves; zero at the token endpoint. A store fault on that read is503 SERVICE_UNAVAILABLEnamingsys_user; a missing user row or an unprovisioned table keeps today's answer.plugin-authAPIError code pin now also reads an OAuth 2.0 error response (a literalerrorbeside anerror_description, RFC 6749 §5.2) as a coded body, with fixtures for both directions: the token-endpoint refusal must answer exactly as the provider's own error does, which carries no ADR-0112code.Tests
packages/runtime/src/security/resolve-execution-context.test.ts— new top-level describe: banned subject resolves exactly as an unverified bearer; a future expiry binds, a passed one does not; not-banned, unbanned, no-row and no-table controls admit as before; a ban read that throws is503 SERVICE_UNAVAILABLEnamingsys_user; cost pinned at one ban-column read per token and none for an unverified bearer; a forward credential for a banned user resolves as one that does not redeem.packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.test.ts— the refusal against the provider's own error (class, status, body, headers) for each grant type; expiry in both spellings; not-banned and no-user controls.packages/plugins/plugin-auth/src/auth-manager.oauth-consent-organization.test.ts— new top-level describe on the real provider with AuthManager's own options: a banned user's refresh grant equals an invalid refresh token's answer, writes no refresh row, and the same refresh token renews once the ban lapses; not-banned control.packages/qa/dogfood/test/oauth-banned-subject.dogfood.test.ts— booted showcase, real DCR → authorize → consent → token:initialize,query_records,create_recordand the refresh grant against a made-up token / an invalid refresh token, the refused write landing nothing; peer, unban and timed-ban controls.Acceptance notes
Skills-docs:line above).plugin-authAPIError code pin gains a third accepted shape, the RFC 6749 §5.2 error response, so a token-endpoint refusal can carry exactly the provider's own body; a literalerrorwith noerror_description, or a non-literalerror, is still reported (fixtures in both directions).permission-set-clone-door.dogfood.test.ts, the knownmainred; the same test is red with the same assertion on a tree without this branch's commits (control below).Verification
Readings at head
9032503647unless stated; this branch isclaude/sec-hardening-r4(12c342a23d) plus the commits listed in the PR.Unit and integration suites
pnpm --filter @objectstack/runtime test: Test Files 541 passed (541), Tests 8626 passed, 19 skipped (at14794ebef2);src/security/resolve-execution-context.test.tsre-run at9032503647: 47 passed (47).pnpm --filter @objectstack/plugin-auth test: Test Files 221 passed (221), Tests 3885 passed, 10 skipped (at14794ebef2; no plugin-auth file changed after it).pnpm --filter @objectstack/runtime typecheck,pnpm --filter @objectstack/plugin-auth typecheck: exit 0.Ablations (one per refusal; each red, then restored, blob equal to HEAD and `git diff HEAD` empty)
oauth-grant-ban-refusal.test.tsand the real-provider describe; restored.Dogfood
test/oauth-banned-subject.dogfood.test.ts: 4 passed (4).pnpm --filter @objectstack/dogfood test, at14794ebef2; later commits touch onlypackages/runtime/src/security/resolve-execution-context.test.ts): Test Files 1 failed, 420 passed, 3 skipped (424); Tests 1 failed, 3499 passed, 36 skipped (3536). The failure ispermission-set-clone-door.dogfood.test.ts,422 INVALID_METADATAwhere200is expected. Control: the same file on12c342a23d(no commit of this branch) fails the same assertion, 1 failed and 10 passed of 11.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat9032503647: 96 commands, each run with its exit code captured before any pipe: 96 exit 0.--ranreconciliation: 96 derived, 96 run, 0 NOT-MEASURED (derived from recorded exit codes).Generated by Claude Code