diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b0ea8d1e8..c3049124e 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -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"); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index b5a3a6a43..0f8e84b35 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -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; + addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata): void | Promise; /** * If defined, overrides the selection and validation of the @@ -319,7 +321,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) { @@ -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 { + 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; @@ -831,7 +980,7 @@ export async function refreshAuthorization( addClientAuthentication, fetchFn, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; refreshToken: string; resource?: URL; @@ -902,7 +1051,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 24bfe094c..4fce9976f 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 467680a56..47eba9ac5 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;