Skip to content

Commit 42d0fd5

Browse files
committed
feat: use scopes_supported from resource metadata by default (fixes #580)
1 parent 7d29cee commit 42d0fd5

File tree

2 files changed

+112
-3
lines changed

2 files changed

+112
-3
lines changed

src/client/auth.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "./auth.js";
1515
import {ServerError} from "../server/auth/errors.js";
1616
import { AuthorizationServerMetadata } from '../shared/auth.js';
17+
import { OAuthClientMetadata, OAuthProtectedResourceMetadata } from '../shared/auth.js';
1718

1819
// Mock fetch globally
1920
const mockFetch = jest.fn();
@@ -1457,6 +1458,48 @@ describe("OAuth Authorization", () => {
14571458
);
14581459
});
14591460

1461+
it("registers client with scopes_supported from resourceMetadata if scope is not provided", async () => {
1462+
const resourceMetadata: OAuthProtectedResourceMetadata = {
1463+
scopes_supported: ["openid", "profile"],
1464+
resource: "https://api.example.com/mcp-server",
1465+
};
1466+
1467+
const validClientMetadataWithoutScope: OAuthClientMetadata = {
1468+
...validClientMetadata,
1469+
scope: undefined,
1470+
};
1471+
1472+
const expectedClientInfo = {
1473+
...validClientInfo,
1474+
scope: "openid profile",
1475+
};
1476+
1477+
mockFetch.mockResolvedValueOnce({
1478+
ok: true,
1479+
status: 200,
1480+
json: async () => expectedClientInfo,
1481+
});
1482+
1483+
const clientInfo = await registerClient("https://auth.example.com", {
1484+
clientMetadata: validClientMetadataWithoutScope,
1485+
resourceMetadata,
1486+
});
1487+
1488+
expect(clientInfo).toEqual(expectedClientInfo);
1489+
expect(mockFetch).toHaveBeenCalledWith(
1490+
expect.objectContaining({
1491+
href: "https://auth.example.com/register",
1492+
}),
1493+
expect.objectContaining({
1494+
method: "POST",
1495+
headers: {
1496+
"Content-Type": "application/json",
1497+
},
1498+
body: JSON.stringify({ ...validClientMetadata, scope: "openid profile" }),
1499+
})
1500+
);
1501+
});
1502+
14601503
it("validates client information response schema", async () => {
14611504
mockFetch.mockResolvedValueOnce({
14621505
ok: true,
@@ -1799,6 +1842,64 @@ describe("OAuth Authorization", () => {
17991842
expect(body.get("refresh_token")).toBe("refresh123");
18001843
});
18011844

1845+
it("uses scopes_supported from resource metadata if scope is not provided", async () => {
1846+
// Mock successful metadata discovery - need to include protected resource metadata
1847+
mockFetch.mockImplementation((url) => {
1848+
const urlString = url.toString();
1849+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1850+
return Promise.resolve({
1851+
ok: true,
1852+
status: 200,
1853+
json: async () => ({
1854+
resource: "https://api.example.com/mcp-server",
1855+
authorization_servers: ["https://auth.example.com"],
1856+
scopes_supported: ["openid", "profile"],
1857+
}),
1858+
});
1859+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1860+
return Promise.resolve({
1861+
ok: true,
1862+
status: 200,
1863+
json: async () => ({
1864+
issuer: "https://auth.example.com",
1865+
authorization_endpoint: "https://auth.example.com/authorize",
1866+
token_endpoint: "https://auth.example.com/token",
1867+
response_types_supported: ["code"],
1868+
code_challenge_methods_supported: ["S256"],
1869+
}),
1870+
});
1871+
}
1872+
return Promise.resolve({ ok: false, status: 404 });
1873+
});
1874+
1875+
// Mock provider methods for authorization flow
1876+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
1877+
client_id: "test-client",
1878+
client_secret: "test-secret",
1879+
});
1880+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
1881+
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
1882+
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
1883+
1884+
// Call auth without authorization code (should trigger redirect)
1885+
const result = await auth(mockProvider, {
1886+
serverUrl: "https://api.example.com/mcp-server",
1887+
});
1888+
1889+
expect(result).toBe("REDIRECT");
1890+
1891+
// Verify the authorization URL includes the resource parameter
1892+
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
1893+
expect.objectContaining({
1894+
searchParams: expect.any(URLSearchParams),
1895+
})
1896+
);
1897+
1898+
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
1899+
const authUrl: URL = redirectCall[0];
1900+
expect(authUrl.searchParams.get("scope")).toBe("openid profile");
1901+
});
1902+
18021903
it("skips default PRM resource validation when custom validateResourceURL is provided", async () => {
18031904
const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined);
18041905
const providerWithCustomValidation = {

src/client/auth.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ async function authInternal(
358358

359359
const fullInformation = await registerClient(authorizationServerUrl, {
360360
metadata,
361+
resourceMetadata,
361362
clientMetadata: provider.clientMetadata,
362363
fetchFn,
363364
});
@@ -420,7 +421,7 @@ async function authInternal(
420421
clientInformation,
421422
state,
422423
redirectUrl: provider.redirectUrl,
423-
scope: scope || provider.clientMetadata.scope,
424+
scope: (scope || provider.clientMetadata.scope) ?? resourceMetadata?.scopes_supported?.join(" "),
424425
resource,
425426
});
426427

@@ -1055,10 +1056,12 @@ export async function registerClient(
10551056
authorizationServerUrl: string | URL,
10561057
{
10571058
metadata,
1059+
resourceMetadata,
10581060
clientMetadata,
10591061
fetchFn,
10601062
}: {
1061-
metadata?: AuthorizationServerMetadata;
1063+
metadata?: OAuthMetadata;
1064+
resourceMetadata?: OAuthProtectedResourceMetadata;
10621065
clientMetadata: OAuthClientMetadata;
10631066
fetchFn?: FetchLike;
10641067
},
@@ -1075,12 +1078,17 @@ export async function registerClient(
10751078
registrationUrl = new URL("/register", authorizationServerUrl);
10761079
}
10771080

1081+
const scope = clientMetadata?.scope ?? resourceMetadata?.scopes_supported?.join(" ");
1082+
10781083
const response = await (fetchFn ?? fetch)(registrationUrl, {
10791084
method: "POST",
10801085
headers: {
10811086
"Content-Type": "application/json",
10821087
},
1083-
body: JSON.stringify(clientMetadata),
1088+
body: JSON.stringify({
1089+
...clientMetadata,
1090+
...(scope !== undefined ? { scope } : undefined)
1091+
}),
10841092
});
10851093

10861094
if (!response.ok) {

0 commit comments

Comments
 (0)