Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
73 changes: 72 additions & 1 deletion src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1585,10 +1585,81 @@ describe('OAuth Authorization', () => {
// First call should be to protected resource metadata
expect(mockFetch.mock.calls[0][0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');

// Second call should be to oauth metadata
// Second call should be to oauth metadata at the root path
expect(mockFetch.mock.calls[1][0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server');
});

it('uses base URL (with root path) as authorization server when protected-resource-metadata discovery fails', async () => {
// Setup: First call to protected resource metadata fails (404)
// When no authorization_servers are found in protected resource metadata,
// the auth server URL should be set to the base URL with "/" path
let callCount = 0;
mockFetch.mockImplementation(url => {
callCount++;

const urlString = url.toString();

if (urlString.includes('/.well-known/oauth-protected-resource')) {
// Protected resource metadata discovery attempts (both path-aware and root) fail with 404
return Promise.resolve({
ok: false,
status: 404
});
} else if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') {
// Should fetch from base URL with root path, not the full serverUrl path
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://resource.example.com/',
authorization_endpoint: 'https://resource.example.com/authorize',
token_endpoint: 'https://resource.example.com/token',
registration_endpoint: 'https://resource.example.com/register',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
});
} else if (urlString.includes('/register')) {
// Client registration succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
client_id: 'test-client-id',
client_secret: 'test-client-secret',
client_id_issued_at: 1612137600,
client_secret_expires_at: 1612224000,
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
})
});
}

return Promise.reject(new Error(`Unexpected fetch call #${callCount}: ${urlString}`));
});

// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
mockProvider.saveClientInformation = jest.fn();

// Call the auth function with a server URL that has a path
const result = await auth(mockProvider, {
serverUrl: 'https://resource.example.com/path/to/server'
});

// Verify the result
expect(result).toBe('REDIRECT');

// Verify that the oauth-authorization-server call uses the base URL
// This proves the fix: using new URL("/", serverUrl) instead of serverUrl
const authServerCall = mockFetch.mock.calls.find(call =>
call[0].toString().includes('/.well-known/oauth-authorization-server')
);
expect(authServerCall).toBeDefined();
expect(authServerCall[0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server');
});

it('passes resource parameter through authorization flow', async () => {
// Mock successful metadata discovery - need to include protected resource metadata
mockFetch.mockImplementation(url => {
Expand Down
5 changes: 3 additions & 2 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ async function authInternal(
): Promise<AuthResult> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | URL | undefined;

try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn);
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
Expand All @@ -359,10 +360,10 @@ async function authInternal(

/**
* If we don't get a valid authorization server metadata from protected resource metadata,
* fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server acts as the Authorization server.
* fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server.
*/
if (!authorizationServerUrl) {
authorizationServerUrl = serverUrl;
authorizationServerUrl = new URL('/', serverUrl);
}

const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
Expand Down
Loading