From b90d5ba25cf0a9d253d3889f5bad7714e6f5dfce Mon Sep 17 00:00:00 2001 From: itsuki Date: Sun, 27 Apr 2025 19:01:57 +0900 Subject: [PATCH 01/25] add type and schema for protected resource metadata --- src/shared/auth.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 60a28b802..98df098d0 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -1,5 +1,27 @@ import { z } from "zod"; +/** + * RFC 8414 OAuth 2.0 Authorization Server Metadata + */ +export const OAuthProtectedResourceMetadataSchema = z + .object({ + resource: z.string(), + authorization_servers: z.array(z.string()).optional(), + jwks_uri: z.string().optional(), + scopes_supported: z.array(z.string()).optional(), + bearer_methods_supported: z.array(z.string()).optional(), + resource_signing_alg_values_supported: z.array(z.string()).optional(), + resource_name: z.string().optional(), + resource_documentation: z.string().optional(), + resource_policy_uri: z.string().optional(), + resource_tos_uri: z.string().optional(), + tls_client_certificate_bound_access_tokens: z.boolean().optional(), + authorization_details_types_supported: z.array(z.string()).optional(), + dpop_signing_alg_values_supported: z.array(z.string()).optional(), + dpop_bound_access_tokens_required: z.boolean().optional(), + }) + .passthrough(); + /** * RFC 8414 OAuth 2.0 Authorization Server Metadata */ @@ -109,6 +131,7 @@ export const OAuthTokenRevocationRequestSchema = z.object({ token_type_hint: z.string().optional(), }).strip(); +export type OAuthProtectedResourceMetadata = z.infer; export type OAuthMetadata = z.infer; export type OAuthTokens = z.infer; export type OAuthErrorResponse = z.infer; From 0ce2da82d711122b4e41e4ffdca6466b53ad5425 Mon Sep 17 00:00:00 2001 From: itsuki Date: Sun, 27 Apr 2025 19:02:35 +0900 Subject: [PATCH 02/25] add private variable to keep track of authorization server url to avoid fetching Protected Resource Metadata every time --- src/client/sse.ts | 31 ++++++++++++++++++++++--------- src/client/streamableHttp.ts | 28 ++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 5e9f0cf00..cfdaa9c22 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -1,7 +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, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { auth, AuthResult, discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; export class SseError extends Error { constructor( @@ -19,23 +19,23 @@ export class SseError extends Error { export type SSEClientTransportOptions = { /** * An OAuth client provider to use for authentication. - * + * * When an `authProvider` is specified and the SSE connection is started: * 1. The connection is attempted with any existing access token from the `authProvider`. * 2. If the access token has expired, the `authProvider` is used to refresh the token. * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. - * + * * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection. - * + * * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. - * + * * `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected. */ authProvider?: OAuthClientProvider; /** * Customizes the initial SSE request to the server (the request that begins the stream). - * + * * NOTE: Setting this property will prevent an `Authorization` header from * being automatically attached to the SSE request, if an `authProvider` is * also given. This can be worked around by setting the `Authorization` header @@ -58,6 +58,7 @@ export class SSEClientTransport implements Transport { private _endpoint?: URL; private _abortController?: AbortController; private _url: URL; + private _authServerUrl: URL | undefined; private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; @@ -71,6 +72,7 @@ export class SSEClientTransport implements Transport { opts?: SSEClientTransportOptions, ) { this._url = url; + this._authServerUrl = undefined; this._eventSourceInit = opts?.eventSourceInit; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; @@ -83,7 +85,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url }); + result = await auth(this._authProvider, { resourceServerUrl: this._url, authServerUrl: this._authServerUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -193,7 +195,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }); + const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, authServerUrl: this._authServerUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -225,7 +227,18 @@ export class SSEClientTransport implements Transport { const response = await fetch(this._endpoint, init); if (!response.ok) { if (response.status === 401 && this._authProvider) { - const result = await auth(this._authProvider, { serverUrl: this._url }); + + const resourceMetadataUrl = extractResourceMetadataUrl(response); + const protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, { + resourceMetadataUrl: resourceMetadataUrl + }) + if (protectedResourceMetadata.authorization_servers === undefined || protectedResourceMetadata.authorization_servers.length === 0) { + throw new Error("Server does not speicify any authorization servers."); + } + this._authServerUrl = new URL(protectedResourceMetadata.authorization_servers[0]); + + const result = await auth(this._authProvider, { resourceServerUrl: this._url, authServerUrl: this._authServerUrl }); + if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 3462b2ab6..ff5fd2658 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,6 +1,6 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; -import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { auth, AuthResult, discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; // Default reconnection options for StreamableHTTP connections @@ -119,6 +119,7 @@ export type StreamableHTTPClientTransportOptions = { export class StreamableHTTPClientTransport implements Transport { private _abortController?: AbortController; private _url: URL; + private _authServerUrl: URL | undefined; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; @@ -133,6 +134,7 @@ export class StreamableHTTPClientTransport implements Transport { opts?: StreamableHTTPClientTransportOptions, ) { this._url = url; + this._authServerUrl = undefined; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._sessionId = opts?.sessionId; @@ -146,7 +148,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url }); + result = await auth(this._authProvider, { resourceServerUrl: this._url, authServerUrl: this._authServerUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -225,7 +227,7 @@ export class StreamableHTTPClientTransport implements Transport { /** * Calculates the next reconnection delay using backoff algorithm - * + * * @param attempt Current reconnection attempt count for the specific stream * @returns Time to wait in milliseconds before next reconnection attempt */ @@ -242,7 +244,7 @@ export class StreamableHTTPClientTransport implements Transport { /** * Schedule a reconnection attempt with exponential backoff - * + * * @param lastEventId The ID of the last received event for resumability * @param attemptCount Current reconnection attempt count for this specific stream */ @@ -356,7 +358,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }); + const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, authServerUrl: this._authServerUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -401,7 +403,17 @@ export class StreamableHTTPClientTransport implements Transport { if (!response.ok) { if (response.status === 401 && this._authProvider) { - const result = await auth(this._authProvider, { serverUrl: this._url }); + + const resourceMetadataUrl = extractResourceMetadataUrl(response); + const protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, { + resourceMetadataUrl: resourceMetadataUrl + }) + if (protectedResourceMetadata.authorization_servers === undefined || protectedResourceMetadata.authorization_servers.length === 0) { + throw new Error("Server does not speicify any authorization servers."); + } + this._authServerUrl = new URL(protectedResourceMetadata.authorization_servers[0]); + + const result = await auth(this._authProvider, { resourceServerUrl: this._url, authServerUrl: this._authServerUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } @@ -470,12 +482,12 @@ export class StreamableHTTPClientTransport implements Transport { /** * Terminates the current session by sending a DELETE request to the server. - * + * * Clients that no longer need a particular session * (e.g., because the user is leaving the client application) SHOULD send an * HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly * terminate the session. - * + * * The server MAY respond with HTTP 405 Method Not Allowed, indicating that * the server does not allow clients to terminate sessions. */ From 3c69e5d80f8d1d8a93fa85f1bd97b493c1997316 Mon Sep 17 00:00:00 2001 From: itsuki Date: Sun, 27 Apr 2025 19:03:23 +0900 Subject: [PATCH 03/25] change auth flow to use authroization server and add function to discover protected resource metadata --- src/client/auth.ts | 135 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 22 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index a279121b4..30d304a89 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -1,11 +1,11 @@ import pkceChallenge from "pkce-challenge"; import { LATEST_PROTOCOL_VERSION } from "../types.js"; -import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js"; -import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; +import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js"; +import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. - * + * * This client relies upon a concept of an authorized "session," the exact * meaning of which is application-defined. Tokens, authorization codes, and * code verifiers should not cross different sessions. @@ -32,7 +32,7 @@ export interface OAuthClientProvider { * If implemented, this permits the OAuth client to dynamically register with * the server. Client information saved this way should later be read via * `clientInformation()`. - * + * * This method is not required to be implemented if client information is * statically known (e.g., pre-registered). */ @@ -78,14 +78,29 @@ export class UnauthorizedError extends Error { /** * Orchestrates the full auth flow with a server. - * + * * This can be used as a single entry point for all authorization functionality, * instead of linking together the other lower-level functions in this module. */ export async function auth( provider: OAuthClientProvider, - { serverUrl, authorizationCode }: { serverUrl: string | URL, authorizationCode?: string }): Promise { - const metadata = await discoverOAuthMetadata(serverUrl); + { resourceServerUrl, authorizationCode, authServerUrl }: { resourceServerUrl: string | URL, authorizationCode?: string, authServerUrl?: string | URL }): Promise { + + let authorizationServerUrl = authServerUrl + + if (!authorizationServerUrl) { + const protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(resourceServerUrl); + if (protectedResourceMetadata.authorization_servers === undefined || protectedResourceMetadata.authorization_servers.length === 0) { + throw new Error("Server does not speicify any authorization servers."); + } + authorizationServerUrl = protectedResourceMetadata.authorization_servers[0]; + } + + if (!authorizationServerUrl) { + throw new Error("Server does not speicify any authorization servers."); + } + + const metadata = await discoverOAuthMetadata(authorizationServerUrl); // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -98,7 +113,7 @@ export async function auth( throw new Error("OAuth client information must be saveable for dynamic registration"); } - const fullInformation = await registerClient(serverUrl, { + const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, }); @@ -110,7 +125,7 @@ export async function auth( // Exchange authorization code for tokens if (authorizationCode !== undefined) { const codeVerifier = await provider.codeVerifier(); - const tokens = await exchangeAuthorization(serverUrl, { + const tokens = await exchangeAuthorization(authorizationServerUrl, { metadata, clientInformation, authorizationCode, @@ -128,7 +143,7 @@ export async function auth( if (tokens?.refresh_token) { try { // Attempt to refresh the token - const newTokens = await refreshAuthorization(serverUrl, { + const newTokens = await refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken: tokens.refresh_token, @@ -142,7 +157,7 @@ export async function auth( } // Start new authorization flow - const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, { + const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { metadata, clientInformation, redirectUrl: provider.redirectUrl @@ -153,6 +168,82 @@ export async function auth( return "REDIRECT"; } +/** + * Extract resource_metadata from response header. + */ +export function extractResourceMetadataUrl(res: Response): URL | undefined { + + const authenticateHeader = res.headers.get("WWW-Authenticate"); + if (!authenticateHeader) { + return undefined; + } + + const [type, scheme] = authenticateHeader.split(' '); + if (type.toLowerCase() !== 'bearer' || !scheme) { + console.log("Invalid WWW-Authenticate header format, expected 'Bearer'"); + return undefined; + } + const regex = /resource_metadata="([^"]*)"/; + const match = regex.exec(authenticateHeader); + + if (!match) { + return undefined; + } + + try { + return new URL(match[1]); + } catch(error) { + console.log("Invalid resource metadata url."); + return undefined; + } +} + +/** + * Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata. + * + * If the server returns a 404 for the well-known endpoint, this function will + * return `undefined`. Any other errors will be thrown as exceptions. + */ +export async function discoverOAuthProtectedResourceMetadata( + resourceServerUrl: string | URL, + opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL }, +): Promise { + + let url: URL + if (opts?.resourceMetadataUrl) { + url = new URL(opts?.resourceMetadataUrl); + } else { + url = new URL("/.well-known/oauth-protected-resource", resourceServerUrl); + } + + let response: Response; + try { + response = await fetch(url, { + headers: { + "MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION + } + }); + } catch (error) { + // CORS errors come back as TypeError + if (error instanceof TypeError) { + response = await fetch(url); + } else { + throw error; + } + } + + if (response.status === 404) { + throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); + } + + if (!response.ok) { + throw new Error( + `HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`, + ); + } + return OAuthProtectedResourceMetadataSchema.parse(await response.json()); +} + /** * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. * @@ -160,10 +251,10 @@ export async function auth( * return `undefined`. Any other errors will be thrown as exceptions. */ export async function discoverOAuthMetadata( - serverUrl: string | URL, + authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { - const url = new URL("/.well-known/oauth-authorization-server", serverUrl); + const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl); let response: Response; try { response = await fetch(url, { @@ -197,7 +288,7 @@ export async function discoverOAuthMetadata( * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ export async function startAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, @@ -230,7 +321,7 @@ export async function startAuthorization( ); } } else { - authorizationUrl = new URL("/authorize", serverUrl); + authorizationUrl = new URL("/authorize", authorizationServerUrl); } // Generate PKCE challenge @@ -254,7 +345,7 @@ export async function startAuthorization( * Exchanges an authorization code for an access token with the given server. */ export async function exchangeAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, @@ -284,7 +375,7 @@ export async function exchangeAuthorization( ); } } else { - tokenUrl = new URL("/token", serverUrl); + tokenUrl = new URL("/token", authorizationServerUrl); } // Exchange code for tokens @@ -319,7 +410,7 @@ export async function exchangeAuthorization( * Exchange a refresh token for an updated access token. */ export async function refreshAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, @@ -345,7 +436,7 @@ export async function refreshAuthorization( ); } } else { - tokenUrl = new URL("/token", serverUrl); + tokenUrl = new URL("/token", authorizationServerUrl); } // Exchange refresh token @@ -366,7 +457,7 @@ export async function refreshAuthorization( }, body: params, }); - +console.log(response) if (!response.ok) { throw new Error(`Token refresh failed: HTTP ${response.status}`); } @@ -378,7 +469,7 @@ export async function refreshAuthorization( * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. */ export async function registerClient( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientMetadata, @@ -396,7 +487,7 @@ export async function registerClient( registrationUrl = new URL(metadata.registration_endpoint); } else { - registrationUrl = new URL("/register", serverUrl); + registrationUrl = new URL("/register", authorizationServerUrl); } const response = await fetch(registrationUrl, { From 9f6aa38b907f240284e49915d98d93a8280709d5 Mon Sep 17 00:00:00 2001 From: itsuki Date: Sun, 27 Apr 2025 19:03:57 +0900 Subject: [PATCH 04/25] add/modify tests for new added auth functions --- src/client/auth.test.ts | 164 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index cc4717ec4..d264f8fce 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -4,6 +4,8 @@ import { exchangeAuthorization, refreshAuthorization, registerClient, + discoverOAuthProtectedResourceMetadata, + extractResourceMetadataUrl, } from "./auth.js"; // Mock fetch globally @@ -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", From bfabc37eb8c73345085ce4063b97056e2108ff43 Mon Sep 17 00:00:00 2001 From: itsuki Date: Sun, 27 Apr 2025 19:04:20 +0900 Subject: [PATCH 05/25] modify test to use the authorization server --- src/client/sse.test.ts | 302 +++++++++++++++++++++++++++++++---------- 1 file changed, 232 insertions(+), 70 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 77b285080..6773376e5 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -6,9 +6,11 @@ import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { OAuthTokens } from "../shared/auth.js"; describe("SSEClientTransport", () => { - let server: Server; + let resourceServer: Server; + let authServer: Server; let transport: SSEClientTransport; - let baseUrl: URL; + let resourceBaseUrl: URL; + let authBaseUrl: URL; let lastServerRequest: IncomingMessage; let sendServerMessage: ((message: string) => void) | null = null; @@ -17,8 +19,26 @@ describe("SSEClientTransport", () => { lastServerRequest = null as unknown as IncomingMessage; sendServerMessage = null; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(200, { + "Content-Type": "application/json" + }); + res.end(JSON.stringify({ + 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"], + })); + return; + } + res.writeHead(401).end(); + }); + // Create a test server that will receive the EventSource connection - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { lastServerRequest = req; // Send SSE headers @@ -30,7 +50,7 @@ describe("SSEClientTransport", () => { // Send the endpoint event res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); // Store reference to send function for tests sendServerMessage = (message: string) => { @@ -51,9 +71,9 @@ describe("SSEClientTransport", () => { }); // Start server on random port - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); done(); }); @@ -62,14 +82,15 @@ describe("SSEClientTransport", () => { afterEach(async () => { await transport.close(); - await server.close(); + await resourceServer.close(); + await authServer.close(); jest.clearAllMocks(); }); describe("connection handling", () => { it("establishes SSE connection and receives endpoint", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); expect(lastServerRequest.headers.accept).toBe("text/event-stream"); @@ -78,27 +99,27 @@ describe("SSEClientTransport", () => { it("rejects if server returns non-200 status", async () => { // Create a server that returns 403 - await server.close(); + await resourceServer.close(); - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { res.writeHead(403); res.end(); }); await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await expect(transport.start()).rejects.toThrow(); }); it("closes EventSource connection on close()", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const closePromise = new Promise((resolve) => { @@ -113,7 +134,7 @@ describe("SSEClientTransport", () => { describe("message handling", () => { it("receives and parses JSON-RPC messages", async () => { const receivedMessages: JSONRPCMessage[] = []; - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); transport.onmessage = (msg) => receivedMessages.push(msg); await transport.start(); @@ -136,7 +157,7 @@ describe("SSEClientTransport", () => { it("handles malformed JSON messages", async () => { const errors: Error[] = []; - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); transport.onerror = (err) => errors.push(err); await transport.start(); @@ -151,7 +172,7 @@ describe("SSEClientTransport", () => { }); it("handles messages via POST requests", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const testMessage: JSONRPCMessage = { @@ -179,9 +200,9 @@ describe("SSEClientTransport", () => { it("handles POST request failures", async () => { // Create a server that returns 500 for POST - await server.close(); + await resourceServer.close(); - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { if (req.method === "GET") { res.writeHead(200, { "Content-Type": "text/event-stream", @@ -189,7 +210,7 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); } else { res.writeHead(500); res.end("Internal error"); @@ -197,14 +218,14 @@ describe("SSEClientTransport", () => { }); await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const testMessage: JSONRPCMessage = { @@ -229,7 +250,7 @@ describe("SSEClientTransport", () => { return fetch(url.toString(), { ...init, headers }); }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { eventSourceInit: { fetch: fetchWithAuth, }, @@ -247,7 +268,7 @@ describe("SSEClientTransport", () => { "X-Custom-Header": "custom-value", }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { requestInit: { headers: customHeaders, }, @@ -319,7 +340,7 @@ describe("SSEClientTransport", () => { token_type: "Bearer" }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -335,7 +356,7 @@ describe("SSEClientTransport", () => { token_type: "Bearer" }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -355,27 +376,50 @@ describe("SSEClientTransport", () => { }); it("attempts auth flow on 401 during SSE connection", async () => { + // Create server that returns 401s - await server.close(); + resourceServer.close(); + authServer.close(); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { - res.writeHead(404).end(); + res.writeHead(404).end(); } else { res.writeHead(401).end(); } }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -385,13 +429,33 @@ describe("SSEClientTransport", () => { it("attempts auth flow on 401 during POST request", async () => { // Create server that accepts SSE but returns 401 on POST - await server.close(); + resourceServer.close(); + authServer.close(); - server = createServer((req, res) => { + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + resourceServer = createServer((req, res) => { lastServerRequest = req; switch (req.method) { case "GET": + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; @@ -403,7 +467,7 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); break; case "POST": @@ -414,14 +478,14 @@ describe("SSEClientTransport", () => { }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -448,7 +512,7 @@ describe("SSEClientTransport", () => { "X-Custom-Header": "custom-value", }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, requestInit: { headers: customHeaders, @@ -483,13 +547,17 @@ describe("SSEClientTransport", () => { }); // Create server that returns 401 for expired token, then accepts new token - await server.close(); + resourceServer.close(); + authServer.close(); - let connectionAttempts = 0; - server = createServer((req, res) => { - lastServerRequest = req; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { + console.log("token here") // Handle token refresh request let body = ""; req.on("data", chunk => { body += chunk; }); @@ -512,6 +580,34 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).end(); + + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + let connectionAttempts = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; @@ -530,7 +626,7 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); connectionAttempts++; return; } @@ -539,14 +635,14 @@ describe("SSEClientTransport", () => { }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -574,11 +670,17 @@ describe("SSEClientTransport", () => { }); // Create server that accepts SSE but returns 401 on POST with expired token - await server.close(); + resourceServer.close(); - let postAttempts = 0; - server = createServer((req, res) => { - lastServerRequest = req; + // Create server that returns 401 for expired token, then accepts new token + resourceServer.close(); + authServer.close(); + + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { // Handle token refresh request @@ -603,6 +705,34 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).end(); + + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + let postAttempts = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + switch (req.method) { case "GET": if (req.url !== "/") { @@ -616,7 +746,7 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); break; case "POST": { @@ -644,14 +774,14 @@ describe("SSEClientTransport", () => { }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -688,10 +818,14 @@ describe("SSEClientTransport", () => { }); // Create server that returns 401 for all tokens - await server.close(); + resourceServer.close(); + authServer.close(); - server = createServer((req, res) => { - lastServerRequest = req; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { // Handle token refresh request - always fail @@ -699,6 +833,34 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).end(); + + }); + + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; @@ -707,14 +869,14 @@ describe("SSEClientTransport", () => { }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); From 956094bc7123b8a86e51db557e9dadf084f3d24e Mon Sep 17 00:00:00 2001 From: itsuki Date: Sun, 27 Apr 2025 19:13:58 +0900 Subject: [PATCH 06/25] remove unused variable --- src/client/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 30d304a89..2e3e93109 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -192,8 +192,8 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { try { return new URL(match[1]); - } catch(error) { - console.log("Invalid resource metadata url."); + } catch { + console.log("Invalid resource metadata url: ", match[1]); return undefined; } } From 130b4fe976ca6450a3221a422a673c38d1f06303 Mon Sep 17 00:00:00 2001 From: itsuki Date: Mon, 28 Apr 2025 12:47:23 +0900 Subject: [PATCH 07/25] add resource parameter when building auth url --- src/client/auth.test.ts | 7 +++++-- src/client/auth.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index d264f8fce..934d668db 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -320,6 +320,7 @@ describe("OAuth Authorization", () => { it("generates authorization URL with PKCE challenge", async () => { const { authorizationUrl, codeVerifier } = await startAuthorization( + "https://resource.example.com", "https://auth.example.com", { clientInformation: validClientInfo, @@ -330,6 +331,7 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.toString()).toMatch( /^https:\/\/auth\.example\.com\/authorize\?/ ); + expect(authorizationUrl.searchParams.get("resource")).toBe("https://resource.example.com"); expect(authorizationUrl.searchParams.get("response_type")).toBe("code"); expect(authorizationUrl.searchParams.get("code_challenge")).toBe("test_challenge"); expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe( @@ -343,6 +345,7 @@ describe("OAuth Authorization", () => { it("uses metadata authorization_endpoint when provided", async () => { const { authorizationUrl } = await startAuthorization( + "https://resource.example.com", "https://auth.example.com", { metadata: validMetadata, @@ -363,7 +366,7 @@ describe("OAuth Authorization", () => { }; await expect( - startAuthorization("https://auth.example.com", { + startAuthorization("https://resource.example.com", "https://auth.example.com", { metadata, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", @@ -379,7 +382,7 @@ describe("OAuth Authorization", () => { }; await expect( - startAuthorization("https://auth.example.com", { + startAuthorization("https://resource.example.com", "https://auth.example.com", { metadata, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", diff --git a/src/client/auth.ts b/src/client/auth.ts index 2e3e93109..131c97316 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -86,7 +86,7 @@ export async function auth( provider: OAuthClientProvider, { resourceServerUrl, authorizationCode, authServerUrl }: { resourceServerUrl: string | URL, authorizationCode?: string, authServerUrl?: string | URL }): Promise { - let authorizationServerUrl = authServerUrl + let authorizationServerUrl = authServerUrl if (!authorizationServerUrl) { const protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(resourceServerUrl); @@ -157,7 +157,7 @@ export async function auth( } // Start new authorization flow - const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { + const { authorizationUrl, codeVerifier } = await startAuthorization(resourceServerUrl, authorizationServerUrl, { metadata, clientInformation, redirectUrl: provider.redirectUrl @@ -288,6 +288,7 @@ export async function discoverOAuthMetadata( * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ export async function startAuthorization( + resourceServerUrl: string | URL, authorizationServerUrl: string | URL, { metadata, @@ -338,6 +339,10 @@ export async function startAuthorization( ); authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl)); + const serverURL = new URL(resourceServerUrl) + const baseURL = `${serverURL.protocol}//${serverURL.host}` + authorizationUrl.searchParams.set("resource", String(baseURL)); + return { authorizationUrl, codeVerifier }; } From d6772e0d99779790983a9fdf4797565045e3cdc8 Mon Sep 17 00:00:00 2001 From: itsuki Date: Mon, 28 Apr 2025 14:04:42 +0900 Subject: [PATCH 08/25] Change resource to use the protected resource metadata --- src/client/auth.test.ts | 11 +++++++---- src/client/auth.ts | 26 +++++++++----------------- src/client/sse.ts | 18 +++++++----------- src/client/streamableHttp.ts | 17 +++++++---------- 4 files changed, 30 insertions(+), 42 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 934d668db..9d4984356 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -320,9 +320,10 @@ describe("OAuth Authorization", () => { it("generates authorization URL with PKCE challenge", async () => { const { authorizationUrl, codeVerifier } = await startAuthorization( - "https://resource.example.com", "https://auth.example.com", { + resource: "https://resource.example.com", + metadata: undefined, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", } @@ -345,9 +346,9 @@ describe("OAuth Authorization", () => { it("uses metadata authorization_endpoint when provided", async () => { const { authorizationUrl } = await startAuthorization( - "https://resource.example.com", "https://auth.example.com", { + resource: "https://resource.example.com", metadata: validMetadata, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", @@ -366,7 +367,8 @@ describe("OAuth Authorization", () => { }; await expect( - startAuthorization("https://resource.example.com", "https://auth.example.com", { + startAuthorization("https://auth.example.com", { + resource: "https://resource.example.com", metadata, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", @@ -382,7 +384,8 @@ describe("OAuth Authorization", () => { }; await expect( - startAuthorization("https://resource.example.com", "https://auth.example.com", { + startAuthorization("https://auth.example.com", { + resource: "https://resource.example.com", metadata, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", diff --git a/src/client/auth.ts b/src/client/auth.ts index 131c97316..644e98126 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -84,21 +84,14 @@ export class UnauthorizedError extends Error { */ export async function auth( provider: OAuthClientProvider, - { resourceServerUrl, authorizationCode, authServerUrl }: { resourceServerUrl: string | URL, authorizationCode?: string, authServerUrl?: string | URL }): Promise { + { resourceServerUrl, authorizationCode, protectedResourceMetadata }: { resourceServerUrl: string | URL, authorizationCode?: string, protectedResourceMetadata?: OAuthProtectedResourceMetadata }): Promise { - let authorizationServerUrl = authServerUrl + let resourceMetadata = protectedResourceMetadata ?? await discoverOAuthProtectedResourceMetadata(resourceServerUrl); - if (!authorizationServerUrl) { - const protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(resourceServerUrl); - if (protectedResourceMetadata.authorization_servers === undefined || protectedResourceMetadata.authorization_servers.length === 0) { - throw new Error("Server does not speicify any authorization servers."); - } - authorizationServerUrl = protectedResourceMetadata.authorization_servers[0]; - } - - if (!authorizationServerUrl) { + if (resourceMetadata.authorization_servers === undefined || resourceMetadata.authorization_servers.length === 0) { throw new Error("Server does not speicify any authorization servers."); } + const authorizationServerUrl = resourceMetadata.authorization_servers[0]; const metadata = await discoverOAuthMetadata(authorizationServerUrl); @@ -157,7 +150,8 @@ export async function auth( } // Start new authorization flow - const { authorizationUrl, codeVerifier } = await startAuthorization(resourceServerUrl, authorizationServerUrl, { + const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { + resource: resourceMetadata.resource, metadata, clientInformation, redirectUrl: provider.redirectUrl @@ -288,13 +282,14 @@ export async function discoverOAuthMetadata( * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ export async function startAuthorization( - resourceServerUrl: string | URL, authorizationServerUrl: string | URL, { + resource, metadata, clientInformation, redirectUrl, }: { + resource: string | URL; metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; @@ -338,10 +333,7 @@ export async function startAuthorization( codeChallengeMethod, ); authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl)); - - const serverURL = new URL(resourceServerUrl) - const baseURL = `${serverURL.protocol}//${serverURL.host}` - authorizationUrl.searchParams.set("resource", String(baseURL)); + authorizationUrl.searchParams.set("resource", String(resource)); return { authorizationUrl, codeVerifier }; } diff --git a/src/client/sse.ts b/src/client/sse.ts index cfdaa9c22..12c594437 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,6 +2,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 "src/shared/auth.js"; export class SseError extends Error { constructor( @@ -58,7 +59,7 @@ export class SSEClientTransport implements Transport { private _endpoint?: URL; private _abortController?: AbortController; private _url: URL; - private _authServerUrl: URL | undefined; + private _protectedResourceMetadata: OAuthProtectedResourceMetadata | undefined; private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; @@ -72,7 +73,7 @@ export class SSEClientTransport implements Transport { opts?: SSEClientTransportOptions, ) { this._url = url; - this._authServerUrl = undefined; + this._protectedResourceMetadata = undefined; this._eventSourceInit = opts?.eventSourceInit; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; @@ -85,7 +86,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { resourceServerUrl: this._url, authServerUrl: this._authServerUrl }); + result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -195,7 +196,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, authServerUrl: this._authServerUrl }); + const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, protectedResourceMetadata: this._protectedResourceMetadata }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -229,16 +230,11 @@ export class SSEClientTransport implements Transport { if (response.status === 401 && this._authProvider) { const resourceMetadataUrl = extractResourceMetadataUrl(response); - const protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, { + this._protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, { resourceMetadataUrl: resourceMetadataUrl }) - if (protectedResourceMetadata.authorization_servers === undefined || protectedResourceMetadata.authorization_servers.length === 0) { - throw new Error("Server does not speicify any authorization servers."); - } - this._authServerUrl = new URL(protectedResourceMetadata.authorization_servers[0]); - - const result = await auth(this._authProvider, { resourceServerUrl: this._url, authServerUrl: this._authServerUrl }); + const result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index ff5fd2658..d770630c8 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,3 +1,4 @@ +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"; @@ -119,7 +120,7 @@ export type StreamableHTTPClientTransportOptions = { export class StreamableHTTPClientTransport implements Transport { private _abortController?: AbortController; private _url: URL; - private _authServerUrl: URL | undefined; + private _protectedResourceMetadata: OAuthProtectedResourceMetadata | undefined; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; @@ -134,7 +135,7 @@ export class StreamableHTTPClientTransport implements Transport { opts?: StreamableHTTPClientTransportOptions, ) { this._url = url; - this._authServerUrl = undefined; + this._protectedResourceMetadata = undefined; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._sessionId = opts?.sessionId; @@ -148,7 +149,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { resourceServerUrl: this._url, authServerUrl: this._authServerUrl }); + result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -358,7 +359,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, authServerUrl: this._authServerUrl }); + const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, protectedResourceMetadata: this._protectedResourceMetadata }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -405,15 +406,11 @@ export class StreamableHTTPClientTransport implements Transport { if (response.status === 401 && this._authProvider) { const resourceMetadataUrl = extractResourceMetadataUrl(response); - const protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, { + this._protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, { resourceMetadataUrl: resourceMetadataUrl }) - if (protectedResourceMetadata.authorization_servers === undefined || protectedResourceMetadata.authorization_servers.length === 0) { - throw new Error("Server does not speicify any authorization servers."); - } - this._authServerUrl = new URL(protectedResourceMetadata.authorization_servers[0]); - const result = await auth(this._authProvider, { resourceServerUrl: this._url, authServerUrl: this._authServerUrl }); + const result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } From eb43c47682ca3d5690e7cda7763697cc117bc9a1 Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:51:16 +0900 Subject: [PATCH 09/25] resolve typo. Co-authored-by: Paul Carleton --- src/client/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 644e98126..7f3e99fe2 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -89,7 +89,7 @@ export async function auth( let resourceMetadata = protectedResourceMetadata ?? await discoverOAuthProtectedResourceMetadata(resourceServerUrl); if (resourceMetadata.authorization_servers === undefined || resourceMetadata.authorization_servers.length === 0) { - throw new Error("Server does not speicify any authorization servers."); + throw new Error("Server does not specify any authorization servers."); } const authorizationServerUrl = resourceMetadata.authorization_servers[0]; From 402455473c849c3607d80ec601d9248f7e1b338b Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:51:30 +0900 Subject: [PATCH 10/25] Update src/client/sse.ts import. Co-authored-by: Paul Carleton --- src/client/sse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 12c594437..60b7839ca 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,7 +2,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 "src/shared/auth.js"; +import { OAuthProtectedResourceMetadata } from "../shared/auth.js"; export class SseError extends Error { constructor( From 6a636810c07106973925e7ebd587390004471a88 Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:51:44 +0900 Subject: [PATCH 11/25] remove log. Co-authored-by: Paul Carleton --- src/client/sse.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 6773376e5..6dd04ec7f 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -557,7 +557,6 @@ describe("SSEClientTransport", () => { } if (req.url === "/token" && req.method === "POST") { - console.log("token here") // Handle token refresh request let body = ""; req.on("data", chunk => { body += chunk; }); From ff65f816a9e2b58547649eca019536ea5125fa19 Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:52:02 +0900 Subject: [PATCH 12/25] Update section title. Co-authored-by: Paul Carleton --- src/shared/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 98df098d0..1e422980d 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -1,7 +1,7 @@ import { z } from "zod"; /** - * RFC 8414 OAuth 2.0 Authorization Server Metadata + * RFC 9728 OAuth Protected Resource Metadata */ export const OAuthProtectedResourceMetadataSchema = z .object({ From c3cd5afe23fe0543d3037861b97da86c2f864174 Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:52:13 +0900 Subject: [PATCH 13/25] remove log. Co-authored-by: Paul Carleton --- src/client/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 7f3e99fe2..b211fc1d7 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -454,7 +454,6 @@ export async function refreshAuthorization( }, body: params, }); -console.log(response) if (!response.ok) { throw new Error(`Token refresh failed: HTTP ${response.status}`); } From b2878fb8afba6a66074b02580fe5d77542238ef1 Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:54:55 +0900 Subject: [PATCH 14/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index b211fc1d7..fdf3d29ab 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -284,7 +284,6 @@ export async function discoverOAuthMetadata( export async function startAuthorization( authorizationServerUrl: string | URL, { - resource, metadata, clientInformation, redirectUrl, From a3e2f710ca55ad7217db9198640e2a08f4e9f5ee Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:55:18 +0900 Subject: [PATCH 15/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index fdf3d29ab..daad5e317 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -288,7 +288,6 @@ export async function startAuthorization( clientInformation, redirectUrl, }: { - resource: string | URL; metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; From ed06b1131496ec14d10e6ae6f57fe4febdfdea19 Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:55:36 +0900 Subject: [PATCH 16/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index daad5e317..ac86541c8 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -331,7 +331,6 @@ export async function startAuthorization( codeChallengeMethod, ); authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl)); - authorizationUrl.searchParams.set("resource", String(resource)); return { authorizationUrl, codeVerifier }; } From 42ee88b6f9d085119165b12dd23971d98006acfa Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:55:47 +0900 Subject: [PATCH 17/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 9d4984356..6ab6b765b 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -368,7 +368,6 @@ describe("OAuth Authorization", () => { await expect( startAuthorization("https://auth.example.com", { - resource: "https://resource.example.com", metadata, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", From 2dac816a056ccb6c6917b84740dbfd85868933f1 Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:56:03 +0900 Subject: [PATCH 18/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 6ab6b765b..8dd63ae43 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -322,7 +322,6 @@ describe("OAuth Authorization", () => { const { authorizationUrl, codeVerifier } = await startAuthorization( "https://auth.example.com", { - resource: "https://resource.example.com", metadata: undefined, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", From 17b970e8c0f39da92e34243748179c026ab3decf Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:56:17 +0900 Subject: [PATCH 19/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8dd63ae43..589a69c38 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -331,7 +331,6 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.toString()).toMatch( /^https:\/\/auth\.example\.com\/authorize\?/ ); - expect(authorizationUrl.searchParams.get("resource")).toBe("https://resource.example.com"); expect(authorizationUrl.searchParams.get("response_type")).toBe("code"); expect(authorizationUrl.searchParams.get("code_challenge")).toBe("test_challenge"); expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe( From 68eaf7af78f5a73b2d3fb6bb3b27aa4404cf4b3d Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:56:32 +0900 Subject: [PATCH 20/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 589a69c38..e7a6d43d5 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -346,7 +346,6 @@ describe("OAuth Authorization", () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", { - resource: "https://resource.example.com", metadata: validMetadata, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", From 0a632bf0b9932d5cb310b809c035164a6333e0cf Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:56:53 +0900 Subject: [PATCH 21/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index e7a6d43d5..941d7f9b9 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -381,7 +381,6 @@ describe("OAuth Authorization", () => { await expect( startAuthorization("https://auth.example.com", { - resource: "https://resource.example.com", metadata, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", From cfd572af5825ecbc36479d6e27d738dc570cc72d Mon Sep 17 00:00:00 2001 From: Itsuki Date: Tue, 20 May 2025 14:56:59 +0900 Subject: [PATCH 22/25] remove resource parameter. Co-authored-by: Paul Carleton --- src/client/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index ac86541c8..cb12f57b9 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -151,7 +151,6 @@ export async function auth( // Start new authorization flow const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { - resource: resourceMetadata.resource, metadata, clientInformation, redirectUrl: provider.redirectUrl From 421959e2ba937c7f0556357bfca1785dafe57e60 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 21 May 2025 11:38:59 +0100 Subject: [PATCH 23/25] make backwards compatibile --- src/client/auth.test.ts | 104 +++++++++++++++++++++++++++++++++-- src/client/auth.ts | 20 ++++--- src/client/sse.test.ts | 3 - src/client/sse.ts | 18 +++--- src/client/streamableHttp.ts | 18 +++--- 5 files changed, 127 insertions(+), 36 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 509bb538d..b1d81576d 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -6,6 +6,8 @@ import { registerClient, discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, + auth, + type OAuthClientProvider, } from "./auth.js"; // Mock fetch globally @@ -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 () => { @@ -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) + }); + }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index c46f47571..e8329d6d7 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -87,18 +87,24 @@ export async function auth( { resourceServerUrl, authorizationCode, scope, - protectedResourceMetadata }: { + resourceMetadataUrl + }: { resourceServerUrl: string | URL; authorizationCode?: string; scope?: string; - protectedResourceMetadata?: OAuthProtectedResourceMetadata }): Promise { + resourceMetadataUrl?: URL }): Promise { - 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) { + 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); @@ -509,4 +515,4 @@ export async function registerClient( } return OAuthClientInformationFullSchema.parse(await response.json()); -} \ No newline at end of file +} diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index f1d88dd00..714e1fddf 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -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(); diff --git a/src/client/sse.ts b/src/client/sse.ts index 60b7839ca..370d87726 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -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( @@ -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; @@ -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; @@ -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; @@ -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"); } @@ -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(); } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index d770630c8..bab9e5f05 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -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 @@ -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; @@ -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; @@ -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; @@ -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"); } @@ -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(); } From 7c12e614b1d8ec22cb7410d20ec1aba8a15dec67 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 21 May 2025 13:42:01 +0100 Subject: [PATCH 24/25] s/resourceServerUrl/serverUrl/g for backwards compat --- src/client/auth.test.ts | 2 +- src/client/auth.ts | 12 ++++++------ src/client/sse.ts | 6 +++--- src/client/streamableHttp.ts | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b1d81576d..013a5aabf 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -744,7 +744,7 @@ describe("OAuth Authorization", () => { // Call the auth function const result = await auth(mockProvider, { - resourceServerUrl: "https://resource.example.com", + serverUrl: "https://resource.example.com", }); // Verify the result diff --git a/src/client/auth.ts b/src/client/auth.ts index e8329d6d7..b96d6b0ed 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -84,20 +84,20 @@ export class UnauthorizedError extends Error { */ export async function auth( provider: OAuthClientProvider, - { resourceServerUrl, + { serverUrl, authorizationCode, scope, resourceMetadataUrl }: { - resourceServerUrl: string | URL; + serverUrl: string | URL; authorizationCode?: string; scope?: string; resourceMetadataUrl?: URL }): Promise { - let authorizationServerUrl = resourceServerUrl; + let authorizationServerUrl = serverUrl; try { const resourceMetadata = await discoverOAuthProtectedResourceMetadata( - resourceMetadataUrl || resourceServerUrl); + resourceMetadataUrl || serverUrl); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; @@ -212,7 +212,7 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { * return `undefined`. Any other errors will be thrown as exceptions. */ export async function discoverOAuthProtectedResourceMetadata( - resourceServerUrl: string | URL, + serverUrl: string | URL, opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL }, ): Promise { @@ -220,7 +220,7 @@ export async function discoverOAuthProtectedResourceMetadata( if (opts?.resourceMetadataUrl) { url = new URL(opts?.resourceMetadataUrl); } else { - url = new URL("/.well-known/oauth-protected-resource", resourceServerUrl); + url = new URL("/.well-known/oauth-protected-resource", serverUrl); } let response: Response; diff --git a/src/client/sse.ts b/src/client/sse.ts index 370d87726..878a4919e 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -85,7 +85,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { resourceServerUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -195,7 +195,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -230,7 +230,7 @@ export class SSEClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { resourceServerUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index bab9e5f05..1bcfbb2d1 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -148,7 +148,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { resourceServerUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -358,7 +358,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -406,7 +406,7 @@ export class StreamableHTTPClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { resourceServerUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } From 7ce3f85803013b1bbb9d58dc643b0e20c3deb3e6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 21 May 2025 13:52:56 +0100 Subject: [PATCH 25/25] fix test comment --- src/client/auth.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 013a5aabf..eb01d50d5 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -758,10 +758,10 @@ describe("OAuth Authorization", () => { "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) + // Second call should be to oauth metadata + expect(mockFetch.mock.calls[1][0].toString()).toBe( + "https://resource.example.com/.well-known/oauth-authorization-server" + ); }); }); });