diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b0ea8d1e..9aca7221 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1,6 +1,7 @@ import { LATEST_PROTOCOL_VERSION } from '../types.js'; import { discoverOAuthMetadata, + discoverAuthorizationServerMetadata, startAuthorization, exchangeAuthorization, refreshAuthorization, @@ -11,7 +12,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(); @@ -683,6 +684,302 @@ describe("OAuth Authorization", () => { }); }); + 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("returns OAuth metadata when authorizationServerUrl is provided and OAuth discovery succeeds", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://mcp.example.com", + "https://auth.example.com" + ); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url, options] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(options.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("falls back to OpenID Connect discovery when OAuth discovery fails", async () => { + // First call (OAuth) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (OpenID Connect) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://mcp.example.com", + "https://auth.example.com" + ); + + expect(metadata).toEqual(validOpenIdMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be OAuth discovery + expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + + // Second call should be OpenID Connect discovery + expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration"); + }); + + it("returns undefined when authorizationServerUrl is provided but both discoveries fail", async () => { + // Both calls return 404 + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://mcp.example.com", + "https://auth.example.com" + ); + + expect(metadata).toBeUndefined(); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it("handles authorization server URL with path in OAuth discovery", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://mcp.example.com", + "https://auth.example.com/tenant1" + ); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1"); + }); + + it("handles authorization server URL with path in OpenID Connect discovery", async () => { + // OAuth discovery fails + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // OpenID Connect discovery succeeds with path insertion + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://mcp.example.com", + "https://auth.example.com/tenant1" + ); + + expect(metadata).toEqual(validOpenIdMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be OAuth with path + expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1"); + + // Second call should be OpenID Connect with path insertion + expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration/tenant1"); + }); + + it("tries multiple OpenID Connect endpoints when path is present", async () => { + // OAuth discovery fails + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // First OpenID Connect attempt (path insertion) fails + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second OpenID Connect attempt (path prepending) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://mcp.example.com", + "https://auth.example.com/tenant1" + ); + + expect(metadata).toEqual(validOpenIdMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // First call should be OAuth with path + expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1"); + + // Second call should be OpenID Connect with path insertion + expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration/tenant1"); + + // Third call should be OpenID Connect with path prepending + expect(calls[2][0].toString()).toBe("https://auth.example.com/tenant1/.well-known/openid-configuration"); + }); + + it("falls back to legacy MCP server when authorizationServerUrl is undefined", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://mcp.example.com", + undefined + ); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]; + expect(url.toString()).toBe("https://mcp.example.com/.well-known/oauth-authorization-server"); + }); + + it("returns fallback metadata when legacy MCP server returns 404", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://mcp.example.com", + undefined + ); + + expect(metadata).toEqual({ + issuer: "https://mcp.example.com", + authorization_endpoint: "https://mcp.example.com/authorize", + token_endpoint: "https://mcp.example.com/token", + registration_endpoint: "https://mcp.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }); + }); + + it("throws on non-404 errors in legacy mode", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect( + discoverAuthorizationServerMetadata("https://mcp.example.com", undefined) + ).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://mcp.example.com", + "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://mcp.example.com", + "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://mcp.example.com", + "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 +1206,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 +1388,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 ?? '?'); diff --git a/src/client/auth.ts b/src/client/auth.ts index b5a3a6a4..185d42ef 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -7,7 +7,10 @@ import { OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata, - OAuthErrorResponseSchema + OAuthErrorResponseSchema, + OpenIdProviderDiscoveryMetadata, + 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 +111,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; + addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata): void | Promise; /** * If defined, overrides the selection and validation of the @@ -319,7 +322,7 @@ async function authInternal( ): Promise { 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) { @@ -331,9 +334,17 @@ async function authInternal( const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverOAuthMetadata(serverUrl, { - authorizationServerUrl - }, fetchFn); + const metadata = await discoverAuthorizationServerMetadata(serverUrl, authorizationServerUrl, { + fetchFn, + }); + + /** + * 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; + } // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -524,15 +535,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 +611,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, @@ -640,6 +659,219 @@ export async function discoverOAuthMetadata( return OAuthMetadataSchema.parse(await response.json()); } +/** + * 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. If `authorizationServerUrl` is provided, attempts RFC 8414 OAuth metadata discovery first + * 2. If OAuth discovery fails, falls back to OpenID Connect Discovery + * 3. If `authorizationServerUrl` is not provided, uses legacy MCP specification behavior + * + * @param serverUrl - The MCP Server URL, used for legacy specification support where the MCP server + * acts as both the resource server and authorization server + * @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's + * protected resource metadata. If this parameter is `undefined`, + * it indicates that protected resource metadata was not successfully + * retrieved, triggering legacy fallback behavior + * @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( + serverUrl: string | URL, + authorizationServerUrl?: string | URL, + { + fetchFn = fetch, + protocolVersion = LATEST_PROTOCOL_VERSION, + }: { + fetchFn?: FetchLike; + protocolVersion?: string; + } = {} +): Promise { + if (!authorizationServerUrl) { + // Legacy support: MCP servers act as the Auth server. + return retrieveOAuthMetadataFromMcpServer(serverUrl, { + fetchFn, + protocolVersion, + }); + } + + const oauthMetadata = await retrieveOAuthMetadataFromAuthorizationServer(authorizationServerUrl, { + fetchFn, + protocolVersion, + }); + + if (oauthMetadata) { + return oauthMetadata; + } + + return retrieveOpenIdProviderMetadataFromAuthorizationServer(authorizationServerUrl, { + fetchFn, + protocolVersion, + }); +} + +/** + * Legacy implementation where the MCP server acts as the Auth server. + * According to MCP spec version 2025-03-26. + * + * @param serverUrl - The MCP Server URL + * @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 (required) + * @returns Promise resolving to OAuth metadata + */ +async function retrieveOAuthMetadataFromMcpServer( + serverUrl: string | URL, + { + fetchFn = fetch, + protocolVersion, + }: { + fetchFn?: FetchLike; + protocolVersion: string; + } +): Promise { + const serverOrigin = typeof serverUrl === 'string' ? new URL(serverUrl).origin : serverUrl.origin; + + const metadataEndpoint = new URL(buildWellKnownPath('oauth-authorization-server'), serverOrigin); + + const response = await fetchWithCorsRetry(metadataEndpoint, getProtocolVersionHeader(protocolVersion), fetchFn); + + if (!response) { + throw new Error(`CORS error trying to load OAuth metadata from ${metadataEndpoint}`); + } + + if (!response.ok) { + if (response.status === 404) { + /** + * The MCP server does not implement OAuth 2.0 Authorization Server Metadata + * + * Return fallback OAuth 2.0 Authorization Server Metadata + */ + return { + issuer: serverOrigin, + authorization_endpoint: new URL('/authorize', serverOrigin).href, + token_endpoint: new URL('/token', serverOrigin).href, + registration_endpoint: new URL('/register', serverOrigin).href, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }; + } + + throw new Error(`HTTP ${response.status} trying to load OAuth metadata from ${metadataEndpoint}`); + } + + return OAuthMetadataSchema.parse(await response.json()); +} + +/** + * Retrieves RFC 8414 OAuth 2.0 Authorization Server Metadata from the authorization server. + * + * @param authorizationServerUrl - The authorization server URL + * @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 (required) + * @returns Promise resolving to OAuth metadata, or undefined if discovery fails + */ +async function retrieveOAuthMetadataFromAuthorizationServer( + authorizationServerUrl: string | URL, + { + fetchFn = fetch, + protocolVersion, + }: { + fetchFn?: FetchLike; + protocolVersion: string; + } +): Promise { + const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl; + + const hasPath = url.pathname !== '/'; + + const metadataEndpoint = new URL( + buildWellKnownPath('oauth-authorization-server', hasPath ? url.pathname : ''), + url.origin + ); + + const response = await fetchWithCorsRetry(metadataEndpoint, getProtocolVersionHeader(protocolVersion), fetchFn); + + if (!response) { + throw new Error(`CORS error trying to load OAuth metadata from ${metadataEndpoint}`); + } + + if (!response.ok) { + if (response.status === 404) { + return undefined; + } + + throw new Error(`HTTP ${response.status} trying to load OAuth metadata from ${metadataEndpoint}`); + } + + return OAuthMetadataSchema.parse(await response.json()); +} + +/** + * Retrieves OpenID Connect Discovery 1.0 metadata from the authorization server. + * + * @param authorizationServerUrl - The authorization server URL + * @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 (required) + * @returns Promise resolving to OpenID provider metadata, or undefined if discovery fails + */ +async function retrieveOpenIdProviderMetadataFromAuthorizationServer( + authorizationServerUrl: string | URL, + { + fetchFn = fetch, + protocolVersion, + }: { + fetchFn?: FetchLike; + protocolVersion: string; + } +): Promise { + const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl; + const hasPath = url.pathname !== '/'; + + const potentialMetadataEndpoints = hasPath + ? [ + // https://example.com/.well-known/openid-configuration/tenant1 + new URL(buildWellKnownPath('openid-configuration', url.pathname), url.origin), + // https://example.com/tenant1/.well-known/openid-configuration + new URL(buildWellKnownPath('openid-configuration', url.pathname, { prependPathname: true }), `${url.origin}`), + ] + : [ + // https://example.com/.well-known/openid-configuration + new URL(buildWellKnownPath('openid-configuration'), url.origin), + ]; + + for (const endpoint of potentialMetadataEndpoints) { + const response = await fetchWithCorsRetry(endpoint, getProtocolVersionHeader(protocolVersion), fetchFn); + + if (!response) { + throw new Error(`CORS error trying to load OpenID provider metadata from ${endpoint}`); + } + + if (!response.ok) { + if (response.status === 404) { + continue; + } + + throw new Error(`HTTP ${response.status} trying to load OpenID provider metadata from ${endpoint}`); + } + + return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + } + + return undefined; +} + +function getProtocolVersionHeader(protocolVersion: string): Record { + return { + 'MCP-Protocol-Version': protocolVersion, + }; +} + /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ @@ -653,7 +885,7 @@ export async function startAuthorization( state, resource, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; scope?: string; @@ -746,7 +978,7 @@ export async function exchangeAuthorization( addClientAuthentication, fetchFn, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; authorizationCode: string; codeVerifier: string; @@ -831,7 +1063,7 @@ export async function refreshAuthorization( addClientAuthentication, fetchFn, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; refreshToken: string; resource?: URL; @@ -902,7 +1134,7 @@ export async function registerClient( clientMetadata, fetchFn, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientMetadata: OAuthClientMetadata; fetchFn?: FetchLike; }, diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 24bfe094..4fce9976 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -352,6 +352,11 @@ describe("SSEClientTransport", () => { }); describe("auth handling", () => { + const authServerMetadataUrls = [ + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", + ]; + let mockAuthProvider: jest.Mocked; 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; } diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 467680a5..47eba9ac 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -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; +export type OpenIdProviderMetadata = z.infer; +export type OpenIdProviderDiscoveryMetadata = z.infer; + export type OAuthTokens = z.infer; export type OAuthErrorResponse = z.infer; export type OAuthClientMetadata = z.infer; @@ -143,3 +207,6 @@ export type OAuthClientInformationFull = z.infer; export type OAuthTokenRevocationRequest = z.infer; export type OAuthProtectedResourceMetadata = z.infer; + +// Unified type for authorization server metadata +export type AuthorizationServerMetadata = OAuthMetadata | OpenIdProviderDiscoveryMetadata;