Skip to content
10 changes: 10 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
42 changes: 32 additions & 10 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
Expand All @@ -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.
Expand Down
92 changes: 92 additions & 0 deletions src/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
38 changes: 38 additions & 0 deletions src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading