Skip to content

Commit b5c62d9

Browse files
committed
feat: implement SEP-985 OAuth 2.0 Protected Resource Metadata fallback
Aligns OAuth 2.0 Protected Resource Metadata handling with RFC 9728 and SEP-985 by making the WWW-Authenticate header optional and implementing graceful fallback behavior. Changes: - Updated discoverOAuthProtectedResourceMetadata() to return undefined instead of throwing on 404, making protected resource metadata optional - Enhanced JSDoc comments to document SEP-985 fallback behavior - Updated authInternal() to handle optional metadata with proper null checks - Added comprehensive test suite for SEP-985 scenarios: - WWW-Authenticate header with resource_metadata present - WWW-Authenticate header without resource_metadata (fallback to well-known) - Missing WWW-Authenticate header (fallback to well-known) - 404 on well-known endpoint (graceful degradation) - CORS errors on metadata discovery (graceful fallback) - Updated existing tests to expect undefined instead of errors on 404 Per SEP-985, clients now: 1. Check WWW-Authenticate header for resource_metadata parameter 2. Fallback to /.well-known/oauth-protected-resource if not present 3. Continue gracefully using the MCP server as auth server if metadata unavailable All 856 tests pass. Related: #920 Implements: SEP-985 (modelcontextprotocol/modelcontextprotocol#971)
1 parent 5a8fb39 commit b5c62d9

File tree

2 files changed

+261
-30
lines changed

2 files changed

+261
-30
lines changed

src/client/auth.test.ts

Lines changed: 228 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,14 @@ describe('OAuth Authorization', () => {
147147
expect(mockFetch).toHaveBeenCalledTimes(2);
148148
});
149149

150-
it('throws on 404 errors', async () => {
150+
it('returns undefined on 404 errors (per SEP-985)', async () => {
151151
mockFetch.mockResolvedValueOnce({
152152
ok: false,
153153
status: 404
154154
});
155155

156-
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(
157-
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
158-
);
156+
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com');
157+
expect(metadata).toBeUndefined();
159158
});
160159

161160
it('throws on non-404 errors', async () => {
@@ -248,7 +247,7 @@ describe('OAuth Authorization', () => {
248247
}
249248
);
250249

251-
it('throws error when both path-aware and root discovery return 404', async () => {
250+
it('returns undefined when both path-aware and root discovery return 404 (per SEP-985)', async () => {
252251
// First call (path-aware) returns 404
253252
mockFetch.mockResolvedValueOnce({
254253
ok: false,
@@ -261,9 +260,8 @@ describe('OAuth Authorization', () => {
261260
status: 404
262261
});
263262

264-
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow(
265-
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
266-
);
263+
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name');
264+
expect(metadata).toBeUndefined();
267265

268266
const calls = mockFetch.mock.calls;
269267
expect(calls.length).toBe(2);
@@ -282,16 +280,15 @@ describe('OAuth Authorization', () => {
282280
expect(calls.length).toBe(1); // Should not attempt fallback
283281
});
284282

285-
it('does not fallback when the original URL is already at root path', async () => {
283+
it('returns undefined when the original URL is already at root path and returns 404 (per SEP-985)', async () => {
286284
// First call (path-aware for root) returns 404
287285
mockFetch.mockResolvedValueOnce({
288286
ok: false,
289287
status: 404
290288
});
291289

292-
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/')).rejects.toThrow(
293-
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
294-
);
290+
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/');
291+
expect(metadata).toBeUndefined();
295292

296293
const calls = mockFetch.mock.calls;
297294
expect(calls.length).toBe(1); // Should not attempt fallback
@@ -300,16 +297,15 @@ describe('OAuth Authorization', () => {
300297
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
301298
});
302299

303-
it('does not fallback when the original URL has no path', async () => {
300+
it('returns undefined when the original URL has no path and returns 404 (per SEP-985)', async () => {
304301
// First call (path-aware for no path) returns 404
305302
mockFetch.mockResolvedValueOnce({
306303
ok: false,
307304
status: 404
308305
});
309306

310-
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(
311-
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
312-
);
307+
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com');
308+
expect(metadata).toBeUndefined();
313309

314310
const calls = mockFetch.mock.calls;
315311
expect(calls.length).toBe(1); // Should not attempt fallback
@@ -349,18 +345,17 @@ describe('OAuth Authorization', () => {
349345
});
350346
});
351347

352-
it('does not fallback when resourceMetadataUrl is provided', async () => {
348+
it('returns undefined when resourceMetadataUrl is provided but returns 404 (per SEP-985)', async () => {
353349
// Call with explicit URL returns 404
354350
mockFetch.mockResolvedValueOnce({
355351
ok: false,
356352
status: 404
357353
});
358354

359-
await expect(
360-
discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', {
361-
resourceMetadataUrl: 'https://custom.example.com/metadata'
362-
})
363-
).rejects.toThrow('Resource server does not implement OAuth 2.0 Protected Resource Metadata.');
355+
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', {
356+
resourceMetadataUrl: 'https://custom.example.com/metadata'
357+
});
358+
expect(metadata).toBeUndefined();
364359

365360
const calls = mockFetch.mock.calls;
366361
expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided
@@ -2180,6 +2175,217 @@ describe('OAuth Authorization', () => {
21802175
// Verify custom fetch was called for AS metadata discovery
21812176
expect(customFetch.mock.calls[1][0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
21822177
});
2178+
2179+
describe('SEP-985: WWW-Authenticate fallback behavior', () => {
2180+
it('uses resource_metadata URL from WWW-Authenticate header when provided', async () => {
2181+
// Mock PRM discovery from explicit URL
2182+
mockFetch.mockImplementation(url => {
2183+
const urlString = url.toString();
2184+
2185+
if (urlString === 'https://resource.example.com/custom/.well-known/oauth-protected-resource') {
2186+
return Promise.resolve({
2187+
ok: true,
2188+
status: 200,
2189+
json: async () => ({
2190+
resource: 'https://resource.example.com',
2191+
authorization_servers: ['https://auth.example.com']
2192+
})
2193+
});
2194+
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
2195+
return Promise.resolve({
2196+
ok: true,
2197+
status: 200,
2198+
json: async () => ({
2199+
issuer: 'https://auth.example.com',
2200+
authorization_endpoint: 'https://auth.example.com/authorize',
2201+
token_endpoint: 'https://auth.example.com/token',
2202+
response_types_supported: ['code'],
2203+
code_challenge_methods_supported: ['S256']
2204+
})
2205+
});
2206+
}
2207+
2208+
return Promise.resolve({ ok: false, status: 404 });
2209+
});
2210+
2211+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
2212+
client_id: 'test-client',
2213+
client_secret: 'test-secret'
2214+
});
2215+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
2216+
2217+
// Pass resourceMetadataUrl extracted from WWW-Authenticate header
2218+
const result = await auth(mockProvider, {
2219+
serverUrl: 'https://resource.example.com',
2220+
resourceMetadataUrl: new URL('https://resource.example.com/custom/.well-known/oauth-protected-resource')
2221+
});
2222+
2223+
expect(result).toBe('REDIRECT');
2224+
2225+
// Verify that the custom URL was used, not the default well-known path
2226+
const firstCall = mockFetch.mock.calls[0];
2227+
expect(firstCall[0].toString()).toBe('https://resource.example.com/custom/.well-known/oauth-protected-resource');
2228+
});
2229+
2230+
it('falls back to well-known when WWW-Authenticate header has no resource_metadata', async () => {
2231+
// Simulate: WWW-Authenticate present but without resource_metadata parameter
2232+
// In this case, resourceMetadataUrl would be undefined
2233+
mockFetch.mockImplementation(url => {
2234+
const urlString = url.toString();
2235+
2236+
if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') {
2237+
return Promise.resolve({
2238+
ok: true,
2239+
status: 200,
2240+
json: async () => ({
2241+
resource: 'https://resource.example.com',
2242+
authorization_servers: ['https://auth.example.com']
2243+
})
2244+
});
2245+
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
2246+
return Promise.resolve({
2247+
ok: true,
2248+
status: 200,
2249+
json: async () => ({
2250+
issuer: 'https://auth.example.com',
2251+
authorization_endpoint: 'https://auth.example.com/authorize',
2252+
token_endpoint: 'https://auth.example.com/token',
2253+
response_types_supported: ['code'],
2254+
code_challenge_methods_supported: ['S256']
2255+
})
2256+
});
2257+
}
2258+
2259+
return Promise.resolve({ ok: false, status: 404 });
2260+
});
2261+
2262+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
2263+
client_id: 'test-client',
2264+
client_secret: 'test-secret'
2265+
});
2266+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
2267+
2268+
// No resourceMetadataUrl provided (as if WWW-Authenticate had no resource_metadata)
2269+
const result = await auth(mockProvider, {
2270+
serverUrl: 'https://resource.example.com'
2271+
});
2272+
2273+
expect(result).toBe('REDIRECT');
2274+
2275+
// Verify fallback to well-known path
2276+
const firstCall = mockFetch.mock.calls[0];
2277+
expect(firstCall[0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
2278+
});
2279+
2280+
it('continues gracefully when well-known PRM returns 404', async () => {
2281+
// Per SEP-985, protected resource metadata is optional
2282+
// Client should fall back to using server as auth server
2283+
mockFetch.mockImplementation(url => {
2284+
const urlString = url.toString();
2285+
2286+
if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') {
2287+
// PRM not available - return 404
2288+
return Promise.resolve({
2289+
ok: false,
2290+
status: 404
2291+
});
2292+
} else if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') {
2293+
// Fall back to server as auth server
2294+
return Promise.resolve({
2295+
ok: true,
2296+
status: 200,
2297+
json: async () => ({
2298+
issuer: 'https://resource.example.com',
2299+
authorization_endpoint: 'https://resource.example.com/authorize',
2300+
token_endpoint: 'https://resource.example.com/token',
2301+
registration_endpoint: 'https://resource.example.com/register',
2302+
response_types_supported: ['code'],
2303+
code_challenge_methods_supported: ['S256']
2304+
})
2305+
});
2306+
} else if (urlString === 'https://resource.example.com/register') {
2307+
return Promise.resolve({
2308+
ok: true,
2309+
status: 200,
2310+
json: async () => ({
2311+
client_id: 'registered-client-id',
2312+
client_secret: 'registered-secret',
2313+
redirect_uris: ['http://localhost:3000/callback'],
2314+
client_name: 'Test Client'
2315+
})
2316+
});
2317+
}
2318+
2319+
return Promise.resolve({ ok: false, status: 404 });
2320+
});
2321+
2322+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
2323+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
2324+
mockProvider.saveClientInformation = jest.fn();
2325+
2326+
const result = await auth(mockProvider, {
2327+
serverUrl: 'https://resource.example.com'
2328+
});
2329+
2330+
expect(result).toBe('REDIRECT');
2331+
2332+
// Verify we tried PRM discovery
2333+
expect(mockFetch.mock.calls[0][0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
2334+
2335+
// Verify we fell back to auth server metadata on same server
2336+
expect(mockFetch.mock.calls[1][0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server');
2337+
2338+
// Verify client registration happened
2339+
expect(mockProvider.saveClientInformation).toHaveBeenCalled();
2340+
});
2341+
2342+
it('handles CORS error on PRM discovery and falls back gracefully', async () => {
2343+
let callCount = 0;
2344+
2345+
mockFetch.mockImplementation(url => {
2346+
callCount++;
2347+
const urlString = url.toString();
2348+
2349+
if (callCount <= 2 && urlString.includes('oauth-protected-resource')) {
2350+
// Simulate CORS error on PRM discovery (both with and without headers)
2351+
return Promise.reject(new TypeError('Network request failed'));
2352+
} else if (urlString.includes('oauth-authorization-server')) {
2353+
// Auth server metadata succeeds
2354+
return Promise.resolve({
2355+
ok: true,
2356+
status: 200,
2357+
json: async () => ({
2358+
issuer: 'https://resource.example.com',
2359+
authorization_endpoint: 'https://resource.example.com/authorize',
2360+
token_endpoint: 'https://resource.example.com/token',
2361+
response_types_supported: ['code'],
2362+
code_challenge_methods_supported: ['S256']
2363+
})
2364+
});
2365+
}
2366+
2367+
return Promise.resolve({ ok: false, status: 404 });
2368+
});
2369+
2370+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
2371+
client_id: 'test-client',
2372+
client_secret: 'test-secret'
2373+
});
2374+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
2375+
2376+
const result = await auth(mockProvider, {
2377+
serverUrl: 'https://resource.example.com'
2378+
});
2379+
2380+
expect(result).toBe('REDIRECT');
2381+
2382+
// Verify we tried PRM discovery (with retry for CORS)
2383+
expect(mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')).length).toBeGreaterThan(0);
2384+
2385+
// Verify we eventually fell back to auth server metadata
2386+
expect(mockFetch.mock.calls.some(call => call[0].toString().includes('oauth-authorization-server'))).toBe(true);
2387+
});
2388+
});
21832389
});
21842390

21852391
describe('exchangeAuthorization with multiple client authentication methods', () => {

0 commit comments

Comments
 (0)