Skip to content

Commit 037fb1d

Browse files
committed
Support WWW-Authenticate scope param
1 parent 7d29cee commit 037fb1d

File tree

5 files changed

+98
-37
lines changed

5 files changed

+98
-37
lines changed

src/client/auth.test.ts

Lines changed: 21 additions & 10 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
} from "./auth.js";
@@ -24,7 +24,7 @@ describe("OAuth Authorization", () => {
2424
mockFetch.mockReset();
2525
});
2626

27-
describe("extractResourceMetadataUrl", () => {
27+
describe("extractWWWAuthenticateParams", () => {
2828
it("returns resource metadata url when present", async () => {
2929
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
3030
const mockResponse = {
@@ -33,39 +33,50 @@ describe("OAuth Authorization", () => {
3333
}
3434
} as unknown as Response
3535

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

39-
it("returns undefined if not bearer", async () => {
39+
it("returns scope when present", async () => {
40+
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
41+
const mockResponse = {
42+
headers: {
43+
get: jest.fn((name) => name === "WWW-Authenticate" ? `Bearer realm="mcp", resource_metadata="${resourceUrl}", scope="read"` : null),
44+
}
45+
} as unknown as Response
46+
47+
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ resourceMetadataUrl: new URL(resourceUrl), scope: "read" });
48+
});
49+
50+
it("returns empty object if not bearer", async () => {
4051
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
4152
const mockResponse = {
4253
headers: {
4354
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
4455
}
4556
} as unknown as Response
4657

47-
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
58+
expect(extractWWWAuthenticateParams(mockResponse)).toBe({});
4859
});
4960

50-
it("returns undefined if resource_metadata not present", async () => {
61+
it("returns empty object if resource_metadata and scope not present", async () => {
5162
const mockResponse = {
5263
headers: {
5364
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp"` : null),
5465
}
5566
} as unknown as Response
5667

57-
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
68+
expect(extractWWWAuthenticateParams(mockResponse)).toBe({});
5869
});
5970

60-
it("returns undefined on invalid url", async () => {
71+
it("returns undefined resourceMetadataUrl on invalid url", async () => {
6172
const resourceUrl = "invalid-url"
6273
const mockResponse = {
6374
headers: {
64-
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
75+
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}" scope="read"` : null),
6576
}
6677
} as unknown as Response
6778

68-
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
79+
expect(extractWWWAuthenticateParams(mockResponse)).toBe({ scope: "read" });
6980
});
7081
});
7182

src/client/auth.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -451,31 +451,40 @@ export async function selectResourceURL(serverUrl: string | URL, provider: OAuth
451451
}
452452

453453
/**
454-
* Extract resource_metadata from response header.
454+
* Extract resource_metadata and scope from WWW-Authenticate header.
455455
*/
456-
export function extractResourceMetadataUrl(res: Response): URL | undefined {
457-
456+
export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL, scope?: string } {
458457
const authenticateHeader = res.headers.get("WWW-Authenticate");
459458
if (!authenticateHeader) {
460-
return undefined;
459+
return {};
461460
}
462461

463462
const [type, scheme] = authenticateHeader.split(' ');
464463
if (type.toLowerCase() !== 'bearer' || !scheme) {
465-
return undefined;
464+
return {};
466465
}
467-
const regex = /resource_metadata="([^"]*)"/;
468-
const match = regex.exec(authenticateHeader);
469466

470-
if (!match) {
471-
return undefined;
472-
}
467+
const resourceMetadataRegex = /resource_metadata="([^"]*)"/;
468+
const resourceMetadataMatch = resourceMetadataRegex.exec(authenticateHeader);
473469

474-
try {
475-
return new URL(match[1]);
476-
} catch {
477-
return undefined;
470+
const scopeRegex = /scope="([^"]*)"/;
471+
const scopeMatch = scopeRegex.exec(authenticateHeader);
472+
473+
let resourceMetadataUrl: URL | undefined;
474+
if (resourceMetadataMatch) {
475+
try {
476+
resourceMetadataUrl = new URL(resourceMetadataMatch[1]);
477+
} catch {
478+
// Ignore invalid URL
479+
}
478480
}
481+
482+
const scope = scopeMatch?.[1] || undefined;
483+
484+
return {
485+
resourceMetadataUrl,
486+
scope,
487+
};
479488
}
480489

481490
/**

src/client/middleware.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
auth,
3-
extractResourceMetadataUrl,
3+
extractWWWAuthenticateParams,
44
OAuthClientProvider,
55
UnauthorizedError,
66
} from "./auth.js";
@@ -59,7 +59,7 @@ export const withOAuth =
5959
// Handle 401 responses by attempting re-authentication
6060
if (response.status === 401) {
6161
try {
62-
const resourceMetadataUrl = extractResourceMetadataUrl(response);
62+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
6363

6464
// Use provided baseUrl or extract from request URL
6565
const serverUrl =
@@ -70,6 +70,7 @@ export const withOAuth =
7070
serverUrl,
7171
resourceMetadataUrl,
7272
fetchFn: next,
73+
scope,
7374
});
7475

7576
if (result === "REDIRECT") {

src/client/sse.ts

Lines changed: 27 additions & 6 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(
@@ -64,6 +64,7 @@ export class SSEClientTransport implements Transport {
6464
private _abortController?: AbortController;
6565
private _url: URL;
6666
private _resourceMetadataUrl?: URL;
67+
private _scope?: string;
6768
private _eventSourceInit?: EventSourceInit;
6869
private _requestInit?: RequestInit;
6970
private _authProvider?: OAuthClientProvider;
@@ -80,6 +81,7 @@ export class SSEClientTransport implements Transport {
8081
) {
8182
this._url = url;
8283
this._resourceMetadataUrl = undefined;
84+
this._scope = undefined;
8385
this._eventSourceInit = opts?.eventSourceInit;
8486
this._requestInit = opts?.requestInit;
8587
this._authProvider = opts?.authProvider;
@@ -93,7 +95,12 @@ export class SSEClientTransport implements Transport {
9395

9496
let result: AuthResult;
9597
try {
96-
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
98+
result = await auth(this._authProvider, {
99+
serverUrl: this._url,
100+
resourceMetadataUrl: this._resourceMetadataUrl,
101+
scope: this._scope,
102+
fetchFn: this._fetch,
103+
});
97104
} catch (error) {
98105
this.onerror?.(error as Error);
99106
throw error;
@@ -139,7 +146,9 @@ export class SSEClientTransport implements Transport {
139146
})
140147

141148
if (response.status === 401 && response.headers.has('www-authenticate')) {
142-
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
149+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
150+
this._resourceMetadataUrl = resourceMetadataUrl;
151+
this._scope = scope;
143152
}
144153

145154
return response
@@ -218,7 +227,12 @@ export class SSEClientTransport implements Transport {
218227
throw new UnauthorizedError("No auth provider");
219228
}
220229

221-
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
230+
const result = await auth(this._authProvider, {
231+
serverUrl: this._url, authorizationCode,
232+
resourceMetadataUrl: this._resourceMetadataUrl,
233+
scope: this._scope,
234+
fetchFn: this._fetch,
235+
});
222236
if (result !== "AUTHORIZED") {
223237
throw new UnauthorizedError("Failed to authorize");
224238
}
@@ -250,9 +264,16 @@ export class SSEClientTransport implements Transport {
250264
if (!response.ok) {
251265
if (response.status === 401 && this._authProvider) {
252266

253-
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
267+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
268+
this._resourceMetadataUrl = resourceMetadataUrl;
269+
this._scope = scope;
254270

255-
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
271+
const result = await auth(this._authProvider, {
272+
serverUrl: this._url,
273+
resourceMetadataUrl: this._resourceMetadataUrl,
274+
scope: this._scope,
275+
fetchFn: this._fetch,
276+
});
256277
if (result !== "AUTHORIZED") {
257278
throw new UnauthorizedError();
258279
}

src/client/streamableHttp.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Transport, FetchLike } from "../shared/transport.js";
22
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
3-
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
3+
import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from "./auth.js";
44
import { EventSourceParserStream } from "eventsource-parser/stream";
55

66
// Default reconnection options for StreamableHTTP connections
@@ -125,6 +125,7 @@ export class StreamableHTTPClientTransport implements Transport {
125125
private _abortController?: AbortController;
126126
private _url: URL;
127127
private _resourceMetadataUrl?: URL;
128+
private _scope?: string;
128129
private _requestInit?: RequestInit;
129130
private _authProvider?: OAuthClientProvider;
130131
private _fetch?: FetchLike;
@@ -143,6 +144,7 @@ export class StreamableHTTPClientTransport implements Transport {
143144
) {
144145
this._url = url;
145146
this._resourceMetadataUrl = undefined;
147+
this._scope = undefined;
146148
this._requestInit = opts?.requestInit;
147149
this._authProvider = opts?.authProvider;
148150
this._fetch = opts?.fetch;
@@ -157,7 +159,12 @@ export class StreamableHTTPClientTransport implements Transport {
157159

158160
let result: AuthResult;
159161
try {
160-
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
162+
result = await auth(this._authProvider, {
163+
serverUrl: this._url,
164+
resourceMetadataUrl: this._resourceMetadataUrl,
165+
scope: this._scope,
166+
fetchFn: this._fetch,
167+
});
161168
} catch (error) {
162169
this.onerror?.(error as Error);
163170
throw error;
@@ -393,7 +400,12 @@ export class StreamableHTTPClientTransport implements Transport {
393400
throw new UnauthorizedError("No auth provider");
394401
}
395402

396-
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
403+
const result = await auth(this._authProvider, {
404+
serverUrl: this._url, authorizationCode,
405+
resourceMetadataUrl: this._resourceMetadataUrl,
406+
scope: this._scope,
407+
fetchFn: this._fetch,
408+
});
397409
if (result !== "AUTHORIZED") {
398410
throw new UnauthorizedError("Failed to authorize");
399411
}
@@ -443,9 +455,16 @@ export class StreamableHTTPClientTransport implements Transport {
443455
throw new StreamableHTTPError(401, "Server returned 401 after successful authentication");
444456
}
445457

446-
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
458+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
459+
this._resourceMetadataUrl = resourceMetadataUrl;
460+
this._scope = scope;
447461

448-
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
462+
const result = await auth(this._authProvider, {
463+
serverUrl: this._url,
464+
resourceMetadataUrl: this._resourceMetadataUrl,
465+
scope: this._scope,
466+
fetchFn: this._fetch,
467+
});
449468
if (result !== "AUTHORIZED") {
450469
throw new UnauthorizedError();
451470
}

0 commit comments

Comments
 (0)