Repository navigation
fix(auth): treat null optional fields in token responses as absent - #2461
claude[bot] wants to merge 7 commits into
Conversation
Some OAuth authorization servers serialize absent optional members as JSON null (e.g. "refresh_token": null). RFC 6749 does not sanction null values, but such servers exist in the wild, and OAuthTokensSchema previously rejected these responses (null string fields failed validation) or mishandled them (expires_in: null coerced to 0 via Number(null), yielding an instantly-expired token). This broke token exchange and refresh in exchangeAuthorization/refreshAuthorization. Normalize null-valued optional members (id_token, expires_in, scope, refresh_token) to absent in a preprocess step before validation. The inferred OAuthTokens output type is unchanged, and null members are truly absent from the parsed output (not present as undefined), so spreads that preserve a previous refresh_token keep working. Related: #754 (same null-emitting-server pattern in the registration response schema). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
🦋 Changeset detectedLatest commit: d12cbb1 The changes in this PR will be included in the next version bump. 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 |
commit: |
…n OAuthTokensSchema
Rework of the previous commit after review: wrapping the exported
OAuthTokensSchema in z.preprocess changed its class (ZodObject ->
ZodEffects/ZodPipe), breaking downstream .shape/.extend users on the
stable 1.x line.
- Revert OAuthTokensSchema to its original plain object definition
(byte-identical to v1.x; class, .shape, .extend, z.input and emitted
d.ts verified unchanged on zod 3.25.x and 4.x).
- Add OAuthTokenResponseSchema, a z.preprocess wrapper that normalizes
JSON null values in optional members to absent before validation
(RFC 6749 §5.1 does not sanction nulls; expires_in: null must never
reach z.coerce.number(), which would coerce it to 0). Optional keys
are derived from the schema shape, not hardcoded, so future optional
members are covered automatically.
- Use the new schema at the SDK-owned parse sites: executeTokenRequest
(client) and ProxyOAuthServerProvider token exchange/refresh.
- Harden refreshAuthorization's refresh-token preservation:
{ ...tokens, refresh_token: tokens.refresh_token ?? refreshToken }
so preservation holds regardless of key presence.
- Tests: strict key-absence assertions, string expires_in coercion,
null access_token / missing token_type rejection, a regression test
pinning OAuthTokensSchema's class and strictness, a drift guard over
every optional shape member, and e2e exchangeAuthorization /
refreshAuthorization coverage (including refresh_token: null
preserving the original refresh token).
- Add the missing changeset (patch).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
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.
There was a problem hiding this comment.
I didn't find any bugs, but this changes OAuth token-response validation behavior and adds a new public export (OAuthTokenResponseSchema) on the stable 1.x line, so a maintainer should weigh the API surface and leniency policy.
What was reviewed:
- The
z.preprocessnormalization: null is only stripped from members whose field schema acceptsundefined, soaccess_token: nulland missingtoken_typestill reject;expires_in: nullis deleted before reachingz.coerce.number(), so it can no longer coerce to0. - Confirmed the exported
OAuthTokensSchemasymbol is untouched (still a plainZodObject;.shape/.extendintact, pinned by a regression test). - The
refreshAuthorizationspread reorder (tokens.refresh_token ?? refreshToken) is behavior-preserving given the new schema strips null keys. - Ran both changed test files (164 tests) and
npm run typechecklocally — all pass.
Extended reasoning...
Overview
This PR makes the SDK tolerant of authorization servers that serialize absent optional members of OAuth token responses as JSON null (nonconformant with RFC 6749 §5.1 but seen in the wild, e.g. Ory Hydra-style servers). It adds a new exported OAuthTokenResponseSchema in src/shared/auth.ts that wraps OAuthTokensSchema in a z.preprocess step deleting null-valued optional members before validation, and switches the SDK's three token-response parse sites (executeTokenRequest in src/client/auth.ts, and both token exchanges in ProxyOAuthServerProvider) to use it. It also hardens refreshAuthorization's refresh-token preservation and includes a patch changeset plus thorough tests.
Security risks
The change is in the OAuth client/proxy token-parsing path, which is inherently security-sensitive. The specific normalization is low-risk: it only converts null → absent for members that are already optional, derived from the schema shape rather than a hardcoded list, and required members still reject null (verified by tests). The most meaningful semantic subtlety — that stripping scope: null turns an undefined-semantics response into RFC 6749's "granted scope equals requested scope" assertion — is explicitly documented in the JSDoc and changeset, with the note that the SDK never uses tokens.scope for authorization decisions. I found no injection, bypass, or data-exposure vector. Still, loosening validation of security-relevant responses is a policy decision.
Level of scrutiny
High: this is production auth code in a widely-used SDK, changes what the client accepts from authorization servers, and adds a new public export to src/shared/auth.ts on the stable 1.x line (with a v2 counterpart in #2462). Per my guidelines, auth-touching changes and new public API surface should not be shadow-approved regardless of how clean the implementation is.
Other factors
Verification performed: I ran npx vitest run test/shared/auth.test.ts test/client/auth.test.ts (164 tests pass) and npm run typecheck (clean) on the PR branch. The test coverage is strong — per-field null-as-absent with key-absence assertions, a drift guard over every optional member of the shape, a regression test pinning that OAuthTokensSchema remains a usable ZodObject, and e2e exchange/refresh tests. I also checked the preprocess edge cases: non-object input passes through unchanged, the input object is shallow-copied rather than mutated, and expires_in's .optional() short-circuits before z.coerce.number() so the optionality detection (safeParse(undefined)) is correct for coerced fields. No human reviewer has looked at the PR yet; the design tradeoff (new exported schema vs. modifying the existing one) is well-reasoned in the description but deserves maintainer sign-off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q2uDjqaRGTv8vFth6iiU17
There was a problem hiding this comment.
I didn't find any bugs, but this changes OAuth token-response validation semantics in both the client auth flow and the proxy server provider on the stable 1.x line, so it deserves a maintainer's sign-off on the leniency decision itself.
What was reviewed:
- The
z.preprocessnormalization: only null-valued members whose field schema acceptsundefinedare stripped, soaccess_token: nulland missingtoken_typestill reject; non-object input passes through to fail normally. - All three SDK token parse sites (
executeTokenRequest, bothProxyOAuthServerProviderexchanges) now use the new schema — no strict parse site was left behind. - The
refreshAuthorizationspread reorder:tokens.refresh_token ?? refreshTokencorrectly preserves the prior refresh token whether the key is absent or stripped-from-null. - Ran
test/shared/auth.test.ts,test/client/auth.test.ts, andtest/server/auth/providers/proxyProvider.test.tsat the PR head — all 198 tests pass.
Extended reasoning...
Overview
The PR adds a new exported OAuthTokenResponseSchema in src/shared/auth.ts that wraps the unchanged OAuthTokensSchema in a z.preprocess step stripping null-valued optional members before validation, and switches the SDK's three token-response parse sites (executeTokenRequest in src/client/auth.ts, and exchangeAuthorizationCode/exchangeRefreshToken in ProxyOAuthServerProvider) to use it. It also hardens refreshAuthorization's refresh-token preservation from a spread-order trick to an explicit ?? fallback. Extensive tests and a patch changeset are included.
Security risks
This is auth code, and the change deliberately relaxes validation of authorization-server token responses. The relaxation is narrow and well-reasoned: only optional members (derived from the schema shape, not hardcoded) are normalized, required members (access_token, token_type) still reject null, and the fix removes a real footgun where expires_in: null coerced to 0 (an instantly-expired token). The changeset itself flags the one semantic subtlety — a stripped scope: null becomes indistinguishable from an omitted scope, which RFC 6749 §5.1 defines as "granted scope equals requested scope" — and correctly notes the SDK never uses tokens.scope for authorization decisions. I found no injection, bypass, or exposure vector, but whether the SDK should accept nonconformant server responses at all is a policy call.
Level of scrutiny
High: this is OAuth client/server code on the stable 1.x release line, and the approval guidelines exclude security-sensitive auth code from bot approval regardless of implementation quality. The implementation is clean and the API-compatibility concern (keeping OAuthTokensSchema a plain ZodObject so .shape/.extend keep working downstream) was explicitly handled and regression-tested, but the leniency-vs-strictness tradeoff and the new exported symbol on a patch release warrant maintainer judgment.
Other factors
Test coverage is thorough — per-field null-as-absent with strict key-absence assertions, a drift guard over all optional shape members, negative tests pinning that required fields still reject null, and e2e tests for both exchange and refresh flows. I ran the three touched test files at the PR head and all 198 tests pass. I also verified via grep that no OAuthTokensSchema.parse call site in src/ was left on the strict schema. The bug hunting system reported no findings, and there are no prior human reviews on the thread.
Resolves conflicts with the #2888 issuer-binding backport, which added a client-only issuer member to OAuthTokensSchema and parsed wire responses through local OAuthTokensSchema.omit({ issuer: true }) schemas. The resolution folds that omission into OAuthTokenResponseSchema itself: the exported wrapper now validates against the issuer-less wire shape, so an authorization server can neither slip null-valued optional members past validation nor inject the issuer value the client binds stored credentials to. The three parse sites (executeTokenRequest and both proxy provider exchanges) keep using OAuthTokenResponseSchema, and the now-redundant local TokenResponseSchema constants are removed. The exported OAuthTokensSchema remains byte-identical to v1.x. Both test additions are kept: this branch's null-optional-field proxy provider suite and v1.x's issuer-forwarding rejection cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
| * without its `issuer` member, so an authorization server cannot inject the | ||
| * value the client binds stored credentials to. | ||
| */ | ||
| export const OAuthTokenResponseSchema = z.preprocess( |
There was a problem hiding this comment.
🟡 nit (optional): REVIEW.md prefers improving an existing API over adding a parallel one: src/shared/auth.ts adds a second public token schema, OAuthTokenResponseSchema, beside OAuthTokensSchema, so @ modelcontextprotocol/sdk/shared/auth.js now exports two ways to parse the same RFC 6749 §5.1 token JSON — one strict, one null-lenient — and downstream code parsing token responses itself has to know which to pick. Fix: fold null-as-absent into the existing schema's field definitions (e.g. per-field z.preprocess(v => v === null ? undefined : v, ...) on the optional members, keeping the ZodObject class, .shape and .extend), or keep the wire schema unexported and used only at the SDK's own parse sites.
Why this was flagged
Nothing fails at runtime. The instruction guards public API surface growth: consumers who already call OAuthTokensSchema.parse(json) on token endpoint responses keep hitting the null-rejection bug this PR fixes unless they discover and switch to the new export, and the package now carries two token schemas that must be kept in sync (the PR adds a drift-guard test for that). The author states the parallel export was chosen deliberately after an earlier revision wrapped OAuthTokensSchema itself and changed its class (breaking .extend()/.shape), and that this is a v1.x patch where z.input/.d.ts stability matters; a per-field preprocess inside the existing ZodObject would preserve the class while widening only the input type. Low impact; a maintainer judgement call on API surface.
Verification: REVIEW.md block, Checklist § API surface: "One way to do things — improving an existing API beats adding a parallel one". Diff adds at /home/claude/typescript-sdk/src/shared/auth.ts:177 export const OAuthTokenResponseSchema = z.preprocess(..., OAuthTokensWireSchema), a second public schema for parsing RFC 6749 §5.1 token JSON beside the existing exported OAuthTokensSchema (line ~120), and…
| export const OAuthTokenResponseSchema = z.preprocess( | ||
| data => { |
There was a problem hiding this comment.
🟡 nit (optional): CLAUDE.md asks for 2-space indentation: every added line in this PR (the new OAuthTokenResponseSchema block here, the TokenResponseSchema replacements in src/client/auth.ts and src/server/auth/providers/proxyProvider.ts, and all added tests) is indented with 4 spaces. The repo's committed .prettierrc.json sets tabWidth: 4, so the code matches the enforced formatter and it is the CLAUDE.md line that is out of date. Fix: keep the Prettier-formatted 4-space code and update the CLAUDE.md Formatting bullet to say 4-space indentation so the written rule and npm run lint agree.
Why this was flagged
Nothing fails at runtime. This is a pure documentation/convention mismatch: CLAUDE.md says 2-space, but .prettierrc.json (tabWidth 4) and npm run lint (Prettier check) enforce 4-space, so a diff that literally followed CLAUDE.md would fail lint. The added code follows the formatter, not the written instruction. Consequence is nil for consumers; the only cost is a stale instruction misleading future contributors or agents that read CLAUDE.md before .prettierrc.json.
Verification: CLAUDE.md at base 51ad4f0 (Code Style Guidelines) reads verbatim "- Formatting: 2-space indentation, semicolons required, single quotes preferred"; the added OAuthTokenResponseSchema block in src/shared/auth.ts (lines ~178-193, e.g. data => { / if (data === null || ...) and every other added line in this diff is indented with 4 spaces, contrary to the instruction as…
| } | ||
|
|
||
| return TokenResponseSchema.parse(await response.json()); | ||
| return OAuthTokenResponseSchema.parse(await response.json()); |
There was a problem hiding this comment.
🟣 pre-existing, not blocking: Clients talking to the same null-emitting authorization servers still fail on the sibling parse sites, so the fix is partial. Token responses are normalized at src/client/auth.ts:1365, but OAuthErrorResponseSchema at src/client/auth.ts:395 and RegistrationResponseSchema at src/client/auth.ts:1596 still reject null optional members. An error body like {"error":"invalid_grant","error_description":null} falls into the catch and becomes a generic ServerError, so auth() at src/client/auth.ts:467-472 never invalidates credentials or retries. Fix: apply the same null-as-absent preprocess to the error-response and registration-response parse sites (client and proxyProvider), or document why the same servers do not hit them.
A small fix can ride a push you are already making; otherwise a short reply is enough.
Why this was flagged
Trigger: an authorization server that serializes absent members as null (the population this PR targets, e.g. the Ory Hydra pattern in #754) returns a 4xx token or registration error with error_description: null, or a registration response with a null optional member. Entry: executeTokenRequest at src/client/auth.ts:1362 calls parseErrorResponse; registerClient at src/client/auth.ts:1596 calls RegistrationResponseSchema.parse. OAuthErrorResponseSchema (src/shared/auth.ts:198-202) has error_description: z.string().optional(), so null throws inside parseErrorResponse at src/client/auth.ts:395 and the catch at :398 returns a ServerError with 'Invalid OAuth error response'. auth() at src/client/auth.ts:467-472 only recovers on InvalidClientError/UnauthorizedClientError/InvalidGrantError, so the user gets a hard failure instead of credential invalidation and retry. The base branch behaves the same, but REVIEW.md Completeness asks that every leftover sibling site be flagged when a PR replaces a pattern; the PR text carves these out by assertion only. Remedy: route error and registration parses…
Verification: nit — acknowledged in diff: the PR description explicitly carves out both sibling sites ("The OAuth error-response schema ... intentionally remains strict about null members — consistent with the #754 registration-schema carve-out above"), so this is a declared scope trade-off put up for sign-off; the runtime behaviour at those sites is identical on the base branch (the diff does not touch…
Routine update-merge to satisfy the repository's new up-to-date-branch requirement; no conflicts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
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 asundefined, not0.How: the exported
OAuthTokensSchemais left completely untouched (still a plainZodObject; class,.shape,.extend,z.input, and the emitted.d.tsare identical tov1.x, verified on zod 3.25.x and 4.x). Null normalization instead happens at the SDK's own response-parsing layer: a new exportedOAuthTokenResponseSchemainsrc/shared/auth.tswrapsOAuthTokensSchemain az.preprocessthat removes null-valued optional members before validation (matching the existingElicitResultnull-leniency idiom insrc/types.ts). The optional keys are derived from the schema's shape rather than hardcoded, so future optional members are covered automatically. The SDK's parse sites —executeTokenRequestinsrc/client/auth.tsand both token exchanges inProxyOAuthServerProvider— now use the new schema.access_token: nulland a missingtoken_typeare still rejected, andrefreshAuthorization's refresh-token preservation is hardened to{ ...tokens, refresh_token: tokens.refresh_token ?? refreshToken }so it holds regardless of how the key is serialized. A changeset (patch) is included.Note
Revised after an adversarial review: the previous approach wrapped
OAuthTokensSchemaitself in an object-levelz.preprocess, which changed the exported symbol's class (ZodObject→ZodEffects/ZodPipe), breaking downstream consumers that call.extend()or read.shape— unacceptable in a patch release on the stable 1.x line. It also hardcoded the optional-field list.Tests (
test/shared/auth.test.ts,test/client/auth.test.ts): per-field null-as-absent with strict key-absence assertions, all-optionals-nulltoStrictEqual,expires_in: nullnever becomes0, stringexpires_instill coerces, nullaccess_token/ missingtoken_typestill rejected, a regression test pinning thatOAuthTokensSchemakeeps rejecting nulls and remains usable with.shape/.extend, a drift guard walking every optional member of the shape, plus e2eexchangeAuthorization(all-null optionals) andrefreshAuthorization(server returnsrefresh_token: null, original refresh token preserved).Counterpart for the
main(v2) line: #2462. RFC 6749 doesn't sanctionnullfor 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. The OAuth error-response schema (
OAuthErrorResponseSchema, used byparseErrorResponseinsrc/client/auth.ts) intentionally remains strict about null members — consistent with the #754 registration-schema carve-out above — and can be revisited if field evidence of null-emitting servers turns up for error responses.🤖 Generated with Claude Code
https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
Generated by Claude Code