Skip to content

Commit 8386834

Browse files
committed
feat: use scopes_supported from resource metadata by default (fixes #580)
1 parent 11e84f0 commit 8386834

File tree

2 files changed

+114
-5
lines changed

2 files changed

+114
-5
lines changed

src/client/auth.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
auth,
1111
type OAuthClientProvider,
1212
} from "./auth.js";
13-
import { OAuthMetadata } from '../shared/auth.js';
13+
import { OAuthClientMetadata, OAuthMetadata, OAuthProtectedResourceMetadata } from '../shared/auth.js';
1414

1515
// Mock fetch globally
1616
const mockFetch = jest.fn();
@@ -926,6 +926,48 @@ describe("OAuth Authorization", () => {
926926
);
927927
});
928928

929+
it("registers client with scopes_supported from resourceMetadata if scope is not provided", async () => {
930+
const resourceMetadata: OAuthProtectedResourceMetadata = {
931+
scopes_supported: ["openid", "profile"],
932+
resource: "https://api.example.com/mcp-server",
933+
};
934+
935+
const validClientMetadataWithoutScope: OAuthClientMetadata = {
936+
...validClientMetadata,
937+
scope: undefined,
938+
};
939+
940+
const expectedClientInfo = {
941+
...validClientInfo,
942+
scope: "openid profile",
943+
};
944+
945+
mockFetch.mockResolvedValueOnce({
946+
ok: true,
947+
status: 200,
948+
json: async () => expectedClientInfo,
949+
});
950+
951+
const clientInfo = await registerClient("https://auth.example.com", {
952+
clientMetadata: validClientMetadataWithoutScope,
953+
resourceMetadata,
954+
});
955+
956+
expect(clientInfo).toEqual(expectedClientInfo);
957+
expect(mockFetch).toHaveBeenCalledWith(
958+
expect.objectContaining({
959+
href: "https://auth.example.com/register",
960+
}),
961+
expect.objectContaining({
962+
method: "POST",
963+
headers: {
964+
"Content-Type": "application/json",
965+
},
966+
body: JSON.stringify({ ...validClientMetadata, scope: "openid profile" }),
967+
})
968+
);
969+
});
970+
929971
it("validates client information response schema", async () => {
930972
mockFetch.mockResolvedValueOnce({
931973
ok: true,
@@ -1266,6 +1308,64 @@ describe("OAuth Authorization", () => {
12661308
expect(body.get("refresh_token")).toBe("refresh123");
12671309
});
12681310

1311+
it("uses scopes_supported from resource metadata if scope is not provided", async () => {
1312+
// Mock successful metadata discovery - need to include protected resource metadata
1313+
mockFetch.mockImplementation((url) => {
1314+
const urlString = url.toString();
1315+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1316+
return Promise.resolve({
1317+
ok: true,
1318+
status: 200,
1319+
json: async () => ({
1320+
resource: "https://api.example.com/mcp-server",
1321+
authorization_servers: ["https://auth.example.com"],
1322+
scopes_supported: ["openid", "profile"],
1323+
}),
1324+
});
1325+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1326+
return Promise.resolve({
1327+
ok: true,
1328+
status: 200,
1329+
json: async () => ({
1330+
issuer: "https://auth.example.com",
1331+
authorization_endpoint: "https://auth.example.com/authorize",
1332+
token_endpoint: "https://auth.example.com/token",
1333+
response_types_supported: ["code"],
1334+
code_challenge_methods_supported: ["S256"],
1335+
}),
1336+
});
1337+
}
1338+
return Promise.resolve({ ok: false, status: 404 });
1339+
});
1340+
1341+
// Mock provider methods for authorization flow
1342+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
1343+
client_id: "test-client",
1344+
client_secret: "test-secret",
1345+
});
1346+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
1347+
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
1348+
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
1349+
1350+
// Call auth without authorization code (should trigger redirect)
1351+
const result = await auth(mockProvider, {
1352+
serverUrl: "https://api.example.com/mcp-server",
1353+
});
1354+
1355+
expect(result).toBe("REDIRECT");
1356+
1357+
// Verify the authorization URL includes the resource parameter
1358+
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
1359+
expect.objectContaining({
1360+
searchParams: expect.any(URLSearchParams),
1361+
})
1362+
);
1363+
1364+
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
1365+
const authUrl: URL = redirectCall[0];
1366+
expect(authUrl.searchParams.get("scope")).toBe("openid profile");
1367+
});
1368+
12691369
it("skips default PRM resource validation when custom validateResourceURL is provided", async () => {
12701370
const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined);
12711371
const providerWithCustomValidation = {

src/client/auth.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,13 @@ export async function auth(
235235
serverUrl: string | URL;
236236
authorizationCode?: string;
237237
scope?: string;
238-
resourceMetadataUrl?: URL }): Promise<AuthResult> {
238+
resourceMetadataUrl?: URL
239+
}): Promise<AuthResult> {
239240

240241
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
241242
let authorizationServerUrl = serverUrl;
242243
try {
243-
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl});
244+
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl });
244245
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
245246
authorizationServerUrl = resourceMetadata.authorization_servers[0];
246247
}
@@ -265,6 +266,7 @@ export async function auth(
265266

266267
const fullInformation = await registerClient(authorizationServerUrl, {
267268
metadata,
269+
resourceMetadata,
268270
clientMetadata: provider.clientMetadata,
269271
});
270272

@@ -318,7 +320,7 @@ export async function auth(
318320
clientInformation,
319321
state,
320322
redirectUrl: provider.redirectUrl,
321-
scope: scope || provider.clientMetadata.scope,
323+
scope: (scope || provider.clientMetadata.scope) ?? resourceMetadata?.scopes_supported?.join(" "),
322324
resource,
323325
});
324326

@@ -761,9 +763,11 @@ export async function registerClient(
761763
authorizationServerUrl: string | URL,
762764
{
763765
metadata,
766+
resourceMetadata,
764767
clientMetadata,
765768
}: {
766769
metadata?: OAuthMetadata;
770+
resourceMetadata?: OAuthProtectedResourceMetadata;
767771
clientMetadata: OAuthClientMetadata;
768772
},
769773
): Promise<OAuthClientInformationFull> {
@@ -779,12 +783,17 @@ export async function registerClient(
779783
registrationUrl = new URL("/register", authorizationServerUrl);
780784
}
781785

786+
const scope = clientMetadata?.scope ?? resourceMetadata?.scopes_supported?.join(" ");
787+
782788
const response = await fetch(registrationUrl, {
783789
method: "POST",
784790
headers: {
785791
"Content-Type": "application/json",
786792
},
787-
body: JSON.stringify(clientMetadata),
793+
body: JSON.stringify({
794+
...clientMetadata,
795+
...(scope !== undefined ? { scope } : undefined)
796+
}),
788797
});
789798

790799
if (!response.ok) {

0 commit comments

Comments
 (0)