Skip to content

feat: use scopes_supported from resource metadata by default (fixes #580) #757

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
102 changes: 101 additions & 1 deletion src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
17 changes: 13 additions & 4 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,13 @@ export async function auth(
serverUrl: string | URL;
authorizationCode?: string;
scope?: string;
resourceMetadataUrl?: URL }): Promise<AuthResult> {
resourceMetadataUrl?: URL
}): Promise<AuthResult> {

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];
}
Expand All @@ -265,6 +266,7 @@ export async function auth(

const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
resourceMetadata,
clientMetadata: provider.clientMetadata,
});

Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -761,9 +763,11 @@ export async function registerClient(
authorizationServerUrl: string | URL,
{
metadata,
resourceMetadata,
clientMetadata,
}: {
metadata?: OAuthMetadata;
resourceMetadata?: OAuthProtectedResourceMetadata;
clientMetadata: OAuthClientMetadata;
},
): Promise<OAuthClientInformationFull> {
Expand All @@ -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) {
Expand Down