Skip to content

Commit be90ac5

Browse files
committed
feat: support oidc discovery in client sdk
1 parent 16ea277 commit be90ac5

File tree

5 files changed

+603
-179
lines changed

5 files changed

+603
-179
lines changed

.claude/settings.local.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(npm test:*)",
5+
"Bash(npm run lint)",
6+
"WebFetch(domain:github.com)",
7+
"WebFetch(domain:openid.net)"
8+
],
9+
"deny": []
10+
}
11+
}

src/client/auth.test.ts

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ describe("OAuth Authorization", () => {
359359
code_challenge_methods_supported: ["S256"],
360360
};
361361

362-
it("returns metadata when discovery succeeds", async () => {
362+
it("returns metadata when oauth-authorization-server discovery succeeds", async () => {
363363
mockFetch.mockResolvedValueOnce({
364364
ok: true,
365365
status: 200,
@@ -377,6 +377,28 @@ describe("OAuth Authorization", () => {
377377
});
378378
});
379379

380+
it("returns metadata when oidc discovery succeeds", async () => {
381+
mockFetch.mockImplementation((url) => {
382+
if (url.toString().includes('openid-configuration')) {
383+
return Promise.resolve({
384+
ok: true,
385+
status: 200,
386+
json: async () => validMetadata,
387+
});
388+
}
389+
return Promise.resolve({
390+
ok: false,
391+
status: 404,
392+
});
393+
});
394+
395+
const metadata = await discoverOAuthMetadata("https://auth.example.com");
396+
expect(metadata).toEqual(validMetadata);
397+
expect(mockFetch).toHaveBeenCalledTimes(2);
398+
expect(mockFetch.mock.calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
399+
expect(mockFetch.mock.calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration");
400+
});
401+
380402
it("returns metadata when discovery succeeds with path", async () => {
381403
mockFetch.mockResolvedValueOnce({
382404
ok: true,
@@ -395,14 +417,14 @@ describe("OAuth Authorization", () => {
395417
});
396418
});
397419

398-
it("falls back to root discovery when path-aware discovery returns 404", async () => {
399-
// First call (path-aware) returns 404
420+
it("tries discovery endpoints in new spec order for URLs with path", async () => {
421+
// First call (OAuth with path insertion) returns 404
400422
mockFetch.mockResolvedValueOnce({
401423
ok: false,
402424
status: 404,
403425
});
404426

405-
// Second call (root fallback) succeeds
427+
// Second call (OIDC with path insertion) succeeds
406428
mockFetch.mockResolvedValueOnce({
407429
ok: true,
408430
status: 200,
@@ -415,29 +437,35 @@ describe("OAuth Authorization", () => {
415437
const calls = mockFetch.mock.calls;
416438
expect(calls.length).toBe(2);
417439

418-
// First call should be path-aware
440+
// First call should be OAuth with path insertion
419441
const [firstUrl, firstOptions] = calls[0];
420442
expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
421443
expect(firstOptions.headers).toEqual({
422444
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
423445
});
424446

425-
// Second call should be root fallback
447+
// Second call should be OIDC with path insertion
426448
const [secondUrl, secondOptions] = calls[1];
427-
expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
449+
expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/openid-configuration/path/name");
428450
expect(secondOptions.headers).toEqual({
429451
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
430452
});
431453
});
432454

433-
it("returns undefined when both path-aware and root discovery return 404", async () => {
434-
// First call (path-aware) returns 404
455+
it("returns undefined when all discovery endpoints return 404", async () => {
456+
// First call (OAuth with path insertion) returns 404
435457
mockFetch.mockResolvedValueOnce({
436458
ok: false,
437459
status: 404,
438460
});
439461

440-
// Second call (root fallback) also returns 404
462+
// Second call (OIDC with path insertion) returns 404
463+
mockFetch.mockResolvedValueOnce({
464+
ok: false,
465+
status: 404,
466+
});
467+
468+
// Third call (OIDC with path appending) returns 404
441469
mockFetch.mockResolvedValueOnce({
442470
ok: false,
443471
status: 404,
@@ -447,7 +475,33 @@ describe("OAuth Authorization", () => {
447475
expect(metadata).toBeUndefined();
448476

449477
const calls = mockFetch.mock.calls;
450-
expect(calls.length).toBe(2);
478+
expect(calls.length).toBe(3);
479+
});
480+
481+
it("tries all endpoints in correct order for URLs with path", async () => {
482+
// All calls return 404 to test the order
483+
mockFetch.mockResolvedValue({
484+
ok: false,
485+
status: 404,
486+
});
487+
488+
const metadata = await discoverOAuthMetadata("https://auth.example.com/tenant1");
489+
expect(metadata).toBeUndefined();
490+
491+
const calls = mockFetch.mock.calls;
492+
expect(calls.length).toBe(3);
493+
494+
// First call should be OAuth 2.0 Authorization Server Metadata with path insertion
495+
const [firstUrl] = calls[0];
496+
expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1");
497+
498+
// Second call should be OpenID Connect Discovery 1.0 with path insertion
499+
const [secondUrl] = calls[1];
500+
expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/openid-configuration/tenant1");
501+
502+
// Third call should be OpenID Connect Discovery 1.0 path appending
503+
const [thirdUrl] = calls[2];
504+
expect(thirdUrl.toString()).toBe("https://auth.example.com/tenant1/.well-known/openid-configuration");
451505
});
452506

453507
it("does not fallback when the original URL is already at root path", async () => {
@@ -457,11 +511,17 @@ describe("OAuth Authorization", () => {
457511
status: 404,
458512
});
459513

514+
// Second call (OIDC discovery) also returns 404
515+
mockFetch.mockResolvedValueOnce({
516+
ok: false,
517+
status: 404,
518+
});
519+
460520
const metadata = await discoverOAuthMetadata("https://auth.example.com/");
461521
expect(metadata).toBeUndefined();
462522

463523
const calls = mockFetch.mock.calls;
464-
expect(calls.length).toBe(1); // Should not attempt fallback
524+
expect(calls.length).toBe(2); // Should not attempt fallback but will try OIDC
465525

466526
const [url] = calls[0];
467527
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
@@ -474,27 +534,42 @@ describe("OAuth Authorization", () => {
474534
status: 404,
475535
});
476536

537+
// Second call (OIDC discovery) also returns 404
538+
mockFetch.mockResolvedValueOnce({
539+
ok: false,
540+
status: 404,
541+
});
542+
477543
const metadata = await discoverOAuthMetadata("https://auth.example.com");
478544
expect(metadata).toBeUndefined();
479545

480546
const calls = mockFetch.mock.calls;
481-
expect(calls.length).toBe(1); // Should not attempt fallback
547+
expect(calls.length).toBe(2); // Should not attempt fallback but will try OIDC
482548

483549
const [url] = calls[0];
484550
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
485551
});
486552

487-
it("falls back when path-aware discovery encounters CORS error", async () => {
488-
// First call (path-aware) fails with TypeError (CORS)
553+
it("tries all endpoints when discovery encounters CORS error", async () => {
554+
// First call (OAuth with path insertion) fails with TypeError (CORS)
489555
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
490556

491-
// Retry path-aware without headers (simulating CORS retry)
557+
// Retry OAuth with path insertion without headers (simulating CORS retry)
492558
mockFetch.mockResolvedValueOnce({
493559
ok: false,
494560
status: 404,
495561
});
496562

497-
// Second call (root fallback) succeeds
563+
// Second call (OIDC with path insertion) fails with TypeError (CORS)
564+
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
565+
566+
// Retry OIDC with path insertion without headers (simulating CORS retry)
567+
mockFetch.mockResolvedValueOnce({
568+
ok: false,
569+
status: 404,
570+
});
571+
572+
// Third call (OIDC with path appending) succeeds
498573
mockFetch.mockResolvedValueOnce({
499574
ok: true,
500575
status: 200,
@@ -505,11 +580,11 @@ describe("OAuth Authorization", () => {
505580
expect(metadata).toEqual(validMetadata);
506581

507582
const calls = mockFetch.mock.calls;
508-
expect(calls.length).toBe(3);
583+
expect(calls.length).toBe(5);
509584

510-
// Final call should be root fallback
511-
const [lastUrl, lastOptions] = calls[2];
512-
expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
585+
// Final call should be OIDC with path appending
586+
const [lastUrl, lastOptions] = calls[4];
587+
expect(lastUrl.toString()).toBe("https://auth.example.com/deep/path/.well-known/openid-configuration");
513588
expect(lastOptions.headers).toEqual({
514589
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
515590
});
@@ -587,13 +662,14 @@ describe("OAuth Authorization", () => {
587662
});
588663

589664
it("returns undefined when discovery endpoint returns 404", async () => {
590-
mockFetch.mockResolvedValueOnce({
665+
mockFetch.mockResolvedValue({
591666
ok: false,
592667
status: 404,
593668
});
594669

595670
const metadata = await discoverOAuthMetadata("https://auth.example.com");
596671
expect(metadata).toBeUndefined();
672+
expect(mockFetch).toHaveBeenCalledTimes(2);
597673
});
598674

599675
it("throws on non-404 errors", async () => {

0 commit comments

Comments
 (0)