Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/oauth-token-response-null-optional-fields.md
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.
10 changes: 5 additions & 5 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
OAuthClientInformationFullSchema,
OAuthMetadataSchema,
OAuthProtectedResourceMetadataSchema,
OAuthTokensSchema
OAuthTokenResponseSchema
} from '../shared/auth.js';
import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js';
import {
Expand Down Expand Up @@ -434,8 +434,8 @@ function discardIfIssuerMismatch<T extends { issuer?: string }>(stored: T | null
return issuersMatch(stored.issuer, issuer) ? stored : undefined;
}

// `issuer` is added by the client when it stores a value; authorization server responses are parsed without it.
const TokenResponseSchema = OAuthTokensSchema.omit({ issuer: true });
// `issuer` is added by the client when it stores a value; authorization server responses are parsed without it
// (token responses go through OAuthTokenResponseSchema, which omits `issuer` the same way).
const RegistrationResponseSchema = OAuthClientInformationFullSchema.omit({ issuer: true });

function boundElsewhereError(stored: { issuer?: string }, issuer: string): Error {
Expand Down Expand Up @@ -1363,7 +1363,7 @@ async function executeTokenRequest(
throw await parseErrorResponse(response);
}

return TokenResponseSchema.parse(await response.json());
return OAuthTokenResponseSchema.parse(await response.json());

Copy link
Copy Markdown
Contributor Author

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…

}

/**
Expand Down Expand Up @@ -1457,7 +1457,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 };
}

/**
Expand Down
10 changes: 5 additions & 5 deletions src/server/auth/providers/proxyProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import {
OAuthClientInformationFullSchema,
OAuthTokenRevocationRequest,
OAuthTokens,
OAuthTokensSchema
OAuthTokenResponseSchema
} from '../../../shared/auth.js';
import { AuthInfo } from '../types.js';
import { AuthorizationParams, OAuthServerProvider } from '../provider.js';
import { ServerError } from '../errors.js';
import { FetchLike } from '../../../shared/transport.js';

// `issuer` is added by a client when it stores a value; upstream responses are parsed without it.
const TokenResponseSchema = OAuthTokensSchema.omit({ issuer: true });
// `issuer` is added by a client when it stores a value; upstream responses are parsed without it
// (token responses go through OAuthTokenResponseSchema, which omits `issuer` the same way).
const RegistrationResponseSchema = OAuthClientInformationFullSchema.omit({ issuer: true });

export type ProxyEndpoints = {
Expand Down Expand Up @@ -192,7 +192,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
}

const data = await response.json();
return TokenResponseSchema.parse(data);
return OAuthTokenResponseSchema.parse(data);
}

async exchangeRefreshToken(
Expand Down Expand Up @@ -233,7 +233,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
}

const data = await response.json();
return TokenResponseSchema.parse(data);
return OAuthTokenResponseSchema.parse(data);
}

async verifyAccessToken(token: string): Promise<AuthInfo> {
Expand Down
49 changes: 49 additions & 0 deletions src/shared/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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, 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…

data => {
Comment on lines +177 to +178

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 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…

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
*/
Expand Down
54 changes: 54 additions & 0 deletions test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,36 @@ describe('OAuth Authorization', () => {
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
});

it('accepts a token response with all optional fields null', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'access123',
token_type: 'Bearer',
id_token: null,
expires_in: null,
scope: null,
refresh_token: null
})
});

const tokens = await exchangeAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
authorizationCode: 'code123',
codeVerifier: 'verifier123',
redirectUri: 'http://localhost:3000/callback'
});

// The null members must be truly absent from the result, not
// present with an undefined value.
expect(tokens).toStrictEqual({
access_token: 'access123',
token_type: 'Bearer'
});
});

it('exchanges code for tokens with auth', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -1852,6 +1882,30 @@ describe('OAuth Authorization', () => {
expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens });
});

it('keeps the existing refresh token if the server returns a null refresh_token', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'newaccess123',
token_type: 'Bearer',
refresh_token: null
})
});

const refreshToken = 'refresh123';
const tokens = await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken
});

expect(tokens).toStrictEqual({
access_token: 'newaccess123',
token_type: 'Bearer',
refresh_token: refreshToken
});
});

it('validates token response schema', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
Expand Down
50 changes: 50 additions & 0 deletions test/server/auth/providers/proxyProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,56 @@ describe('Proxy OAuth Server Provider', () => {
expect(tokens).toEqual(mockTokenResponse);
});

describe('null optional fields in upstream response', () => {
// Some authorization servers (e.g. AWS Cognito) serialize absent optional
// members as JSON null instead of omitting them
const nullFieldTokenResponse = {
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: null,
refresh_token: null,
scope: null,
id_token: null
};

beforeEach(() => {
(global.fetch as Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(nullFieldTokenResponse)
})
);
});

it('exchanges authorization code and treats null fields as absent', async () => {
const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier');

expect(tokens).toStrictEqual({
access_token: 'new-access-token',
token_type: 'Bearer'
});
expect('refresh_token' in tokens).toBe(false);
expect('expires_in' in tokens).toBe(false);
expect('scope' in tokens).toBe(false);
expect('id_token' in tokens).toBe(false);
expect(tokens.expires_in).toBeUndefined();
});

it('exchanges refresh token and treats null fields as absent', async () => {
const tokens = await provider.exchangeRefreshToken(validClient, 'test-refresh-token', ['read', 'write']);

expect(tokens).toStrictEqual({
access_token: 'new-access-token',
token_type: 'Bearer'
});
expect('refresh_token' in tokens).toBe(false);
expect('expires_in' in tokens).toBe(false);
expect('scope' in tokens).toBe(false);
expect('id_token' in tokens).toBe(false);
expect(tokens.expires_in).toBeUndefined();
});
});

it.each(['https://upstream.example.com', 42])('does not forward an issuer of %j from the upstream token response', async issuer => {
(global.fetch as Mock).mockImplementation(() =>
Promise.resolve({
Expand Down
Loading
Loading