Skip to content

Commit 3485a06

Browse files
Nayana-Parameswarappapcarletonclaude
authored
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>
1 parent fc4a6ec commit 3485a06

File tree

4 files changed

+172
-10
lines changed

4 files changed

+172
-10
lines changed

src/client/auth.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ describe('OAuth Authorization', () => {
9595

9696
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
9797
});
98+
99+
it('returns error when present', async () => {
100+
const mockResponse = {
101+
headers: {
102+
get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer error="insufficient_scope", scope="admin"` : null))
103+
}
104+
} as unknown as Response;
105+
106+
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' });
107+
});
98108
});
99109

100110
describe('discoverOAuthProtectedResourceMetadata', () => {

src/client/auth.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -522,9 +522,9 @@ export async function selectResourceURL(
522522
}
523523

524524
/**
525-
* Extract resource_metadata and scope from WWW-Authenticate header.
525+
* Extract resource_metadata, scope, and error from WWW-Authenticate header.
526526
*/
527-
export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string } {
527+
export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } {
528528
const authenticateHeader = res.headers.get('WWW-Authenticate');
529529
if (!authenticateHeader) {
530530
return {};
@@ -535,29 +535,51 @@ export function extractWWWAuthenticateParams(res: Response): { resourceMetadataU
535535
return {};
536536
}
537537

538-
const resourceMetadataRegex = /resource_metadata="([^"]*)"/;
539-
const resourceMetadataMatch = resourceMetadataRegex.exec(authenticateHeader);
540-
541-
const scopeRegex = /scope="([^"]*)"/;
542-
const scopeMatch = scopeRegex.exec(authenticateHeader);
538+
const resourceMetadataMatch = extractFieldFromWwwAuth(res, 'resource_metadata') || undefined;
543539

544540
let resourceMetadataUrl: URL | undefined;
545541
if (resourceMetadataMatch) {
546542
try {
547-
resourceMetadataUrl = new URL(resourceMetadataMatch[1]);
543+
resourceMetadataUrl = new URL(resourceMetadataMatch);
548544
} catch {
549545
// Ignore invalid URL
550546
}
551547
}
552548

553-
const scope = scopeMatch?.[1] || undefined;
549+
const scope = extractFieldFromWwwAuth(res, 'scope') || undefined;
550+
const error = extractFieldFromWwwAuth(res, 'error') || undefined;
554551

555552
return {
556553
resourceMetadataUrl,
557-
scope
554+
scope,
555+
error
558556
};
559557
}
560558

559+
/**
560+
* Extracts a specific field's value from the WWW-Authenticate header string.
561+
*
562+
* @param response The HTTP response object containing the headers.
563+
* @param fieldName The name of the field to extract (e.g., "realm", "nonce").
564+
* @returns The field value
565+
*/
566+
function extractFieldFromWwwAuth(response: Response, fieldName: string): string | null {
567+
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
568+
if (!wwwAuthHeader) {
569+
return null;
570+
}
571+
572+
const pattern = new RegExp(`${fieldName}=(?:"([^"]+)"|([^\\s,]+))`);
573+
const match = wwwAuthHeader.match(pattern);
574+
575+
if (match) {
576+
// Pattern matches: field_name="value" or field_name=value (unquoted)
577+
return match[1] || match[2];
578+
}
579+
580+
return null;
581+
}
582+
561583
/**
562584
* Extract resource_metadata from response header.
563585
* @deprecated Use `extractWWWAuthenticateParams` instead.

src/client/streamableHttp.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,98 @@ describe('StreamableHTTPClientTransport', () => {
593593
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
594594
});
595595

596+
it('attempts upscoping on 403 with WWW-Authenticate header', async () => {
597+
const message: JSONRPCMessage = {
598+
jsonrpc: '2.0',
599+
method: 'test',
600+
params: {},
601+
id: 'test-id'
602+
};
603+
604+
const fetchMock = global.fetch as Mock;
605+
fetchMock
606+
// First call: returns 403 with insufficient_scope
607+
.mockResolvedValueOnce({
608+
ok: false,
609+
status: 403,
610+
statusText: 'Forbidden',
611+
headers: new Headers({
612+
'WWW-Authenticate':
613+
'Bearer error="insufficient_scope", scope="new_scope", resource_metadata="http://example.com/resource"'
614+
}),
615+
text: () => Promise.resolve('Insufficient scope')
616+
})
617+
// Second call: successful after upscoping
618+
.mockResolvedValueOnce({
619+
ok: true,
620+
status: 202,
621+
headers: new Headers()
622+
});
623+
624+
// Spy on the imported auth function and mock successful authorization
625+
const authModule = await import('./auth.js');
626+
const authSpy = vi.spyOn(authModule, 'auth');
627+
authSpy.mockResolvedValue('AUTHORIZED');
628+
629+
await transport.send(message);
630+
631+
// Verify fetch was called twice
632+
expect(fetchMock).toHaveBeenCalledTimes(2);
633+
634+
// Verify auth was called with the new scope
635+
expect(authSpy).toHaveBeenCalledWith(
636+
mockAuthProvider,
637+
expect.objectContaining({
638+
scope: 'new_scope',
639+
resourceMetadataUrl: new URL('http://example.com/resource')
640+
})
641+
);
642+
643+
authSpy.mockRestore();
644+
});
645+
646+
it('prevents infinite upscoping on repeated 403', async () => {
647+
const message: JSONRPCMessage = {
648+
jsonrpc: '2.0',
649+
method: 'test',
650+
params: {},
651+
id: 'test-id'
652+
};
653+
654+
// Mock fetch calls to always return 403 with insufficient_scope
655+
const fetchMock = global.fetch as Mock;
656+
fetchMock.mockResolvedValue({
657+
ok: false,
658+
status: 403,
659+
statusText: 'Forbidden',
660+
headers: new Headers({
661+
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="new_scope"'
662+
}),
663+
text: () => Promise.resolve('Insufficient scope')
664+
});
665+
666+
// Spy on the imported auth function and mock successful authorization
667+
const authModule = await import('./auth.js');
668+
const authSpy = vi.spyOn(authModule, 'auth');
669+
authSpy.mockResolvedValue('AUTHORIZED');
670+
671+
// First send: should trigger upscoping
672+
await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping');
673+
674+
expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth
675+
expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once
676+
677+
// Second send: should fail immediately without re-calling auth
678+
fetchMock.mockClear();
679+
authSpy.mockClear();
680+
await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping');
681+
682+
expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call
683+
expect(authSpy).not.toHaveBeenCalled(); // Auth not called again
684+
685+
authSpy.mockRestore();
686+
});
687+
596688
describe('Reconnection Logic', () => {
597689
let transport: StreamableHTTPClientTransport;
598690

src/client/streamableHttp.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export class StreamableHTTPClientTransport implements Transport {
134134
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
135135
private _protocolVersion?: string;
136136
private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401
137+
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
137138

138139
onclose?: () => void;
139140
onerror?: (error: Error) => void;
@@ -452,12 +453,49 @@ export class StreamableHTTPClientTransport implements Transport {
452453
return this.send(message);
453454
}
454455

456+
if (response.status === 403 && this._authProvider) {
457+
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
458+
459+
if (error === 'insufficient_scope') {
460+
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
461+
462+
// Check if we've already tried upscoping with this header to prevent infinite loops.
463+
if (this._lastUpscopingHeader === wwwAuthHeader) {
464+
throw new StreamableHTTPError(403, 'Server returned 403 after trying upscoping');
465+
}
466+
467+
if (scope) {
468+
this._scope = scope;
469+
}
470+
471+
if (resourceMetadataUrl) {
472+
this._resourceMetadataUrl = resourceMetadataUrl;
473+
}
474+
475+
// Mark that upscoping was tried.
476+
this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
477+
const result = await auth(this._authProvider, {
478+
serverUrl: this._url,
479+
resourceMetadataUrl: this._resourceMetadataUrl,
480+
scope: this._scope,
481+
fetchFn: this._fetch
482+
});
483+
484+
if (result !== 'AUTHORIZED') {
485+
throw new UnauthorizedError();
486+
}
487+
488+
return this.send(message);
489+
}
490+
}
491+
455492
const text = await response.text().catch(() => null);
456493
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`);
457494
}
458495

459496
// Reset auth loop flag on successful response
460497
this._hasCompletedAuthFlow = false;
498+
this._lastUpscopingHeader = undefined;
461499

462500
// If the response is 202 Accepted, there's no body to process
463501
if (response.status === 202) {

0 commit comments

Comments
 (0)