fix: Support WWW-Authenticate scope param for SEP-835 (#983)

Co-authored-by: OtotheA <oscar@otothea.com>
This commit is contained in:
Chip 2025-11-03 09:24:42 -06:00 • committed by GitHub
commit ee9fed28cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 118 additions and 74 deletions

View file

@ -8,7 +8,7 @@ import {
refreshAuthorization,
registerClient,
discoverOAuthProtectedResourceMetadata,
extractResourceMetadataUrl,
extractWWWAuthenticateParams,
auth,
type OAuthClientProvider,
selectClientAuthMethod
@ -25,7 +25,7 @@ describe('OAuth Authorization', () => {
mockFetch.mockReset();
});
describe('extractResourceMetadataUrl', () => {
describe('extractWWWAuthenticateParams', () => {
it('returns resource metadata url when present', async () => {
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource';
const mockResponse = {
@ -34,39 +34,56 @@ describe('OAuth Authorization', () => {
}
} as unknown as Response;
expect(extractResourceMetadataUrl(mockResponse)).toEqual(new URL(resourceUrl));
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ resourceMetadataUrl: new URL(resourceUrl) });
});
it('returns undefined if not bearer', async () => {
it('returns scope when present', async () => {
const scope = 'read';
const mockResponse = {
headers: {
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", scope="${scope}"` : null))
}
} as unknown as Response;
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
});
it('returns empty object if not bearer', async () => {
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource';
const scope = 'read';
const mockResponse = {
headers: {
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null))
get: jest.fn(name =>
name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null
)
}
} as unknown as Response;
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({});
});
it('returns undefined if resource_metadata not present', async () => {
it('returns empty object if resource_metadata and scope not present', async () => {
const mockResponse = {
headers: {
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Basic realm="mcp"` : null))
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp"` : null))
}
} as unknown as Response;
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({});
});
it('returns undefined on invalid url', async () => {
it('returns undefined resourceMetadataUrl on invalid url', async () => {
const resourceUrl = 'invalid-url';
const scope = 'read';
const mockResponse = {
headers: {
get: jest.fn(name => (name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null))
get: jest.fn(name =>
name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null
)
}
} as unknown as Response;
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
});
});

View file

@ -480,8 +480,46 @@ export async function selectResourceURL(
return new URL(resourceMetadata.resource);
}
/**
* Extract resource_metadata and scope from WWW-Authenticate header.
*/
export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string } {
const authenticateHeader = res.headers.get('WWW-Authenticate');
if (!authenticateHeader) {
return {};
}
const [type, scheme] = authenticateHeader.split(' ');
if (type.toLowerCase() !== 'bearer' || !scheme) {
return {};
}
const resourceMetadataRegex = /resource_metadata="([^"]*)"/;
const resourceMetadataMatch = resourceMetadataRegex.exec(authenticateHeader);
const scopeRegex = /scope="([^"]*)"/;
const scopeMatch = scopeRegex.exec(authenticateHeader);
let resourceMetadataUrl: URL | undefined;
if (resourceMetadataMatch) {
try {
resourceMetadataUrl = new URL(resourceMetadataMatch[1]);
} catch {
// Ignore invalid URL
}
}
const scope = scopeMatch?.[1] || undefined;
return {
resourceMetadataUrl,
scope
};
}
/**
* Extract resource_metadata from response header.
* @deprecated Use `extractWWWAuthenticateParams` instead.
*/
export function extractResourceMetadataUrl(res: Response): URL | undefined {
const authenticateHeader = res.headers.get('WWW-Authenticate');

View file

@ -7,14 +7,14 @@ jest.mock('../client/auth.js', () => {
return {
...actual,
auth: jest.fn(),
extractResourceMetadataUrl: jest.fn()
extractWWWAuthenticateParams: jest.fn()
};
});
import { auth, extractResourceMetadataUrl } from './auth.js';
import { auth, extractWWWAuthenticateParams } from './auth.js';
const mockAuth = auth as jest.MockedFunction<typeof auth>;
const mockExtractResourceMetadataUrl = extractResourceMetadataUrl as jest.MockedFunction<typeof extractResourceMetadataUrl>;
const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as jest.MockedFunction<typeof extractWWWAuthenticateParams>;
describe('withOAuth', () => {
let mockProvider: jest.Mocked<OAuthClientProvider>;
@ -129,8 +129,11 @@ describe('withOAuth', () => {
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
const mockResourceUrl = new URL('https://oauth.example.com/.well-known/oauth-protected-resource');
mockExtractResourceMetadataUrl.mockReturnValue(mockResourceUrl);
const mockWWWAuthenticateParams = {
resourceMetadataUrl: new URL('https://oauth.example.com/.well-known/oauth-protected-resource'),
scope: 'read'
};
mockExtractWWWAuthenticateParams.mockReturnValue(mockWWWAuthenticateParams);
mockAuth.mockResolvedValue('AUTHORIZED');
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
@ -141,7 +144,8 @@ describe('withOAuth', () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: mockResourceUrl,
resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl,
scope: mockWWWAuthenticateParams.scope,
fetchFn: mockFetch
});
@ -172,8 +176,11 @@ describe('withOAuth', () => {
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
const mockResourceUrl = new URL('https://oauth.example.com/.well-known/oauth-protected-resource');
mockExtractResourceMetadataUrl.mockReturnValue(mockResourceUrl);
const mockWWWAuthenticateParams = {
resourceMetadataUrl: new URL('https://oauth.example.com/.well-known/oauth-protected-resource'),
scope: 'read'
};
mockExtractWWWAuthenticateParams.mockReturnValue(mockWWWAuthenticateParams);
mockAuth.mockResolvedValue('AUTHORIZED');
// Test without baseUrl - should extract from request URL
@ -185,7 +192,8 @@ describe('withOAuth', () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com', // Should be extracted from request URL
resourceMetadataUrl: mockResourceUrl,
resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl,
scope: mockWWWAuthenticateParams.scope,
fetchFn: mockFetch
});
@ -203,7 +211,7 @@ describe('withOAuth', () => {
});
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
mockExtractResourceMetadataUrl.mockReturnValue(undefined);
mockExtractWWWAuthenticateParams.mockReturnValue({});
mockAuth.mockResolvedValue('REDIRECT');
// Test without baseUrl
@ -222,7 +230,7 @@ describe('withOAuth', () => {
});
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
mockExtractResourceMetadataUrl.mockReturnValue(undefined);
mockExtractWWWAuthenticateParams.mockReturnValue({});
mockAuth.mockRejectedValue(new Error('Network error'));
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
@ -239,7 +247,7 @@ describe('withOAuth', () => {
// Always return 401
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
mockExtractResourceMetadataUrl.mockReturnValue(undefined);
mockExtractWWWAuthenticateParams.mockReturnValue({});
mockAuth.mockResolvedValue('AUTHORIZED');
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
@ -345,7 +353,7 @@ describe('withOAuth', () => {
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractResourceMetadataUrl.mockReturnValue(undefined);
mockExtractWWWAuthenticateParams.mockReturnValue({});
mockAuth.mockResolvedValue('AUTHORIZED');
const enhancedFetch = withOAuth(mockProvider)(mockFetch);
@ -876,7 +884,10 @@ describe('Integration Tests', () => {
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractResourceMetadataUrl.mockReturnValue(new URL('https://auth.example.com/.well-known/oauth-protected-resource'));
mockExtractWWWAuthenticateParams.mockReturnValue({
resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'),
scope: 'read'
});
mockAuth.mockResolvedValue('AUTHORIZED');
// Use custom logger to avoid console output
@ -896,6 +907,7 @@ describe('Integration Tests', () => {
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://mcp-server.example.com',
resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'),
scope: 'read',
fetchFn: mockFetch
});
});

View file

@ -1,4 +1,4 @@
import { auth, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js';
import { auth, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js';
import { FetchLike } from '../shared/transport.js';
/**
@ -54,7 +54,7 @@ export const withOAuth =
// Handle 401 responses by attempting re-authentication
if (response.status === 401) {
try {
const resourceMetadataUrl = extractResourceMetadataUrl(response);
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
// Use provided baseUrl or extract from request URL
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
@ -62,6 +62,7 @@ export const withOAuth =
const result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
fetchFn: next
});

View file

@ -1,7 +1,7 @@
import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource';
import { Transport, FetchLike } from '../shared/transport.js';
import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js';
import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js';
export class SseError extends Error {
constructor(
@ -65,6 +65,7 @@ export class SSEClientTransport implements Transport {
private _abortController?: AbortController;
private _url: URL;
private _resourceMetadataUrl?: URL;
private _scope?: string;
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
@ -78,6 +79,7 @@ export class SSEClientTransport implements Transport {
constructor(url: URL, opts?: SSEClientTransportOptions) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._scope = undefined;
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
@ -94,6 +96,7 @@ export class SSEClientTransport implements Transport {
result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
});
} catch (error) {
@ -137,7 +140,9 @@ export class SSEClientTransport implements Transport {
});
if (response.status === 401 && response.headers.has('www-authenticate')) {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
}
return response;
@ -214,6 +219,7 @@ export class SSEClientTransport implements Transport {
serverUrl: this._url,
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
});
if (result !== 'AUTHORIZED') {
@ -246,11 +252,14 @@ export class SSEClientTransport implements Transport {
const response = await (this._fetch ?? fetch)(this._endpoint, init);
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
const result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
});
if (result !== 'AUTHORIZED') {

View file

@ -1,6 +1,6 @@
import { Transport, FetchLike } from '../shared/transport.js';
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js';
import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js';
import { EventSourceParserStream } from 'eventsource-parser/stream';
// Default reconnection options for StreamableHTTP connections
@ -125,6 +125,7 @@ export class StreamableHTTPClientTransport implements Transport {
private _abortController?: AbortController;
private _url: URL;
private _resourceMetadataUrl?: URL;
private _scope?: string;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _fetch?: FetchLike;
@ -140,6 +141,7 @@ export class StreamableHTTPClientTransport implements Transport {
constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._scope = undefined;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._fetch = opts?.fetch;
@ -157,6 +159,7 @@ export class StreamableHTTPClientTransport implements Transport {
result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
});
} catch (error) {
@ -381,6 +384,7 @@ export class StreamableHTTPClientTransport implements Transport {
serverUrl: this._url,
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
});
if (result !== 'AUTHORIZED') {
@ -437,11 +441,14 @@ export class StreamableHTTPClientTransport implements Transport {
throw new StreamableHTTPError(401, 'Server returned 401 after successful authentication');
}
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
const result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
});
if (result !== 'AUTHORIZED') {

View file

@ -218,38 +218,6 @@ describe('Authorization Handler', () => {
});
});
describe('Scope validation', () => {
it('validates requested scopes against client registered scopes', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
scope: 'profile email admin' // 'admin' not in client scopes
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.get('error')).toBe('invalid_scope');
});
it('accepts valid scopes subset', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
scope: 'profile' // subset of client scopes
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.has('code')).toBe(true);
});
});
describe('Resource parameter validation', () => {
it('propagates resource parameter', async () => {
const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize');

View file

@ -4,7 +4,7 @@ import express from 'express';
import { OAuthServerProvider } from '../provider.js';
import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit';
import { allowedMethods } from '../middleware/allowedMethods.js';
import { InvalidRequestError, InvalidClientError, InvalidScopeError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js';
import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js';
export type AuthorizationHandlerOptions = {
provider: OAuthServerProvider;
@ -120,14 +120,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
let requestedScopes: string[] = [];
if (scope !== undefined) {
requestedScopes = scope.split(' ');
const allowedScopes = new Set(client.scope?.split(' '));
// Check each requested scope against allowed scopes
for (const scope of requestedScopes) {
if (!allowedScopes.has(scope)) {
throw new InvalidScopeError(`Client was not registered with scope ${scope}`);
}
}
}
// All validation passed, proceed with authorization