Repository navigation
fix(auth): treat null optional fields in token responses as absent #2462
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: main
Are you sure you want to change the base?
Changes from all commits
97ee7bd
1639827
20893fb
8d9dee8
4e62b73
e3d7aab
0c105c1
791c379
3219818
3501e62
8a715bf
1142f7f
80adfcf
0e4a6f1
db68fb0
7000109
b29a6cc
bc45c34
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,38 @@ | ||
| --- | ||
| '@modelcontextprotocol/core': minor | ||
| '@modelcontextprotocol/core-internal': patch | ||
| '@modelcontextprotocol/client': patch | ||
| '@modelcontextprotocol/server-legacy': patch | ||
| '@modelcontextprotocol/codemod': patch | ||
| --- | ||
|
|
||
| OAuth token responses with null-valued optional members no longer fail | ||
| validation. Some authorization servers serialize absent optional members as | ||
| JSON `null` (nonconformant with RFC 6749 §5.1, but common in the wild); | ||
| previously `refresh_token`, `scope`, or `id_token` set to `null` failed token | ||
| exchange and refresh, and `expires_in: null` silently coerced to `0`, yielding | ||
| an instantly-expired token. The SDK's own token-response parse sites (client | ||
| token exchange/refresh, JWT-grant cross-app exchange, and the server-legacy | ||
| proxy provider) now validate with a new `OAuthTokenResponseSchema` (defined in | ||
| `@modelcontextprotocol/core`'s auth schema module and forwarded through | ||
| `core-internal`'s re-export shim) that removes | ||
| null-valued optional members before validation, so they are strictly absent | ||
| from the parsed output. The exported `OAuthTokensSchema` is unchanged — still a | ||
| plain object schema, with its `.shape`/`.extend` and input types intact: it | ||
| rejects `null` for its string-typed optional members, though `expires_in: null` | ||
| still coerces to `0` there (use `OAuthTokenResponseSchema` for raw wire | ||
| input). `refreshAuthorization` additionally hardens its merge with the | ||
| previously-stored refresh token, so an explicitly `undefined` `refresh_token` | ||
| in a parsed response can never clobber the preserved token. 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. | ||
|
|
||
| `OAuthTokenResponseSchema` is also a public export from | ||
| `@modelcontextprotocol/core`'s root, since the sibling v1 release exports it | ||
| from `@modelcontextprotocol/sdk/shared/auth.js` and migrating code needs a v2 | ||
| home for it. The v1-to-v2 codemod's auth schema allowlist now includes the | ||
| name, so `import { OAuthTokenResponseSchema } from | ||
| '@modelcontextprotocol/sdk/shared/auth.js'` rewrites to | ||
| `@modelcontextprotocol/core` alongside the other auth schema constants. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,7 @@ import { | |
| OAuthErrorResponseSchema, | ||
| OAuthMetadataSchema, | ||
| OAuthProtectedResourceMetadataSchema, | ||
| OAuthTokensSchema, | ||
| OAuthTokenResponseSchema, | ||
| OpenIdProviderDiscoveryMetadataSchema, | ||
| resourceUrlFromServerUrl, | ||
| stampErrorBrands, | ||
|
|
@@ -1426,7 +1426,14 @@ async function authInternal( | |
| // token, so silently dropping the new tokens would leave the client | ||
| // with credentials that are already invalid server-side. | ||
| if (newTokens) { | ||
| await provider.saveTokens({ ...newTokens, issuer }, infoCtx); | ||
| // RFC 6749 §5.1/§6: the SDK sends no `scope` parameter on refresh, so a | ||
| // response without `scope` (including a null-stripped one) asserts the grant | ||
| // is unchanged — preserve the stored scope instead of erasing the recorded | ||
| // grant (the 403 insufficient_scope step-up unions it to avoid losing | ||
| // previously-granted permissions). The conditional spread keeps `scope` | ||
| // strictly absent when neither side has one — never present-but-undefined. | ||
|
Comment on lines
+1429
to
+1434
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): Maintainers get a six-line inline comment in authInternal, which CLAUDE.md principle 8 asks to keep to one line. The block at packages/client/src/client/auth.ts:1429-1434 restates RFC 6749 reasoning that the PR description and commit message already carry. Fix: shrink it to a single line such as "// Refresh responses omit scope when the grant is unchanged (RFC 6749 §6); keep the stored one, strictly absent if neither has it" and leave the rest of the reasoning in the PR/commit message. Why this was flaggedThe hunk added in the latest push at packages/client/src/client/auth.ts:1429-1434 is a six-line inline comment above Verification: nit. Trigger: any maintainer reading the hunk the diff adds at packages/client/src/client/auth.ts:1429-1434. The diff adds a six-line |
||
| const preservedScope = newTokens.scope ?? tokens.scope; | ||
| await provider.saveTokens({ ...newTokens, ...(preservedScope !== undefined && { scope: preservedScope }), issuer }, infoCtx); | ||
| return 'AUTHORIZED'; | ||
| } | ||
| } | ||
|
|
@@ -2261,7 +2268,7 @@ export async function executeTokenRequest( | |
| const json: unknown = await response.json(); | ||
|
|
||
| try { | ||
| return OAuthTokensSchema.parse(withoutIssuer(json)); | ||
| return OAuthTokenResponseSchema.parse(withoutIssuer(json)); | ||
| } catch (parseError) { | ||
| // Some OAuth servers (e.g., GitHub) return error responses with HTTP 200 status. | ||
| // Check for error field only if token parsing failed. | ||
|
claude[bot] marked this conversation as resolved.
|
||
|
|
@@ -2384,7 +2391,7 @@ export async function refreshAuthorization( | |
| }); | ||
|
|
||
| // Preserve original refresh token if server didn't return a new one | ||
| return { refresh_token: refreshToken, ...tokens }; | ||
| return { ...tokens, refresh_token: tokens.refresh_token ?? refreshToken }; | ||
|
claude[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.