Skip to content

Commit 9dfd025

Browse files
authored
Merge pull request #752 from modelcontextprotocol/ochafik/fix-path-as
auth: fetch AS metadata in well-known subpath from serverUrl even when PRM returns external AS
2 parents 442e13b + 1e32f14 commit 9dfd025

File tree

2 files changed

+87
-7
lines changed

2 files changed

+87
-7
lines changed

src/client/auth.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,6 +1747,64 @@ describe("OAuth Authorization", () => {
17471747
expect(body.get("grant_type")).toBe("refresh_token");
17481748
expect(body.get("refresh_token")).toBe("refresh123");
17491749
});
1750+
1751+
it("fetches AS metadata with path from serverUrl when PRM returns external AS", async () => {
1752+
// Mock PRM discovery that returns an external AS
1753+
mockFetch.mockImplementation((url) => {
1754+
const urlString = url.toString();
1755+
1756+
if (urlString === "https://my.resource.com/.well-known/oauth-protected-resource/path/name") {
1757+
return Promise.resolve({
1758+
ok: true,
1759+
status: 200,
1760+
json: async () => ({
1761+
resource: "https://my.resource.com/",
1762+
authorization_servers: ["https://auth.example.com/"],
1763+
}),
1764+
});
1765+
} else if (urlString === "https://auth.example.com/.well-known/oauth-authorization-server/path/name") {
1766+
// Path-aware discovery on AS with path from serverUrl
1767+
return Promise.resolve({
1768+
ok: true,
1769+
status: 200,
1770+
json: async () => ({
1771+
issuer: "https://auth.example.com",
1772+
authorization_endpoint: "https://auth.example.com/authorize",
1773+
token_endpoint: "https://auth.example.com/token",
1774+
response_types_supported: ["code"],
1775+
code_challenge_methods_supported: ["S256"],
1776+
}),
1777+
});
1778+
}
1779+
1780+
return Promise.resolve({ ok: false, status: 404 });
1781+
});
1782+
1783+
// Mock provider methods
1784+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
1785+
client_id: "test-client",
1786+
client_secret: "test-secret",
1787+
});
1788+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
1789+
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
1790+
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
1791+
1792+
// Call auth with serverUrl that has a path
1793+
const result = await auth(mockProvider, {
1794+
serverUrl: "https://my.resource.com/path/name",
1795+
});
1796+
1797+
expect(result).toBe("REDIRECT");
1798+
1799+
// Verify the correct URLs were fetched
1800+
const calls = mockFetch.mock.calls;
1801+
1802+
// First call should be to PRM
1803+
expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource/path/name");
1804+
1805+
// Second call should be to AS metadata with the path from serverUrl
1806+
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
1807+
});
17501808
});
17511809

17521810
describe("exchangeAuthorization with multiple client authentication methods", () => {

src/client/auth.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,9 @@ export async function auth(
251251

252252
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
253253

254-
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
254+
const metadata = await discoverOAuthMetadata(serverUrl, {
255+
authorizationServerUrl
256+
});
255257

256258
// Handle client registration if needed
257259
let clientInformation = await Promise.resolve(provider.clientInformation());
@@ -469,7 +471,7 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string)
469471
async function discoverMetadataWithFallback(
470472
serverUrl: string | URL,
471473
wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource',
472-
opts?: { protocolVersion?: string; metadataUrl?: string | URL },
474+
opts?: { protocolVersion?: string; metadataUrl?: string | URL, metadataServerUrl?: string | URL },
473475
): Promise<Response | undefined> {
474476
const issuer = new URL(serverUrl);
475477
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
@@ -480,7 +482,7 @@ async function discoverMetadataWithFallback(
480482
} else {
481483
// Try path-aware discovery first
482484
const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
483-
url = new URL(wellKnownPath, issuer);
485+
url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer);
484486
url.search = issuer.search;
485487
}
486488

@@ -502,13 +504,33 @@ async function discoverMetadataWithFallback(
502504
* return `undefined`. Any other errors will be thrown as exceptions.
503505
*/
504506
export async function discoverOAuthMetadata(
505-
authorizationServerUrl: string | URL,
506-
opts?: { protocolVersion?: string },
507+
issuer: string | URL,
508+
{
509+
authorizationServerUrl,
510+
protocolVersion,
511+
}: {
512+
authorizationServerUrl?: string | URL,
513+
protocolVersion?: string,
514+
} = {},
507515
): Promise<OAuthMetadata | undefined> {
516+
if (typeof issuer === 'string') {
517+
issuer = new URL(issuer);
518+
}
519+
if (!authorizationServerUrl) {
520+
authorizationServerUrl = issuer;
521+
}
522+
if (typeof authorizationServerUrl === 'string') {
523+
authorizationServerUrl = new URL(authorizationServerUrl);
524+
}
525+
protocolVersion ??= LATEST_PROTOCOL_VERSION;
526+
508527
const response = await discoverMetadataWithFallback(
509-
authorizationServerUrl,
528+
issuer,
510529
'oauth-authorization-server',
511-
opts,
530+
{
531+
protocolVersion,
532+
metadataServerUrl: authorizationServerUrl,
533+
},
512534
);
513535

514536
if (!response || response.status === 404) {

0 commit comments

Comments
 (0)