diff --git a/package-lock.json b/package-lock.json index bb5518e84..43a2321d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1318,6 +1318,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.11.0", "@typescript-eslint/types": "8.11.0", @@ -1717,6 +1718,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2285,6 +2287,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -4271,6 +4274,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4362,6 +4366,7 @@ "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4407,6 +4412,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4608,6 +4614,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4621,6 +4628,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4773,6 +4781,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 3cd717614..28edd2a44 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -750,7 +750,7 @@ describe('OAuth Authorization', () => { it('generates correct URLs for server with path', () => { const urls = buildDiscoveryUrls('https://auth.example.com/tenant1'); - expect(urls).toHaveLength(3); + expect(urls).toHaveLength(4); expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([ { url: 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', @@ -763,6 +763,10 @@ describe('OAuth Authorization', () => { { url: 'https://auth.example.com/tenant1/.well-known/openid-configuration', type: 'oidc' + }, + { + url: 'https://auth.example.com/.well-known/oauth-authorization-server', + type: 'oauth' } ]); }); @@ -770,9 +774,22 @@ describe('OAuth Authorization', () => { it('handles URL object input', () => { const urls = buildDiscoveryUrls(new URL('https://auth.example.com/tenant1')); - expect(urls).toHaveLength(3); + expect(urls).toHaveLength(4); expect(urls[0].url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/tenant1'); }); + + it('includes root fallback for URLs with path (RFC 9470 workaround)', () => { + // Some servers incorrectly set authorization_servers to an endpoint path + // (e.g., "/api/auth") instead of the issuer URL, violating RFC 9470. + // The root fallback allows discovery to succeed in these cases. + const urls = buildDiscoveryUrls('https://example.com/api/auth'); + + // Last URL should be the root fallback + expect(urls[urls.length - 1]).toEqual({ + url: new URL('https://example.com/.well-known/oauth-authorization-server'), + type: 'oauth' + }); + }); }); describe('discoverAuthorizationServerMetadata', () => { @@ -912,7 +929,8 @@ describe('OAuth Authorization', () => { expect(metadata).toBeUndefined(); // Verify that all discovery URLs were attempted - expect(mockFetch).toHaveBeenCalledTimes(6); // 3 URLs × 2 attempts each (with and without headers) + // 4 URLs (3 path-based + 1 root fallback) × 2 attempts each (with and without headers) + expect(mockFetch).toHaveBeenCalledTimes(8); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 4c82b5114..7eac45755 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -816,8 +816,9 @@ export async function discoverOAuthMetadata( /** * Builds a list of discovery URLs to try for authorization server metadata. * URLs are returned in priority order: - * 1. OAuth metadata at the given URL + * 1. OAuth metadata at the given URL (path-suffixed if URL has path) * 2. OIDC metadata endpoints at the given URL + * 3. Fallback to root OAuth metadata (when URL has path) */ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: URL; type: 'oauth' | 'oidc' }[] { const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl; @@ -866,6 +867,15 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: type: 'oidc' }); + // Fallback: Try root path discovery when path-based discovery fails. + // This handles cases where authorization_servers contains an endpoint path + // (e.g., "/api/auth") instead of the issuer URL, which violates RFC 9470 + // but occurs in practice with some MCP server implementations (better-auth) apparently. + urlsToTry.push({ + url: new URL('/.well-known/oauth-authorization-server', url.origin), + type: 'oauth' + }); + return urlsToTry; }