Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/fix-auth-fallback-non-root-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/client': patch
---

Throw error on auth fallback for non-root AS paths instead of silently using incorrect absolute paths. Fixes URL path prefix loss when authorization server metadata discovery fails.

Fixes modelcontextprotocol/typescript-sdk#1716
57 changes: 49 additions & 8 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,10 @@ async function authInternal(
authorizationServerUrl = cachedState.authorizationServerUrl;
resourceMetadata = cachedState.resourceMetadata;
metadata =
cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }));
cachedState.authorizationServerMetadata ??
(await discoverAuthorizationServerMetadata(authorizationServerUrl, {
fetchFn
}));

// If resource metadata wasn't cached, try to fetch it for selectResourceURL
if (!resourceMetadata) {
Expand Down Expand Up @@ -664,7 +667,10 @@ async function authInternal(
}
} else {
// Full discovery via RFC 9728
const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn });
const serverInfo = await discoverOAuthServerInfo(serverUrl, {
resourceMetadataUrl: effectiveResourceMetadataUrl,
fetchFn
});
authorizationServerUrl = serverInfo.authorizationServerUrl;
metadata = serverInfo.authorizationServerMetadata;
resourceMetadata = serverInfo.resourceMetadata;
Expand Down Expand Up @@ -836,7 +842,12 @@ export async function selectResourceURL(
}

// Validate that the metadata's resource is compatible with our request
if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) {
if (
!checkResourceAllowed({
requestedResource: defaultResource,
configuredResource: resourceMetadata.resource
})
) {
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`);
}
// Prefer the resource from metadata since it's what the server is telling us to request
Expand All @@ -846,7 +857,11 @@ export async function selectResourceURL(
/**
* Extract `resource_metadata`, `scope`, and `error` from `WWW-Authenticate` header.
*/
export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } {
export function extractWWWAuthenticateParams(res: Response): {
resourceMetadataUrl?: URL;
scope?: string;
error?: string;
} {
const authenticateHeader = res.headers.get('WWW-Authenticate');
if (!authenticateHeader) {
return {};
Expand Down Expand Up @@ -1047,7 +1062,11 @@ async function discoverMetadataWithFallback(
serverUrl: string | URL,
wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource',
fetchFn: FetchLike,
opts?: { protocolVersion?: string; metadataUrl?: string | URL; metadataServerUrl?: string | URL }
opts?: {
protocolVersion?: string;
metadataUrl?: string | URL;
metadataServerUrl?: string | URL;
}
): Promise<Response | undefined> {
const issuer = new URL(serverUrl);
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
Expand Down Expand Up @@ -1371,8 +1390,13 @@ export async function startAuthorization(
) {
throw new Error(`Incompatible auth server: does not support code challenge method ${AUTHORIZATION_CODE_CHALLENGE_METHOD}`);
}
} else {
} else if (new URL(authorizationServerUrl).pathname === '/') {
authorizationUrl = new URL('/authorize', authorizationServerUrl);
} else {
throw new Error(
`Authorization server metadata discovery failed and the server URL (${authorizationServerUrl}) has a non-root path. ` +
`Cannot determine the authorization endpoint. Please ensure the authorization server is reachable and supports metadata discovery.`
);
}

// Generate PKCE challenge
Expand Down Expand Up @@ -1454,7 +1478,17 @@ export async function executeTokenRequest(
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl);
let tokenUrl: URL;
if (metadata?.token_endpoint) {
tokenUrl = new URL(metadata.token_endpoint);
} else if (new URL(authorizationServerUrl).pathname === '/') {
tokenUrl = new URL('/token', authorizationServerUrl);
} else {
throw new Error(
`Authorization server metadata discovery failed and the server URL (${authorizationServerUrl}) has a non-root path. ` +
`Cannot determine the token endpoint.`
);
}

const headers = new Headers({
'Content-Type': 'application/x-www-form-urlencoded',
Expand Down Expand Up @@ -1701,7 +1735,14 @@ export async function registerClient(

registrationUrl = new URL(metadata.registration_endpoint);
} else {
registrationUrl = new URL('/register', authorizationServerUrl);
if (new URL(authorizationServerUrl).pathname === '/') {
registrationUrl = new URL('/register', authorizationServerUrl);
} else {
throw new Error(
`Authorization server metadata discovery failed and the server URL (${authorizationServerUrl}) has a non-root path. ` +
`Cannot determine the registration endpoint.`
);
}
}

const response = await (fetchFn ?? fetch)(registrationUrl, {
Expand Down
34 changes: 26 additions & 8 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ describe('OAuth Authorization', () => {
}
} as unknown as Response;

expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ resourceMetadataUrl: new URL(resourceUrl) });
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({
resourceMetadataUrl: new URL(resourceUrl)
});
});

it('returns scope when present', async () => {
Expand All @@ -83,7 +85,9 @@ describe('OAuth Authorization', () => {
}
} as unknown as Response;

expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({
scope: scope
});
});

it('returns empty object if not bearer', async () => {
Expand Down Expand Up @@ -121,7 +125,9 @@ describe('OAuth Authorization', () => {
}
} as unknown as Response;

expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({
scope: scope
});
});

it('returns error when present', async () => {
Expand All @@ -131,7 +137,10 @@ describe('OAuth Authorization', () => {
}
} as unknown as Response;

expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' });
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({
error: 'insufficient_scope',
scope: 'admin'
});
});
});

Expand Down Expand Up @@ -1443,7 +1452,10 @@ describe('OAuth Authorization', () => {
it('defaults to client_secret_basic when server omits token_endpoint_auth_methods_supported (RFC 8414 §2)', () => {
// RFC 8414 §2: if omitted, the default is client_secret_basic.
// RFC 6749 §2.3.1: servers MUST support HTTP Basic for clients with a secret.
const clientInfo = { client_id: 'test-client-id', client_secret: 'test-client-secret' };
const clientInfo = {
client_id: 'test-client-id',
client_secret: 'test-client-secret'
};
const authMethod = selectClientAuthMethod(clientInfo, []);
expect(authMethod).toBe('client_secret_basic');
});
Expand Down Expand Up @@ -2081,7 +2093,10 @@ describe('OAuth Authorization', () => {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' })
body: JSON.stringify({
...validClientMetadata,
scope: 'openid profile'
})
})
);
});
Expand Down Expand Up @@ -3049,8 +3064,11 @@ describe('OAuth Authorization', () => {
authorization_servers: ['https://auth.example.com/oauth']
})
});
} else if (urlString === 'https://auth.example.com/.well-known/oauth-authorization-server/path/name') {
// Path-aware discovery on AS with path from serverUrl
} else if (
urlString === 'https://auth.example.com/.well-known/oauth-authorization-server/path/name' ||
urlString === 'https://auth.example.com/.well-known/oauth-authorization-server/oauth'
) {
// Path-aware discovery on AS with path from serverUrl or AS path
return Promise.resolve({
ok: true,
status: 200,
Expand Down
Loading