feat: support oidc discovery in client sdk (#652)
Co-authored-by: Paul Carleton <paulc@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
62c608d8ce
commit
bb7cccc3ba
1 changed files with 486 additions and 47 deletions
|
|
@ -1,6 +1,8 @@
|
|||
import { LATEST_PROTOCOL_VERSION } from '../types.js';
|
||||
import {
|
||||
discoverOAuthMetadata,
|
||||
discoverAuthorizationServerMetadata,
|
||||
buildDiscoveryUrls,
|
||||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
refreshAuthorization,
|
||||
|
|
@ -11,7 +13,7 @@ import {
|
|||
type OAuthClientProvider,
|
||||
} from "./auth.js";
|
||||
import {ServerError} from "../server/auth/errors.js";
|
||||
import { OAuthMetadata } from '../shared/auth.js';
|
||||
import { AuthorizationServerMetadata } from '../shared/auth.js';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = jest.fn();
|
||||
|
|
@ -216,7 +218,7 @@ describe("OAuth Authorization", () => {
|
|||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
|
||||
// Second call (root fallback) succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -226,17 +228,17 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
|
||||
|
||||
// First call should be path-aware
|
||||
const [firstUrl, firstOptions] = calls[0];
|
||||
expect(firstUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path/name");
|
||||
expect(firstOptions.headers).toEqual({
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
|
||||
|
||||
// Second call should be root fallback
|
||||
const [secondUrl, secondOptions] = calls[1];
|
||||
expect(secondUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
|
||||
|
|
@ -251,7 +253,7 @@ describe("OAuth Authorization", () => {
|
|||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
|
||||
// Second call (root fallback) also returns 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
|
|
@ -260,7 +262,7 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name"))
|
||||
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
|
|
@ -274,10 +276,10 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/"))
|
||||
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||||
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
|
||||
});
|
||||
|
|
@ -291,10 +293,10 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
|
||||
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||||
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
|
||||
});
|
||||
|
|
@ -302,13 +304,13 @@ describe("OAuth Authorization", () => {
|
|||
it("falls back when path-aware discovery encounters CORS error", async () => {
|
||||
// First call (path-aware) fails with TypeError (CORS)
|
||||
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
|
||||
|
||||
|
||||
// Retry path-aware without headers (simulating CORS retry)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
|
||||
// Second call (root fallback) succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -318,10 +320,10 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/deep/path");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(3);
|
||||
|
||||
|
||||
// Final call should be root fallback
|
||||
const [lastUrl, lastOptions] = calls[2];
|
||||
expect(lastUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
|
||||
|
|
@ -340,10 +342,10 @@ describe("OAuth Authorization", () => {
|
|||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path", {
|
||||
resourceMetadataUrl: "https://custom.example.com/metadata"
|
||||
})).rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided
|
||||
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://custom.example.com/metadata");
|
||||
});
|
||||
|
|
@ -683,6 +685,222 @@ describe("OAuth Authorization", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("buildDiscoveryUrls", () => {
|
||||
it("generates correct URLs for server without path", () => {
|
||||
const urls = buildDiscoveryUrls("https://auth.example.com");
|
||||
|
||||
expect(urls).toHaveLength(2);
|
||||
expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([
|
||||
{
|
||||
url: "https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
type: "oauth"
|
||||
},
|
||||
{
|
||||
url: "https://auth.example.com/.well-known/openid-configuration",
|
||||
type: "oidc"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("generates correct URLs for server with path", () => {
|
||||
const urls = buildDiscoveryUrls("https://auth.example.com/tenant1");
|
||||
|
||||
expect(urls).toHaveLength(4);
|
||||
expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([
|
||||
{
|
||||
url: "https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
|
||||
type: "oauth"
|
||||
},
|
||||
{
|
||||
url: "https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
type: "oauth"
|
||||
},
|
||||
{
|
||||
url: "https://auth.example.com/.well-known/openid-configuration/tenant1",
|
||||
type: "oidc"
|
||||
},
|
||||
{
|
||||
url: "https://auth.example.com/tenant1/.well-known/openid-configuration",
|
||||
type: "oidc"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles URL object input", () => {
|
||||
const urls = buildDiscoveryUrls(new URL("https://auth.example.com/tenant1"));
|
||||
|
||||
expect(urls).toHaveLength(4);
|
||||
expect(urls[0].url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverAuthorizationServerMetadata", () => {
|
||||
const validOAuthMetadata = {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
registration_endpoint: "https://auth.example.com/register",
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
};
|
||||
|
||||
const validOpenIdMetadata = {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
jwks_uri: "https://auth.example.com/jwks",
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
};
|
||||
|
||||
it("tries URLs in order and returns first successful metadata", async () => {
|
||||
// First OAuth URL fails with 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
// Second OAuth URL (root) succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validOAuthMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverAuthorizationServerMetadata(
|
||||
"https://auth.example.com/tenant1"
|
||||
);
|
||||
|
||||
expect(metadata).toEqual(validOAuthMetadata);
|
||||
|
||||
// Verify it tried the URLs in the correct order
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1");
|
||||
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
});
|
||||
|
||||
it("throws error when OIDC provider does not support S256 PKCE", async () => {
|
||||
// OAuth discovery fails
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
// OpenID Connect discovery succeeds but without S256 support
|
||||
const invalidOpenIdMetadata = {
|
||||
...validOpenIdMetadata,
|
||||
code_challenge_methods_supported: ["plain"], // Missing S256
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => invalidOpenIdMetadata,
|
||||
});
|
||||
|
||||
await expect(
|
||||
discoverAuthorizationServerMetadata(
|
||||
"https://auth.example.com"
|
||||
)
|
||||
).rejects.toThrow("does not support S256 code challenge method required by MCP specification");
|
||||
});
|
||||
|
||||
it("continues on 4xx errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validOpenIdMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverAuthorizationServerMetadata("https://mcp.example.com");
|
||||
|
||||
expect(metadata).toEqual(validOpenIdMetadata);
|
||||
|
||||
});
|
||||
|
||||
it("throws on non-4xx errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(
|
||||
discoverAuthorizationServerMetadata("https://mcp.example.com")
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
|
||||
it("handles CORS errors with retry", async () => {
|
||||
// First call fails with CORS
|
||||
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
|
||||
|
||||
// Retry without headers succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validOAuthMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverAuthorizationServerMetadata(
|
||||
"https://auth.example.com"
|
||||
);
|
||||
|
||||
expect(metadata).toEqual(validOAuthMetadata);
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
|
||||
// First call should have headers
|
||||
expect(calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version");
|
||||
|
||||
// Second call should not have headers (CORS retry)
|
||||
expect(calls[1][1]?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports custom fetch function", async () => {
|
||||
const customFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validOAuthMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverAuthorizationServerMetadata(
|
||||
"https://auth.example.com",
|
||||
{ fetchFn: customFetch }
|
||||
);
|
||||
|
||||
expect(metadata).toEqual(validOAuthMetadata);
|
||||
expect(customFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports custom protocol version", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validOAuthMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverAuthorizationServerMetadata(
|
||||
"https://auth.example.com",
|
||||
{ protocolVersion: "2025-01-01" }
|
||||
);
|
||||
|
||||
expect(metadata).toEqual(validOAuthMetadata);
|
||||
const calls = mockFetch.mock.calls;
|
||||
const [, options] = calls[0];
|
||||
expect(options.headers).toEqual({
|
||||
"MCP-Protocol-Version": "2025-01-01"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("startAuthorization", () => {
|
||||
const validMetadata = {
|
||||
issuer: "https://auth.example.com",
|
||||
|
|
@ -909,7 +1127,7 @@ describe("OAuth Authorization", () => {
|
|||
authorizationCode: "code123",
|
||||
codeVerifier: "verifier123",
|
||||
redirectUri: "http://localhost:3000/callback",
|
||||
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata: OAuthMetadata) => {
|
||||
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata: AuthorizationServerMetadata) => {
|
||||
headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret));
|
||||
params.set("example_url", typeof url === 'string' ? url : url.toString());
|
||||
params.set("example_metadata", metadata.authorization_endpoint);
|
||||
|
|
@ -1091,7 +1309,7 @@ describe("OAuth Authorization", () => {
|
|||
metadata: validMetadata,
|
||||
clientInformation: validClientInfo,
|
||||
refreshToken: "refresh123",
|
||||
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata) => {
|
||||
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata) => {
|
||||
headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret));
|
||||
params.set("example_url", typeof url === 'string' ? url : url.toString());
|
||||
params.set("example_metadata", metadata?.authorization_endpoint ?? '?');
|
||||
|
|
@ -1919,17 +2137,17 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
// Verify the correct URLs were fetched
|
||||
const calls = mockFetch.mock.calls;
|
||||
|
||||
|
||||
// First call should be to PRM
|
||||
expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource/path/name");
|
||||
|
||||
|
||||
// Second call should be to AS metadata with the path from authorization server
|
||||
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/oauth");
|
||||
});
|
||||
|
||||
it("supports overriding the fetch function used for requests", async () => {
|
||||
const customFetch = jest.fn();
|
||||
|
||||
|
||||
// Mock PRM discovery
|
||||
customFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -1939,7 +2157,7 @@ describe("OAuth Authorization", () => {
|
|||
authorization_servers: ["https://auth.example.com"],
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
// Mock AS metadata discovery
|
||||
customFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -1956,7 +2174,7 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
const mockProvider: OAuthClientProvider = {
|
||||
get redirectUrl() { return "http://localhost:3000/callback"; },
|
||||
get clientMetadata() {
|
||||
get clientMetadata() {
|
||||
return {
|
||||
client_name: "Test Client",
|
||||
redirect_uris: ["http://localhost:3000/callback"],
|
||||
|
|
@ -1981,10 +2199,10 @@ describe("OAuth Authorization", () => {
|
|||
expect(result).toBe("REDIRECT");
|
||||
expect(customFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
// Verify custom fetch was called for PRM discovery
|
||||
expect(customFetch.mock.calls[0][0].toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
|
||||
|
||||
|
||||
// Verify custom fetch was called for AS metadata discovery
|
||||
expect(customFetch.mock.calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue