fix(auth): treat null optional fields in token responses as absent - #2462
fix(auth): treat null optional fields in token responses as absent#2462claude[bot] wants to merge 14 commits into
Conversation
Some authorization servers serialize absent optional members as JSON null, which RFC 6749 does not sanction but is common in the wild. Previously, OAuthTokensSchema rejected refresh_token/scope/id_token when null with a Zod validation error, and expires_in: null silently coerced to 0 (Number(null) === 0), producing a token the client treated as already expired. This broke token exchange and refresh against such servers. Normalize null optional members to absent (undefined) before validation. Inferred output types are unchanged (string | undefined, number | undefined), so OAuthTokens consumers are unaffected. Related: #754 (same null-serialization pattern hitting the client registration schema).
🦋 Changeset detectedLatest commit: 0e4a6f1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…in OAuthTokensSchema
Rework after review: the per-field z.preprocess mechanic left null-valued
keys present-as-undefined in the parsed output rather than absent, so
refreshAuthorization's spread over the previous refresh token was clobbered
by the explicit refresh_token: undefined — for exactly the null-emitting
servers this fix targets, every refresh silently destroyed the stored
refresh token. It also degraded z.input of the exported schema on zod <4.4.
- Revert OAuthTokensSchema to its original plain object definition,
restoring .shape/.extend/z.input for consumers (and the derived
specTypeSchemas/isSpecType input types).
- Add OAuthTokenResponseSchema, which removes null-valued optional members
(derived from the schema shape, not a hardcoded field list) before
validation, mirroring ElicitResult's null-leniency idiom, and use it at
the SDK's own token-response parse sites: executeTokenRequest, the
JWT-grant cross-app exchange, and server-legacy's proxyProvider.
- Harden refreshAuthorization's merge to
{ ...tokens, refresh_token: tokens.refresh_token ?? refreshToken } so a
present-but-undefined key can never clobber the preserved token.
- Tests now pin strict key absence (toStrictEqual / 'in' checks), null
access_token and missing token_type rejection, the exported schema's
unchanged shape/extend/input behavior, a shape-driven drift guard for
future optional members, and a refreshAuthorization e2e with
refresh_token: null that fails against the previous mechanic.
Related: #754 (same null-serialization pattern hitting the client
registration schema).
Stripping a null scope from a token response makes it indistinguishable from an omitted scope, which RFC 6749 §5.1 defines as an assertion that the granted scope is identical to the requested scope. Document that consumers must not infer the granted scope from its absence and should use token introspection for the authoritative grant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
The changeset no longer bumps @modelcontextprotocol/core: its exported OAuthTokensSchema is deliberately unchanged and OAuthTokenResponseSchema is not part of core's shipped surface (not exported from core's index, not in core-internal's authSchemas registry that core's export group is test-pinned to), matching the sibling changeset convention of patching core-internal without core. The OAuthTokenResponseSchema JSDoc no longer claims the SDK never uses tokens.scope for authorization decisions: the 403 insufficient_scope step-up path feeds tokens.scope into isStrictScopeSuperset, where an absent scope is treated as the empty set and forces a fresh authorization request rather than a refresh. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
There was a problem hiding this comment.
No new issues found on this revision — the latest commit resolves two of the three items from my earlier review (the changeset no longer bumps @modelcontextprotocol/core, and the OAuthTokenResponseSchema JSDoc now correctly describes the step-up path's conservative use of tokens.scope) — but since this touches OAuth token handling across client, cross-app, and proxy-provider flows, it should get a human maintainer's sign-off.
What was reviewed on this pass:
- Confirmed the null-stripping preprocess only removes keys whose member schema accepts
undefined, soaccess_token: null/ missingtoken_typestill reject, and.strip()semantics are preserved. - Confirmed the
refreshAuthorizationmerge hardening plus the strict key-absence tests pin the clobbering regression from the first revision. - The one item still open from my earlier review is the sibling-parse-site scoping question (
OAuthErrorResponseSchema,IdJagTokenExchangeResponseSchema) — deliberate scoping there seems defensible; that's a maintainer call, not a blocker.
Extended reasoning...
Overview
This PR makes the SDK's own OAuth token-response parse sites tolerant of authorization servers that serialize absent optional members as JSON null (nonconformant with RFC 6749 §5.1 but common in the wild, e.g. Ory Hydra per #754). It introduces OAuthTokenResponseSchema in packages/core-internal/src/shared/auth.ts — an object-level z.preprocess that deletes null-valued keys whose member schema accepts undefined, then delegates to the unchanged OAuthTokensSchema — and switches four wire-parse sites to it (executeTokenRequest, exchangeJwtAuthGrant, and both proxyProvider exchanges). It also hardens refreshAuthorization's merge to { ...tokens, refresh_token: tokens.refresh_token ?? refreshToken } so a present-but-undefined key can never clobber the stored refresh token.
Security risks
This is auth code, and the change is a leniency change in token-response validation. The risk surface is narrow: only null-valued optional members are stripped (the drift-guard test derives the member list from the schema shape and the tests pin that access_token: null and a missing token_type still reject), the exported OAuthTokensSchema is unchanged, and expires_in: null now parses as absent rather than silently coercing to 0. The semantic subtlety — a stripped scope: null becoming indistinguishable from RFC 6749 §5.1's "granted = requested" assertion — is documented in both the JSDoc and the changeset, and the step-up path treats absence conservatively (forces re-authorization). I did not find a way for the normalization to widen what a malicious AS can assert.
Level of scrutiny
High — this is production OAuth flow code shipped in three packages, and it deliberately relaxes validation against RFC-nonconformant servers. That trade-off (interop vs. strictness), plus the introduction of a new internal schema alongside the exported one, is exactly the kind of judgment a human maintainer should ratify, per the repo's minimalism/burden-of-proof conventions. The mechanics themselves are sound and unusually well-tested (strict key-absence assertions, drift guard over future optional members, end-to-end refresh-token-preservation regression test).
Other factors
This revision responds to prior review: commit e3d7aab drops @modelcontextprotocol/core from the changeset frontmatter (verified — the frontmatter now lists only core-internal/client/server-legacy, matching changeset-bot's 3-package report) and corrects the JSDoc's overbroad "never uses tokens.scope" claim to accurately describe the 403 insufficient_scope step-up behavior. The remaining open item from my earlier inline comments — sibling parse sites (OAuthErrorResponseSchema at both executeTokenRequest error branches, IdJagTokenExchangeResponseSchema in requestJwtAuthorizationGrant) still rejecting null-valued optionals — is unchanged; the PR description explicitly scopes out the registration schema, and whether to extend the normalization to error/exchange responses is a scoping decision for the maintainer rather than a defect in what this PR ships. No bugs were found by this run's bug-hunting pass.
Resolves the conflict with #2477 (schema sources moved into @modelcontextprotocol/core): OAuthTokenResponseSchema now lives in packages/core/src/auth.ts next to OAuthTokensSchema, and core-internal's shared/auth.ts re-export shim forwards it, per the schemaShims purity pins. The normalization design is unchanged; all parse sites keep importing from @modelcontextprotocol/core-internal, which now forwards from core. Because the schema is now part of core's shipped /internal entry, the changeset re-adds the @modelcontextprotocol/core patch bump that was dropped earlier today (that removal was correct against the pre-#2477 base, where core's shipped surface was untouched). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
…uide The guide claimed core's exports are the exact constants the SDK validates OAuth payloads against, and pointed gateway authors at OAuthTokensSchema for token responses. Since the SDK's own token parse sites now validate with OAuthTokenResponseSchema (an internal null-tolerant wrapper around OAuthTokensSchema), qualify the intro and note the wrapper next to the OAuth naming-convention list. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
There was a problem hiding this comment.
I found no bugs on the current revision, and the earlier review findings (JSDoc scope-usage claim, the core changeset bump, and the wire-schemas doc drift) are all addressed by e3d7aab, 0c105c1, and 791c379 — but this changes OAuth token-exchange/refresh behavior across client, server-legacy, and core's shipped /internal surface, so a maintainer should make the final call.
What was reviewed this run:
- The null-stripping preprocess in OAuthTokenResponseSchema — only optional members with a null value are removed; access_token/token_type stay strict, and expires_in: null no longer coerces to 0.
- Migration completeness — grepped packages/*/src for surviving OAuthTokensSchema wire-parse sites: none remain (error-response and ID-JAG schemas stay strict by documented design).
- The refreshAuthorization merge reorder — tokens.refresh_token ?? refreshToken preserves the stored refresh token, pinned by an end-to-end test.
Extended reasoning...
Overview
This PR makes the SDK tolerate authorization servers that serialize absent optional token-response members as JSON null (Ory-Hydra-style, cf. #754). It adds OAuthTokenResponseSchema — an object-level z.preprocess wrapper around the unchanged OAuthTokensSchema that deletes null-valued optional keys before validation — defined in packages/core/src/auth.ts and forwarded through core-internal's re-export shim. All four SDK token parse sites migrate to it (executeTokenRequest, exchangeJwtAuthGrant, and both proxyProvider exchanges), and refreshAuthorization's merge is hardened to { ...tokens, refresh_token: tokens.refresh_token ?? refreshToken }. Test coverage is unusually thorough: strict key-absence assertions, per-field and all-null cases, expires_in null-vs-0, required-field rejection pins, a shape-driven drift guard, and end-to-end exchange/refresh tests.
Security risks
Low. The change loosens parsing only for optional members whose value is null; required members (access_token, token_type) still reject null/absence, so no token can be minted from a degenerate response. The refresh-merge reorder is the one behavioral edge — verified that the parsed response's keys are strictly absent (not present-but-undefined) so the stored refresh token can't be clobbered, and the test suite pins exactly that failure mode from the PR's first revision. Still, this is the OAuth client token path, which is squarely auth-sensitive code.
Level of scrutiny
High — token exchange, refresh, and the cross-app JWT-grant flow are production auth paths spanning three published packages, and the PR adds a symbol to core's shipped /internal surface (with a deliberate decision, recorded in 0c105c1, to re-add the core patch bump after #2477 moved the schema source into core). Those are exactly the API-surface and release-hygiene calls a maintainer should ratify, per this repo's minimalism/burden-of-proof conventions.
Other factors
All three earlier inline findings from prior review runs are resolved in the current head: the JSDoc no longer over-claims about tokens.scope (e3d7aab), the core changeset bump is now justified by the schema shipping in core's /internal entry post-#2477 (0c105c1), and docs/advanced/wire-schemas.md now documents the null-tolerant wrapper (791c379). The remaining strict sibling schemas (OAuthErrorResponseSchema, IdJagTokenExchangeResponseSchema) are an explicitly documented scope carve-out in the PR description rather than an oversight. Nothing here blocks the PR; deferring only because auth-path behavior changes and a new shipped-surface symbol warrant human sign-off.
…to-v2 codemod The sibling v1.x PR makes OAuthTokenResponseSchema public v1 API, so the codemod must route its imports somewhere that exports it. Core's root barrel now exports the schema, AUTH_SCHEMA_NAMES includes it, and the drift-guard tests pin the new membership. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q2uDjqaRGTv8vFth6iiU17
…ctness OAuthTokenResponseSchema is a public core export as of the codemod mapping commit, so the wire-schemas guide no longer calls it internal and now points raw-wire consumers at it directly. Both the guide and the changeset also stop claiming the plain OAuthTokensSchema rejects nulls outright: it rejects null for its string-typed members, but expires_in: null coerces to 0 there (verified empirically against the branch schemas). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
…nalogy RFC 6749 sends no scope parameter on refresh, so a refresh response without a scope member (including one whose scope: null was stripped by OAuthTokenResponseSchema) asserts the grant is unchanged. The refresh branch previously saved the response as-is, erasing the stored scope; after a restart the insufficient_scope step-up would compute its union without the original grant and force interactive re-authorization. Mirror the refresh_token hardening: preserve the stored scope when the response has none, while a scope the server does return stays authoritative. Tests pin null, omitted, and server-narrowed scope on the auth() refresh path. Also reword the OAuthTokenResponseSchema JSDoc: it claimed the key removal mirrors ElicitResult's content null normalization, but that mechanic maps null to a present-but-undefined member — exactly what this schema rejects. The analogy now states the shared goal and the stronger mechanic. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
The scope-preservation merge wrote scope: undefined into the saveTokens payload when both the refresh response and the stored tokens lacked a scope — the present-but-undefined key shape this PR's normalization exists to prevent. A conditional spread now includes the scope member only when a preserved value exists. New test pins the no-scope-anywhere refresh payload with toStrictEqual (asserting on the refresh save, not the SEP-2352 issuer back-stamp call). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
… export Additive public API on core's root barrel is a minor under semver, and core's changelog ships export-surface additions as minor (#2354, #2513). The changeset fixed group lifts core, client, server, server-legacy, and codemod together to 2.1.0. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
Flipping core to minor major-bumps express, fastify, hono, and node to 3.0.0: they peer-depend on server via workspace:^, server rides the fixed group to minor, and .changeset/config.json does not set onlyUpdatePeerDependentsWhenOutOfRange, so changesets' default major-bumps peer-dependents on any non-patch peer bump. The additive public export arguably warrants a minor per repo precedent, but that blast radius is a maintainer decision; the PR body records the options. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
There was a problem hiding this comment.
No new issues found this run — the follow-up commits resolve all of my earlier inline findings, but this is OAuth token-handling code with an explicitly open maintainer decision (changeset bump level), so it needs a human sign-off rather than a shadow approval.
What was reviewed this run:
- Confirmed the refresh save site now preserves the stored scope via a conditional spread, keeping
scopestrictly absent when neither side has one (auth.ts:1323-1324), with tests covering all four scope cases. - Confirmed the
OAuthTokenResponseSchemaJSDoc now correctly contrasts its key-removal mechanic with ElicitResult's null→undefined mapping, and that wire-schemas.md and the changeset now accurately describe theexpires_in: nullcoercion caveat in the strict schema. - Re-checked the preprocess: only optional members are stripped (required
access_token/token_typestill reject null), non-object inputs pass through, and the codemod allowlist/drift guards pin the new public export.
Extended reasoning...
Overview
This PR makes the SDK tolerate OAuth token responses whose optional members are serialized as JSON null (nonconformant but common, e.g. Ory-Hydra-style servers). It adds OAuthTokenResponseSchema — an object-level z.preprocess wrapper that removes null-valued optional keys before OAuthTokensSchema validates — and switches all four SDK token parse sites to it (client exchange/refresh, JWT-grant cross-app exchange, server-legacy proxy provider). It also hardens the refresh merge (refresh_token and scope preservation), exports the new schema publicly from core, updates the codemod allowlist, and documents everything in wire-schemas.md and a changeset.
Security risks
This is auth-flow code, so the risk surface is real: token persistence, refresh-token preservation, and scope handling. The changes are conservative — required members (access_token, token_type) still reject null, only optional members are normalized, and the merge hardening only fills absent values from previously-stored tokens (a server-returned scope remains authoritative, so no scope-widening path is introduced). Prior review rounds on this PR specifically adversarially examined refresh-token clobbering and scope erasure; the current revision fixes both with strict key-absence tests (toStrictEqual / 'key' in assertions).
Level of scrutiny
High. OAuth client/server token handling is security-sensitive per the approval guidelines, which alone rules out shadow approval regardless of correctness. Additionally, the PR body explicitly leaves a versioning decision to maintainers: the new public core export arguably warrants a minor bump, but flipping it currently major-bumps the middleware packages via changesets' peer-dependent default — a repo-policy call no bot should make.
Other factors
Test coverage is thorough: unit tests for the new schema (per-field null stripping, key-absence pins, expires_in coercion behavior, a shape-driven drift guard for future members), end-to-end exchangeAuthorization/refreshAuthorization/auth() tests including all four scope-preservation cases, and drift guards in core and the codemod pinning the export surface. All previous inline findings from earlier review runs (doc drift, JSDoc analogy inversion, scope erasure, present-but-undefined scope key, bump level) were verified as addressed in the follow-up commits. The remaining open item is purely the bump-level maintainer call documented in the PR body.
Requested by Den Delimarsky · Slack thread
Before: connecting to an MCP server whose authorization server returns
"refresh_token": null(or"scope": null/"id_token": null) fails with a Zod validation error ("expected string, received null") insideexchangeAuthorization/refreshAuthorization, before any tokens are saved. Worse,"expires_in": nullpassed validation but silently coerced to0(Number(null) === 0), producing a token the client treats as already expired.After: those responses are accepted, and the null-valued fields are treated exactly as if they had been omitted.
expires_in: nullparses as absent, not0.How: the exported
OAuthTokensSchemais unchanged frommain— still a plain object schema that rejects nulls, with its.shape/.extendandz.inputbehavior intact (sospecTypeSchemas/isSpecTypeinput types are untouched). A newOAuthTokenResponseSchemawraps it in an object-levelz.preprocessthat removes null-valued optional members before validation — the member list is derived from the schema's shape, not hardcoded, mirroring the ElicitResultcontentnull-leniency idiom — and the SDK's own token-response parse sites now use it:executeTokenRequest(client token exchange + refresh), the JWT-grant cross-app exchange (crossAppAccess.ts), and server-legacy'sproxyProvider. Following the #2477 schema-source move, the new schema is defined inpackages/core/src/auth.ts(next toOAuthTokensSchema) and forwarded through core-internal'sshared/auth.tsre-export shim, so every existing import path keeps working. Removing the key (rather than mapping it toundefined) means null members are strictly absent from the parsed output, sorefreshAuthorization's merge with previously-stored tokens keeps the prior refresh token; that merge is additionally hardened to{ ...tokens, refresh_token: tokens.refresh_token ?? refreshToken }.access_token: nulland a missingtoken_typeare still rejected.Revision after review: an adversarial review of the first version of this PR found that its per-field
z.preprocess(v => v ?? undefined, ...)mechanic left null-valued keys present with anundefinedvalue rather than absent, sorefreshAuthorization's spread of the previous refresh token was clobbered by the explicitrefresh_token: undefined— for exactly the null-emitting servers this PR targets, every refresh would have silently destroyed the stored refresh token (it also degradedz.inputof the exported schema on zod <4.4). This revision fixes that by normalizing at the SDK's parse sites via the new response schema and hardening the merge.Tests (
packages/core-internal/test/shared/auth.test.ts,packages/client/test/client/auth.test.ts): strict key-absence assertions (toStrictEqual/'field' in parsedchecks, which distinguish absent keys from present-but-undefined ones), each null optional field, all-optionals-null,expires_in: null !== 0, stringexpires_incoercion,access_token: nulland missingtoken_typerejection, regression pins that the exportedOAuthTokensSchemastill rejects nulls and keeps itsZodObjectshape/extend/input behavior, a shape-driven drift guard covering every optional member, an end-to-endexchangeAuthorizationwith an all-null-optional response, and an end-to-endrefreshAuthorizationwhere the server returnsrefresh_token: nullasserting the original refresh token is preserved (verified to fail against the previous mechanic). The changeset (patch forcore,core-internal,client,server-legacy) is updated to describe the final mechanic;coreis bumped because, after #2477, the new schema ships in core's/internalentry (core's public barrel and theauthSchemasregistry are deliberately unchanged).RFC 6749 doesn't sanction
nullfor absent members, but real-world servers emit it anyway — see #754 for the same null-emitting-server pattern (Ory Hydra) hitting the client registration response schema (that schema is intentionally left out of scope here).Scope of the normalization: this change deliberately covers only RFC 6749 §5.1 token responses at the SDK's token parse sites. Two sibling parse sites intentionally remain strict about null members: the OAuth error-response schema (
OAuthErrorResponseSchema, used byparseErrorResponse) and the RFC 8693 §2.2.1 ID-JAG token-exchange schema (IdJagTokenExchangeResponseSchemaincrossAppAccess.ts). That is consistent with the #754 registration-schema carve-out above; either can be revisited if field evidence of null-emitting servers turns up for those responses.Sibling PR with the same fix against
v1.x: #2461.🤖 Generated with Claude Code
https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
Generated by Claude Code
Update (2026-08-05)
OAuthTokenResponseSchemais now also exported from@modelcontextprotocol/core's public root, since the sibling v1.x PR #2461 makes it public v1 API and migrating code needs a v2 home for it. The v1-to-v2 codemod'sAUTH_SCHEMA_NAMESallowlist now includes the name, so imports of it from@modelcontextprotocol/sdk/shared/auth.jsrewrite to@modelcontextprotocol/coreinstead of landing on a package that does not export it. Drift-guard tests in core and the codemod pin the new membership, and the changeset now covers the codemod package.Versioning note for maintainers: the new public core export arguably warrants a
minorper this repo's precedent for export-surface additions (#2354, #2513). However, flipping the changeset's core entry tominorcurrently major-bumps@modelcontextprotocol/express/fastify/hono/nodeto 3.0.0: they peer-depend on@modelcontextprotocol/serverviaworkspace:^,serverrides the fixed group to 2.1.0, and.changeset/config.jsondoes not setonlyUpdatePeerDependentsWhenOutOfRange: true, so changesets' default major-bumps peer-dependents on any non-patch peer bump. The changeset is left atpatchpending a maintainer decision: either add that config flag and flip core tominor, or accept the additive export shipping in a patch.