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:
Xiao Yijun 2025-07-24 21:11:48 +08:00 • committed by GitHub
commit bb7cccc3ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 486 additions and 47 deletions

View file

@ -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");
});

View file

@ -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;
},

View file

@ -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;
}

View file

@ -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;