Support upscoping on insufficient_scope 403 (#1115)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
This commit is contained in:
Nayana Parameswarappa 2025-11-20 11:37:44 -08:00 • committed by GitHub
commit 3485a06f85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 172 additions and 10 deletions

View file

@ -95,6 +95,16 @@ describe('OAuth Authorization', () => {
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
});
it('returns error when present', async () => {
const mockResponse = {
headers: {
get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer error="insufficient_scope", scope="admin"` : null))
}
} as unknown as Response;
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' });
});
});
describe('discoverOAuthProtectedResourceMetadata', () => {

View file

@ -522,9 +522,9 @@ export async function selectResourceURL(
}
/**
* Extract resource_metadata and scope from WWW-Authenticate header.
* Extract resource_metadata, scope, and error from WWW-Authenticate header.
*/
export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string } {
export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } {
const authenticateHeader = res.headers.get('WWW-Authenticate');
if (!authenticateHeader) {
return {};
@ -535,29 +535,51 @@ export function extractWWWAuthenticateParams(res: Response): { resourceMetadataU
return {};
}
const resourceMetadataRegex = /resource_metadata="([^"]*)"/;
const resourceMetadataMatch = resourceMetadataRegex.exec(authenticateHeader);
const scopeRegex = /scope="([^"]*)"/;
const scopeMatch = scopeRegex.exec(authenticateHeader);
const resourceMetadataMatch = extractFieldFromWwwAuth(res, 'resource_metadata') || undefined;
let resourceMetadataUrl: URL | undefined;
if (resourceMetadataMatch) {
try {
resourceMetadataUrl = new URL(resourceMetadataMatch[1]);
resourceMetadataUrl = new URL(resourceMetadataMatch);
} catch {
// Ignore invalid URL
}
}
const scope = scopeMatch?.[1] || undefined;
const scope = extractFieldFromWwwAuth(res, 'scope') || undefined;
const error = extractFieldFromWwwAuth(res, 'error') || undefined;
return {
resourceMetadataUrl,
scope
scope,
error
};
}
/**
* Extracts a specific field's value from the WWW-Authenticate header string.
*
* @param response The HTTP response object containing the headers.
* @param fieldName The name of the field to extract (e.g., "realm", "nonce").
* @returns The field value
*/
function extractFieldFromWwwAuth(response: Response, fieldName: string): string | null {
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
if (!wwwAuthHeader) {
return null;
}
const pattern = new RegExp(`${fieldName}=(?:"([^"]+)"|([^\\s,]+))`);
const match = wwwAuthHeader.match(pattern);
if (match) {
// Pattern matches: field_name="value" or field_name=value (unquoted)
return match[1] || match[2];
}
return null;
}
/**
* Extract resource_metadata from response header.
* @deprecated Use `extractWWWAuthenticateParams` instead.

View file

@ -593,6 +593,98 @@ describe('StreamableHTTPClientTransport', () => {
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
});
it('attempts upscoping on 403 with WWW-Authenticate header', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
const fetchMock = global.fetch as Mock;
fetchMock
// First call: returns 403 with insufficient_scope
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate':
'Bearer error="insufficient_scope", scope="new_scope", resource_metadata="http://example.com/resource"'
}),
text: () => Promise.resolve('Insufficient scope')
})
// Second call: successful after upscoping
.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});
// Spy on the imported auth function and mock successful authorization
const authModule = await import('./auth.js');
const authSpy = vi.spyOn(authModule, 'auth');
authSpy.mockResolvedValue('AUTHORIZED');
await transport.send(message);
// Verify fetch was called twice
expect(fetchMock).toHaveBeenCalledTimes(2);
// Verify auth was called with the new scope
expect(authSpy).toHaveBeenCalledWith(
mockAuthProvider,
expect.objectContaining({
scope: 'new_scope',
resourceMetadataUrl: new URL('http://example.com/resource')
})
);
authSpy.mockRestore();
});
it('prevents infinite upscoping on repeated 403', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};
// Mock fetch calls to always return 403 with insufficient_scope
const fetchMock = global.fetch as Mock;
fetchMock.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="new_scope"'
}),
text: () => Promise.resolve('Insufficient scope')
});
// Spy on the imported auth function and mock successful authorization
const authModule = await import('./auth.js');
const authSpy = vi.spyOn(authModule, 'auth');
authSpy.mockResolvedValue('AUTHORIZED');
// First send: should trigger upscoping
await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping');
expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth
expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once
// Second send: should fail immediately without re-calling auth
fetchMock.mockClear();
authSpy.mockClear();
await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping');
expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call
expect(authSpy).not.toHaveBeenCalled(); // Auth not called again
authSpy.mockRestore();
});
describe('Reconnection Logic', () => {
let transport: StreamableHTTPClientTransport;

View file

@ -134,6 +134,7 @@ export class StreamableHTTPClientTransport implements Transport {
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
private _protocolVersion?: string;
private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
onclose?: () => void;
onerror?: (error: Error) => void;
@ -452,12 +453,49 @@ export class StreamableHTTPClientTransport implements Transport {
return this.send(message);
}
if (response.status === 403 && this._authProvider) {
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
if (error === 'insufficient_scope') {
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
// Check if we've already tried upscoping with this header to prevent infinite loops.
if (this._lastUpscopingHeader === wwwAuthHeader) {
throw new StreamableHTTPError(403, 'Server returned 403 after trying upscoping');
}
if (scope) {
this._scope = scope;
}
if (resourceMetadataUrl) {
this._resourceMetadataUrl = resourceMetadataUrl;
}
// Mark that upscoping was tried.
this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
const result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();
}
return this.send(message);
}
}
const text = await response.text().catch(() => null);
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`);
}
// Reset auth loop flag on successful response
this._hasCompletedAuthFlow = false;
this._lastUpscopingHeader = undefined;
// If the response is 202 Accepted, there's no body to process
if (response.status === 202) {