Skip to content

Commit 9040945

Browse files
fix(plugin-auth): the MCP access-token verifier follows the signing keyring instead of holding its own copy of the key set for five minutes (#24225)
Fixes #24186 Clause-②: no The MCP access-token verifier (`AuthManager.verifyMcpAccessToken`) no longer holds its own copy of the signing key set on a 5-minute TTL. It reads the key set on every call through the keyring reader that PR #24185 added for the signer (`signing-key-read-cache.ts`). So the set it verifies against follows both halves of #11633's cross-request caching rule: an observed `sys_jwks` write retires it, and a TTL bounds what that seam cannot see. A token signed by a key the set does not carry gets one bounded fresh read before it is refused. ## The two consequences, and what each pin holds Stated abstractly, as the card does: 1. **A key removed from the keyring kept verifying the tokens it signed** until the verifier's held set lapsed. Now the removal reaches the next verification on this node. 2. **A key added to the keyring was refused** until the held set lapsed. Now it verifies on the first call. When the write passes this node's engine, the seam covers it. When it does not (another node's write), the one fresh read covers it. ## Reach, measured on `origin/main` `99e9d2957e` - **The door.** One door calls the verifier: the runtime dispatcher's MCP route (`/api/v1/mcp` and its environment-scoped form). It sets `acceptOAuthAccessToken` (`packages/runtime/src/http-dispatcher.ts:736`), and `resolveExecutionContext` hands a compact-JWS Bearer to `authService.verifyMcpAccessToken` (`packages/runtime/src/security/resolve-execution-context.ts:159`). REST, GraphQL and `/auth/me/*` do not accept OAuth access tokens. - **On by default.** `isMcpServerEnabled()` defaults to on (`packages/types/src/env.ts:594`). `resolveOidcProviderEnabled` follows it unless `OS_OIDC_PROVIDER_ENABLED` or `plugins.oidcProvider` says otherwise. `isMcpOAuthEnabled()` is then true whenever the canonical origin is TLS, or plain HTTP on a loopback or private address. A default deployment over HTTPS runs this verifier. - **Who writes `sys_jwks` today.** Only better-auth's jwt plugin, which mints a key on first use when the keyring holds no live key. On a host without Ed25519, it mints a fresh ES256 key once and hides the EdDSA rows. No `jwks.rotationInterval` is configured, so keys carry no expiry and nothing rotates them on a schedule. `sys_jwks` declares `apiEnabled: false` and `apiMethods: []`, and has no Setup nav entry. No CLI command, setting or admin route removes or rotates a key. - **So:** nothing in the product removes or rotates a key today. Outside the first-use mint, the writes that reach these consequences are out-of-band: a direct database edit, a restore, another node's first-use mint. The card still holds under the rule, because a write by any path must be seen, and this change goes ahead on that basis. ## Which route, and why: the verifier reads through PR #24185's reader The seat's premise measures true. The verifier's refresh was already `api.getJwks()`, better-auth's `getJwks` endpoint. Its keyring read is `getJwksAdapter().getAllKeys()`, which calls `options.adapter.getJwks`, and that is `createSigningKeyReader` (`better-auth@1.7.3` `dist/plugins/jwt/adapter.mjs` and `index.mjs`). It reads the same `sys_jwks` rows, uses the same per-engine write counters and the same TTL. The verifier only held the endpoint's answer for five more minutes on top. So the fix removes that held copy (`jwksCache`, `JWKS_CACHE_TTL_MS`) and reads through on every call. One seam and one bound now cover the signer, `/jwks` and the verifier. - **No private key material reaches the verifier.** It receives the endpoint's public projection (`alg`, `crv`, the parsed `publicKey`, `kid`), and only that is handed to jose. The row read beneath it is the shared reader's, held for the signer. That read includes the `private_key` readback (`internal-field-readback.ts`), as it did on the verifier's refresh path before this change. With a warm reader the verifier causes no keyring read at all. - **An engine without the middleware seam holds nothing.** The reader reads through, so the verifier does too, as the ruling requires. - **What was added to `signing-key-read-cache.ts`:** `retireHeldKeyrings(engine)`. It records a forced re-read as a keyring write that entered and settled, so the reader's existing rule retires every held entry and refuses to hold a read that was in flight. The reader needs no second case. Also `UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS`. Neither is reachable from the package entry (the module is not re-exported from `index.ts`). ## The bound on the fresh read A token whose verification fails with jose's `JWKSNoMatchingKey` (checked with `instanceof errors.JWKSNoMatchingKey` on the installed `jose@6.2.12`, code `ERR_JWKS_NO_MATCHING_KEY`) earns one fresh read. Every other failure is refused at once, because a re-read cannot change it. - **Single-flight.** A read already in flight is joined, never repeated. - **Floor: 30 seconds** (`UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS`, measured from when the last forced read started). Inside the floor, the token is refused as it would have been without the retry. A failed read still spends the interval. - **Why 30 seconds.** It equals the reader's own TTL (`SIGNING_KEY_CACHE_TTL_MS`). A stream of tokens naming keys that do not exist therefore at most doubles the keyring read rate the TTL already allows. While such a stream runs, a key added where this node cannot see it is verified no later than the TTL alone would allow. It is also `jose`'s default `cooldownDuration` for the same mechanism in its remote key set, measured in the installed `dist/webapi/jwks/remote.js`: a reload on an unmatched key, at most once per cooldown. It is a constant, not a configuration key. ## The multi-node bound A `sys_jwks` write is observed only by the node whose engine it enters. On every other node: - A removed key keeps verifying for at most the reader's TTL, 30 seconds. Before this change it was up to 5 minutes plus that TTL. The fresh read does not help here, because the key is still in that node's held set. - An added key verifies on first sight through the fresh read. If the floor is spent, it verifies within 30 seconds. ## Clause-② measurement: built entry declarations, BASE `99e9d2957e` vs this head `@objectstack/plugin-auth` `dist/index.d.ts` and `dist/index.d.mts`, built at both ends (`rate-limit-storage.d.ts` / `.d.mts`: byte-identical, `diff` exit 0). With comments stripped (TypeScript printer, `removeComments`), the only declaration lines that change are four `private` members of `class AuthManager`, which print without types: - removed: `private jwksCache?;` and `private static readonly JWKS_CACHE_TTL_MS;` - added: `private unknownKeyReread?;` and `private rereadKeyringForUnknownKey;` The rest of the raw diff is doc-comment prose. No public member, export, type, configuration key or environment variable changes. The two removed members are the defect itself, so no correct version of this change leaves them in place. The claim's `Clause-②: no` holds on the clause's own question: no accepted set is widened and no public surface grows. The seat reads this; see the acceptance notes. ## Tests (at head `90849befc`, which merges `origin/main` `b9ac35cbf`; none of the four incoming commits touches `plugin-auth`) - New `src/auth-manager.mcp-verifier-keyring.test.ts`. A real `AuthManager` runs on a real `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, with its embedded authorization server on and tokens signed by the jwt plugin's own `signJWT`. Keyring reads are counted at the driver. - a signing key removed from the keyring stops verifying its tokens on the next call, with no clock movement (consequence 1) - a key that joins the keyring through this engine after the last read verifies on the first call (consequence 2, seam leg) - a key that joins the keyring where this engine cannot see it verifies on the first call, through one fresh read (consequence 2, fresh-read leg: a row written below the engine) - a burst of tokens naming keys the keyring does not carry costs at most one keyring read inside the floor interval (20 concurrent, then 20 sequential: exactly one keyring read) - and, on a counting key-set double, the bound's edges: a token arriving while the fresh read is in flight shares it; the floor re-arms exactly at its interval and not a millisecond before; a fresh read that fails still spends the interval - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` (whole package): **158 files, 3091 passed, 10 skipped, 0 failed**. - `pnpm --filter @objectstack/plugin-auth typecheck` exit 0. That covers `tsc --noEmit`, the examples project and `check:test-typecheck`, and `--listFilesOnly -p tsconfig.test.json` lists the new file. ## Ablations, one per pin, through `scripts/ablation-replace.mjs` (anchor hit once, blob changed, restore proven blob == HEAD and `git diff HEAD` empty) The subject is imported by relative path from `src/`, so there is no `dist/` leg. | ablation | mutation | red | |---|---|---| | a held set with no seam | the key-set read memoized for the instance's life | the removed-key pin (and the three double cases, which count reads per call) | | no fresh read | the retry's read replaced by `undefined` | the unseen-key pin, the burst pin, the three double cases; the removed-key and seam-leg pins stay green | | no floor | the floor test replaced by `false` | the burst pin, the floor case and the failed-read case; single-flight stays green | Reverse leg: the BASE `auth-manager.ts` (`git restore --source=99e9d2957e`, blob verified) turns all seven red. The removed-key pin reads "expected a principal to be null", and the unseen-key pin reads "expected null to match a principal". Restored to HEAD and verified. ## Cost, measured 3,000 sequential verifications of a valid token on the rig above, warm. Two runs each, on a shared box, so read the ratio: BASE 372 and 385 µs per call, head 696 and 765 µs per call, about 1.9x. Both make **zero** keyring reads in the loop. The added cost is CPU only: the in-process `getJwks` endpoint call per verification, replacing the 5-minute held copy. ## Gates Local, at head `90849befc`. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths (merge base `b9ac35cbf`, 4 paths, +472 / -16), derives 67 families. They are the same 67 the seat derived at `99e9d2957e`. - All 67 were run, each exit code captured before any pipe: **67 exit 0**. - `--ran` reconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. - A first run at `6902fdd09`, before the merge, had `pnpm check:dual-build-cjs-loads` exit 3 (PREREQUISITE NOT MET: not every package had a `dist/` yet). On the merged head it measured: 111 published require entry points across 69 packages load. - After the merge, `pnpm --filter @objectstack/spec check:generated` exits 0. `origin/main`'s side moved `packages/spec`; this diff does not touch it. - The `plugin-auth` closure was rebuilt on the merged head, and the head's `dist/index.d.ts` is byte-identical to the pre-merge head's (md5 equal). `test:repo` and the rest of the CI battery are CI's; the PR's check-runs are the reading for those. ## Acceptance notes - **Clause-② and the dispatch's measurement rule.** The dispatch said a changed declaration line makes the line `yes`. The only changed lines are the four `private` members above, which carry no type and are unreachable from outside the class. I kept the claim's `no`, because a `yes` would declare a widening this diff does not make and force a `minor` grade. If the seat rules the other way, the edit is this body's and the changeset's `Clause-②` line to `yes`, and the changeset grade to `minor`. - **Per-call cost.** It is about +0.3 ms of CPU per MCP verification on this box, with no added I/O. The alternative that avoids it is a second held set in the verifier on the same observer counters. That needs its own TTL anchored to the reader's read time, or unobserved writes compound to two TTLs. It was not taken: one cache under one rule is the smaller surface. - **A joined read can predate the token.** A token that arrives while a fresh read is in flight shares that read, which opened before the token arrived. A key added in that gap is refused for that token and picked up by the next read. This is the price of single-flight, and it is the same trade jose's remote key set makes. --- _Generated by [Claude Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent df000d4 commit 9040945

4 files changed

Lines changed: 472 additions & 16 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): the MCP access-token verifier follows the signing keyring instead of holding its own copy of the key set for five minutes
6+
7+
Clause-②: no
8+
9+
- **What changed.** `AuthManager.verifyMcpAccessToken` no longer keeps its own copy of the signing key set for five minutes. It reads the key set on every call through the same keyring reader the token signer and `/jwks` use, so the set it checks a signature against is held only under both halves of the cross-request caching rule: a `sys_jwks` write that passes this process's data engine retires it in the same operation, and a 30-second bound covers a write the process cannot see, such as one made on another node.
10+
- **A token signed by a key the set does not carry gets one fresh read.** Before such a token is refused, the keyring is read once more from the table, and the token is checked again. That read is shared by every token that arrives while it is in flight, and a new one starts no sooner than 30 seconds after the previous one, so tokens that name keys which do not exist cost at most one keyring read per 30 seconds. A token whose key is still absent after that read is refused, as before.
11+
- **Unchanged.** Issuer, audience, expiry, the `client_credentials` refusal and the `organization_id` claim are checked exactly as before. Only the public key set `/jwks` publishes is used for verification. No export, configuration key or environment variable is added.
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24186] The MCP access-token verifier's key set follows the cross-request
5+
* caching rule: it is read through the signer's own keyring reader, so a
6+
* keyring write this process observes reaches the next verification, the
7+
* reader's TTL bounds a write it cannot see, and a token signed by a key the
8+
* set does not carry earns one bounded fresh read before it is refused.
9+
*
10+
* ## The composition
11+
*
12+
* A real `AuthManager` on a real `ObjectQL` over `@objectstack/driver-sql` +
13+
* better-sqlite3, with this plugin's identity objects. Its embedded
14+
* authorization server is on (a loopback origin, the MCP surface on), so the
15+
* jwt plugin and its keyring reader are the ones a deployment runs. Tokens are
16+
* signed by that jwt plugin's own `signJWT` with the stored key, for this
17+
* deployment's issuer and MCP audience.
18+
*
19+
* ## What is pinned — one consequence per case
20+
*
21+
* 1. A signing key removed from the keyring stops verifying the tokens it
22+
* signed on the very next call, with no clock movement.
23+
* 2. A key that joins the keyring after the verifier's last read verifies on
24+
* the first call: through the write seam when the write passes this
25+
* process's engine, and through the one bounded fresh read when it does not
26+
* (written below the engine here, as a write on another node would be).
27+
* 3. A burst of tokens naming keys the keyring does not carry costs at most
28+
* one keyring read inside the floor interval, however the burst is spread.
29+
*
30+
* The bound's own edges — a read in flight is joined, the floor re-arms
31+
* exactly at its interval, a failed read still spends it — are pinned on a
32+
* counting key-set double at the foot of the file.
33+
*/
34+
35+
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
36+
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
37+
import { ObjectQL } from '@objectstack/objectql';
38+
import { SqlDriver } from '@objectstack/driver-sql';
39+
import { AuthManager } from './auth-manager.js';
40+
import { authIdentityObjects } from './manifest.js';
41+
import { retireHeldKeyrings, UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS } from './signing-key-read-cache.js';
42+
43+
const BASE = 'http://localhost:3000';
44+
const SECRET = 'test-secret-at-least-32-chars-long-24186';
45+
const SYS = { context: { isSystem: true } } as const;
46+
const USER = 'usr_24186';
47+
const CLIENT = 'client_24186';
48+
49+
const ENV_KEYS = ['OS_MCP_SERVER_ENABLED', 'OS_OIDC_PROVIDER_ENABLED'] as const;
50+
const savedEnv: Record<string, string | undefined> = {};
51+
const engines: ObjectQL[] = [];
52+
53+
beforeEach(() => {
54+
for (const k of ENV_KEYS) {
55+
savedEnv[k] = process.env[k];
56+
delete process.env[k];
57+
}
58+
process.env.OS_MCP_SERVER_ENABLED = 'true';
59+
});
60+
afterEach(async () => {
61+
vi.useRealTimers();
62+
vi.restoreAllMocks();
63+
for (const k of ENV_KEYS) {
64+
if (savedEnv[k] === undefined) delete process.env[k];
65+
else process.env[k] = savedEnv[k];
66+
}
67+
while (engines.length) {
68+
try {
69+
await (engines.pop() as unknown as { destroy?(): Promise<void> })?.destroy?.();
70+
} catch {
71+
/* noop */
72+
}
73+
}
74+
});
75+
76+
/** The `kid` a compact JWS names. */
77+
const kidOf = (jwt: string): string =>
78+
JSON.parse(Buffer.from(jwt.split('.')[0]!, 'base64url').toString('utf8')).kid;
79+
80+
/** A real AuthManager on a real engine, its keyring reads counted below the engine. */
81+
async function verifierRig() {
82+
vi.spyOn(console, 'warn').mockImplementation(() => {});
83+
const engine = new ObjectQL();
84+
engines.push(engine);
85+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
86+
engine.registerDriver(driver, true);
87+
await engine.init();
88+
for (const object of authIdentityObjects) {
89+
engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
90+
}
91+
await engine.syncSchemas();
92+
vi.spyOn((engine as any).logger, 'info').mockImplementation(() => undefined);
93+
94+
// Every driver read of the keyring table, whichever verb carries it.
95+
let keyringDriverReads = 0;
96+
for (const verb of ['find', 'findOne'] as const) {
97+
const original = (driver as any)[verb].bind(driver);
98+
(driver as any)[verb] = async (object: string, ...rest: unknown[]) => {
99+
if (object === 'sys_jwks') keyringDriverReads += 1;
100+
return original(object, ...rest);
101+
};
102+
}
103+
104+
const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never } as never);
105+
const api: any = await manager.getApi();
106+
107+
/** A token for this deployment's MCP resource, signed by the jwt plugin with the stored key. */
108+
const mint = async (): Promise<string> =>
109+
(await api.signJWT({
110+
body: {
111+
payload: {
112+
sub: USER,
113+
azp: CLIENT,
114+
scope: 'data:read',
115+
iss: manager.getAuthIssuer(),
116+
aud: manager.getMcpResourceUrl(),
117+
},
118+
},
119+
})).token;
120+
121+
/** A token for the same issuer and audience, signed by `privateKey` under `kid`. */
122+
const signWith = (privateKey: CryptoKey, kid: string): Promise<string> => {
123+
const now = Math.floor(Date.now() / 1000);
124+
return new SignJWT({ scope: 'data:read', azp: CLIENT })
125+
.setProtectedHeader({ alg: 'EdDSA', kid })
126+
.setIssuer(manager.getAuthIssuer())
127+
.setAudience(manager.getMcpResourceUrl())
128+
.setSubject(USER)
129+
.setIssuedAt(now)
130+
.setExpirationTime(now + 900)
131+
.sign(privateKey);
132+
};
133+
134+
/** What ONE keyring read costs at the driver on this engine — the unit the burst is counted in. */
135+
const oneKeyringRead = async (): Promise<number> => {
136+
const before = keyringDriverReads;
137+
retireHeldKeyrings(engine);
138+
await api.getJwks();
139+
return keyringDriverReads - before;
140+
};
141+
142+
return { engine, driver, manager, api, mint, signWith, oneKeyringRead, keyringReads: () => keyringDriverReads };
143+
}
144+
145+
describe('[#24186] the MCP verifier\'s key set follows the keyring, on a real engine', () => {
146+
it('a signing key removed from the keyring stops verifying its tokens on the next call, with no clock movement', async () => {
147+
const { engine, manager, mint } = await verifierRig();
148+
const token = await mint();
149+
expect(await manager.verifyMcpAccessToken(token)).toMatchObject({ userId: USER, clientId: CLIENT });
150+
151+
await engine.delete('sys_jwks', { where: { id: kidOf(token) }, ...SYS });
152+
153+
expect(await manager.verifyMcpAccessToken(token)).toBeNull();
154+
// The verifier itself is intact: a token from the key that replaced it verifies.
155+
const next = await mint();
156+
expect(kidOf(next)).not.toBe(kidOf(token));
157+
expect(await manager.verifyMcpAccessToken(next)).toMatchObject({ userId: USER });
158+
}, 60_000);
159+
160+
it('a key that joins the keyring through this engine after the last read verifies on the first call', async () => {
161+
const { engine, manager, mint } = await verifierRig();
162+
const first = await mint();
163+
expect(await manager.verifyMcpAccessToken(first)).toMatchObject({ userId: USER });
164+
165+
// The stored key expires a moment ago, so the next signature mints its successor.
166+
await engine.update('sys_jwks', { id: kidOf(first), expires_at: new Date(Date.now() - 1_000).toISOString() }, SYS);
167+
const second = await mint();
168+
expect(kidOf(second)).not.toBe(kidOf(first));
169+
170+
expect(await manager.verifyMcpAccessToken(second)).toMatchObject({ userId: USER });
171+
}, 60_000);
172+
173+
it('a key that joins the keyring where this engine cannot see it verifies on the first call, through one fresh read', async () => {
174+
const { driver, manager, mint, signWith, oneKeyringRead, keyringReads } = await verifierRig();
175+
expect(await manager.verifyMcpAccessToken(await mint())).toMatchObject({ userId: USER });
176+
const unit = await oneKeyringRead();
177+
expect(unit, 'the instrument sees a keyring read').toBeGreaterThan(0);
178+
179+
// A key row written BELOW the engine, as another node's write would land:
180+
// nothing in this process observes it. Its private half is never read here.
181+
const pair = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true });
182+
const kid = 'key_24186_unobserved';
183+
await driver.create('sys_jwks', {
184+
id: kid,
185+
public_key: JSON.stringify(await exportJWK(pair.publicKey)),
186+
private_key: '{}',
187+
alg: 'EdDSA',
188+
crv: 'Ed25519',
189+
created_at: new Date().toISOString(),
190+
});
191+
192+
const before = keyringReads();
193+
expect(await manager.verifyMcpAccessToken(await signWith(pair.privateKey as CryptoKey, kid)))
194+
.toMatchObject({ userId: USER, clientId: CLIENT });
195+
expect(keyringReads() - before, 'the first call paid exactly one fresh keyring read').toBe(unit);
196+
}, 60_000);
197+
198+
it('a burst of tokens naming keys the keyring does not carry costs at most one keyring read inside the floor interval', async () => {
199+
const { manager, mint, signWith, oneKeyringRead, keyringReads } = await verifierRig();
200+
expect(await manager.verifyMcpAccessToken(await mint())).toMatchObject({ userId: USER });
201+
const unit = await oneKeyringRead();
202+
expect(unit, 'the instrument sees a keyring read').toBeGreaterThan(0);
203+
204+
const stranger = await generateKeyPair('EdDSA', { crv: 'Ed25519' });
205+
const unknown = (i: number) => signWith(stranger.privateKey as CryptoKey, `key_24186_absent_${i}`);
206+
207+
const before = keyringReads();
208+
// Concurrent, then one after another: neither shape buys a second read.
209+
const concurrent = await Promise.all(
210+
Array.from({ length: 20 }, async (_, i) => manager.verifyMcpAccessToken(await unknown(i))),
211+
);
212+
const sequential: unknown[] = [];
213+
for (let i = 20; i < 40; i += 1) sequential.push(await manager.verifyMcpAccessToken(await unknown(i)));
214+
215+
expect([...concurrent, ...sequential].every((r) => r === null)).toBe(true);
216+
expect(keyringReads() - before).toBe(unit);
217+
// And a token the keyring does carry still verifies from the held set, for free.
218+
const known = await mint();
219+
const beforeKnown = keyringReads();
220+
expect(await manager.verifyMcpAccessToken(known)).toMatchObject({ userId: USER });
221+
expect(keyringReads() - beforeKnown).toBe(0);
222+
}, 60_000);
223+
});
224+
225+
describe('[#24186] the bound on the fresh read an unknown key earns', () => {
226+
const ISSUER = 'https://acme.example.com/api/v1/auth';
227+
const AUDIENCE = 'https://acme.example.com/api/v1/mcp';
228+
229+
/**
230+
* A manager whose key set is a counting double: every `getJwks()` call is
231+
* one read, numbered from 1, and `answer(n)` decides what read `n` returns
232+
* (or throws). With no reader behind it, every call is a read through.
233+
*/
234+
async function countingManager() {
235+
const known = await generateKeyPair('RS256');
236+
const rotated = await generateKeyPair('RS256');
237+
const before = { keys: [{ ...(await exportJWK(known.publicKey)), alg: 'RS256', kid: 'known' }] };
238+
const after = { keys: [...before.keys, { ...(await exportJWK(rotated.publicKey)), alg: 'RS256', kid: 'rotated' }] };
239+
const state = { reads: 0, answer: (_n: number): Promise<unknown> | unknown => before };
240+
const manager = new AuthManager({ secret: SECRET, baseUrl: 'https://acme.example.com' });
241+
vi.spyOn(manager, 'getApi').mockResolvedValue({
242+
getJwks: async () => {
243+
state.reads += 1;
244+
return state.answer(state.reads);
245+
},
246+
} as any);
247+
const token = (privateKey: CryptoKey, kid: string) => {
248+
const now = Math.floor(Date.now() / 1000);
249+
return new SignJWT({ scope: 'data:read', azp: CLIENT })
250+
.setProtectedHeader({ alg: 'RS256', kid })
251+
.setIssuer(ISSUER)
252+
.setAudience(AUDIENCE)
253+
.setSubject(USER)
254+
.setIssuedAt(now)
255+
.setExpirationTime(now + 900)
256+
.sign(privateKey);
257+
};
258+
return {
259+
manager,
260+
state,
261+
after,
262+
rotatedToken: () => token(rotated.privateKey as CryptoKey, 'rotated'),
263+
absentToken: (kid: string) => token(rotated.privateKey as CryptoKey, kid),
264+
};
265+
}
266+
267+
it('a token arriving while the fresh read is in flight shares it', async () => {
268+
const { manager, state, after, rotatedToken } = await countingManager();
269+
let open!: () => void;
270+
const gate = new Promise<void>((resolve) => { open = resolve; });
271+
// Read 2 is the fresh read: it answers the rotated set, once the gate opens.
272+
// Every other read answers the set from before the rotation.
273+
const original = state.answer;
274+
state.answer = async (n) => (n === 2 ? (await gate, after) : original(n));
275+
276+
const first = manager.verifyMcpAccessToken(await rotatedToken());
277+
await vi.waitFor(() => expect(state.reads).toBe(2));
278+
const second = manager.verifyMcpAccessToken(await rotatedToken());
279+
await vi.waitFor(() => expect(state.reads).toBe(3));
280+
// Let the second call reach the fresh read before it settles.
281+
await new Promise((resolve) => setTimeout(resolve, 25));
282+
open();
283+
284+
// The second call verifies only by joining read 2: a read of its own (read
285+
// 4) would answer the old set, and a refusal by the floor would be `null`.
286+
expect(await first).toMatchObject({ userId: USER });
287+
expect(await second).toMatchObject({ userId: USER });
288+
expect(state.reads).toBe(3);
289+
});
290+
291+
it('the floor re-arms exactly at its interval, and not a millisecond before', async () => {
292+
vi.useFakeTimers({ toFake: ['Date'] });
293+
vi.setSystemTime(new Date('2026-10-17T12:00:00Z'));
294+
const { manager, state, absentToken } = await countingManager();
295+
296+
expect(await manager.verifyMcpAccessToken(await absentToken('absent_1'))).toBeNull();
297+
expect(state.reads).toBe(2); // the set, and the one fresh read
298+
299+
vi.setSystemTime(Date.now() + UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS - 1);
300+
expect(await manager.verifyMcpAccessToken(await absentToken('absent_2'))).toBeNull();
301+
expect(state.reads).toBe(3); // the set only: still inside the floor
302+
303+
vi.setSystemTime(Date.now() + 1);
304+
expect(await manager.verifyMcpAccessToken(await absentToken('absent_3'))).toBeNull();
305+
expect(state.reads).toBe(5); // the set, and a fresh read again
306+
});
307+
308+
it('a fresh read that fails still spends the interval', async () => {
309+
const { manager, state, absentToken } = await countingManager();
310+
const original = state.answer;
311+
state.answer = (n) => {
312+
if (n === 2) throw new Error('keyring unreadable');
313+
return original(n);
314+
};
315+
316+
expect(await manager.verifyMcpAccessToken(await absentToken('absent_x'))).toBeNull();
317+
expect(await manager.verifyMcpAccessToken(await absentToken('absent_y'))).toBeNull();
318+
expect(state.reads).toBe(3); // the set and the failed fresh read, then the set alone
319+
});
320+
});

0 commit comments

Comments
 (0)