fix(plugin-auth): the MCP surface refuses a client_credentials token — principal-bound to a human - #17441
Conversation
`verifyMcpAccessToken` resolved a machine-to-machine access token to a principal while its own docblock declared such tokens rejected. The docblock's premise was that they "carry no `sub`"; the installed `@better-auth/oauth-provider` stamps `sub = user?.id ?? client.clientId`, so the premise is never true and the rejection it described could not fire. The discriminator is the `sub`/`client_id` pair as RFC 9068 defines it for a JWT access token: `client_id` is REQUIRED (§2.2) and `sub` is the resource owner when a grant had one, or an identifier for the client application when it did not (§2.2.3.1). A token whose `sub` equals its own `client_id`/`azp` therefore states that no human delegated it, and a token carrying neither client claim is refused too — the check cannot run on it and must not silently pass. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
The predecessor pin signed a token with no `sub` at all — a shape the provider does not mint — so it stayed green while every real client_credentials token was admitted. It is joined by a differential against a real authorization server: one server, one JWKS, two tokens, one variable (which grant produced them). The machine leg must assemble no principal; the human leg must resolve exactly as before, so a method that refuses everything cannot satisfy the pair. Claim-shape cases cover the two client spellings independently, including a token that disagrees with itself, and the no-client-claim case where the discriminator has no input. The runtime door owes the other half: a refused JWT bearer must assemble no execution context at all, not merely answer 401. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
…p-token-human-principal
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 13 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 8d950218bfcf103b44f1c21f30613e05968af10c && git checkout 8d950218bfcf103b44f1c21f30613e05968af10c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba9f0299086cb589350b38884377caf1d138147d 2e8563b7b70534f6bb7ba690702085be353dbaa2 && git checkout -B drift-repro ba9f0299086cb589350b38884377caf1d138147d && git merge --no-ff 2e8563b7b70534f6bb7ba690702085be353dbaa2
node scripts/docs-audit/affected-docs.mjs --json ba9f0299086cb589350b38884377caf1d138147d
|
Docs-drift advisory — answered by the review seat: no page falsified, measured
The measurement, on
|
| page | why it was listed | verifyMcpAccessToken |
client_credentials / M2M / 「carry no sub」 |
|---|---|---|---|
content/docs/kernel/contracts/auth-service.mdx |
the METHOD, by name | 1 | 0 |
content/docs/kernel/services-checklist.mdx |
the class AuthManager only |
0 | 0 |
content/docs/permissions/authentication.mdx |
the class AuthManager only |
0 | 0 |
⇒ Zero across all three. The false premise lived only in the code docblock this PR rewrote; it was never restated in hand-written docs. Two of the three pages are anchored on the class name alone, which this diff does not change.
The one page that names the method carries it as an interface signature excerpt, not as a behavioural description:
/** Verify an OAuth 2.1 access token from the embedded AS (MCP surface). Optional. */
verifyMcpAccessToken?(token: string): Promise<{ userId: string; scopes: string[]; clientId?: string } | undefined>;⇒ The behaviour change (which tokens resolve) is invisible to that signature, and the signature's shape is unchanged by this PR — the refused case was already a non-value before it.
undefined, while the implementation returns null (it did so before this PR too — the pre-image if (!userId) return null; is visible in this diff's removed lines). So the null/undefined question is pre-existing and not this PR's, and I have measured only the doc excerpt, ⛔ not the real interface declaration — an excerpt may legitimately be abridged. Worth one check by whoever owns that page (content/docs/** is domain:devx, not this lane); ⛔ I am not asserting a defect and this PR is ⛔ not widened to chase it.
⛔ Release-owned pages
This advisory listed none for this PR, and this PR touches 0 files under content/docs/ of any kind — the guardrail is not in play here.
⇒ Advisory answered, no action. ⛔ Nothing to change in this PR on account of it.
Generated by Claude Code
Fixes #16418
Clause-②: no
(declaration line written by the⚠️ STRIPPED because the declaration was wrong, ⛔ NOT because a contract review happened.)
domain:servicesreview seat, not by the implementer. This diff adds 0 exported symbols and narrows the accept set — aclient_credentialstoken that resolved now returnsnull— so it is a pull-back to the contractauth-manager.ts:6133already published ("Client-credentials (M2M) tokens carry nosuband are rejected"), not a widening. Thepatchlevel stands. Full two-limb measurement and the seat's correction of its own earlieryes: #16418 comment 5620303472. Theneeds:contract-reviewcarrier was cleared for the same reason —Route (b) as ruled (director seat, summon #18, decision batch #104, 2026-09-09). The MCP surface stays principal-bound to a human; the API-key door is untouched; no machine principal is minted here.
What changed
AuthManager.verifyMcpAccessTokenresolved aclient_credentials(machine-to-machine) access token to a principal while its own docblock declared such tokens rejected. The docblock's premise was that they "carry nosub". The provider stamps the subject as the user id when a user exists and the client id otherwise, so the premise is never true for an M2M token and the rejection it described could not fire.The method now reads the subject and the client identity as a pair and returns
nullwhen they are the same principal. The docblock names that mechanism in place of the false sentence. Nothing else on this path moved.The discriminator — chosen on measurement
The ruling deliberately did not choose it: "What the ruling does not choose — the dev's, under the services seat's
Clause-②: yesclaim: the discriminator." So it is defended here.What was measured
Two tokens minted by one real authorization server, booted in-test from the exact options
AuthManagerhands@better-auth/oauth-provider(1.7.2, the version today's lockfile resolves — M2 re-measured, unchanged since the card was filed). Both payloads were read off the token, never inferred from provider source (card re-derive step 3):authorization_code(human)client_credentials(machine)subsys_useridclient_idazpsidaudA third leg was measured for the fail-closed check below: an access token re-minted through the refresh_token grant carries
client_idand asubthat still differs from it, so nothing on the long-lived MCP client path is refused by the new rule.Why the
sub/client_idpairIt is not a quirk of this provider — it is what RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens) defines, and the tokens this deployment mints carry that profile's own media type in their header:
client_idREQUIRED on a JWT access token.submeans beside it: the resource owner for a grant that had one, and "an identifier the authorization server uses to indicate the client application" for a grant that did not.So a token whose
subequals its ownclient_idstates, in the authorization server's own words, that no human delegated it. That is a first-class fact about the grant, carried by the credential itself, and it survives a provider bump — and even a provider swap — in a way an implementation detail does not.Two halves make it fail closed rather than fail quiet:
client_idandazp), never collapsed through a??chain first. A token that disagrees with itself across the two is refused on either match; a precedence chain would let the losing spelling carry the client id past the comparison. There is a pin for exactly that shape./oauth2/authorizeenv-access gate silently does not run for a signed bearer credential — its inline token lookup is a stale copy ofresolveActor#8102 — a declared check that never fires). No token this AS mints is affected:client_idandazpare both stamped unconditionally.The two alternatives, and why not
sidpresence. It does separate today's two shapes, and it is a positive claim, which is what the ruling's input paragraph asked for. But it is an OIDC session-management convenience that the installed provider already gates per client on ID tokens (enableEndSession || backchannelLogoutUri). A bump that gated it on access tokens the same way would refuse every human on this surface — a total MCP outage, which is precisely the failure mode this method must not have (constraint C1: nothing here may refuse a user token that resolves today). The absence is recorded as an assertion in the new pin so the fact stays measured, but it is not the gate.sys_userresolution before the principal is assembled. This was the ruling's other suggested positive form, and it is not taken, for four reasons. (a) It converts a deliberately local, I/O-free verifier — its own docblock says so — into one that depends on the data engine, turning a transient store error into a401for a legitimate human on every MCP request. (b) It answers the wrong question: a row's existence is not humanity.isHumanUserRowexists in this very package precisely becauseusr_systemis a row and not a person, so the honest predicate would be a human-row read, not an existence read — more machinery, still in the token verifier. (c) It would fork the identity model: the existence check would hold on the MCP OAuth chain only, and not for API keys or sessions, which is a partial invariant rather than a contract. (d) It cannot be adopted without editing the negative-control suite the ruling requires to stay unchanged — those tests resolve a principal with no data engine wired, so requiring one would redden acceptance (2)'s own pins.The residual gap is stated rather than hidden: if a future provider version minted an M2M token whose
subis neither the client id nor asys_userid, the pair rule would admit it. That gap is closed by the option-liveness and minted-token pins inauth-manager.mcp-oauth-resource.test.ts— both read the installed provider, so a bump that changes the shape reddens there rather than passing silently.验收备注
Carried verbatim from the ruling (comment 5595112789), each with what discharges it.
client_credentialstoken minted through the real@better-auth/oauth-provider(the trace's recipe, incl.client_credentials_scopeson the client row) →verifyMcpAccessTokenreturnsnull; through the MCP HTTP door → 401, fail-closed, no context assembled."✅ First half:
auth-manager.mcp-oauth-resource.test.tsboots the real AS, registers a confidential client authorised for that grant and linked to the MCP resource, drives the AS's own token endpoint, and hands the minted token to a realAuthManagerverifying against that server's JWKS. Second half: the door'snull → 401 with nothing assembledmapping is pinned inpackages/runtime/src/http-dispatcher.mcp-oauth.test.ts, which now asserts the MCP runtime is never reached and no execution context is built — not merely that the status is 401. That file fakes the verifier by design (its own docblock says the verifier half lives in plugin-auth); the two pins compose to the acceptance sentence, and neither carries it alone.auth-manager.mcp-oauth.test.ts+auth-manager.mcp-oauth-resource.test.tsstay 44/44."✅ The two files were 44/44 before this change and all 44 are still green; they are now 51/51, the 7 additions being this card's pins. The user leg is not asserted only by the old fixtures: the new differential runs the full flow on the same server, same JWKS, same audience as the machine leg, one variable apart, and requires the resolved principal to be exactly the user id, the granted scopes and the client. Without that half a method that refuses everything would satisfy the refusal.
/oauth2/authorizeenv-access gate silently does not run for a signed bearer credential — its inline token lookup is a stale copy ofresolveActor#8102)."✅ Joined. The old test kept its subject (a sub-less token is still refused) and lost its false title — it no longer claims to be the M2M pin. The M2M pin is the minted-token differential, plus claim-shape cases for both client spellings and for the no-client-claim case.
x-api-key/Bearer osk_…on MCP HTTP andOS_MCP_STDIO_API_KEYon stdio (ADR-0101 D1) resolve exactly as before — separate chain (resolveApiKeyPrincipal), separate credential shape (extractJwtBearerroutes only three-segment non-osk_bearers to the JWT verifier)."✅ No file on that chain is in this diff. The pins are the two
REGRESSION:cases inhttp-dispatcher.mcp-oauth.test.ts(header key and prefixed bearer both resolve the key principal, unscoped) and the bearer-routing case inresolve-execution-context.test.ts; all are green in the runs below, alongside the pin that a non-JWT opaque bearer still takes the session path. The credential shapes cannot collide: the prefixed key and the opaque session bearer are both rejected by the router before the verifier, and the verifier itself still refuses a prefixed key without touching the JWKS.content/docs/ai/agents.mdxlines 58–88 already describe the two connection routes (OAuth for interactive clients, API key for headless) and need no change unless the dev finds a sentence that contradicts the ruling."✅ Docblock rewritten around the pair rule, with the two rejected discriminators named so the next author does not "improve" it into the fragile one. The docs were read: that section says the OAuth client "acts as an agent on your behalf" and names the API key as the headless route. Nothing there contradicts the ruling, and
client_credentialsdoes not occur anywhere undercontent/docs. No docs change./oauth2/tokenresponse, not this method."✅ Taken as established; nothing re-routed. Recorded for the next reviewer: this PR's own probe uses exactly that channel — the token endpoint's response is read directly, and the claims are asserted on the decoded payload before the verifier is consulted at all.
Ablation
Required by the card's security discipline: put the admitted behaviour back and show the new pins go red.
Run from the committed state; the mutation removed the two guard statements and left a marker in their place. On-disk proof, not an exit code — occurrence counts across the mutation: guard-pair
1 → 0, no-client-guard1 → 0, marker0 → 1; worktree blob306cd6aa…→e6aa7c5c….5 tests red under ablation, and the real minted M2M token resolved to a principal again — reproducing the card's finding, with the client id in the
userIdslot:The other four are the claim-shape cases (
subequal toazp;subequal toclient_id; the self-disagreeing token; the no-client-claim token). The 44 pre-existing tests stayed green under ablation — the ablation moves this card's pins and nothing else.Restoration proven by state, not by exit code:
git checkout HEAD --naming the file (never a baregit checkout --, which restores from the polluted index), thengit diff --quiet HEADexits0, the worktree blob hash is306cd6aa1f4713e4fea363e74253131b3a7adab0— byte-identical to theHEADblob — the marker count is back to0, andgit status --porcelainis empty. The mutation script carried anEXIT INT TERMtrap on an absolute path throughout.Verification
Exit codes captured before any pipe.
pnpm --filter '@objectstack/plugin-auth^...' buildand then the full package build CI's typecheck job requires (turbo run buildover both package roots):72 successful, 72 total, exit0. The full build was needed because two gates answerPREREQUISITE NOT MET(exit 3, not a finding) without everydist/present — recorded here so the pair is not misread as a red.node scripts/pm/dispatch-gates.mjs --commands(no hand-written path list; the tool takes the change set from the merge base itself), re-derived after mergingmainso the derivation is not from a stale tree. 61 families, 61 run, all exit 0. Reconciled with--rancarrying every exit code: "61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3)".@objectstack/plugin-auth: 106 files / 2252 tests passed.@objectstack/runtime: 251 files / 3533 tests passed. Bothtypecheckscripts exit0, test-layer ledgers unchanged (plugin-auth 10 files / 94 errors / 23 pinned; runtime 27 / 191 / 69). The two MCP OAuth suites alone: 51/51, up from 44/44 by this card's 7 pins.pnpm lint(eslint . --no-inline-config, the only style authority here) run in full rather than narrowed: exit 0, no output, at2e8563b7b.Acceptance notes
Observations from this lane, noted, not filed — none is a reproducible defect, a contract violation, or a metadata-authoring trap, so none meets the filing bar:
client_credentialsgrant for an unauthenticated registration, and the only registration path that may set a client's machine-grant scope ceiling requires an administrative privilege hook this deployment does not configure — so today the ceiling can only arrive by an operator editing the stored row. That narrows who could have reached the old admission; it does not change the ruling, and it is why the probe seeds the client through the server's own adapter rather than through a public endpoint. Carrier: whoever next builds an admin surface over OAuth clients.{ user: { id } }and nothing else, so no layer below the verifier ever asserts that the id names a user. That is exactly why the refusal had to land in the verifier and why thesys_userroute was tempting; it is a shape worth knowing, not a defect on its own — every id that reaches it now came from a delegated grant. Carrier: any future card that widens what may reach this door.audis an array on a token whose scopes includeopenidand a bare string otherwise, so it is grant- and scope-dependent and cannot serve as a token-shape discriminator. Recorded because the ruling listed it among the measured differences. Carrier: none.⛔ No
packages/specchange was needed (constraint C2): the fix returnsnulland the door's existing 401 envelope carries it, so noERROR_CODE_LEDGERrow is required.Generated by Claude Code
Generated by Claude Code