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
4 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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import {
|
|||
OAuthMetadata,
|
||||
OAuthClientInformationFull,
|
||||
OAuthProtectedResourceMetadata,
|
||||
OAuthErrorResponseSchema
|
||||
OAuthErrorResponseSchema,
|
||||
AuthorizationServerMetadata,
|
||||
OpenIdProviderDiscoveryMetadataSchema
|
||||
} from "../shared/auth.js";
|
||||
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
|
||||
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
|
||||
|
|
@ -108,7 +110,7 @@ export interface OAuthClientProvider {
|
|||
* @param url - The token endpoint URL being called
|
||||
* @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods
|
||||
*/
|
||||
addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata): void | Promise<void>;
|
||||
addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata): void | Promise<void>;
|
||||
|
||||
/**
|
||||
* If defined, overrides the selection and validation of the
|
||||
|
|
@ -319,7 +321,7 @@ async function authInternal(
|
|||
): Promise<AuthResult> {
|
||||
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
|
||||
let authorizationServerUrl = serverUrl;
|
||||
let authorizationServerUrl: string | URL | undefined;
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn);
|
||||
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
|
||||
|
|
@ -329,11 +331,19 @@ async function authInternal(
|
|||
// Ignore errors and fall back to /.well-known/oauth-authorization-server
|
||||
}
|
||||
|
||||
/**
|
||||
* If we don't get a valid authorization server metadata from protected resource metadata,
|
||||
* fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server acts as the Authorization server.
|
||||
*/
|
||||
if (!authorizationServerUrl) {
|
||||
authorizationServerUrl = serverUrl;
|
||||
}
|
||||
|
||||
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
|
||||
|
||||
const metadata = await discoverOAuthMetadata(serverUrl, {
|
||||
authorizationServerUrl
|
||||
}, fetchFn);
|
||||
const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, {
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
// Handle client registration if needed
|
||||
let clientInformation = await Promise.resolve(provider.clientInformation());
|
||||
|
|
@ -524,15 +534,21 @@ async function fetchWithCorsRetry(
|
|||
}
|
||||
|
||||
/**
|
||||
* Constructs the well-known path for OAuth metadata discovery
|
||||
* Constructs the well-known path for auth-related metadata discovery
|
||||
*/
|
||||
function buildWellKnownPath(wellKnownPrefix: string, pathname: string): string {
|
||||
let wellKnownPath = `/.well-known/${wellKnownPrefix}${pathname}`;
|
||||
function buildWellKnownPath(
|
||||
wellKnownPrefix: 'oauth-authorization-server' | 'oauth-protected-resource' | 'openid-configuration',
|
||||
pathname: string = '',
|
||||
options: { prependPathname?: boolean } = {}
|
||||
): string {
|
||||
// Strip trailing slash from pathname to avoid double slashes
|
||||
if (pathname.endsWith('/')) {
|
||||
// Strip trailing slash from pathname to avoid double slashes
|
||||
wellKnownPath = wellKnownPath.slice(0, -1);
|
||||
pathname = pathname.slice(0, -1);
|
||||
}
|
||||
return wellKnownPath;
|
||||
|
||||
return options.prependPathname
|
||||
? `${pathname}/.well-known/${wellKnownPrefix}`
|
||||
: `/.well-known/${wellKnownPrefix}${pathname}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -594,6 +610,8 @@ async function discoverMetadataWithFallback(
|
|||
*
|
||||
* If the server returns a 404 for the well-known endpoint, this function will
|
||||
* return `undefined`. Any other errors will be thrown as exceptions.
|
||||
*
|
||||
* @deprecated This function is deprecated in favor of `discoverAuthorizationServerMetadata`.
|
||||
*/
|
||||
export async function discoverOAuthMetadata(
|
||||
issuer: string | URL,
|
||||
|
|
@ -615,7 +633,7 @@ export async function discoverOAuthMetadata(
|
|||
if (typeof authorizationServerUrl === 'string') {
|
||||
authorizationServerUrl = new URL(authorizationServerUrl);
|
||||
}
|
||||
protocolVersion ??= LATEST_PROTOCOL_VERSION;
|
||||
protocolVersion ??= LATEST_PROTOCOL_VERSION ;
|
||||
|
||||
const response = await discoverMetadataWithFallback(
|
||||
authorizationServerUrl,
|
||||
|
|
@ -640,6 +658,137 @@ export async function discoverOAuthMetadata(
|
|||
return OAuthMetadataSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Builds a list of discovery URLs to try for authorization server metadata.
|
||||
* URLs are returned in priority order:
|
||||
* 1. OAuth metadata at the given URL
|
||||
* 2. OAuth metadata at root (if URL has path)
|
||||
* 3. OIDC metadata endpoints
|
||||
*/
|
||||
export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: URL; type: 'oauth' | 'oidc' }[] {
|
||||
const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl;
|
||||
const hasPath = url.pathname !== '/';
|
||||
const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = [];
|
||||
|
||||
|
||||
if (!hasPath) {
|
||||
// Root path: https://example.com/.well-known/oauth-authorization-server
|
||||
urlsToTry.push({
|
||||
url: new URL('/.well-known/oauth-authorization-server', url.origin),
|
||||
type: 'oauth'
|
||||
});
|
||||
|
||||
// OIDC: https://example.com/.well-known/openid-configuration
|
||||
urlsToTry.push({
|
||||
url: new URL(`/.well-known/openid-configuration`, url.origin),
|
||||
type: 'oidc'
|
||||
});
|
||||
|
||||
return urlsToTry;
|
||||
}
|
||||
|
||||
// Strip trailing slash from pathname to avoid double slashes
|
||||
let pathname = url.pathname;
|
||||
if (pathname.endsWith('/')) {
|
||||
pathname = pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
// 1. OAuth metadata at the given URL
|
||||
// Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1
|
||||
urlsToTry.push({
|
||||
url: new URL(`/.well-known/oauth-authorization-server${pathname}`, url.origin),
|
||||
type: 'oauth'
|
||||
});
|
||||
|
||||
// Root path: https://example.com/.well-known/oauth-authorization-server
|
||||
urlsToTry.push({
|
||||
url: new URL('/.well-known/oauth-authorization-server', url.origin),
|
||||
type: 'oauth'
|
||||
});
|
||||
|
||||
// 3. OIDC metadata endpoints
|
||||
// RFC 8414 style: Insert /.well-known/openid-configuration before the path
|
||||
urlsToTry.push({
|
||||
url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin),
|
||||
type: 'oidc'
|
||||
});
|
||||
// OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path
|
||||
urlsToTry.push({
|
||||
url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin),
|
||||
type: 'oidc'
|
||||
});
|
||||
|
||||
return urlsToTry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers authorization server metadata with support for RFC 8414 OAuth 2.0 Authorization Server Metadata
|
||||
* and OpenID Connect Discovery 1.0 specifications.
|
||||
*
|
||||
* This function implements a fallback strategy for authorization server discovery:
|
||||
* 1. Attempts RFC 8414 OAuth metadata discovery first
|
||||
* 2. If OAuth discovery fails, falls back to OpenID Connect Discovery
|
||||
*
|
||||
* @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's
|
||||
* protected resource metadata, or the MCP server's URL if the
|
||||
* metadata was not found.
|
||||
* @param options - Configuration options
|
||||
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
|
||||
* @param options.protocolVersion - MCP protocol version to use, defaults to LATEST_PROTOCOL_VERSION
|
||||
* @returns Promise resolving to authorization server metadata, or undefined if discovery fails
|
||||
*/
|
||||
export async function discoverAuthorizationServerMetadata(
|
||||
authorizationServerUrl: string | URL,
|
||||
{
|
||||
fetchFn = fetch,
|
||||
protocolVersion = LATEST_PROTOCOL_VERSION,
|
||||
}: {
|
||||
fetchFn?: FetchLike;
|
||||
protocolVersion?: string;
|
||||
} = {}
|
||||
): Promise<AuthorizationServerMetadata | undefined> {
|
||||
const headers = { 'MCP-Protocol-Version': protocolVersion };
|
||||
|
||||
// Get the list of URLs to try
|
||||
const urlsToTry = buildDiscoveryUrls(authorizationServerUrl);
|
||||
|
||||
// Try each URL in order
|
||||
for (const { url: endpointUrl, type } of urlsToTry) {
|
||||
const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn);
|
||||
|
||||
if (!response) {
|
||||
throw new Error(`CORS error trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Continue looking for any 4xx response code.
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
continue; // Try next URL
|
||||
}
|
||||
throw new Error(`HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`);
|
||||
}
|
||||
|
||||
// Parse and validate based on type
|
||||
if (type === 'oauth') {
|
||||
return OAuthMetadataSchema.parse(await response.json());
|
||||
} else {
|
||||
const metadata = OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
|
||||
|
||||
// MCP spec requires OIDC providers to support S256 PKCE
|
||||
if (!metadata.code_challenge_methods_supported?.includes('S256')) {
|
||||
throw new Error(
|
||||
`Incompatible OIDC provider at ${endpointUrl}: does not support S256 code challenge method required by MCP specification`
|
||||
);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
|
||||
*/
|
||||
|
|
@ -653,7 +802,7 @@ export async function startAuthorization(
|
|||
state,
|
||||
resource,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
redirectUrl: string | URL;
|
||||
scope?: string;
|
||||
|
|
@ -746,7 +895,7 @@ export async function exchangeAuthorization(
|
|||
addClientAuthentication,
|
||||
fetchFn,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
authorizationCode: string;
|
||||
codeVerifier: string;
|
||||
|
|
@ -832,7 +981,7 @@ export async function refreshAuthorization(
|
|||
addClientAuthentication,
|
||||
fetchFn,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
refreshToken: string;
|
||||
resource?: URL;
|
||||
|
|
@ -903,7 +1052,7 @@ export async function registerClient(
|
|||
clientMetadata,
|
||||
fetchFn,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
clientMetadata: OAuthClientMetadata;
|
||||
fetchFn?: FetchLike;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -352,6 +352,11 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
describe("auth handling", () => {
|
||||
const authServerMetadataUrls = [
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/openid-configuration",
|
||||
];
|
||||
|
||||
let mockAuthProvider: jest.Mocked<OAuthClientProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -608,7 +613,7 @@ describe("SSEClientTransport", () => {
|
|||
authServer.close();
|
||||
|
||||
authServer = createServer((req, res) => {
|
||||
if (req.url === "/.well-known/oauth-authorization-server") {
|
||||
if (req.url && authServerMetadataUrls.includes(req.url)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
|
@ -730,7 +735,7 @@ describe("SSEClientTransport", () => {
|
|||
authServer.close();
|
||||
|
||||
authServer = createServer((req, res) => {
|
||||
if (req.url === "/.well-known/oauth-authorization-server") {
|
||||
if (req.url && authServerMetadataUrls.includes(req.url)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
|
@ -875,7 +880,7 @@ describe("SSEClientTransport", () => {
|
|||
authServer.close();
|
||||
|
||||
authServer = createServer((req, res) => {
|
||||
if (req.url === "/.well-known/oauth-authorization-server") {
|
||||
if (req.url && authServerMetadataUrls.includes(req.url)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,68 @@ export const OAuthMetadataSchema = z
|
|||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* OpenID Connect Discovery 1.0 Provider Metadata
|
||||
* see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
*/
|
||||
export const OpenIdProviderMetadataSchema = z
|
||||
.object({
|
||||
issuer: z.string(),
|
||||
authorization_endpoint: z.string(),
|
||||
token_endpoint: z.string(),
|
||||
userinfo_endpoint: z.string().optional(),
|
||||
jwks_uri: z.string(),
|
||||
registration_endpoint: z.string().optional(),
|
||||
scopes_supported: z.array(z.string()).optional(),
|
||||
response_types_supported: z.array(z.string()),
|
||||
response_modes_supported: z.array(z.string()).optional(),
|
||||
grant_types_supported: z.array(z.string()).optional(),
|
||||
acr_values_supported: z.array(z.string()).optional(),
|
||||
subject_types_supported: z.array(z.string()),
|
||||
id_token_signing_alg_values_supported: z.array(z.string()),
|
||||
id_token_encryption_alg_values_supported: z.array(z.string()).optional(),
|
||||
id_token_encryption_enc_values_supported: z.array(z.string()).optional(),
|
||||
userinfo_signing_alg_values_supported: z.array(z.string()).optional(),
|
||||
userinfo_encryption_alg_values_supported: z.array(z.string()).optional(),
|
||||
userinfo_encryption_enc_values_supported: z.array(z.string()).optional(),
|
||||
request_object_signing_alg_values_supported: z.array(z.string()).optional(),
|
||||
request_object_encryption_alg_values_supported: z
|
||||
.array(z.string())
|
||||
.optional(),
|
||||
request_object_encryption_enc_values_supported: z
|
||||
.array(z.string())
|
||||
.optional(),
|
||||
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
|
||||
token_endpoint_auth_signing_alg_values_supported: z
|
||||
.array(z.string())
|
||||
.optional(),
|
||||
display_values_supported: z.array(z.string()).optional(),
|
||||
claim_types_supported: z.array(z.string()).optional(),
|
||||
claims_supported: z.array(z.string()).optional(),
|
||||
service_documentation: z.string().optional(),
|
||||
claims_locales_supported: z.array(z.string()).optional(),
|
||||
ui_locales_supported: z.array(z.string()).optional(),
|
||||
claims_parameter_supported: z.boolean().optional(),
|
||||
request_parameter_supported: z.boolean().optional(),
|
||||
request_uri_parameter_supported: z.boolean().optional(),
|
||||
require_request_uri_registration: z.boolean().optional(),
|
||||
op_policy_uri: z.string().optional(),
|
||||
op_tos_uri: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* OpenID Connect Discovery metadata that may include OAuth 2.0 fields
|
||||
* This schema represents the real-world scenario where OIDC providers
|
||||
* return a mix of OpenID Connect and OAuth 2.0 metadata fields
|
||||
*/
|
||||
export const OpenIdProviderDiscoveryMetadataSchema =
|
||||
OpenIdProviderMetadataSchema.merge(
|
||||
OAuthMetadataSchema.pick({
|
||||
code_challenge_methods_supported: true,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* OAuth 2.1 token response
|
||||
*/
|
||||
|
|
@ -133,8 +195,10 @@ export const OAuthTokenRevocationRequestSchema = z.object({
|
|||
token_type_hint: z.string().optional(),
|
||||
}).strip();
|
||||
|
||||
|
||||
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
|
||||
export type OpenIdProviderMetadata = z.infer<typeof OpenIdProviderMetadataSchema>;
|
||||
export type OpenIdProviderDiscoveryMetadata = z.infer<typeof OpenIdProviderDiscoveryMetadataSchema>;
|
||||
|
||||
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
|
||||
export type OAuthErrorResponse = z.infer<typeof OAuthErrorResponseSchema>;
|
||||
export type OAuthClientMetadata = z.infer<typeof OAuthClientMetadataSchema>;
|
||||
|
|
@ -143,3 +207,6 @@ export type OAuthClientInformationFull = z.infer<typeof OAuthClientInformationFu
|
|||
export type OAuthClientRegistrationError = z.infer<typeof OAuthClientRegistrationErrorSchema>;
|
||||
export type OAuthTokenRevocationRequest = z.infer<typeof OAuthTokenRevocationRequestSchema>;
|
||||
export type OAuthProtectedResourceMetadata = z.infer<typeof OAuthProtectedResourceMetadataSchema>;
|
||||
|
||||
// Unified type for authorization server metadata
|
||||
export type AuthorizationServerMetadata = OAuthMetadata | OpenIdProviderDiscoveryMetadata;
|
||||
|
|
|
|||
Loading…
Reference in a new issue