Skip to content

Commit ecb76f1

Browse files
pcarletonclaude
andcommitted
feat: add S256 PKCE validation for OIDC discovery
Adds validation during OIDC provider discovery to ensure S256 code challenge method is supported as required by MCP specification. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent c148ef1 commit ecb76f1

File tree

2 files changed

+70
-34
lines changed

2 files changed

+70
-34
lines changed

src/client/auth.test.ts

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ describe("OAuth Authorization", () => {
217217
ok: false,
218218
status: 404,
219219
});
220-
220+
221221
// Second call (root fallback) succeeds
222222
mockFetch.mockResolvedValueOnce({
223223
ok: true,
@@ -227,17 +227,17 @@ describe("OAuth Authorization", () => {
227227

228228
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name");
229229
expect(metadata).toEqual(validMetadata);
230-
230+
231231
const calls = mockFetch.mock.calls;
232232
expect(calls.length).toBe(2);
233-
233+
234234
// First call should be path-aware
235235
const [firstUrl, firstOptions] = calls[0];
236236
expect(firstUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path/name");
237237
expect(firstOptions.headers).toEqual({
238238
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
239239
});
240-
240+
241241
// Second call should be root fallback
242242
const [secondUrl, secondOptions] = calls[1];
243243
expect(secondUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
@@ -252,7 +252,7 @@ describe("OAuth Authorization", () => {
252252
ok: false,
253253
status: 404,
254254
});
255-
255+
256256
// Second call (root fallback) also returns 404
257257
mockFetch.mockResolvedValueOnce({
258258
ok: false,
@@ -261,7 +261,7 @@ describe("OAuth Authorization", () => {
261261

262262
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name"))
263263
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
264-
264+
265265
const calls = mockFetch.mock.calls;
266266
expect(calls.length).toBe(2);
267267
});
@@ -275,10 +275,10 @@ describe("OAuth Authorization", () => {
275275

276276
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/"))
277277
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
278-
278+
279279
const calls = mockFetch.mock.calls;
280280
expect(calls.length).toBe(1); // Should not attempt fallback
281-
281+
282282
const [url] = calls[0];
283283
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
284284
});
@@ -292,24 +292,24 @@ describe("OAuth Authorization", () => {
292292

293293
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
294294
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
295-
295+
296296
const calls = mockFetch.mock.calls;
297297
expect(calls.length).toBe(1); // Should not attempt fallback
298-
298+
299299
const [url] = calls[0];
300300
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
301301
});
302302

303303
it("falls back when path-aware discovery encounters CORS error", async () => {
304304
// First call (path-aware) fails with TypeError (CORS)
305305
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
306-
306+
307307
// Retry path-aware without headers (simulating CORS retry)
308308
mockFetch.mockResolvedValueOnce({
309309
ok: false,
310310
status: 404,
311311
});
312-
312+
313313
// Second call (root fallback) succeeds
314314
mockFetch.mockResolvedValueOnce({
315315
ok: true,
@@ -319,10 +319,10 @@ describe("OAuth Authorization", () => {
319319

320320
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/deep/path");
321321
expect(metadata).toEqual(validMetadata);
322-
322+
323323
const calls = mockFetch.mock.calls;
324324
expect(calls.length).toBe(3);
325-
325+
326326
// Final call should be root fallback
327327
const [lastUrl, lastOptions] = calls[2];
328328
expect(lastUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
@@ -341,10 +341,10 @@ describe("OAuth Authorization", () => {
341341
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path", {
342342
resourceMetadataUrl: "https://custom.example.com/metadata"
343343
})).rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
344-
344+
345345
const calls = mockFetch.mock.calls;
346346
expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided
347-
347+
348348
const [url] = calls[0];
349349
expect(url.toString()).toBe("https://custom.example.com/metadata");
350350
});
@@ -749,10 +749,10 @@ describe("OAuth Authorization", () => {
749749
expect(metadata).toEqual(validOpenIdMetadata);
750750
const calls = mockFetch.mock.calls;
751751
expect(calls.length).toBe(2);
752-
752+
753753
// First call should be OAuth discovery
754754
expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
755-
755+
756756
// Second call should be OpenID Connect discovery
757757
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration");
758758
});
@@ -815,10 +815,10 @@ describe("OAuth Authorization", () => {
815815
expect(metadata).toEqual(validOpenIdMetadata);
816816
const calls = mockFetch.mock.calls;
817817
expect(calls.length).toBe(2);
818-
818+
819819
// First call should be OAuth with path
820820
expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1");
821-
821+
822822
// Second call should be OpenID Connect with path insertion
823823
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration/tenant1");
824824
});
@@ -851,17 +851,44 @@ describe("OAuth Authorization", () => {
851851
expect(metadata).toEqual(validOpenIdMetadata);
852852
const calls = mockFetch.mock.calls;
853853
expect(calls.length).toBe(3);
854-
854+
855855
// First call should be OAuth with path
856856
expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1");
857-
857+
858858
// Second call should be OpenID Connect with path insertion
859859
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration/tenant1");
860-
860+
861861
// Third call should be OpenID Connect with path prepending
862862
expect(calls[2][0].toString()).toBe("https://auth.example.com/tenant1/.well-known/openid-configuration");
863863
});
864864

865+
it("throws error when OIDC provider does not support S256 PKCE", async () => {
866+
// OAuth discovery fails
867+
mockFetch.mockResolvedValueOnce({
868+
ok: false,
869+
status: 404,
870+
});
871+
872+
// OpenID Connect discovery succeeds but without S256 support
873+
const invalidOpenIdMetadata = {
874+
...validOpenIdMetadata,
875+
code_challenge_methods_supported: ["plain"], // Missing S256
876+
};
877+
878+
mockFetch.mockResolvedValueOnce({
879+
ok: true,
880+
status: 200,
881+
json: async () => invalidOpenIdMetadata,
882+
});
883+
884+
await expect(
885+
discoverAuthorizationServerMetadata(
886+
"https://mcp.example.com",
887+
"https://auth.example.com"
888+
)
889+
).rejects.toThrow("does not support S256 code challenge method required by MCP specification");
890+
});
891+
865892
it("falls back to legacy MCP server when authorizationServerUrl is undefined", async () => {
866893
mockFetch.mockResolvedValueOnce({
867894
ok: true,
@@ -916,7 +943,7 @@ describe("OAuth Authorization", () => {
916943
it("handles CORS errors with retry", async () => {
917944
// First call fails with CORS
918945
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
919-
946+
920947
// Retry without headers succeeds
921948
mockFetch.mockResolvedValueOnce({
922949
ok: true,
@@ -932,10 +959,10 @@ describe("OAuth Authorization", () => {
932959
expect(metadata).toEqual(validOAuthMetadata);
933960
const calls = mockFetch.mock.calls;
934961
expect(calls.length).toBe(2);
935-
962+
936963
// First call should have headers
937964
expect(calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version");
938-
965+
939966
// Second call should not have headers (CORS retry)
940967
expect(calls[1][1]?.headers).toBeUndefined();
941968
});
@@ -2216,17 +2243,17 @@ describe("OAuth Authorization", () => {
22162243

22172244
// Verify the correct URLs were fetched
22182245
const calls = mockFetch.mock.calls;
2219-
2246+
22202247
// First call should be to PRM
22212248
expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource/path/name");
2222-
2249+
22232250
// Second call should be to AS metadata with the path from authorization server
22242251
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/oauth");
22252252
});
22262253

22272254
it("supports overriding the fetch function used for requests", async () => {
22282255
const customFetch = jest.fn();
2229-
2256+
22302257
// Mock PRM discovery
22312258
customFetch.mockResolvedValueOnce({
22322259
ok: true,
@@ -2236,7 +2263,7 @@ describe("OAuth Authorization", () => {
22362263
authorization_servers: ["https://auth.example.com"],
22372264
}),
22382265
});
2239-
2266+
22402267
// Mock AS metadata discovery
22412268
customFetch.mockResolvedValueOnce({
22422269
ok: true,
@@ -2253,7 +2280,7 @@ describe("OAuth Authorization", () => {
22532280

22542281
const mockProvider: OAuthClientProvider = {
22552282
get redirectUrl() { return "http://localhost:3000/callback"; },
2256-
get clientMetadata() {
2283+
get clientMetadata() {
22572284
return {
22582285
client_name: "Test Client",
22592286
redirect_uris: ["http://localhost:3000/callback"],
@@ -2278,10 +2305,10 @@ describe("OAuth Authorization", () => {
22782305
expect(result).toBe("REDIRECT");
22792306
expect(customFetch).toHaveBeenCalledTimes(2);
22802307
expect(mockFetch).not.toHaveBeenCalled();
2281-
2308+
22822309
// Verify custom fetch was called for PRM discovery
22832310
expect(customFetch.mock.calls[0][0].toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
2284-
2311+
22852312
// Verify custom fetch was called for AS metadata discovery
22862313
expect(customFetch.mock.calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
22872314
});

src/client/auth.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,16 @@ async function retrieveOpenIdProviderMetadataFromAuthorizationServer(
860860
throw new Error(`HTTP ${response.status} trying to load OpenID provider metadata from ${endpoint}`);
861861
}
862862

863-
return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
863+
const metadata = OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
864+
865+
// MCP spec requires OIDC providers to support S256 PKCE
866+
if (!metadata.code_challenge_methods_supported?.includes('S256')) {
867+
throw new Error(
868+
`Incompatible OIDC provider at ${endpoint}: does not support S256 code challenge method required by MCP specification`
869+
);
870+
}
871+
872+
return metadata;
864873
}
865874

866875
return undefined;

0 commit comments

Comments
 (0)