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:
parent
fc4a6ecb40
commit
3485a06f85
4 changed files with 172 additions and 10 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue