Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
make backwards compatibile
  • Loading branch information
pcarleton committed May 21, 2025
commit 421959e2ba937c7f0556357bfca1785dafe57e60
104 changes: 100 additions & 4 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
registerClient,
discoverOAuthProtectedResourceMetadata,
extractResourceMetadataUrl,
auth,
type OAuthClientProvider,
} from "./auth.js";

// Mock fetch globally
Expand Down Expand Up @@ -79,11 +81,8 @@ describe("OAuth Authorization", () => {
expect(metadata).toEqual(validMetadata);
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(1);
const [url, options] = calls[0];
const [url] = 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 () => {
Expand Down Expand Up @@ -668,4 +667,101 @@ describe("OAuth Authorization", () => {
).rejects.toThrow("Dynamic client registration failed");
});
});

describe("auth function", () => {
const mockProvider: OAuthClientProvider = {
get redirectUrl() { return "http://localhost:3000/callback"; },
get clientMetadata() {
return {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
};
},
clientInformation: jest.fn(),
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
});

it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => {
// Setup: First call to protected resource metadata fails (404)
// Second call to auth server metadata succeeds
let callCount = 0;
mockFetch.mockImplementation((url) => {
callCount++;

const urlString = url.toString();

if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) {
// First call - protected resource metadata fails with 404
return Promise.resolve({
ok: false,
status: 404,
});
} else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) {
// Second call - auth server metadata succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
} else if (callCount === 3 && urlString.includes("/register")) {
// Third call - client registration succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
client_id: "test-client-id",
client_secret: "test-client-secret",
client_id_issued_at: 1612137600,
client_secret_expires_at: 1612224000,
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
}),
});
}

return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
});

// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
mockProvider.saveClientInformation = jest.fn();

// Call the auth function
const result = await auth(mockProvider, {
resourceServerUrl: "https://resource.example.com",
});

// Verify the result
expect(result).toBe("REDIRECT");

// Verify the sequence of calls
expect(mockFetch).toHaveBeenCalledTimes(3);

// First call should be to protected resource metadata
expect(mockFetch.mock.calls[0][0].toString()).toBe(
"https://resource.example.com/.well-known/oauth-protected-resource"
);

// Since protected resource metadata failed, it should fallback to discovering
// the auth server metadata from the default location (but the auth function
// expects authorization_servers from the resource metadata, so this test
// needs to be updated to handle that case properly)
});
});
});
20 changes: 13 additions & 7 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,24 @@ export async function auth(
{ resourceServerUrl,
authorizationCode,
scope,
protectedResourceMetadata }: {
resourceMetadataUrl
}: {
resourceServerUrl: string | URL;
authorizationCode?: string;
scope?: string;
protectedResourceMetadata?: OAuthProtectedResourceMetadata }): Promise<AuthResult> {
resourceMetadataUrl?: URL }): Promise<AuthResult> {

let resourceMetadata = protectedResourceMetadata ?? await discoverOAuthProtectedResourceMetadata(resourceServerUrl);
let authorizationServerUrl = resourceServerUrl;
try {
const resourceMetadata = await discoverOAuthProtectedResourceMetadata(
resourceMetadataUrl || resourceServerUrl);

if (resourceMetadata.authorization_servers === undefined || resourceMetadata.authorization_servers.length === 0) {
throw new Error("Server does not specify any authorization servers.");
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like we could possibly expose a better interface in this scenario - perhaps a function on the provider that would be called when there is more than one authorization server returned from the protected resource metadata that would pass in the array of servers and utilize the one returned from the function. Happy to PR this in if you feel like it makes sense!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a similar spirit, I believe it would be great to have a callback on the oauth provider to let it know about the discovered authorization URL and/or metadata.

authorizationServerUrl = resourceMetadata.authorization_servers[0];
}
} catch (error) {
console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error)
}
const authorizationServerUrl = resourceMetadata.authorization_servers[0];

const metadata = await discoverOAuthMetadata(authorizationServerUrl);

Expand Down Expand Up @@ -509,4 +515,4 @@ export async function registerClient(
}

return OAuthClientInformationFullSchema.parse(await response.json());
}
}
3 changes: 0 additions & 3 deletions src/client/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,9 +668,6 @@ describe("SSEClientTransport", () => {
currentTokens = tokens;
});

// Create server that accepts SSE but returns 401 on POST with expired token
resourceServer.close();

// Create server that returns 401 for expired token, then accepts new token
resourceServer.close();
authServer.close();
Expand Down
18 changes: 7 additions & 11 deletions src/client/sse.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource";
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { OAuthProtectedResourceMetadata } from "../shared/auth.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";

export class SseError extends Error {
constructor(
Expand Down Expand Up @@ -59,7 +58,7 @@ export class SSEClientTransport implements Transport {
private _endpoint?: URL;
private _abortController?: AbortController;
private _url: URL;
private _protectedResourceMetadata: OAuthProtectedResourceMetadata | undefined;
private _resourceMetadataUrl?: URL;
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
Expand All @@ -73,7 +72,7 @@ export class SSEClientTransport implements Transport {
opts?: SSEClientTransportOptions,
) {
this._url = url;
this._protectedResourceMetadata = undefined;
this._resourceMetadataUrl = undefined;
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
Expand All @@ -86,7 +85,7 @@ export class SSEClientTransport implements Transport {

let result: AuthResult;
try {
result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata });
result = await auth(this._authProvider, { resourceServerUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
} catch (error) {
this.onerror?.(error as Error);
throw error;
Expand Down Expand Up @@ -196,7 +195,7 @@ export class SSEClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}

const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, protectedResourceMetadata: this._protectedResourceMetadata });
const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
Expand Down Expand Up @@ -229,12 +228,9 @@ export class SSEClientTransport implements Transport {
if (!response.ok) {
if (response.status === 401 && this._authProvider) {

const resourceMetadataUrl = extractResourceMetadataUrl(response);
this._protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, {
resourceMetadataUrl: resourceMetadataUrl
})
this._resourceMetadataUrl = extractResourceMetadataUrl(response);

const result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata });
const result = await auth(this._authProvider, { resourceServerUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}
Expand Down
18 changes: 7 additions & 11 deletions src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { OAuthProtectedResourceMetadata } from "src/shared/auth.js";
import { Transport } from "../shared/transport.js";
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { EventSourceParserStream } from "eventsource-parser/stream";

// Default reconnection options for StreamableHTTP connections
Expand Down Expand Up @@ -120,7 +119,7 @@ export type StreamableHTTPClientTransportOptions = {
export class StreamableHTTPClientTransport implements Transport {
private _abortController?: AbortController;
private _url: URL;
private _protectedResourceMetadata: OAuthProtectedResourceMetadata | undefined;
private _resourceMetadataUrl?: URL;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _sessionId?: string;
Expand All @@ -135,7 +134,7 @@ export class StreamableHTTPClientTransport implements Transport {
opts?: StreamableHTTPClientTransportOptions,
) {
this._url = url;
this._protectedResourceMetadata = undefined;
this._resourceMetadataUrl = undefined;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._sessionId = opts?.sessionId;
Expand All @@ -149,7 +148,7 @@ export class StreamableHTTPClientTransport implements Transport {

let result: AuthResult;
try {
result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata });
result = await auth(this._authProvider, { resourceServerUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
} catch (error) {
this.onerror?.(error as Error);
throw error;
Expand Down Expand Up @@ -359,7 +358,7 @@ export class StreamableHTTPClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}

const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, protectedResourceMetadata: this._protectedResourceMetadata });
const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
Expand Down Expand Up @@ -405,12 +404,9 @@ export class StreamableHTTPClientTransport implements Transport {
if (!response.ok) {
if (response.status === 401 && this._authProvider) {

const resourceMetadataUrl = extractResourceMetadataUrl(response);
this._protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, {
resourceMetadataUrl: resourceMetadataUrl
})
this._resourceMetadataUrl = extractResourceMetadataUrl(response);

const result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata });
const result = await auth(this._authProvider, { resourceServerUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}
Expand Down