Skip to content
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
101 changes: 101 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "./auth.js";
import {ServerError} from "../server/auth/errors.js";
import { AuthorizationServerMetadata } from '../shared/auth.js';
import { OAuthClientMetadata, OAuthProtectedResourceMetadata } from '../shared/auth.js';

// Mock fetch globally
const mockFetch = jest.fn();
Expand Down Expand Up @@ -1457,6 +1458,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 @@ -1799,6 +1842,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
14 changes: 11 additions & 3 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ async function authInternal(

const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
resourceMetadata,
clientMetadata: provider.clientMetadata,
fetchFn,
});
Expand Down Expand Up @@ -420,7 +421,7 @@ async function authInternal(
clientInformation,
state,
redirectUrl: provider.redirectUrl,
scope: scope || provider.clientMetadata.scope,
scope: (scope || provider.clientMetadata.scope) ?? resourceMetadata?.scopes_supported?.join(" "),
resource,
});

Expand Down Expand Up @@ -1055,10 +1056,12 @@ export async function registerClient(
authorizationServerUrl: string | URL,
{
metadata,
resourceMetadata,
clientMetadata,
fetchFn,
}: {
metadata?: AuthorizationServerMetadata;
metadata?: OAuthMetadata;
resourceMetadata?: OAuthProtectedResourceMetadata;
clientMetadata: OAuthClientMetadata;
fetchFn?: FetchLike;
},
Expand All @@ -1075,12 +1078,17 @@ export async function registerClient(
registrationUrl = new URL("/register", authorizationServerUrl);
}

const scope = clientMetadata?.scope ?? resourceMetadata?.scopes_supported?.join(" ");

const response = await (fetchFn ?? 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