From 57dfe5635374c99a49c9b8103b5cdefd68cff864 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 19 Jan 2026 18:28:15 +0800 Subject: [PATCH 1/2] feat: support discovery config for lazy metadata fetching --- .../auth/auth-server-metadata-cache.test.ts | 181 ++++++++++++++++++ .../src/auth/auth-server-metadata-cache.ts | 63 ++++++ .../auth/authorization-server-handler.test.ts | 16 +- .../src/auth/authorization-server-handler.ts | 23 ++- .../src/auth/resource-server-handler.test.ts | 66 ++++++- .../src/auth/resource-server-handler.ts | 3 +- .../mcp-auth/src/auth/token-verifier.test.ts | 56 +++++- packages/mcp-auth/src/auth/token-verifier.ts | 64 +++++-- .../mcp-auth/src/index.integration.test.ts | 15 +- packages/mcp-auth/src/index.ts | 44 ++++- packages/mcp-auth/src/types/auth-server.ts | 61 +++++- .../mcp-auth/src/utils/fetch-server-config.ts | 10 +- .../utils/transpile-resource-metadata.test.ts | 48 +++++ .../src/utils/transpile-resource-metadata.ts | 13 +- .../src/utils/validate-auth-server.test.ts | 15 +- .../src/utils/validate-auth-server.ts | 27 ++- .../src/utils/validate-server-config.ts | 18 +- 17 files changed, 656 insertions(+), 67 deletions(-) create mode 100644 packages/mcp-auth/src/auth/auth-server-metadata-cache.test.ts create mode 100644 packages/mcp-auth/src/auth/auth-server-metadata-cache.ts diff --git a/packages/mcp-auth/src/auth/auth-server-metadata-cache.test.ts b/packages/mcp-auth/src/auth/auth-server-metadata-cache.test.ts new file mode 100644 index 0000000..6ef7f97 --- /dev/null +++ b/packages/mcp-auth/src/auth/auth-server-metadata-cache.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + type AuthServerConfig, + type AuthServerDiscoveryConfig, + type ResolvedAuthServerConfig, +} from '../types/auth-server.js'; +import { type CamelCaseAuthorizationServerMetadata } from '../types/oauth.js'; +import { fetchServerConfig } from '../utils/fetch-server-config.js'; +import { validateResolvedAuthServer } from '../utils/validate-auth-server.js'; + +import { AuthServerMetadataCache } from './auth-server-metadata-cache.js'; + +vi.mock('../utils/fetch-server-config.js'); +vi.mock('../utils/validate-auth-server.js'); + +const mockMetadata: CamelCaseAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + authorizationEndpoint: 'https://auth.example.com/auth', + tokenEndpoint: 'https://auth.example.com/token', + jwksUri: 'https://auth.example.com/jwks', + responseTypesSupported: ['code'], + grantTypesSupported: ['authorization_code'], + codeChallengeMethodsSupported: ['S256'], +}; + +const resolvedConfig: ResolvedAuthServerConfig = { + type: 'oidc', + metadata: mockMetadata, +}; + +const discoveryConfig: AuthServerDiscoveryConfig = { + issuer: 'https://auth.example.com', + type: 'oidc', +}; + +describe('AuthServerMetadataCache', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getMetadata', () => { + it('should return metadata directly for resolved config', async () => { + const cache = new AuthServerMetadataCache(); + const result = await cache.getMetadata(resolvedConfig); + + expect(result).toBe(resolvedConfig.metadata); + expect(fetchServerConfig).not.toHaveBeenCalled(); + }); + + it('should fetch metadata for discovery config', async () => { + vi.mocked(fetchServerConfig).mockResolvedValue(resolvedConfig); + + const cache = new AuthServerMetadataCache(); + const result = await cache.getMetadata(discoveryConfig); + + expect(fetchServerConfig).toHaveBeenCalledWith(discoveryConfig.issuer, { + type: discoveryConfig.type, + }); + expect(validateResolvedAuthServer).toHaveBeenCalledWith(resolvedConfig); + expect(result).toEqual(mockMetadata); + }); + + it('should cache fetched metadata and return cached value on subsequent calls', async () => { + vi.mocked(fetchServerConfig).mockResolvedValue(resolvedConfig); + + const cache = new AuthServerMetadataCache(); + + // First call - should fetch + const result1 = await cache.getMetadata(discoveryConfig); + expect(fetchServerConfig).toHaveBeenCalledTimes(1); + + // Second call - should return cached + const result2 = await cache.getMetadata(discoveryConfig); + expect(fetchServerConfig).toHaveBeenCalledTimes(1); // Still 1, not 2 + + expect(result1).toEqual(mockMetadata); + expect(result2).toEqual(mockMetadata); + }); + + it('should share the same promise for concurrent requests', async () => { + // eslint-disable-next-line @silverhand/fp/no-let + let resolvePromise: (value: ResolvedAuthServerConfig) => void; + const delayedPromise = new Promise((resolve) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + resolvePromise = resolve; + }); + vi.mocked(fetchServerConfig).mockReturnValue(delayedPromise); + + const cache = new AuthServerMetadataCache(); + + // Start two concurrent requests + const promise1 = cache.getMetadata(discoveryConfig); + const promise2 = cache.getMetadata(discoveryConfig); + + // Should only call fetch once + expect(fetchServerConfig).toHaveBeenCalledTimes(1); + + // Resolve the promise + resolvePromise!(resolvedConfig); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1).toEqual(mockMetadata); + expect(result2).toEqual(mockMetadata); + }); + + it('should validate fetched metadata', async () => { + vi.mocked(fetchServerConfig).mockResolvedValue(resolvedConfig); + + const cache = new AuthServerMetadataCache(); + await cache.getMetadata(discoveryConfig); + + expect(validateResolvedAuthServer).toHaveBeenCalledWith(resolvedConfig); + }); + + it('should propagate validation errors', async () => { + const validationError = new Error('Invalid metadata'); + vi.mocked(fetchServerConfig).mockResolvedValue(resolvedConfig); + vi.mocked(validateResolvedAuthServer).mockImplementation(() => { + throw validationError; + }); + + const cache = new AuthServerMetadataCache(); + + await expect(cache.getMetadata(discoveryConfig)).rejects.toThrow(validationError); + }); + + it('should propagate fetch errors', async () => { + const fetchError = new Error('Network error'); + vi.mocked(fetchServerConfig).mockRejectedValue(fetchError); + + const cache = new AuthServerMetadataCache(); + + await expect(cache.getMetadata(discoveryConfig)).rejects.toThrow(fetchError); + }); + + it('should clear promise cache after fetch completes (success)', async () => { + vi.mocked(fetchServerConfig).mockResolvedValue(resolvedConfig); + + const cache = new AuthServerMetadataCache(); + await cache.getMetadata(discoveryConfig); + + // After completion, promise cache should be cleared but value cache should have the result + // A new discovery config with different issuer should trigger a new fetch + const anotherDiscoveryConfig: AuthServerConfig = { + issuer: 'https://another-auth.example.com', + type: 'oidc', + }; + const anotherMetadata: CamelCaseAuthorizationServerMetadata = { + ...mockMetadata, + issuer: 'https://another-auth.example.com', + }; + vi.mocked(fetchServerConfig).mockResolvedValue({ + type: 'oidc', + metadata: anotherMetadata, + }); + + const result = await cache.getMetadata(anotherDiscoveryConfig); + expect(fetchServerConfig).toHaveBeenCalledTimes(2); + expect(result).toEqual(anotherMetadata); + }); + + it('should clear promise cache after fetch fails', async () => { + const fetchError = new Error('Network error'); + vi.mocked(fetchServerConfig).mockRejectedValueOnce(fetchError); + + const cache = new AuthServerMetadataCache(); + + // First call fails + await expect(cache.getMetadata(discoveryConfig)).rejects.toThrow(fetchError); + + // Second call should retry (promise cache was cleared) + vi.mocked(fetchServerConfig).mockResolvedValue(resolvedConfig); + const result = await cache.getMetadata(discoveryConfig); + + expect(fetchServerConfig).toHaveBeenCalledTimes(2); + expect(result).toEqual(mockMetadata); + }); + }); +}); diff --git a/packages/mcp-auth/src/auth/auth-server-metadata-cache.ts b/packages/mcp-auth/src/auth/auth-server-metadata-cache.ts new file mode 100644 index 0000000..3c1a84a --- /dev/null +++ b/packages/mcp-auth/src/auth/auth-server-metadata-cache.ts @@ -0,0 +1,63 @@ +import { type AuthServerConfig } from '../types/auth-server.js'; +import { type CamelCaseAuthorizationServerMetadata } from '../types/oauth.js'; +import { fetchServerConfig } from '../utils/fetch-server-config.js'; +import { validateResolvedAuthServer } from '../utils/validate-auth-server.js'; + +/** + * A cache that stores auth server metadata with support for on-demand fetching. + * + * For resolved configs (with metadata), returns the metadata directly. + * For discovery configs (issuer + type only), fetches metadata on first access and caches it. + * + * Uses a two-layer cache to prevent duplicate concurrent requests: + * - `valueCache`: stores resolved metadata + * - `promiseCache`: stores in-flight promises (removed after resolution) + */ +export class AuthServerMetadataCache { + private readonly valueCache = new Map(); + private readonly promiseCache = new Map>(); + + /** + * Get metadata for the given auth server config. + * + * - Resolved config: returns metadata directly + * - Discovery config: fetches metadata on first call, validates it, and returns cached value on subsequent calls + * + * Concurrent calls for the same issuer will share a single fetch request. + */ + async getMetadata(config: AuthServerConfig): Promise { + if ('metadata' in config) { + return config.metadata; + } + + const { issuer, type } = config; + + // Return cached value if exists + const cached = this.valueCache.get(issuer); + if (cached) { + return cached; + } + + // Return existing promise if request is in progress + const existingPromise = this.promiseCache.get(issuer); + if (existingPromise) { + return existingPromise; + } + + // Create new promise + const promise = (async () => { + try { + const resolvedConfig = await fetchServerConfig(issuer, { type }); + // Validate the fetched metadata + validateResolvedAuthServer(resolvedConfig); + this.valueCache.set(issuer, resolvedConfig.metadata); + return resolvedConfig.metadata; + } finally { + this.promiseCache.delete(issuer); + } + })(); + + this.promiseCache.set(issuer, promise); + return promise; + } +} diff --git a/packages/mcp-auth/src/auth/authorization-server-handler.test.ts b/packages/mcp-auth/src/auth/authorization-server-handler.test.ts index f7e5a84..2bc40ea 100644 --- a/packages/mcp-auth/src/auth/authorization-server-handler.test.ts +++ b/packages/mcp-auth/src/auth/authorization-server-handler.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { type AuthServerConfig } from '../types/auth-server.js'; +import { type AuthServerConfig, type AuthServerDiscoveryConfig } from '../types/auth-server.js'; import { validateAuthServer } from '../utils/validate-auth-server.js'; import { @@ -30,6 +30,11 @@ describe('AuthorizationServerHandler', () => { server: mockServerConfig, }; + const discoveryConfig: AuthServerDiscoveryConfig = { + issuer: 'https://discovery.example.com', + type: 'oidc', + }; + afterEach(() => { vi.restoreAllMocks(); }); @@ -52,6 +57,15 @@ describe('AuthorizationServerHandler', () => { 'The authorization server mode is deprecated. Please use resource server mode instead.' ); }); + + it('should work with discovery config', () => { + const discoveryMockConfig: AuthServerModeConfig = { + server: discoveryConfig, + }; + const _ = new AuthorizationServerHandler(discoveryMockConfig); + expect(validateAuthServer).toHaveBeenCalledWith(discoveryConfig); + expect(TokenVerifier).toHaveBeenCalledWith([discoveryConfig]); + }); }); describe('createMetadataRouter', () => { diff --git a/packages/mcp-auth/src/auth/authorization-server-handler.ts b/packages/mcp-auth/src/auth/authorization-server-handler.ts index c1a2598..8a21a72 100644 --- a/packages/mcp-auth/src/auth/authorization-server-handler.ts +++ b/packages/mcp-auth/src/auth/authorization-server-handler.ts @@ -1,9 +1,12 @@ -import { type Router } from 'express'; +import cors from 'cors'; +import { Router, type Router as RouterType } from 'express'; +import snakecaseKeys from 'snakecase-keys'; -import { createDelegatedRouter } from '../routers/create-delegated-router.js'; import { type AuthServerConfig } from '../types/auth-server.js'; +import { serverMetadataPaths } from '../utils/fetch-server-config.js'; import { validateAuthServer } from '../utils/validate-auth-server.js'; +import { AuthServerMetadataCache } from './auth-server-metadata-cache.js'; import { MCPAuthHandler } from './mcp-auth-handler.js'; import { type GetTokenVerifierOptions, TokenVerifier } from './token-verifier.js'; @@ -27,6 +30,9 @@ export class AuthorizationServerHandler extends MCPAuthHandler { /** The `TokenVerifier` for the legacy `server` configuration. */ private readonly tokenVerifier: TokenVerifier; + /** Cache for auth server metadata, supporting discovery configs. */ + private readonly metadataCache = new AuthServerMetadataCache(); + constructor(private readonly config: AuthServerModeConfig) { super(); @@ -37,8 +43,17 @@ export class AuthorizationServerHandler extends MCPAuthHandler { this.tokenVerifier = new TokenVerifier([config.server]); } - createMetadataRouter(): Router { - return createDelegatedRouter(this.config.server.metadata); + createMetadataRouter(): RouterType { + // eslint-disable-next-line new-cap + const router = Router(); + + router.use(serverMetadataPaths.oauth, cors()); + router.get(serverMetadataPaths.oauth, async (_, response) => { + const metadata = await this.metadataCache.getMetadata(this.config.server); + response.status(200).json(snakecaseKeys(metadata)); + }); + + return router; } /** diff --git a/packages/mcp-auth/src/auth/resource-server-handler.test.ts b/packages/mcp-auth/src/auth/resource-server-handler.test.ts index 840d3ac..c7c0193 100644 --- a/packages/mcp-auth/src/auth/resource-server-handler.test.ts +++ b/packages/mcp-auth/src/auth/resource-server-handler.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'; import { MCPAuthAuthServerError } from '../errors.js'; -import { type AuthServerConfig } from '../types/auth-server.js'; +import { + type AuthServerDiscoveryConfig, + type ResolvedAuthServerConfig, +} from '../types/auth-server.js'; import { type ResourceServerConfig } from '../types/resource-server.js'; import { validateAuthServer } from '../utils/validate-auth-server.js'; @@ -11,7 +14,7 @@ import { TokenVerifier } from './token-verifier.js'; vi.mock('../utils/validate-auth-server.js'); vi.mock('./token-verifier.js'); -const authServer1: AuthServerConfig = { +const authServer1: ResolvedAuthServerConfig = { metadata: { issuer: 'https://auth1.example.com', authorizationEndpoint: 'https://auth1.example.com/oauth/authorize', @@ -24,7 +27,7 @@ const authServer1: AuthServerConfig = { type: 'oauth', }; -const authServer2: AuthServerConfig = { +const authServer2: ResolvedAuthServerConfig = { type: 'oauth', metadata: { issuer: 'https://auth2.example.com', @@ -37,6 +40,11 @@ const authServer2: AuthServerConfig = { }, }; +const discoveryAuthServer: AuthServerDiscoveryConfig = { + issuer: 'https://discovery-auth.example.com', + type: 'oidc', +}; + const resourceServerConfig1: ResourceServerConfig = { metadata: { resource: 'https://api.example.com/resource1', @@ -117,6 +125,57 @@ describe('ResourceServerHandler', () => { expect(validateAuthServer).toHaveBeenCalledTimes(2); expect(TokenVerifier).toHaveBeenCalledTimes(2); }); + + it('should work with discovery config', () => { + const resourceWithDiscovery: ResourceServerConfig = { + metadata: { + resource: 'https://api.example.com/resource-discovery', + authorizationServers: [discoveryAuthServer], + scopesSupported: ['read', 'write'], + }, + }; + const mockConfig: ResourceServerModeConfig = { + protectedResources: resourceWithDiscovery, + }; + + expect(() => new ResourceServerHandler(mockConfig)).not.toThrow(); + expect(validateAuthServer).toHaveBeenCalledWith(discoveryAuthServer); + expect(TokenVerifier).toHaveBeenCalledWith([discoveryAuthServer]); + }); + + it('should detect duplicate discovery config issuers for a single resource', () => { + const resourceWithDuplicatedDiscovery: ResourceServerConfig = { + metadata: { + resource: 'https://api.example.com/resource-dup', + authorizationServers: [discoveryAuthServer, discoveryAuthServer], + }, + }; + const mockConfig: ResourceServerModeConfig = { + protectedResources: resourceWithDuplicatedDiscovery, + }; + const expectedError = new MCPAuthAuthServerError('invalid_server_config', { + cause: `The authorization server (\`${discoveryAuthServer.issuer}\`) for resource \`https://api.example.com/resource-dup\` is duplicated.`, + }); + expect(() => new ResourceServerHandler(mockConfig)).toThrow(expectedError); + }); + + it('should work with mixed resolved and discovery configs', () => { + const resourceWithMixed: ResourceServerConfig = { + metadata: { + resource: 'https://api.example.com/resource-mixed', + authorizationServers: [authServer1, discoveryAuthServer], + scopesSupported: ['read', 'write'], + }, + }; + const mockConfig: ResourceServerModeConfig = { + protectedResources: resourceWithMixed, + }; + + expect(() => new ResourceServerHandler(mockConfig)).not.toThrow(); + expect(validateAuthServer).toHaveBeenCalledWith(authServer1); + expect(validateAuthServer).toHaveBeenCalledWith(discoveryAuthServer); + expect(TokenVerifier).toHaveBeenCalledWith([authServer1, discoveryAuthServer]); + }); }); describe('createMetadataRouter', () => { @@ -176,6 +235,7 @@ describe('ResourceServerHandler', () => { cause: 'A `resource` must be specified in the `bearerAuth` configuration when using a `protectedResources` configuration.', }); + // @ts-expect-error - Testing runtime behavior when resource is not specified expect(() => handler.getTokenVerifier({})).toThrow(expectedError); }); diff --git a/packages/mcp-auth/src/auth/resource-server-handler.ts b/packages/mcp-auth/src/auth/resource-server-handler.ts index fd93912..47b5b43 100644 --- a/packages/mcp-auth/src/auth/resource-server-handler.ts +++ b/packages/mcp-auth/src/auth/resource-server-handler.ts @@ -2,6 +2,7 @@ import { type Router } from 'express'; import { MCPAuthAuthServerError } from '../errors.js'; import { createResourceMetadataRouter } from '../routers/create-resource-metadata-router.js'; +import { getIssuer } from '../types/auth-server.js'; import { type ResourceServerConfig } from '../types/resource-server.js'; import { transpileResourceMetadata } from '../utils/transpile-resource-metadata.js'; import { validateAuthServer } from '../utils/validate-auth-server.js'; @@ -80,7 +81,7 @@ export class ResourceServerHandler extends MCPAuthHandler { const uniqueAuthServers = new Set(); for (const authServer of authorizationServers ?? []) { - const { issuer } = authServer.metadata; + const issuer = getIssuer(authServer); if (uniqueAuthServers.has(issuer)) { throw new MCPAuthAuthServerError('invalid_server_config', { cause: `The authorization server (\`${issuer}\`) for resource \`${resource}\` is duplicated.`, diff --git a/packages/mcp-auth/src/auth/token-verifier.test.ts b/packages/mcp-auth/src/auth/token-verifier.test.ts index 832050a..120993e 100644 --- a/packages/mcp-auth/src/auth/token-verifier.test.ts +++ b/packages/mcp-auth/src/auth/token-verifier.test.ts @@ -3,7 +3,11 @@ import * as jose from 'jose'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MCPAuthBearerAuthError } from '../errors.js'; -import { type AuthServerConfig } from '../types/auth-server.js'; +import { + type AuthServerConfig, + type AuthServerDiscoveryConfig, + getIssuer, +} from '../types/auth-server.js'; import { createVerifyJwt } from '../utils/create-verify-jwt.js'; import { TokenVerifier } from './token-verifier.js'; @@ -138,7 +142,9 @@ describe('TokenVerifier', () => { it('should reuse the same remote JWK Set instance for the same JWKS URI', async () => { const mockGetKey = vi.fn(); - vi.mocked(jose.createRemoteJWKSet).mockReturnValue(mockGetKey); + vi.mocked(jose.createRemoteJWKSet).mockReturnValue( + mockGetKey as unknown as ReturnType + ); vi.mocked(createVerifyJwt).mockReturnValue(vi.fn()); const token = await createJwt({ @@ -171,12 +177,56 @@ describe('TokenVerifier', () => { const tokenVerifier = new TokenVerifier(authServers); const validator = tokenVerifier.getJwtIssuerValidator(); const expectedError = new MCPAuthBearerAuthError('invalid_issuer', { - expected: authServers.map(({ metadata }) => metadata.issuer).join(', '), + expected: authServers.map((config) => getIssuer(config)).join(', '), actual: 'https://untrusted.issuer.com', }); expect(() => { validator('https://untrusted.issuer.com'); }).toThrow(expectedError); }); + + it('should work with discovery config', () => { + const discoveryConfig: AuthServerDiscoveryConfig = { + issuer: 'https://discovery.issuer.com', + type: 'oidc', + }; + const tokenVerifier = new TokenVerifier([discoveryConfig]); + const validator = tokenVerifier.getJwtIssuerValidator(); + + // Should not throw for the configured issuer + expect(() => { + validator('https://discovery.issuer.com'); + }).not.toThrow(); + + // Should throw for untrusted issuer + expect(() => { + validator('https://untrusted.issuer.com'); + }).toThrow(MCPAuthBearerAuthError); + }); + + it('should work with mixed resolved and discovery configs', () => { + const discoveryConfig: AuthServerDiscoveryConfig = { + issuer: 'https://discovery.issuer.com', + type: 'oidc', + }; + const mixedConfigs: AuthServerConfig[] = [...authServers, discoveryConfig]; + const tokenVerifier = new TokenVerifier(mixedConfigs); + const validator = tokenVerifier.getJwtIssuerValidator(); + + // Should not throw for resolved config issuer + expect(() => { + validator('https://trusted.issuer.com'); + }).not.toThrow(); + + // Should not throw for discovery config issuer + expect(() => { + validator('https://discovery.issuer.com'); + }).not.toThrow(); + + // Should throw for untrusted issuer + expect(() => { + validator('https://untrusted.issuer.com'); + }).toThrow(MCPAuthBearerAuthError); + }); }); }); diff --git a/packages/mcp-auth/src/auth/token-verifier.ts b/packages/mcp-auth/src/auth/token-verifier.ts index a6e1f29..4c66442 100644 --- a/packages/mcp-auth/src/auth/token-verifier.ts +++ b/packages/mcp-auth/src/auth/token-verifier.ts @@ -11,9 +11,11 @@ import { type ValidateIssuerFunction, type VerifyAccessTokenFunction, } from '../handlers/handle-bearer-auth.js'; -import { type AuthServerConfig } from '../types/auth-server.js'; +import { type AuthServerConfig, getIssuer } from '../types/auth-server.js'; import { createVerifyJwt } from '../utils/create-verify-jwt.js'; +import { AuthServerMetadataCache } from './auth-server-metadata-cache.js'; + /** * Defines configuration options for creating a JWT verification function. */ @@ -41,6 +43,9 @@ export type GetTokenVerifierOptions = { * This class is a central internal abstraction that holds the authentication context, such as * the complete list of trusted authorization servers. It is responsible for creating * verification functions and validating token issuers based on that context. + * + * Supports both resolved configs (with metadata) and discovery configs (with on-demand + * metadata fetching), making it compatible with edge runtimes like Cloudflare Workers. */ export class TokenVerifier { /** @@ -50,16 +55,25 @@ export class TokenVerifier { */ private readonly jwksCache = new Map>(); + /** + * Cache for auth server metadata, supporting both resolved and discovery configs. + */ + private readonly metadataCache = new AuthServerMetadataCache(); + /** * Creates an instance of TokenVerifier. * @param authServers The complete configuration of all authorization servers trusted by the - * associated resource. + * associated resource. Can include both resolved configs (with metadata) and discovery configs + * (with just issuer and type). */ constructor(private readonly authServers: AuthServerConfig[]) {} /** * A factory method that creates a JWT verification function tailored to this verifier's policies. * The returned function will only trust issuers specified in this verifier's `authServers`. + * + * For discovery configs, the metadata will be fetched on first token verification. + * * @param config The per-call configuration for JWT verification. * @returns A function that takes a token string and returns a promise resolving with the * verified claims. @@ -74,15 +88,25 @@ export class TokenVerifier { */ this.getJwtIssuerValidator()(unverifiedIssuer); - const { jwksUri } = this.getAuthServerMetadataByIssuer(unverifiedIssuer) ?? {}; + const authServerConfig = this.getAuthServerConfigByIssuer(unverifiedIssuer); - if (!jwksUri) { + if (!authServerConfig) { + // This shouldn't happen as getJwtIssuerValidator would have thrown + throw new MCPAuthBearerAuthError('invalid_issuer', { + expected: this.getTrustedIssuers().join(', '), + actual: unverifiedIssuer, + }); + } + + const metadata = await this.metadataCache.getMetadata(authServerConfig); + + if (!metadata.jwksUri) { throw new MCPAuthAuthServerError('missing_jwks_uri', { cause: `The authorization server (\`${unverifiedIssuer}\`) does not have a JWKS URI configured.`, }); } - const getKey = this.getOrCreateRemoteJWKSet(jwksUri, remoteJwkSet); + const getKey = this.getOrCreateRemoteJWKSet(metadata.jwksUri, remoteJwkSet); return createVerifyJwt(getKey, jwtVerify)(token); }; } @@ -94,17 +118,32 @@ export class TokenVerifier { */ getJwtIssuerValidator(): ValidateIssuerFunction { return (issuer: string) => { - const authServer = this.getAuthServerMetadataByIssuer(issuer); + const authServerConfig = this.getAuthServerConfigByIssuer(issuer); - if (!authServer) { + if (!authServerConfig) { throw new MCPAuthBearerAuthError('invalid_issuer', { - expected: this.authServers.map(({ metadata }) => metadata.issuer).join(', '), + expected: this.getTrustedIssuers().join(', '), actual: issuer, }); } }; } + /** + * Gets the list of trusted issuer URLs from both resolved and discovery configs. + */ + private getTrustedIssuers(): string[] { + return this.authServers.map((config) => getIssuer(config)); + } + + /** + * Finds the auth server config for a given issuer. + * Works with both resolved configs (checks metadata.issuer) and discovery configs (checks issuer directly). + */ + private getAuthServerConfigByIssuer(issuer: string): AuthServerConfig | undefined { + return this.authServers.find((config) => getIssuer(config) === issuer); + } + /** * Decodes a JWT to extract its issuer without performing signature verification. * This is a necessary first step to determine which authorization server's keys to use. @@ -130,15 +169,6 @@ export class TokenVerifier { return payload.iss; } - /** - * Finds the full metadata for a given issuer from the list of configured authorization servers. - * @param issuer The issuer URL to look up. - * @returns The corresponding `AuthServerMetadata` or `undefined` if not found. - */ - private getAuthServerMetadataByIssuer(issuer: string) { - return this.authServers.find(({ metadata }) => metadata.issuer === issuer)?.metadata; - } - /** * Gets an existing remote JWK Set instance from the cache, or creates a new one if not cached. * Caching the instance allows jose's internal mechanisms (cooldownDuration, cacheMaxAge) to diff --git a/packages/mcp-auth/src/index.integration.test.ts b/packages/mcp-auth/src/index.integration.test.ts index b810abe..7d33219 100644 --- a/packages/mcp-auth/src/index.integration.test.ts +++ b/packages/mcp-auth/src/index.integration.test.ts @@ -5,12 +5,7 @@ import snakecaseKeys from 'snakecase-keys'; import request from 'supertest'; import { afterEach, describe, expect, it } from 'vitest'; -import { - type AuthServerConfig, - type AuthServerModeConfig, - MCPAuth, - serverMetadataPaths, -} from './index.js'; +import { type ResolvedAuthServerConfig, MCPAuth, serverMetadataPaths } from './index.js'; import { type ResourceServerConfig } from './types/resource-server.js'; const generateToken = async ({ @@ -60,7 +55,7 @@ describe('MCP Server as authorization server', () => { codeChallengeMethodsSupported: ['S256'], registrationEndpoint: `${issuer}${registrationPath}`, revocationEndpoint: `${issuer}${revocationPath}`, - } satisfies AuthServerModeConfig['server']['metadata']); + } satisfies ResolvedAuthServerConfig['metadata']); it('should create a delegated router with correct metadata', async () => { const auth = new MCPAuth({ server: { type: 'oauth', metadata: serverMetadata } }); @@ -88,7 +83,7 @@ describe('MCP Server as authorization server', () => { responseTypesSupported: ['code'], grantTypesSupported: ['authorization_code'], codeChallengeMethodsSupported: ['S256'], - } satisfies AuthServerModeConfig['server']['metadata']); + } satisfies ResolvedAuthServerConfig['metadata']); const createApp = () => { const auth = new MCPAuth({ server: { type: 'oauth', metadata } }); @@ -146,7 +141,7 @@ describe('MCP Server as resource server', () => { const resource1 = 'https://api.example.com/resource1'; const resource2 = 'https://api.example.com/resource2'; - const authServer1: AuthServerConfig = { + const authServer1: ResolvedAuthServerConfig = { metadata: { issuer: 'https://auth1.example.com', authorizationEndpoint: 'https://auth1.example.com/oauth/authorize', @@ -159,7 +154,7 @@ describe('MCP Server as resource server', () => { type: 'oauth', }; - const authServer2: AuthServerConfig = { + const authServer2: ResolvedAuthServerConfig = { type: 'oauth', metadata: { issuer: 'https://auth2.example.com', diff --git a/packages/mcp-auth/src/index.ts b/packages/mcp-auth/src/index.ts index f11f90c..18794f7 100644 --- a/packages/mcp-auth/src/index.ts +++ b/packages/mcp-auth/src/index.ts @@ -48,17 +48,45 @@ export type VerifyAccessTokenMode = 'jwt'; * * This is the recommended approach for new applications. * + * #### Option 1: Discovery config (recommended for edge runtimes) + * + * Use this when you want metadata to be fetched on-demand. This is especially useful for + * edge runtimes like Cloudflare Workers where top-level async fetch is not allowed. + * * ```ts * import express from 'express'; - * import { MCPAuth, fetchServerConfig } from 'mcp-auth'; + * import { MCPAuth } from 'mcp-auth'; * * const app = express(); + * const resourceIdentifier = 'https://api.example.com/notes'; * + * const mcpAuth = new MCPAuth({ + * protectedResources: [ + * { + * metadata: { + * resource: resourceIdentifier, + * // Just pass issuer and type - metadata will be fetched on first request + * authorizationServers: [{ issuer: 'https://auth.logto.io/oidc', type: 'oidc' }], + * scopesSupported: ['read:notes', 'write:notes'], + * }, + * }, + * ], + * }); + * ``` + * + * #### Option 2: Resolved config (pre-fetched metadata) + * + * Use this when you want to fetch and validate metadata at startup time. + * + * ```ts + * import express from 'express'; + * import { MCPAuth, fetchServerConfig } from 'mcp-auth'; + * + * const app = express(); * const resourceIdentifier = 'https://api.example.com/notes'; * const authServerConfig = await fetchServerConfig('https://auth.logto.io/oidc', { type: 'oidc' }); * * const mcpAuth = new MCPAuth({ - * // `protectedResources` can be a single configuration object or an array of them. * protectedResources: [ * { * metadata: { @@ -69,7 +97,11 @@ export type VerifyAccessTokenMode = 'jwt'; * }, * ], * }); + * ``` * + * #### Using the middleware + * + * ```ts * // Mount the router to handle Protected Resource Metadata * app.use(mcpAuth.protectedResourceMetadataRouter()); * @@ -94,14 +126,12 @@ export type VerifyAccessTokenMode = 'jwt'; * * ```ts * import express from 'express'; - * import { MCPAuth, fetchServerConfig } from 'mcp-auth'; + * import { MCPAuth } from 'mcp-auth'; * * const app = express(); * const mcpAuth = new MCPAuth({ - * server: await fetchServerConfig( - * 'https://auth.logto.io/oidc', - * { type: 'oidc' } - * ), + * // Discovery config - metadata fetched on-demand + * server: { issuer: 'https://auth.logto.io/oidc', type: 'oidc' }, * }); * * // Mount the router to handle legacy Authorization Server Metadata diff --git a/packages/mcp-auth/src/types/auth-server.ts b/packages/mcp-auth/src/types/auth-server.ts index ffa17a6..a241315 100644 --- a/packages/mcp-auth/src/types/auth-server.ts +++ b/packages/mcp-auth/src/types/auth-server.ts @@ -8,9 +8,12 @@ import { type CamelCaseAuthorizationServerMetadata } from './oauth.js'; export type AuthServerType = 'oauth' | 'oidc'; /** - * Configuration for the remote authorization server integrated with the MCP server. + * Resolved configuration for the remote authorization server with metadata. + * + * Use this when the metadata is already available, either hardcoded or fetched beforehand + * via `fetchServerConfig()`. */ -export type AuthServerConfig = { +export type ResolvedAuthServerConfig = { /** * The metadata of the authorization server, which should conform to the MCP specification * (based on OAuth 2.0 Authorization Server Metadata). @@ -33,3 +36,57 @@ export type AuthServerConfig = { */ type: AuthServerType; }; + +/** + * Discovery configuration for the remote authorization server. + * + * Use this when you want the metadata to be fetched on-demand via discovery when first needed. + * This is useful for edge runtimes like Cloudflare Workers where top-level async fetch + * is not allowed. + * + * @example + * ```typescript + * const mcpAuth = new MCPAuth({ + * protectedResources: { + * metadata: { + * resource: 'https://api.example.com', + * authorizationServers: [ + * { issuer: 'https://auth.logto.io/oidc', type: 'oidc' } + * ], + * scopesSupported: ['read', 'write'], + * }, + * }, + * }); + * ``` + */ +export type AuthServerDiscoveryConfig = { + /** + * The issuer URL of the authorization server. The metadata will be fetched from the + * well-known endpoint derived from this issuer. + */ + issuer: string; + /** + * The type of the authorization server. + * + * @see {@link AuthServerType} for the possible values. + */ + type: AuthServerType; +}; + +/** + * Configuration for the remote authorization server integrated with the MCP server. + * + * Can be either: + * - **Resolved**: Contains `metadata` - no network request needed + * - **Discovery**: Contains only `issuer` and `type` - metadata fetched on-demand via discovery + */ +export type AuthServerConfig = ResolvedAuthServerConfig | AuthServerDiscoveryConfig; + +/** + * Get the issuer URL from an auth server config. + * + * - Resolved config: extracts from `metadata.issuer` + * - Discovery config: returns `issuer` directly + */ +export const getIssuer = (config: AuthServerConfig): string => + 'metadata' in config ? config.metadata.issuer : config.issuer; diff --git a/packages/mcp-auth/src/utils/fetch-server-config.ts b/packages/mcp-auth/src/utils/fetch-server-config.ts index b6fc06d..1b4c05a 100644 --- a/packages/mcp-auth/src/utils/fetch-server-config.ts +++ b/packages/mcp-auth/src/utils/fetch-server-config.ts @@ -2,7 +2,7 @@ import { appendPath, joinPath } from '@silverhand/essentials'; import camelcaseKeys from 'camelcase-keys'; import { MCPAuthAuthServerError, MCPAuthConfigError } from '../errors.js'; -import { type AuthServerConfig, type AuthServerType } from '../types/auth-server.js'; +import { type AuthServerType, type ResolvedAuthServerConfig } from '../types/auth-server.js'; import { type AuthorizationServerMetadata, authorizationServerMetadataSchema, @@ -51,7 +51,7 @@ type ServerMetadataConfig = { * @param wellKnownUrl The well-known URL to fetch the server configuration from. This can be a * string or a URL object. * @param config The configuration object containing the server type and optional transpile function. - * @returns A promise that resolves to the server configuration. + * @returns A promise that resolves to the static server configuration with fetched metadata. * @throws {MCPAuthConfigError} if the fetch operation fails. * @throws {MCPAuthAuthServerError} if the server metadata is invalid or does not match the * MCP specification. @@ -59,7 +59,7 @@ type ServerMetadataConfig = { export const fetchServerConfigByWellKnownUrl = async ( wellKnownUrl: string | URL, { type, transpileData }: ServerMetadataConfig -): Promise => { +): Promise => { const response = await fetch(wellKnownUrl); if (!response.ok) { @@ -119,7 +119,7 @@ export const fetchServerConfigByWellKnownUrl = async ( * * @param issuer The issuer URL of the authorization server. * @param config The configuration object containing the server type and optional transpile function. - * @returns A promise that resolves to the server configuration. + * @returns A promise that resolves to the static server configuration with fetched metadata. * @throws {MCPAuthConfigError} if the fetch operation fails. * @throws {MCPAuthAuthServerError} if the server metadata is invalid or does not match the * MCP specification. @@ -127,7 +127,7 @@ export const fetchServerConfigByWellKnownUrl = async ( export const fetchServerConfig = async ( issuer: string, config: ServerMetadataConfig -): Promise => +): Promise => fetchServerConfigByWellKnownUrl( config.type === 'oauth' ? getOAuthWellKnownUrl(issuer) : getOidcWellKnownUrl(issuer), config diff --git a/packages/mcp-auth/src/utils/transpile-resource-metadata.test.ts b/packages/mcp-auth/src/utils/transpile-resource-metadata.test.ts index 515d2e6..4aa87c7 100644 --- a/packages/mcp-auth/src/utils/transpile-resource-metadata.test.ts +++ b/packages/mcp-auth/src/utils/transpile-resource-metadata.test.ts @@ -68,4 +68,52 @@ describe('transpileResourceMetadata', () => { scopesSupported: ['read', 'write'], }); }); + + it('should handle discovery config (issuer + type only)', () => { + const configMetadata: ResourceServerConfig['metadata'] = { + resource: 'https://api.example.com', + authorizationServers: [ + { issuer: 'https://auth.example.com', type: 'oidc' }, + { issuer: 'https://another-auth.example.com', type: 'oauth' }, + ], + scopesSupported: ['read', 'write'], + }; + + const standardMetadata = transpileResourceMetadata(configMetadata); + + expect(standardMetadata).toEqual({ + resource: 'https://api.example.com', + authorizationServers: ['https://auth.example.com', 'https://another-auth.example.com'], + scopesSupported: ['read', 'write'], + }); + }); + + it('should handle mixed resolved and discovery configs', () => { + const configMetadata: ResourceServerConfig['metadata'] = { + resource: 'https://api.example.com', + authorizationServers: [ + // Resolved config + { + type: 'oidc', + metadata: { + issuer: 'https://auth.example.com', + authorizationEndpoint: 'https://auth.example.com/auth', + tokenEndpoint: 'https://auth.example.com/token', + responseTypesSupported: ['code'], + }, + }, + // Discovery config + { issuer: 'https://another-auth.example.com', type: 'oauth' }, + ], + scopesSupported: ['read', 'write'], + }; + + const standardMetadata = transpileResourceMetadata(configMetadata); + + expect(standardMetadata).toEqual({ + resource: 'https://api.example.com', + authorizationServers: ['https://auth.example.com', 'https://another-auth.example.com'], + scopesSupported: ['read', 'write'], + }); + }); }); diff --git a/packages/mcp-auth/src/utils/transpile-resource-metadata.ts b/packages/mcp-auth/src/utils/transpile-resource-metadata.ts index a4d1b27..4f84332 100644 --- a/packages/mcp-auth/src/utils/transpile-resource-metadata.ts +++ b/packages/mcp-auth/src/utils/transpile-resource-metadata.ts @@ -1,5 +1,6 @@ import { cond } from '@silverhand/essentials'; +import { getIssuer } from '../types/auth-server.js'; import { type CamelCaseProtectedResourceMetadata } from '../types/oauth.js'; import { type ResourceServerConfig } from '../types/resource-server.js'; @@ -12,17 +13,25 @@ import { type ResourceServerConfig } from '../types/resource-server.js'; * represented as issuer URL strings, while MCP Auth internally uses `AuthServerConfig` objects to store the complete * authorization server metadata for token validation and issuer verification. * + * Supports both resolved configs (with metadata) and discovery configs (with just issuer and type). + * * @example * ```ts * const configMetadata = { * resource: 'https://api.example.com', * authorizationServers: [ + * // Resolved config * { * type: 'oidc', * metadata: { * issuer: 'https://auth.example.com', * // ... other auth server metadata * } + * }, + * // Discovery config + * { + * issuer: 'https://auth2.example.com', + * type: 'oidc' * } * ], * scopesSupported: ['read', 'write'] @@ -32,7 +41,7 @@ import { type ResourceServerConfig } from '../types/resource-server.js'; * // Result: * // { * // resource: 'https://api.example.com', - * // authorizationServers: ['https://auth.example.com'], + * // authorizationServers: ['https://auth.example.com', 'https://auth2.example.com'], * // scopesSupported: ['read', 'write'] * // } * ``` @@ -49,7 +58,7 @@ export const transpileResourceMetadata = ( ...rest, ...cond( authorizationServers?.length && { - authorizationServers: authorizationServers.map(({ metadata }) => metadata.issuer), + authorizationServers: authorizationServers.map((config) => getIssuer(config)), } ), }; diff --git a/packages/mcp-auth/src/utils/validate-auth-server.test.ts b/packages/mcp-auth/src/utils/validate-auth-server.test.ts index d3bcc31..64b25a2 100644 --- a/packages/mcp-auth/src/utils/validate-auth-server.test.ts +++ b/packages/mcp-auth/src/utils/validate-auth-server.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { MCPAuthAuthServerError } from '../errors.js'; -import { type AuthServerConfig } from '../types/auth-server.js'; +import { type AuthServerConfig, type AuthServerDiscoveryConfig } from '../types/auth-server.js'; import { validateAuthServer } from './validate-auth-server.js'; import { validateServerConfig } from './validate-server-config.js'; @@ -90,4 +90,17 @@ describe('validateAuthServer', () => { }).not.toThrow(); expect(warnSpy).not.toHaveBeenCalled(); }); + + test('should skip validation for discovery config (no metadata)', () => { + const discoveryConfig: AuthServerDiscoveryConfig = { + issuer: 'https://example.com', + type: 'oidc', + }; + + // Should not throw and should not call validateServerConfig + expect(() => { + validateAuthServer(discoveryConfig); + }).not.toThrow(); + expect(validateServerConfig).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mcp-auth/src/utils/validate-auth-server.ts b/packages/mcp-auth/src/utils/validate-auth-server.ts index 9047773..472e719 100644 --- a/packages/mcp-auth/src/utils/validate-auth-server.ts +++ b/packages/mcp-auth/src/utils/validate-auth-server.ts @@ -1,13 +1,14 @@ import { MCPAuthAuthServerError } from '../errors.js'; -import { type AuthServerConfig } from '../types/auth-server.js'; +import { type AuthServerConfig, type ResolvedAuthServerConfig } from '../types/auth-server.js'; import { validateServerConfig } from './validate-server-config.js'; /** - * Validates a single `AuthServerConfig` object and throws on error. - * @param authServer The authorization server configuration to validate. + * Validates a resolved `AuthServerConfig` object and throws on error. + * + * @param authServer The resolved authorization server configuration to validate. */ -export const validateAuthServer = (authServer: AuthServerConfig) => { +export const validateResolvedAuthServer = (authServer: ResolvedAuthServerConfig) => { const result = validateServerConfig(authServer); if (!result.isValid) { @@ -22,3 +23,21 @@ export const validateAuthServer = (authServer: AuthServerConfig) => { ); } }; + +/** + * Validates an `AuthServerConfig` object and throws on error. + * + * For resolved configs (with metadata), performs full validation. + * For discovery configs (without metadata), skips validation as metadata will be + * validated when fetched on-demand. + * + * @param authServer The authorization server configuration to validate. + */ +export const validateAuthServer = (authServer: AuthServerConfig) => { + // Skip validation for discovery configs - will be validated when metadata is fetched + if (!('metadata' in authServer)) { + return; + } + + validateResolvedAuthServer(authServer); +}; diff --git a/packages/mcp-auth/src/utils/validate-server-config.ts b/packages/mcp-auth/src/utils/validate-server-config.ts index 95bcf1f..63efa6d 100644 --- a/packages/mcp-auth/src/utils/validate-server-config.ts +++ b/packages/mcp-auth/src/utils/validate-server-config.ts @@ -1,6 +1,6 @@ import { condObject } from '@silverhand/essentials'; -import { type AuthServerConfig } from '../types/auth-server.js'; +import { type ResolvedAuthServerConfig } from '../types/auth-server.js'; import { camelCaseAuthorizationServerMetadataSchema, defaultValues } from '../types/oauth.js'; /** @@ -139,25 +139,29 @@ type AuthServerConfigValidationResult = type ValidateServerConfig = { /** - * Validates the authorization server configuration against the MCP specification. + * Validates the resolved authorization server configuration against the MCP specification. * - * @param config The configuration object containing the server metadata to validate. + * Note: This function only validates resolved configs with metadata. + * + * @param config The resolved configuration object containing the server metadata to validate. * @returns An object indicating whether the configuration is valid (`{ isValid: true }`) or * invalid (`{ isValid: false }`), along with any errors or warnings encountered during validation. * @see {@link AuthServerConfigValidationResult} for the structure of the return value. */ - (config: Readonly): AuthServerConfigValidationResult; + (config: Readonly): AuthServerConfigValidationResult; /** - * Validates the authorization server configuration against the MCP specification. + * Validates the resolved authorization server configuration against the MCP specification. + * + * Note: This function only validates resolved configs with metadata. * - * @param config The configuration object containing the server metadata to validate. + * @param config The resolved configuration object containing the server metadata to validate. * @param verbose If `true`, the validation will include success messages in the result. * @returns An object indicating whether the configuration is valid (`{ isValid: true }`) or * invalid (`{ isValid: false }`), along with any errors or warnings encountered during validation. * @see {@link AuthServerConfigValidationResult} for the structure of the return value. */ ( - config: Readonly, + config: Readonly, verbose: true ): AuthServerConfigValidationResult & { /** An array of success messages encountered during validation. */ From cbb12df226b7cb4c3192bf9db0be9b3b598c60d7 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Tue, 20 Jan 2026 13:14:05 +0800 Subject: [PATCH 2/2] test(mcp-auth): add tests for uncovered branches --- .../mcp-auth/src/auth/token-verifier.test.ts | 27 +++++++++++++++++++ .../src/utils/validate-server-config.test.ts | 19 ++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/mcp-auth/src/auth/token-verifier.test.ts b/packages/mcp-auth/src/auth/token-verifier.test.ts index 120993e..9058233 100644 --- a/packages/mcp-auth/src/auth/token-verifier.test.ts +++ b/packages/mcp-auth/src/auth/token-verifier.test.ts @@ -229,4 +229,31 @@ describe('TokenVerifier', () => { }).toThrow(MCPAuthBearerAuthError); }); }); + + describe('defensive branch coverage', () => { + it('should throw an MCPAuthBearerAuthError if authServerConfig is not found after validation passes (defensive branch)', async () => { + const token = await createJwt({ + iss: 'https://trusted.issuer.com', + client_id: 'client12345', + }); + + const tokenVerifier = new TokenVerifier(authServers); + + // Mock getJwtIssuerValidator to return a no-op function (bypasses issuer validation) + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(tokenVerifier, 'getJwtIssuerValidator').mockReturnValue(() => {}); + + // Mock getAuthServerConfigByIssuer to return undefined (simulates config not found) + vi.spyOn( + tokenVerifier as unknown as { getAuthServerConfigByIssuer: () => void }, + 'getAuthServerConfigByIssuer' + ).mockReturnValue(); + + await expect( + tokenVerifier.createVerifyJwtFunction({})(token) + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[MCPAuthBearerAuthError: The token issuer does not match the expected issuer.]' + ); + }); + }); }); diff --git a/packages/mcp-auth/src/utils/validate-server-config.test.ts b/packages/mcp-auth/src/utils/validate-server-config.test.ts index bd6a7bc..93f04aa 100644 --- a/packages/mcp-auth/src/utils/validate-server-config.test.ts +++ b/packages/mcp-auth/src/utils/validate-server-config.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert'; import { describe, expect, it } from 'vitest'; -import { type AuthServerConfig } from '../types/auth-server.js'; +import { type AuthServerConfig, type ResolvedAuthServerConfig } from '../types/auth-server.js'; import { validateServerConfig } from './validate-server-config.js'; @@ -136,6 +136,23 @@ describe('validateServerConfig', () => { ]) ); }); + + it('should return invalid_server_metadata error when metadata is missing required fields', () => { + const config = { + type: 'oauth', + metadata: { + // Missing required fields: issuer, authorizationEndpoint, tokenEndpoint, responseTypesSupported + }, + } as unknown as ResolvedAuthServerConfig; + + const result = validateServerConfig(config); + assert(!result.isValid, 'Expected isValid to be false'); + expect(result.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'invalid_server_metadata' })]) + ); + // Should have the ZodError as the cause + expect(result.errors[0]?.cause).toBeDefined(); + }); }); describe('validateServerConfig with verbose mode', () => {