diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 75cf20b9..ed72b67c 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -10,7 +10,7 @@ import { auth, type OAuthClientProvider, } from "./auth.js"; -import { OAuthMetadata } from '../shared/auth.js'; +import { OAuthClientMetadata, OAuthMetadata, OAuthProtectedResourceMetadata } from '../shared/auth.js'; // Mock fetch globally const mockFetch = jest.fn(); @@ -926,6 +926,48 @@ describe("OAuth Authorization", () => { ); }); + it("registers client with scopes_supported from resourceMetadata if scope is not provided", async () => { + const resourceMetadata: OAuthProtectedResourceMetadata = { + scopes_supported: ["openid", "profile"], + resource: "https://api.example.com/mcp-server", + }; + + const validClientMetadataWithoutScope: OAuthClientMetadata = { + ...validClientMetadata, + scope: undefined, + }; + + const expectedClientInfo = { + ...validClientInfo, + scope: "openid profile", + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => expectedClientInfo, + }); + + const clientInfo = await registerClient("https://auth.example.com", { + clientMetadata: validClientMetadataWithoutScope, + resourceMetadata, + }); + + expect(clientInfo).toEqual(expectedClientInfo); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/register", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...validClientMetadata, scope: "openid profile" }), + }) + ); + }); + it("validates client information response schema", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -1266,6 +1308,64 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); }); + it("uses scopes_supported from resource metadata if scope is not provided", async () => { + // Mock successful metadata discovery - need to include protected resource metadata + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["openid", "profile"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for authorization flow + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth without authorization code (should trigger redirect) + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get("scope")).toBe("openid profile"); + }); + it("skips default PRM resource validation when custom validateResourceURL is provided", async () => { const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined); const providerWithCustomValidation = { diff --git a/src/client/auth.ts b/src/client/auth.ts index 8e32dc2f..4231b228 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -235,12 +235,13 @@ export async function auth( serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL }): Promise { + resourceMetadataUrl?: URL + }): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } @@ -265,6 +266,7 @@ export async function auth( const fullInformation = await registerClient(authorizationServerUrl, { metadata, + resourceMetadata, clientMetadata: provider.clientMetadata, }); @@ -318,7 +320,7 @@ export async function auth( clientInformation, state, redirectUrl: provider.redirectUrl, - scope: scope || provider.clientMetadata.scope, + scope: (scope || provider.clientMetadata.scope) ?? resourceMetadata?.scopes_supported?.join(" "), resource, }); @@ -761,9 +763,11 @@ export async function registerClient( authorizationServerUrl: string | URL, { metadata, + resourceMetadata, clientMetadata, }: { metadata?: OAuthMetadata; + resourceMetadata?: OAuthProtectedResourceMetadata; clientMetadata: OAuthClientMetadata; }, ): Promise { @@ -779,12 +783,17 @@ export async function registerClient( registrationUrl = new URL("/register", authorizationServerUrl); } + const scope = clientMetadata?.scope ?? resourceMetadata?.scopes_supported?.join(" "); + const response = await fetch(registrationUrl, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(clientMetadata), + body: JSON.stringify({ + ...clientMetadata, + ...(scope !== undefined ? { scope } : undefined) + }), }); if (!response.ok) {