Skip to content

Commit ee9fed2

Browse files
chipgptotothea
andauthored
fix: Support WWW-Authenticate scope param for SEP-835 (#983)
Co-authored-by: OtotheA <[email protected]>
1 parent 2166047 commit ee9fed2

File tree

8 files changed

+118
-74
lines changed

8 files changed

+118
-74
lines changed

src/client/auth.test.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
refreshAuthorization,
99
registerClient,
1010
discoverOAuthProtectedResourceMetadata,
11-
extractResourceMetadataUrl,
11+
extractWWWAuthenticateParams,
1212
auth,
1313
type OAuthClientProvider,
1414
selectClientAuthMethod
@@ -25,7 +25,7 @@ describe('OAuth Authorization', () => {
2525
mockFetch.mockReset();
2626
});
2727

28-
describe('extractResourceMetadataUrl', () => {
28+
describe('extractWWWAuthenticateParams', () => {
2929
it('returns resource metadata url when present', async () => {
3030
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource';
3131
const mockResponse = {
@@ -34,39 +34,56 @@ describe('OAuth Authorization', () => {
3434
}
3535
} as unknown as Response;
3636

37-
expect(extractResourceMetadataUrl(mockResponse)).toEqual(new URL(resourceUrl));
37+
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ resourceMetadataUrl: new URL(resourceUrl) });
3838
});
3939

40-
it('returns undefined if not bearer', async () => {
40+
it('returns scope when present', async () => {
41+
const scope = 'read';
42+
const mockResponse = {
43+
headers: {
44+
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", scope="${scope}"` : null))
45+
}
46+
} as unknown as Response;
47+
48+
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
49+
});
50+
51+
it('returns empty object if not bearer', async () => {
4152
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource';
53+
const scope = 'read';
4254
const mockResponse = {
4355
headers: {
44-
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null))
56+
get: jest.fn(name =>
57+
name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null
58+
)
4559
}
4660
} as unknown as Response;
4761

48-
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
62+
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({});
4963
});
5064

51-
it('returns undefined if resource_metadata not present', async () => {
65+
it('returns empty object if resource_metadata and scope not present', async () => {
5266
const mockResponse = {
5367
headers: {
54-
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Basic realm="mcp"` : null))
68+
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp"` : null))
5569
}
5670
} as unknown as Response;
5771

58-
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
72+
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({});
5973
});
6074

61-
it('returns undefined on invalid url', async () => {
75+
it('returns undefined resourceMetadataUrl on invalid url', async () => {
6276
const resourceUrl = 'invalid-url';
77+
const scope = 'read';
6378
const mockResponse = {
6479
headers: {
65-
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null))
80+
get: jest.fn(name =>
81+
name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null
82+
)
6683
}
6784
} as unknown as Response;
6885

69-
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
86+
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
7087
});
7188
});
7289

src/client/auth.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,8 +480,46 @@ export async function selectResourceURL(
480480
return new URL(resourceMetadata.resource);
481481
}
482482

483+
/**
484+
* Extract resource_metadata and scope from WWW-Authenticate header.
485+
*/
486+
export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string } {
487+
const authenticateHeader = res.headers.get('WWW-Authenticate');
488+
if (!authenticateHeader) {
489+
return {};
490+
}
491+
492+
const [type, scheme] = authenticateHeader.split(' ');
493+
if (type.toLowerCase() !== 'bearer' || !scheme) {
494+
return {};
495+
}
496+
497+
const resourceMetadataRegex = /resource_metadata="([^"]*)"/;
498+
const resourceMetadataMatch = resourceMetadataRegex.exec(authenticateHeader);
499+
500+
const scopeRegex = /scope="([^"]*)"/;
501+
const scopeMatch = scopeRegex.exec(authenticateHeader);
502+
503+
let resourceMetadataUrl: URL | undefined;
504+
if (resourceMetadataMatch) {
505+
try {
506+
resourceMetadataUrl = new URL(resourceMetadataMatch[1]);
507+
} catch {
508+
// Ignore invalid URL
509+
}
510+
}
511+
512+
const scope = scopeMatch?.[1] || undefined;
513+
514+
return {
515+
resourceMetadataUrl,
516+
scope
517+
};
518+
}
519+
483520
/**
484521
* Extract resource_metadata from response header.
522+
* @deprecated Use `extractWWWAuthenticateParams` instead.
485523
*/
486524
export function extractResourceMetadataUrl(res: Response): URL | undefined {
487525
const authenticateHeader = res.headers.get('WWW-Authenticate');

src/client/middleware.test.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ jest.mock('../client/auth.js', () => {
77
return {
88
...actual,
99
auth: jest.fn(),
10-
extractResourceMetadataUrl: jest.fn()
10+
extractWWWAuthenticateParams: jest.fn()
1111
};
1212
});
1313

14-
import { auth, extractResourceMetadataUrl } from './auth.js';
14+
import { auth, extractWWWAuthenticateParams } from './auth.js';
1515

1616
const mockAuth = auth as jest.MockedFunction<typeof auth>;
17-
const mockExtractResourceMetadataUrl = extractResourceMetadataUrl as jest.MockedFunction<typeof extractResourceMetadataUrl>;
17+
const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as jest.MockedFunction<typeof extractWWWAuthenticateParams>;
1818

1919
describe('withOAuth', () => {
2020
let mockProvider: jest.Mocked<OAuthClientProvider>;
@@ -129,8 +129,11 @@ describe('withOAuth', () => {
129129

130130
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
131131

132-
const mockResourceUrl = new URL('https://oauth.example.com/.well-known/oauth-protected-resource');
133-
mockExtractResourceMetadataUrl.mockReturnValue(mockResourceUrl);
132+
const mockWWWAuthenticateParams = {
133+
resourceMetadataUrl: new URL('https://oauth.example.com/.well-known/oauth-protected-resource'),
134+
scope: 'read'
135+
};
136+
mockExtractWWWAuthenticateParams.mockReturnValue(mockWWWAuthenticateParams);
134137
mockAuth.mockResolvedValue('AUTHORIZED');
135138

136139
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
@@ -141,7 +144,8 @@ describe('withOAuth', () => {
141144
expect(mockFetch).toHaveBeenCalledTimes(2);
142145
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
143146
serverUrl: 'https://api.example.com',
144-
resourceMetadataUrl: mockResourceUrl,
147+
resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl,
148+
scope: mockWWWAuthenticateParams.scope,
145149
fetchFn: mockFetch
146150
});
147151

@@ -172,8 +176,11 @@ describe('withOAuth', () => {
172176

173177
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
174178

175-
const mockResourceUrl = new URL('https://oauth.example.com/.well-known/oauth-protected-resource');
176-
mockExtractResourceMetadataUrl.mockReturnValue(mockResourceUrl);
179+
const mockWWWAuthenticateParams = {
180+
resourceMetadataUrl: new URL('https://oauth.example.com/.well-known/oauth-protected-resource'),
181+
scope: 'read'
182+
};
183+
mockExtractWWWAuthenticateParams.mockReturnValue(mockWWWAuthenticateParams);
177184
mockAuth.mockResolvedValue('AUTHORIZED');
178185

179186
// Test without baseUrl - should extract from request URL
@@ -185,7 +192,8 @@ describe('withOAuth', () => {
185192
expect(mockFetch).toHaveBeenCalledTimes(2);
186193
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
187194
serverUrl: 'https://api.example.com', // Should be extracted from request URL
188-
resourceMetadataUrl: mockResourceUrl,
195+
resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl,
196+
scope: mockWWWAuthenticateParams.scope,
189197
fetchFn: mockFetch
190198
});
191199

@@ -203,7 +211,7 @@ describe('withOAuth', () => {
203211
});
204212

205213
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
206-
mockExtractResourceMetadataUrl.mockReturnValue(undefined);
214+
mockExtractWWWAuthenticateParams.mockReturnValue({});
207215
mockAuth.mockResolvedValue('REDIRECT');
208216

209217
// Test without baseUrl
@@ -222,7 +230,7 @@ describe('withOAuth', () => {
222230
});
223231

224232
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
225-
mockExtractResourceMetadataUrl.mockReturnValue(undefined);
233+
mockExtractWWWAuthenticateParams.mockReturnValue({});
226234
mockAuth.mockRejectedValue(new Error('Network error'));
227235

228236
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
@@ -239,7 +247,7 @@ describe('withOAuth', () => {
239247

240248
// Always return 401
241249
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
242-
mockExtractResourceMetadataUrl.mockReturnValue(undefined);
250+
mockExtractWWWAuthenticateParams.mockReturnValue({});
243251
mockAuth.mockResolvedValue('AUTHORIZED');
244252

245253
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
@@ -345,7 +353,7 @@ describe('withOAuth', () => {
345353

346354
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
347355

348-
mockExtractResourceMetadataUrl.mockReturnValue(undefined);
356+
mockExtractWWWAuthenticateParams.mockReturnValue({});
349357
mockAuth.mockResolvedValue('AUTHORIZED');
350358

351359
const enhancedFetch = withOAuth(mockProvider)(mockFetch);
@@ -876,7 +884,10 @@ describe('Integration Tests', () => {
876884

877885
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
878886

879-
mockExtractResourceMetadataUrl.mockReturnValue(new URL('https://auth.example.com/.well-known/oauth-protected-resource'));
887+
mockExtractWWWAuthenticateParams.mockReturnValue({
888+
resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'),
889+
scope: 'read'
890+
});
880891
mockAuth.mockResolvedValue('AUTHORIZED');
881892

882893
// Use custom logger to avoid console output
@@ -896,6 +907,7 @@ describe('Integration Tests', () => {
896907
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
897908
serverUrl: 'https://mcp-server.example.com',
898909
resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'),
910+
scope: 'read',
899911
fetchFn: mockFetch
900912
});
901913
});

src/client/middleware.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { auth, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js';
1+
import { auth, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js';
22
import { FetchLike } from '../shared/transport.js';
33

44
/**
@@ -54,14 +54,15 @@ export const withOAuth =
5454
// Handle 401 responses by attempting re-authentication
5555
if (response.status === 401) {
5656
try {
57-
const resourceMetadataUrl = extractResourceMetadataUrl(response);
57+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
5858

5959
// Use provided baseUrl or extract from request URL
6060
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
6161

6262
const result = await auth(provider, {
6363
serverUrl,
6464
resourceMetadataUrl,
65+
scope,
6566
fetchFn: next
6667
});
6768

src/client/sse.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource';
22
import { Transport, FetchLike } from '../shared/transport.js';
33
import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
4-
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js';
4+
import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js';
55

66
export class SseError extends Error {
77
constructor(
@@ -65,6 +65,7 @@ export class SSEClientTransport implements Transport {
6565
private _abortController?: AbortController;
6666
private _url: URL;
6767
private _resourceMetadataUrl?: URL;
68+
private _scope?: string;
6869
private _eventSourceInit?: EventSourceInit;
6970
private _requestInit?: RequestInit;
7071
private _authProvider?: OAuthClientProvider;
@@ -78,6 +79,7 @@ export class SSEClientTransport implements Transport {
7879
constructor(url: URL, opts?: SSEClientTransportOptions) {
7980
this._url = url;
8081
this._resourceMetadataUrl = undefined;
82+
this._scope = undefined;
8183
this._eventSourceInit = opts?.eventSourceInit;
8284
this._requestInit = opts?.requestInit;
8385
this._authProvider = opts?.authProvider;
@@ -94,6 +96,7 @@ export class SSEClientTransport implements Transport {
9496
result = await auth(this._authProvider, {
9597
serverUrl: this._url,
9698
resourceMetadataUrl: this._resourceMetadataUrl,
99+
scope: this._scope,
97100
fetchFn: this._fetch
98101
});
99102
} catch (error) {
@@ -137,7 +140,9 @@ export class SSEClientTransport implements Transport {
137140
});
138141

139142
if (response.status === 401 && response.headers.has('www-authenticate')) {
140-
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
143+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
144+
this._resourceMetadataUrl = resourceMetadataUrl;
145+
this._scope = scope;
141146
}
142147

143148
return response;
@@ -214,6 +219,7 @@ export class SSEClientTransport implements Transport {
214219
serverUrl: this._url,
215220
authorizationCode,
216221
resourceMetadataUrl: this._resourceMetadataUrl,
222+
scope: this._scope,
217223
fetchFn: this._fetch
218224
});
219225
if (result !== 'AUTHORIZED') {
@@ -246,11 +252,14 @@ export class SSEClientTransport implements Transport {
246252
const response = await (this._fetch ?? fetch)(this._endpoint, init);
247253
if (!response.ok) {
248254
if (response.status === 401 && this._authProvider) {
249-
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
255+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
256+
this._resourceMetadataUrl = resourceMetadataUrl;
257+
this._scope = scope;
250258

251259
const result = await auth(this._authProvider, {
252260
serverUrl: this._url,
253261
resourceMetadataUrl: this._resourceMetadataUrl,
262+
scope: this._scope,
254263
fetchFn: this._fetch
255264
});
256265
if (result !== 'AUTHORIZED') {

0 commit comments

Comments
 (0)