Skip to content

Commit 7fe019f

Browse files
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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-auth': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(auth): an MCP access token is recorded as used only once the MCP door admits the call, so a banned person's refused calls no longer show their AI assistant as used
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
`IAuthService` gains one optional member, `recordMcpAccessTokenUse(principal)`. Verifying a token is not admitting the call: the MCP door (`@objectstack/runtime`) refuses a banned subject after `verifyMcpAccessToken` answers, and a call whose authorization store cannot be read is a 503. Before this change `AuthManager.verifyMcpAccessToken` itself scheduled the coarse last-use stamp (`sys_oauth_consent.last_used_at`), so an administrator reading `GET /admin/authorized-clients?userId=` for a banned person saw the assistant as used in an hour when every call was refused.
13+
14+
Now `verifyMcpAccessToken` records nothing. The door calls `recordMcpAccessTokenUse` once, after its ban read and after the request context resolves, with the very object the verifier returned; `AuthManager` stamps only a principal object its own verifier returned, once, so a hand-built principal or a copy records nothing. The member returns nothing and never throws. The stamp's bound is unchanged: at most one write per consent per hour in each process.
15+
16+
A custom `IAuthService` needs nothing: the member is optional, and a door whose auth service lacks it admits the call exactly as before. A host that calls `AuthManager.verifyMcpAccessToken` itself and wants the call shown as used calls `recordMcpAccessTokenUse(principal)` with the verifier's result once it has admitted the call.

‎content/docs/kernel/contracts/auth-service.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export interface IAuthService {
4141
/** Verify an OAuth 2.1 access token from the embedded AS (MCP surface); the optional `organizationId` is the organization the user's OAuth consent bound, absent for a token minted before consents bound an organization. Optional. */
4242
verifyMcpAccessToken?(token: string): Promise<{ userId: string; scopes: string[]; clientId?: string; organizationId?: string } | undefined>;
4343

44+
/** Record that the caller admitted a call made with a verified MCP access token: called once, after the caller's last refusal, with the object `verifyMcpAccessToken` returned; a principal the provider did not return records nothing. Never throws. Optional. */
45+
recordMcpAccessTokenUse?(principal: { userId: string; scopes: string[]; clientId?: string; organizationId?: string }): void;
46+
4447
/** The MCP resource identifier (RFC 8707 `resource` / token `aud`). Optional. */
4548
getMcpResourceUrl?(): string;
4649

@@ -51,10 +54,11 @@ export interface IAuthService {
5154

5255
The two required members are the core: it follows the Dependency Inversion Principle so that plugins depend on the contract rather than a concrete provider. Concrete implementations (better-auth, custom, LDAP, etc.) supply the actual logic, and `handleRequest` works directly with the standard `Request`/`Response` types.
5356

54-
Everything after `verify` is optional, and each optional member describes a capability a provider may or may not have — a caller must probe before use. Two pairs are worth reading together:
57+
Everything after `verify` is optional, and each optional member describes a capability a provider may or may not have — a caller must probe before use. Three pairs are worth reading together:
5558

5659
- **`api` and `getApi()`** are the same session handle by two routes. `api` is the legacy direct mount; `getApi()` is the lazy accessor, and it is the one to prefer — the shipped `plugin-auth` registers an `AuthManager`, which has **no `api` member at all**, so a caller that reads only `api` gets `undefined` on every current deployment. Read `api ?? await getApi?.()`.
5760
- **`getMcpResourceUrl()` and `getMcpResourceMetadataUrl()`** both describe the MCP OAuth surface; the second returns `null` (rather than being absent) when the OAuth track is off for the deployment.
61+
- **`verifyMcpAccessToken()` and `recordMcpAccessTokenUse()`** split verifying a token from admitting the call. The MCP door verifies first, then applies its own refusals (a banned subject, an authorization store it could not read), and only for a call it admits hands the verified principal back to `recordMcpAccessTokenUse()`. So a refused call is never recorded as a use of the grant (the coarse "last used" time the authorized-clients reads show). Admitting is the decision about who calls, not every later verdict: a refusal of what the admitted principal then asks for (a 403 `insufficient_scope` for a token that grants none of the MCP tool families, a tool or an action its scopes do not reach) is still a use, and is recorded.
5862

5963
---
6064

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Auth, BetterAuthOptions } from 'better-auth';
4-
import { decodeJwt, type JSONWebKeySet } from 'jose';
4+
import type { JSONWebKeySet } from 'jose';
55
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
66
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
77
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
@@ -225,7 +225,7 @@ import {
225225
type McpAccessTokenHost,
226226
} from './auth-manager-mcp-access-token.js';
227227
// [#30918] The coarse last-use stamp a verified MCP call writes on its consent.
228-
import { McpClientLastUseStamper, mcpClientLastUseStoreFor } from './mcp-client-last-use.js';
228+
import { McpClientLastUseStamper, mcpClientLastUseStoreFor, issuedAtOf } from './mcp-client-last-use.js';
229229
import { runAfterResponse } from './run-after-response.js';
230230
// [objectstack-ai/cloud#3606, ruling A] `/oauth2/revoke` for an MCP access
231231
// token: the answer `handleRequest` gives after the provider's, through
@@ -4632,13 +4632,12 @@ export class AuthManager {
46324632
* organization (`vetOrganizationClaim`, `@objectstack/core`), as the
46334633
* `IAuthService` contract states.
46344634
*
4635-
* [#30918] A token that verifies also schedules, AFTER the verdict and off
4636-
* the request's path, the coarse last-use stamp on the consent it was minted
4637-
* under (`mcp-client-last-use.ts`): at most one write per consent per hour
4638-
* in each process, from this verified token and the server's clock, and
4639-
* only on the consent that admitted it (the consent read above, by the same
4640-
* rule). The stamp reads and writes nothing the verdict depends on, and its
4641-
* failure is never this method's failure.
4635+
* [#30918 · #31211] Verifying is not admitting: this method records NO use.
4636+
* The door that resolves the principal can still refuse the call (a banned
4637+
* subject, an authorization store it could not read), and a refused call is
4638+
* not a use. It remembers the principal object it returns, and the door
4639+
* hands that object back to {@link recordMcpAccessTokenUse} once it has
4640+
* admitted the call.
46424641
*/
46434642
verifyMcpAccessToken(
46444643
token: string,
@@ -4652,18 +4651,48 @@ export class AuthManager {
46524651
rereadKeyringForUnknownKey: (...a) => this.rereadKeyringForUnknownKey(...a),
46534652
getAuthContext: (...a) => this.getAuthContext(...a),
46544653
};
4655-
// [#30918] A token that verified is a verified MCP call: its consent's
4656-
// coarse last-use stamp is settled after the response, never awaited here,
4657-
// and from the verified token only (`mcp-client-last-use.ts`). The
4658-
// verifier has just admitted the token by its consent (#30879,
4659-
// `mcpConsentAdmits`), so a revoked consent refuses the call and stamps
4660-
// nothing; the stamp picks its consent by the same rule.
4654+
// [#31211] Remember the object this verifier answered, with the `iat` of
4655+
// the token it verified, so only that object can later be recorded as
4656+
// used. The token has just verified, so its payload is the one the
4657+
// signature covers: its `iat` is what the verifier admitted the consent
4658+
// against (#30879, `mcpConsentAdmits`).
46614659
return verifyMcpAccessToken.call(host, token).then((principal) => {
4662-
if (principal) this.scheduleMcpClientLastUse(principal, token);
4660+
if (principal) this.verifiedMcpPrincipals.set(principal, { issuedAt: issuedAtOf(token) });
46634661
return principal;
46644662
});
46654663
}
46664664

4665+
/**
4666+
* [#31211] The principal objects {@link verifyMcpAccessToken} answered and
4667+
* nobody has recorded yet, each with its token's `iat`. Weak, so a principal
4668+
* whose call was refused (never recorded) is dropped with the request.
4669+
*/
4670+
private readonly verifiedMcpPrincipals = new WeakMap<object, { issuedAt?: number }>();
4671+
4672+
/**
4673+
* [#31211] `IAuthService.recordMcpAccessTokenUse`: the MCP door admitted a
4674+
* call made with a token {@link verifyMcpAccessToken} verified, so the
4675+
* consent that token was minted under gets its coarse last-use stamp
4676+
* (`mcp-client-last-use.ts`, #30918), after the response and never awaited
4677+
* here: at most one write per consent per hour in each process, from the
4678+
* verified token and the server's clock, and only on the consent that
4679+
* admitted it.
4680+
*
4681+
* Only an object this manager's verifier returned is recorded, and only
4682+
* once; a hand-built principal, a copy, or the same object again records
4683+
* nothing. Never throws: the stamp's failure is never the call's failure.
4684+
*/
4685+
recordMcpAccessTokenUse(principal: { userId: string; scopes: string[]; clientId?: string; organizationId?: string }): void {
4686+
try {
4687+
const verified = principal !== null && typeof principal === 'object' ? this.verifiedMcpPrincipals.get(principal) : undefined;
4688+
if (!verified) return;
4689+
this.verifiedMcpPrincipals.delete(principal);
4690+
this.scheduleMcpClientLastUse(principal, verified.issuedAt);
4691+
} catch {
4692+
// Bookkeeping only: an admitted call is never refused for it.
4693+
}
4694+
}
4695+
46674696
/** [#30918] The per-manager stamper, built on first use. */
46684697
private mcpClientLastUse?: McpClientLastUseStamper;
46694698

@@ -4679,18 +4708,9 @@ export class AuthManager {
46794708
*/
46804709
private scheduleMcpClientLastUse(
46814710
principal: { userId: string; clientId?: string; organizationId?: string },
4682-
token: string,
4711+
issuedAt: number | undefined,
46834712
): void {
46844713
if (!principal.clientId) return;
4685-
// The token has just verified, so its payload is the one the signature
4686-
// covers: its `iat` is what the verifier admitted the consent against.
4687-
let issuedAt: number | undefined;
4688-
try {
4689-
const iat = decodeJwt(token).iat;
4690-
issuedAt = typeof iat === 'number' ? iat : undefined;
4691-
} catch {
4692-
issuedAt = undefined;
4693-
}
46944714
const stamper = (this.mcpClientLastUse ??= new McpClientLastUseStamper(
46954715
mcpClientLastUseStoreFor(() => this.getDataEngine()),
46964716
{

0 commit comments

Comments
 (0)