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.
6 changes: 3 additions & 3 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 @@ -1255,7 +1255,7 @@ async function executeTokenRequest(
throw await parseErrorResponse(response);
}

return OAuthTokensSchema.parse(await response.json());
return OAuthTokenResponseSchema.parse(await response.json());
}

/**
Expand Down Expand Up @@ -1349,7 +1349,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
6 changes: 3 additions & 3 deletions src/server/auth/providers/proxyProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
OAuthClientInformationFullSchema,
OAuthTokenRevocationRequest,
OAuthTokens,
OAuthTokensSchema
OAuthTokenResponseSchema
} from '../../../shared/auth.js';
import { AuthInfo } from '../types.js';
import { AuthorizationParams, OAuthServerProvider } from '../provider.js';
Expand Down Expand Up @@ -188,7 +188,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
}

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

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

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

async verifyAccessToken(token: string): Promise<AuthInfo> {
Expand Down
36 changes: 36 additions & 0 deletions src/shared/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,42 @@ export const OAuthTokensSchema = z
})
.strip();

/**
* 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.
*/
export const OAuthTokenResponseSchema = z.preprocess(data => {
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
return data;
}
const normalized: Record<string, unknown> = { ...data };
for (const [key, fieldSchema] of Object.entries(OAuthTokensSchema.shape)) {
if (normalized[key] === null && fieldSchema.safeParse(undefined).success) {
delete normalized[key];
}
}
return normalized;
}, OAuthTokensSchema);

/**
* 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 @@ -1582,6 +1582,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 @@ -1832,6 +1862,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 @@ -225,6 +225,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();
});
});
});

describe('client registration', () => {
Expand Down
Loading
Loading