Repository navigation
Commit 7fe019f
fix(auth): record an MCP access token as used only after the MCP door admits the call (#31247)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XCEYzJzX4FntEsxxQNvdDn1)_
Fixes #31211
Fixes #30949
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:`IAuthService` 新增一个可选成员
`recordMcpAccessTokenUse(principal)`。plugin-auth 的
`verifyMcpAccessToken` 不再顺手记「最近使用时间」,只记住自己返回的那个对象;MCP
门口在封禁检查和权限解析都通过之后,才把这个对象交回去记一次。
- 为什么改:被封禁的人,其 AI 助手的每次 MCP 调用都被门口拒绝,但同一小时
`sys_oauth_consent.last_used_at` 仍被写上,管理员在 `GET
/admin/authorized-clients?userId=` 里会看到「这一小时用过」。修法是 6 号席在 #30949 上裁定的方案
A(门口唯一拥有「放行」)。
- 风险与回滚:成员可选,没有它的 auth 服务照旧放行;记录本来就在响应之后异步落库、从不抛错,所以不影响调用结果。回滚即还原本 PR。
-
先看:`packages/runtime/src/security/resolve-execution-context.ts`(门口末尾那一处调用)、`packages/plugins/plugin-auth/src/auth-manager.ts`(`verifyMcpAccessToken`
与新方法 `recordMcpAccessTokenUse`,WeakMap
只认自己返回的对象)、`packages/spec/src/contracts/auth-service.ts`(新成员的契约文字)。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/security/resolve-execution-context.test.ts -t 31211`,预期 6
条全过(放行记一次、封禁/503/未验证/转发凭据都不记、缺成员照样放行)。
- 需要你做的:无,席位落地(落地前由 spec 席做契约复审)。
## What changes
- **spec** (`packages/spec/src/contracts/auth-service.ts`):
`IAuthService` gains one optional member beside `verifyMcpAccessToken`,
`recordMcpAccessTokenUse(principal): void`. Its docblock states the
split: verifying a token is not admitting the call; the caller calls
this once per admitted call, after its last refusal, with the very
object the verifier resolved to; the provider records only an object it
returned itself; it returns nothing, never throws and never makes the
caller wait. The parameter type is derived from the verifier's own
result type, so the two cannot drift.
`content/docs/kernel/contracts/auth-service.mdx` lists the member and
adds a third "read together" pair (`verifyMcpAccessToken()` /
`recordMcpAccessTokenUse()`).
- **plugin-auth producer** (`auth-manager.ts`,
`mcp-client-last-use.ts`): `verifyMcpAccessToken` no longer schedules
the consent last-use stamp. It puts the principal it returns in a
`WeakMap` with the verified token's `iat` (`issuedAtOf`, moved to
`mcp-client-last-use.ts` with the `jose` `decodeJwt` read).
`recordMcpAccessTokenUse` stamps only an object in that map, then
deletes it, so a hand-built principal, a copy, or the same object handed
back twice records nothing more. The stamp itself
(`McpClientLastUseStamper`, at most one write per consent per hour per
process, after the response) is unchanged. The whole member sits in a
`try`, so it never throws.
- **runtime reader** (`resolve-execution-context.ts`): the door keeps
the verifier's answer in `verifiedTokenPrincipal` only when the ban read
does not refuse it, awaits `resolveRequestExecutionContext`, and only
then calls
`authService?.recordMcpAccessTokenUse?.(verifiedTokenPrincipal)`. So a
banned subject, a grant-read outage (503, thrown before that line) and
an unverified token all record nothing. A forward credential never sets
it: it is the in-process continuation of a call already recorded. The
member is reached through the declared, slot-typed `IAuthService` (no
cast to an undeclared member).
- **Changeset** `.changeset/31211-mcp-token-use-after-admission.md`:
`patch` on `@objectstack/spec`, `@objectstack/plugin-auth`,
`@objectstack/runtime` (kind `none` takes `patch`; widening keeps
`patch`, AGENTS.md Post-Task Checklist step 3), with both declaration
lines and the host note.
Which owner, and why: the door. Seat 6 ruled option A (record 6710928373
on #30949) of the four-axis analysis in report 6710893443: one owner of
"admitted", so a refusal added at the door later needs no second edit,
and no second ban read anywhere.
## Tests
- **Red first, through the real door** (one-shot rig, not committed: the
`rig()` of `mcp-client-last-use.real-engine.test.ts` with a real
`AuthManager` over a real SQL engine, a banned `sys_user` row, a consent
written by better-auth's adapter, a jwt-plugin token, resolved by
runtime `resolveExecutionContext`). Production sources restored to base
`fe97d85b`: `banned=true principalKind=guest ... stamp=HOUR_10
writes=1`, `AssertionError: a refused call stamps nothing`, 1 failed | 1
passed. At head: `banned=true principalKind=guest userId=undefined
stamp=null writes=0`, `banned=false principalKind=agent ... writes=1`, 2
passed.
- **Door pins** (`resolve-execution-context.test.ts`, new describe
`[#31211]`, 6 cases): admitted records exactly once, with the verifier's
own object (`toBe`); banned records nothing; a grant-read outage asserts
`code` `SERVICE_UNAVAILABLE` + `status` 503 and records nothing; an
unverified token records nothing; a forward credential records nothing;
an auth service without the member still admits.
- **Producer pins** (`mcp-client-last-use.real-engine.test.ts`, new
describe `[#31211]`, 4 cases): verification alone reads and writes no
stamp (control: the same token admitted stamps the hour); a hand-built
principal and a spread copy record nothing (control: the verifier's
object stamps); one object handed back again in a later hour writes
nothing more; the member never throws on an unknown or null principal.
The 23 existing calls that expected a stamp from `verifyMcpAccessToken`
now go through `admittedCall` (verify, then record the returned object),
as the door does.
- **Ablations** (`scripts/ablation-replace.mjs`, each restored to the
HEAD blob with `git diff HEAD` empty; both packages' tests import these
sources directly, so no dist leg):
1. delete the door call: `admitted: recorded exactly once` red
(`expected [] to have a length of 1`).
2. record at verification, before the ban read: admitted (length 2),
banned and 503 cases red, 3 failed.
3. verifier schedules the stamp again: `verification alone reads no
stamp` and the hand-built case red.
4. any object is recorded (`?? {}`): the hand-built case red.
5. delete the once-only `delete`: `the same object again is not a second
admitted call` red (2 vs 1).
- Suites: `pnpm --filter @objectstack/plugin-auth test` 235 files, 4187
passed | 10 skipped (VERDICT command-exit 0); runtime `src/security/` +
`http-dispatcher.mcp-oauth.test.ts` + `mcp-federation-bridge.test.ts` 15
files, 228 passed, and `resolve-execution-context.test.ts` 53 passed at
the head; spec `src/contracts/` 52 files, 475 passed.
- `pnpm typecheck` for `@objectstack/spec`, `@objectstack/plugin-auth`,
`@objectstack/runtime`: exit 0 each (runtime re-run at the head).
## Acceptance notes
- `content/docs/kernel/contracts/auth-service.mdx` is hand-written prose
(not under `content/docs/references/`); `check:docs` and the other spec
artifact gates are green with no regenerated file (`pnpm --filter
@objectstack/spec check:generated`: all 14 artifacts up to date, against
a spec dist built with this member).
- A host that calls `AuthManager.verifyMcpAccessToken` itself (outside
the runtime door) no longer gets a stamp from verification; the
changeset says to call `recordMcpAccessTokenUse` once it admits the
call. No such caller exists in this repo (`verifyMcpAccessToken` has one
non-test caller, the runtime door).
- Not ablated: the doc and changeset prose (os-dev: docs are not
ablated).
## Verification
Gate union derived by `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` (108 families for this diff), run on
`327f91d8`, reconciled with `--ran`: "108 derived famil(ies) accounted
for — 106 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)".
- First pass reds and their reruns: `check:where-matcher` exit 1 flagged
the new door test's table double (no combinator refusal), fixed in
`e8c0e991` (it now refuses `$` operators and object values as the
neighbouring double does), rerun exit 0. `check:query-options-erasure`
exit 124 at a 300 s cap, rerun uncapped exit 0.
`check-plugin-teardown-shape.mjs --self-test` exit 3 on the shallow
clone, exit 0 after `git fetch --depth=1 origin 621a487`.
- **NOT MEASURED** `pnpm --filter @objectstack/spec run
check:skill-examples` and `pnpm check:dual-build-cjs-loads`: exit 3
PREREQUISITE NOT MET (they read every package's built dist, e.g.
`packages/client-react/dist`; only the runtime closure was built). CI
runs both on a full build.
- At head `e8c0e991` (a test-only commit on top): re-ran
`check:where-matcher`, `check:query-options-erasure`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:nul-bytes`, `check:issue-citations`,
`check:closed-ceremony-text`, exit 0 each, plus the runtime typecheck.
- `eslint --no-inline-config` over the six changed source and test
files: exit 0.
- `origin/main` moved 12 commits since the base; none touches a file of
this diff.
- CI families are read from this PR's check-runs, not re-run here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5pERdWox6UVMyKRjPq)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>1 parent 7c523aa commit 7fe019f
8 files changed
Lines changed: 339 additions & 57 deletions
File tree
- .changeset
- content/docs/kernel/contracts
- packages
- plugins/plugin-auth/src
- runtime/src/security
- spec/src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
44 | 47 | | |
45 | 48 | | |
46 | 49 | | |
| |||
51 | 54 | | |
52 | 55 | | |
53 | 56 | | |
54 | | - | |
| 57 | + | |
55 | 58 | | |
56 | 59 | | |
57 | 60 | | |
| 61 | + | |
58 | 62 | | |
59 | 63 | | |
60 | 64 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
228 | | - | |
| 228 | + | |
229 | 229 | | |
230 | 230 | | |
231 | 231 | | |
| |||
4632 | 4632 | | |
4633 | 4633 | | |
4634 | 4634 | | |
4635 | | - | |
4636 | | - | |
4637 | | - | |
4638 | | - | |
4639 | | - | |
4640 | | - | |
4641 | | - | |
| 4635 | + | |
| 4636 | + | |
| 4637 | + | |
| 4638 | + | |
| 4639 | + | |
| 4640 | + | |
4642 | 4641 | | |
4643 | 4642 | | |
4644 | 4643 | | |
| |||
4652 | 4651 | | |
4653 | 4652 | | |
4654 | 4653 | | |
4655 | | - | |
4656 | | - | |
4657 | | - | |
4658 | | - | |
4659 | | - | |
4660 | | - | |
| 4654 | + | |
| 4655 | + | |
| 4656 | + | |
| 4657 | + | |
| 4658 | + | |
4661 | 4659 | | |
4662 | | - | |
| 4660 | + | |
4663 | 4661 | | |
4664 | 4662 | | |
4665 | 4663 | | |
4666 | 4664 | | |
| 4665 | + | |
| 4666 | + | |
| 4667 | + | |
| 4668 | + | |
| 4669 | + | |
| 4670 | + | |
| 4671 | + | |
| 4672 | + | |
| 4673 | + | |
| 4674 | + | |
| 4675 | + | |
| 4676 | + | |
| 4677 | + | |
| 4678 | + | |
| 4679 | + | |
| 4680 | + | |
| 4681 | + | |
| 4682 | + | |
| 4683 | + | |
| 4684 | + | |
| 4685 | + | |
| 4686 | + | |
| 4687 | + | |
| 4688 | + | |
| 4689 | + | |
| 4690 | + | |
| 4691 | + | |
| 4692 | + | |
| 4693 | + | |
| 4694 | + | |
| 4695 | + | |
4667 | 4696 | | |
4668 | 4697 | | |
4669 | 4698 | | |
| |||
4679 | 4708 | | |
4680 | 4709 | | |
4681 | 4710 | | |
4682 | | - | |
| 4711 | + | |
4683 | 4712 | | |
4684 | 4713 | | |
4685 | | - | |
4686 | | - | |
4687 | | - | |
4688 | | - | |
4689 | | - | |
4690 | | - | |
4691 | | - | |
4692 | | - | |
4693 | | - | |
4694 | 4714 | | |
4695 | 4715 | | |
4696 | 4716 | | |
| |||
0 commit comments