Skip to content

Commit 69619b9

Browse files
committed
test: add unit tests
1 parent 997b850 commit 69619b9

File tree

2 files changed

+300
-14
lines changed

2 files changed

+300
-14
lines changed

.claude/settings.local.json

Lines changed: 0 additions & 11 deletions
This file was deleted.

src/client/auth.test.ts

Lines changed: 300 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { LATEST_PROTOCOL_VERSION } from '../types.js';
22
import {
33
discoverOAuthMetadata,
4+
discoverAuthorizationServerMetadata,
45
startAuthorization,
56
exchangeAuthorization,
67
refreshAuthorization,
@@ -11,7 +12,7 @@ import {
1112
type OAuthClientProvider,
1213
} from "./auth.js";
1314
import {ServerError} from "../server/auth/errors.js";
14-
import { OAuthMetadata } from '../shared/auth.js';
15+
import { AuthorizationServerMetadata } from '../shared/auth.js';
1516

1617
// Mock fetch globally
1718
const mockFetch = jest.fn();
@@ -683,6 +684,302 @@ describe("OAuth Authorization", () => {
683684
});
684685
});
685686

687+
describe("discoverAuthorizationServerMetadata", () => {
688+
const validOAuthMetadata = {
689+
issuer: "https://auth.example.com",
690+
authorization_endpoint: "https://auth.example.com/authorize",
691+
token_endpoint: "https://auth.example.com/token",
692+
registration_endpoint: "https://auth.example.com/register",
693+
response_types_supported: ["code"],
694+
code_challenge_methods_supported: ["S256"],
695+
};
696+
697+
const validOpenIdMetadata = {
698+
issuer: "https://auth.example.com",
699+
authorization_endpoint: "https://auth.example.com/authorize",
700+
token_endpoint: "https://auth.example.com/token",
701+
jwks_uri: "https://auth.example.com/jwks",
702+
subject_types_supported: ["public"],
703+
id_token_signing_alg_values_supported: ["RS256"],
704+
response_types_supported: ["code"],
705+
code_challenge_methods_supported: ["S256"],
706+
};
707+
708+
it("returns OAuth metadata when authorizationServerUrl is provided and OAuth discovery succeeds", async () => {
709+
mockFetch.mockResolvedValueOnce({
710+
ok: true,
711+
status: 200,
712+
json: async () => validOAuthMetadata,
713+
});
714+
715+
const metadata = await discoverAuthorizationServerMetadata(
716+
"https://mcp.example.com",
717+
"https://auth.example.com"
718+
);
719+
720+
expect(metadata).toEqual(validOAuthMetadata);
721+
const calls = mockFetch.mock.calls;
722+
expect(calls.length).toBe(1);
723+
const [url, options] = calls[0];
724+
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
725+
expect(options.headers).toEqual({
726+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
727+
});
728+
});
729+
730+
it("falls back to OpenID Connect discovery when OAuth discovery fails", async () => {
731+
// First call (OAuth) returns 404
732+
mockFetch.mockResolvedValueOnce({
733+
ok: false,
734+
status: 404,
735+
});
736+
737+
// Second call (OpenID Connect) succeeds
738+
mockFetch.mockResolvedValueOnce({
739+
ok: true,
740+
status: 200,
741+
json: async () => validOpenIdMetadata,
742+
});
743+
744+
const metadata = await discoverAuthorizationServerMetadata(
745+
"https://mcp.example.com",
746+
"https://auth.example.com"
747+
);
748+
749+
expect(metadata).toEqual(validOpenIdMetadata);
750+
const calls = mockFetch.mock.calls;
751+
expect(calls.length).toBe(2);
752+
753+
// First call should be OAuth discovery
754+
expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
755+
756+
// Second call should be OpenID Connect discovery
757+
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration");
758+
});
759+
760+
it("returns undefined when authorizationServerUrl is provided but both discoveries fail", async () => {
761+
// Both calls return 404
762+
mockFetch.mockResolvedValue({
763+
ok: false,
764+
status: 404,
765+
});
766+
767+
const metadata = await discoverAuthorizationServerMetadata(
768+
"https://mcp.example.com",
769+
"https://auth.example.com"
770+
);
771+
772+
expect(metadata).toBeUndefined();
773+
const calls = mockFetch.mock.calls;
774+
expect(calls.length).toBe(2);
775+
});
776+
777+
it("handles authorization server URL with path in OAuth discovery", async () => {
778+
mockFetch.mockResolvedValueOnce({
779+
ok: true,
780+
status: 200,
781+
json: async () => validOAuthMetadata,
782+
});
783+
784+
const metadata = await discoverAuthorizationServerMetadata(
785+
"https://mcp.example.com",
786+
"https://auth.example.com/tenant1"
787+
);
788+
789+
expect(metadata).toEqual(validOAuthMetadata);
790+
const calls = mockFetch.mock.calls;
791+
expect(calls.length).toBe(1);
792+
const [url] = calls[0];
793+
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1");
794+
});
795+
796+
it("handles authorization server URL with path in OpenID Connect discovery", async () => {
797+
// OAuth discovery fails
798+
mockFetch.mockResolvedValueOnce({
799+
ok: false,
800+
status: 404,
801+
});
802+
803+
// OpenID Connect discovery succeeds with path insertion
804+
mockFetch.mockResolvedValueOnce({
805+
ok: true,
806+
status: 200,
807+
json: async () => validOpenIdMetadata,
808+
});
809+
810+
const metadata = await discoverAuthorizationServerMetadata(
811+
"https://mcp.example.com",
812+
"https://auth.example.com/tenant1"
813+
);
814+
815+
expect(metadata).toEqual(validOpenIdMetadata);
816+
const calls = mockFetch.mock.calls;
817+
expect(calls.length).toBe(2);
818+
819+
// First call should be OAuth with path
820+
expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1");
821+
822+
// Second call should be OpenID Connect with path insertion
823+
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration/tenant1");
824+
});
825+
826+
it("tries multiple OpenID Connect endpoints when path is present", async () => {
827+
// OAuth discovery fails
828+
mockFetch.mockResolvedValueOnce({
829+
ok: false,
830+
status: 404,
831+
});
832+
833+
// First OpenID Connect attempt (path insertion) fails
834+
mockFetch.mockResolvedValueOnce({
835+
ok: false,
836+
status: 404,
837+
});
838+
839+
// Second OpenID Connect attempt (path prepending) succeeds
840+
mockFetch.mockResolvedValueOnce({
841+
ok: true,
842+
status: 200,
843+
json: async () => validOpenIdMetadata,
844+
});
845+
846+
const metadata = await discoverAuthorizationServerMetadata(
847+
"https://mcp.example.com",
848+
"https://auth.example.com/tenant1"
849+
);
850+
851+
expect(metadata).toEqual(validOpenIdMetadata);
852+
const calls = mockFetch.mock.calls;
853+
expect(calls.length).toBe(3);
854+
855+
// First call should be OAuth with path
856+
expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1");
857+
858+
// Second call should be OpenID Connect with path insertion
859+
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration/tenant1");
860+
861+
// Third call should be OpenID Connect with path prepending
862+
expect(calls[2][0].toString()).toBe("https://auth.example.com/tenant1/.well-known/openid-configuration");
863+
});
864+
865+
it("falls back to legacy MCP server when authorizationServerUrl is undefined", async () => {
866+
mockFetch.mockResolvedValueOnce({
867+
ok: true,
868+
status: 200,
869+
json: async () => validOAuthMetadata,
870+
});
871+
872+
const metadata = await discoverAuthorizationServerMetadata(
873+
"https://mcp.example.com",
874+
undefined
875+
);
876+
877+
expect(metadata).toEqual(validOAuthMetadata);
878+
const calls = mockFetch.mock.calls;
879+
expect(calls.length).toBe(1);
880+
const [url] = calls[0];
881+
expect(url.toString()).toBe("https://mcp.example.com/.well-known/oauth-authorization-server");
882+
});
883+
884+
it("returns fallback metadata when legacy MCP server returns 404", async () => {
885+
mockFetch.mockResolvedValueOnce({
886+
ok: false,
887+
status: 404,
888+
});
889+
890+
const metadata = await discoverAuthorizationServerMetadata(
891+
"https://mcp.example.com",
892+
undefined
893+
);
894+
895+
expect(metadata).toEqual({
896+
issuer: "https://mcp.example.com",
897+
authorization_endpoint: "https://mcp.example.com/authorize",
898+
token_endpoint: "https://mcp.example.com/token",
899+
registration_endpoint: "https://mcp.example.com/register",
900+
response_types_supported: ["code"],
901+
code_challenge_methods_supported: ["S256"],
902+
});
903+
});
904+
905+
it("throws on non-404 errors in legacy mode", async () => {
906+
mockFetch.mockResolvedValueOnce({
907+
ok: false,
908+
status: 500,
909+
});
910+
911+
await expect(
912+
discoverAuthorizationServerMetadata("https://mcp.example.com", undefined)
913+
).rejects.toThrow("HTTP 500");
914+
});
915+
916+
it("handles CORS errors with retry", async () => {
917+
// First call fails with CORS
918+
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
919+
920+
// Retry without headers succeeds
921+
mockFetch.mockResolvedValueOnce({
922+
ok: true,
923+
status: 200,
924+
json: async () => validOAuthMetadata,
925+
});
926+
927+
const metadata = await discoverAuthorizationServerMetadata(
928+
"https://mcp.example.com",
929+
"https://auth.example.com"
930+
);
931+
932+
expect(metadata).toEqual(validOAuthMetadata);
933+
const calls = mockFetch.mock.calls;
934+
expect(calls.length).toBe(2);
935+
936+
// First call should have headers
937+
expect(calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version");
938+
939+
// Second call should not have headers (CORS retry)
940+
expect(calls[1][1]?.headers).toBeUndefined();
941+
});
942+
943+
it("supports custom fetch function", async () => {
944+
const customFetch = jest.fn().mockResolvedValue({
945+
ok: true,
946+
status: 200,
947+
json: async () => validOAuthMetadata,
948+
});
949+
950+
const metadata = await discoverAuthorizationServerMetadata(
951+
"https://mcp.example.com",
952+
"https://auth.example.com",
953+
{ fetchFn: customFetch }
954+
);
955+
956+
expect(metadata).toEqual(validOAuthMetadata);
957+
expect(customFetch).toHaveBeenCalledTimes(1);
958+
expect(mockFetch).not.toHaveBeenCalled();
959+
});
960+
961+
it("supports custom protocol version", async () => {
962+
mockFetch.mockResolvedValueOnce({
963+
ok: true,
964+
status: 200,
965+
json: async () => validOAuthMetadata,
966+
});
967+
968+
const metadata = await discoverAuthorizationServerMetadata(
969+
"https://mcp.example.com",
970+
"https://auth.example.com",
971+
{ protocolVersion: "2025-01-01" }
972+
);
973+
974+
expect(metadata).toEqual(validOAuthMetadata);
975+
const calls = mockFetch.mock.calls;
976+
const [, options] = calls[0];
977+
expect(options.headers).toEqual({
978+
"MCP-Protocol-Version": "2025-01-01"
979+
});
980+
});
981+
});
982+
686983
describe("startAuthorization", () => {
687984
const validMetadata = {
688985
issuer: "https://auth.example.com",
@@ -909,7 +1206,7 @@ describe("OAuth Authorization", () => {
9091206
authorizationCode: "code123",
9101207
codeVerifier: "verifier123",
9111208
redirectUri: "http://localhost:3000/callback",
912-
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata: OAuthMetadata) => {
1209+
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata: AuthorizationServerMetadata) => {
9131210
headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret));
9141211
params.set("example_url", typeof url === 'string' ? url : url.toString());
9151212
params.set("example_metadata", metadata.authorization_endpoint);
@@ -1091,7 +1388,7 @@ describe("OAuth Authorization", () => {
10911388
metadata: validMetadata,
10921389
clientInformation: validClientInfo,
10931390
refreshToken: "refresh123",
1094-
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata) => {
1391+
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata) => {
10951392
headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret));
10961393
params.set("example_url", typeof url === 'string' ? url : url.toString());
10971394
params.set("example_metadata", metadata?.authorization_endpoint ?? '?');

0 commit comments

Comments
 (0)