Repository navigation
fix(auth): treat null optional fields in token responses as absent #2461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v1.x
Are you sure you want to change the base?
Changes from all commits
aba8e2a
08ce3d2
1018d9e
f958675
eb19889
e9bfdfd
d12cbb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| '@modelcontextprotocol/sdk': patch | ||
| --- | ||
|
|
||
| The client (and the proxy server provider) now normalize JSON `null` values in optional members of OAuth token responses to absent before validation, via a new exported `OAuthTokenResponseSchema` used at the SDK's own parse sites. Some authorization servers serialize absent | ||
| optional members as `null` (nonconformant with RFC 6749 §5.1); previously such responses failed validation (`refresh_token`, `scope`, `id_token`) or coerced `expires_in: null` to `0`, an instantly-expired token. The exported `OAuthTokensSchema` is unchanged. Note that a stripped | ||
| null `scope` is thereafter indistinguishable from an omitted `scope` — which RFC 6749 §5.1 defines as an assertion that the granted scope is identical to the requested scope — so consumers should not infer the granted scope from its absence. `refreshAuthorization` now also | ||
| preserves the previous refresh token whenever the response does not carry a new one, regardless of how the `refresh_token` key is serialized. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,6 +143,55 @@ export const OAuthTokensSchema = z | |
| }) | ||
| .strip(); | ||
|
|
||
| /** {@link OAuthTokensSchema} as it appears on the wire: without the client-only `issuer` stamp. */ | ||
| const OAuthTokensWireSchema = OAuthTokensSchema.omit({ issuer: true }); | ||
|
|
||
| /** | ||
| * Schema for parsing OAuth 2.1 token responses received from an authorization | ||
| * server. | ||
| * | ||
| * Some authorization servers serialize absent optional members as JSON null | ||
| * (e.g. `"refresh_token": null`), which is nonconformant with RFC 6749 §5.1. | ||
| * We normalize null to undefined for leniency: null values in optional | ||
| * members are normalized to absent before validation, so that (a) otherwise | ||
| * valid responses from such servers parse, and (b) `expires_in: null` never | ||
| * reaches `z.coerce.number()`, which would coerce it to 0 — an | ||
| * instantly-expired token. Null values in required members are still | ||
| * rejected, non-object input is passed through unchanged, and the strict | ||
| * {@link OAuthTokensSchema} is unaffected. | ||
| * | ||
| * Per RFC 6749 §5.1, an absent `scope` member is a positive assertion that | ||
| * the granted scope is identical to the scope the client requested, so | ||
| * stripping `scope: null` converts a response with undefined semantics into | ||
| * that assertion. This has no bearing on enforcement — the SDK never uses | ||
| * `tokens.scope` for authorization decisions, and the resource server remains | ||
| * authoritative — but consumers must not derive granted-scope conclusions | ||
| * from the member's absence. Consumers that need the authoritative grant | ||
| * should use token introspection instead. | ||
| * | ||
| * The client-only `issuer` stamp is not part of the wire format: like the other | ||
| * response parse paths, this schema validates against {@link OAuthTokensSchema} | ||
| * without its `issuer` member, so an authorization server cannot inject the | ||
| * value the client binds stored credentials to. | ||
| */ | ||
| export const OAuthTokenResponseSchema = z.preprocess( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): REVIEW.md prefers improving an existing API over adding a parallel one: src/shared/auth.ts adds a second public token schema, Why this was flaggedNothing fails at runtime. The instruction guards public API surface growth: consumers who already call 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 |
||
| data => { | ||
|
Comment on lines
+177
to
+178
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): CLAUDE.md asks for 2-space indentation: every added line in this PR (the new Why this was flaggedNothing fails at runtime. This is a pure documentation/convention mismatch: CLAUDE.md says 2-space, but Verification: CLAUDE.md at base 51ad4f0 (Code Style Guidelines) reads verbatim "- Formatting: 2-space indentation, semicolons required, single quotes preferred"; the added |
||
| if (data === null || typeof data !== 'object' || Array.isArray(data)) { | ||
| return data; | ||
| } | ||
| const normalized: Record<string, unknown> = { ...data }; | ||
| for (const [key, fieldSchema] of Object.entries(OAuthTokensWireSchema.shape)) { | ||
| if (normalized[key] === null && fieldSchema.safeParse(undefined).success) { | ||
| delete normalized[key]; | ||
| } | ||
| } | ||
| return normalized; | ||
| }, | ||
| // `issuer` is added by the client when it stores a value; authorization server | ||
| // responses are parsed without it (and `.strip()` then drops an incoming key). | ||
| OAuthTokensWireSchema | ||
| ); | ||
|
|
||
| /** | ||
| * OAuth 2.1 error response | ||
| */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 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…