Skip to content

Feat: Separate authorization server and resource server on client auth flow #416

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
May 21, 2025
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b90d5ba
add type and schema for protected resource metadata
Apr 27, 2025
0ce2da8
add private variable to keep track of
Apr 27, 2025
3c69e5d
change auth flow to use authroization server and
Apr 27, 2025
9f6aa38
add/modify tests for new added auth functions
Apr 27, 2025
bfabc37
modify test to use the authorization server
Apr 27, 2025
956094b
remove unused variable
Apr 27, 2025
130b4fe
add resource parameter when building auth url
Apr 28, 2025
d6772e0
Change resource to use the protected resource metadata
Apr 28, 2025
6316d4f
Merge branch 'main' into auth-client
0Itsuki0 Apr 28, 2025
eb43c47
resolve typo.
0Itsuki0 May 20, 2025
4024554
Update src/client/sse.ts import.
0Itsuki0 May 20, 2025
6a63681
remove log.
0Itsuki0 May 20, 2025
ff65f81
Update section title.
0Itsuki0 May 20, 2025
c3cd5af
remove log.
0Itsuki0 May 20, 2025
b2878fb
remove resource parameter.
0Itsuki0 May 20, 2025
a3e2f71
remove resource parameter.
0Itsuki0 May 20, 2025
ed06b11
remove resource parameter.
0Itsuki0 May 20, 2025
42ee88b
remove resource parameter.
0Itsuki0 May 20, 2025
2dac816
remove resource parameter.
0Itsuki0 May 20, 2025
17b970e
remove resource parameter.
0Itsuki0 May 20, 2025
68eaf7a
remove resource parameter.
0Itsuki0 May 20, 2025
0a632bf
remove resource parameter.
0Itsuki0 May 20, 2025
cfd572a
remove resource parameter.
0Itsuki0 May 20, 2025
3ed83d3
Merge remote-tracking branch 'upstream/main' into auth-client
May 20, 2025
421959e
make backwards compatibile
pcarleton May 21, 2025
2bd1780
Merge branch 'main' into auth-client
pcarleton May 21, 2025
7c12e61
s/resourceServerUrl/serverUrl/g for backwards compat
pcarleton May 21, 2025
7ce3f85
fix test comment
pcarleton May 21, 2025
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
165 changes: 165 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
exchangeAuthorization,
refreshAuthorization,
registerClient,
discoverOAuthProtectedResourceMetadata,
extractResourceMetadataUrl,
} from "./auth.js";

// Mock fetch globally
Expand All @@ -15,6 +17,168 @@ describe("OAuth Authorization", () => {
mockFetch.mockReset();
});

describe("extractResourceMetadataUrl", () => {
it("returns resource metadata url when present", async () => {
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response

expect(extractResourceMetadataUrl(mockResponse)).toEqual(new URL(resourceUrl));
});

it("returns undefined if not bearer", async () => {
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response

expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});

it("returns undefined if resource_metadata not present", async () => {
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp"` : null),
}
} as unknown as Response

expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});

it("returns undefined on invalid url", async () => {
const resourceUrl = "invalid-url"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response

expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});
});

describe("discoverOAuthProtectedResourceMetadata", () => {
const validMetadata = {
resource: "https://resource.example.com",
authorization_servers: ["https://auth.example.com"],
};

it("returns metadata when discovery succeeds", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validMetadata,
});

const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com");
expect(metadata).toEqual(validMetadata);
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(1);
const [url, options] = calls[0];
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
expect(options.headers).toEqual({
"MCP-Protocol-Version": "2024-11-05"
});
});

it("returns metadata when first fetch fails but second without MCP header succeeds", async () => {
// Set up a counter to control behavior
let callCount = 0;

// Mock implementation that changes behavior based on call count
mockFetch.mockImplementation((_url, _options) => {
callCount++;

if (callCount === 1) {
// First call with MCP header - fail with TypeError (simulating CORS error)
// We need to use TypeError specifically because that's what the implementation checks for
return Promise.reject(new TypeError("Network error"));
} else {
// Second call without header - succeed
return Promise.resolve({
ok: true,
status: 200,
json: async () => validMetadata
});
}
});

// Should succeed with the second call
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com");
expect(metadata).toEqual(validMetadata);

// Verify both calls were made
expect(mockFetch).toHaveBeenCalledTimes(2);

// Verify first call had MCP header
expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version");
});

it("throws an error when all fetch attempts fail", async () => {
// Set up a counter to control behavior
let callCount = 0;

// Mock implementation that changes behavior based on call count
mockFetch.mockImplementation((_url, _options) => {
callCount++;

if (callCount === 1) {
// First call - fail with TypeError
return Promise.reject(new TypeError("First failure"));
} else {
// Second call - fail with different error
return Promise.reject(new Error("Second failure"));
}
});

// Should fail with the second error
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("Second failure");

// Verify both calls were made
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it("throws on 404 errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});

await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
});

it("throws on non-404 errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});

await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("HTTP 500");
});

it("validates metadata schema", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
// Missing required fields
scopes_supported: ["email", "mcp"],
}),
});

await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow();
});
});

describe("discoverOAuthMetadata", () => {
const validMetadata = {
issuer: "https://auth.example.com",
Expand Down Expand Up @@ -158,6 +322,7 @@ describe("OAuth Authorization", () => {
const { authorizationUrl, codeVerifier } = await startAuthorization(
"https://auth.example.com",
{
metadata: undefined,
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
}
Expand Down
Loading