Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 135 additions & 2 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import {
refreshAuthorization,
registerClient,
discoverOAuthProtectedResourceMetadata,
extractFieldFromWwwAuth,
extractWWWAuthenticateParams,
auth,
type OAuthClientProvider,
selectClientAuthMethod
} from './auth.js';
import { ServerError } from '../server/auth/errors.js';
import { AuthorizationServerMetadata } from '../shared/auth.js';
import { AuthorizationServerMetadata, OAuthClientMetadata } from '../shared/auth.js';
import { expect, vi, type Mock } from 'vitest';

// Mock pkce-challenge
Expand All @@ -34,6 +35,50 @@ describe('OAuth Authorization', () => {
mockFetch.mockReset();
});

describe('extractFieldFromWwwAuth', () => {
function mockResponseWithWWWAuthenticate(headerValue: string): Response {
return {
headers: {
get: vi.fn(name => (name === 'WWW-Authenticate' ? headerValue : null))
}
} as unknown as Response;
}

it('returns the value of a quoted field', () => {
const mockResponse = mockResponseWithWWWAuthenticate(`Bearer realm="example", field="value"`);
expect(extractFieldFromWwwAuth(mockResponse, 'field')).toBe('value');
});

it('returns the value of an unquoted field', () => {
const mockResponse = mockResponseWithWWWAuthenticate(`Bearer realm=example, field=value`);
expect(extractFieldFromWwwAuth(mockResponse, 'field')).toBe('value');
});

it('returns the correct value when multiple parameters are present', () => {
const mockResponse = mockResponseWithWWWAuthenticate(
`Bearer realm="api", error="invalid_token", field="test_value", scope="admin"`
);
expect(extractFieldFromWwwAuth(mockResponse, 'field')).toBe('test_value');
});

it('returns null if the field is not present', () => {
const mockResponse = mockResponseWithWWWAuthenticate(`Bearer realm="api", scope="admin"`);
expect(extractFieldFromWwwAuth(mockResponse, 'missing_field')).toBeNull();
});

it('returns null if the WWW-Authenticate header is missing', () => {
const mockResponse = { headers: new Headers() } as unknown as Response;
expect(extractFieldFromWwwAuth(mockResponse, 'field')).toBeNull();
});

it('handles fields with special characters in quotes', () => {
const mockResponse = mockResponseWithWWWAuthenticate(
`Bearer error="invalid_token", error_description="The token has expired, please re-authenticate."`
);
expect(extractFieldFromWwwAuth(mockResponse, 'error_description')).toBe('The token has expired, please re-authenticate.');
});
});

describe('extractWWWAuthenticateParams', () => {
it('returns resource metadata url when present', async () => {
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource';
Expand Down Expand Up @@ -1507,14 +1552,17 @@ describe('OAuth Authorization', () => {
});

describe('auth function', () => {
let clientMetadataScope: string | undefined = undefined;

const mockProvider: OAuthClientProvider = {
get redirectUrl() {
return 'http://localhost:3000/callback';
},
get clientMetadata() {
return {
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
client_name: 'Test Client',
scope: clientMetadataScope
};
},
clientInformation: vi.fn(),
Expand Down Expand Up @@ -2424,6 +2472,91 @@ describe('OAuth Authorization', () => {
// Verify custom fetch was called for AS metadata discovery
expect(customFetch.mock.calls[1][0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
});

it('prioritizes provided scope over resourceMetadata.scope', async () => {
const providedScope = 'provided_scope';
(mockProvider.clientMetadata as OAuthClientMetadata).scope = 'client_metadata_scope';

mockFetch.mockImplementation(url => {
if (url.toString().includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: 'https://api.example.com/mcp-server',
scopes_supported: ['read', 'write'],
authorization_servers: ['https://auth.example.com']
})
});
}
return Promise.resolve({ ok: false, status: 404 });
});

await auth(mockProvider, {
serverUrl: 'https://api.example.com/mcp-server',
scope: providedScope
});

const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get('scope')).toBe(providedScope);
});

it('uses resourceMetadata.scope when provided scope is missing', async () => {
const resourceScope = 'resource_metadata_scope';
(mockProvider.clientMetadata as OAuthClientMetadata).scope = 'client_metadata_scope';

mockFetch.mockImplementation(url => {
if (url.toString().includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: 'https://api.example.com/mcp-server',
scopes_supported: ['resource_metadata_scope'],
authorization_servers: ['https://auth.example.com']
})
});
}
return Promise.resolve({ ok: false, status: 404 });
});

await auth(mockProvider, {
serverUrl: 'https://api.example.com/mcp-server'
});

const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get('scope')).toBe(resourceScope);
});

it('falls back to clientMetadata.scope when provided and resourceMetadata scopes are missing', async () => {
const expectedScope = 'client_metadata_scope';
clientMetadataScope = expectedScope;

mockFetch.mockImplementation(url => {
if (url.toString().includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: 'https://api.example.com/mcp-server',
resource_metadata_scope: [],
authorization_servers: ['https://auth.example.com']
})
});
}
return Promise.resolve({ ok: false, status: 404 });
});

await auth(mockProvider, {
serverUrl: 'https://api.example.com/mcp-server'
});

const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get('scope')).toBe(clientMetadataScope);
});
});

describe('exchangeAuthorization with multiple client authentication methods', () => {
Expand Down
61 changes: 53 additions & 8 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ async function authInternal(
clientInformation,
state,
redirectUrl: provider.redirectUrl,
scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope,
scope: selectScope(provider, resourceMetadata, scope),
resource
});

Expand All @@ -456,6 +456,31 @@ async function authInternal(
return 'REDIRECT';
}

/**
* Selects the appropriate OAuth scope to use.
*
* The priority order is:
* 1. The provided `scope` argument (if available). The scope is usually provided by WWW-authenticate header.
* 2. Protected Resource Metadata scope (if available)
* 3. The `OAuthClientProvider.clientMetadata.scope` (if available)
*/
export function selectScope(
provider: OAuthClientProvider,
resourceMetadata?: OAuthProtectedResourceMetadata,
scope?: string
): string | undefined {
if (scope) {
return scope;
}

const scopes = resourceMetadata?.scopes_supported;
if (scopes && scopes.length > 0) {
return scopes.join(' ');
}

return provider.clientMetadata.scope;
}

export async function selectResourceURL(
serverUrl: string | URL,
provider: OAuthClientProvider,
Expand Down Expand Up @@ -495,29 +520,49 @@ 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;

return {
resourceMetadataUrl,
scope
};
}

/**
* 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
*/
export 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
Loading
Loading