Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions packages/mcp-auth/src/auth/auth-server-metadata-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<ResolvedAuthServerConfig>((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);
});
});
});
63 changes: 63 additions & 0 deletions packages/mcp-auth/src/auth/auth-server-metadata-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, CamelCaseAuthorizationServerMetadata>();
private readonly promiseCache = new Map<string, Promise<CamelCaseAuthorizationServerMetadata>>();

/**
* 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<CamelCaseAuthorizationServerMetadata> {
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;
}
}
16 changes: 15 additions & 1 deletion packages/mcp-auth/src/auth/authorization-server-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -30,6 +30,11 @@ describe('AuthorizationServerHandler', () => {
server: mockServerConfig,
};

const discoveryConfig: AuthServerDiscoveryConfig = {
issuer: 'https://discovery.example.com',
type: 'oidc',
};

afterEach(() => {
vi.restoreAllMocks();
});
Expand All @@ -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', () => {
Expand Down
23 changes: 19 additions & 4 deletions packages/mcp-auth/src/auth/authorization-server-handler.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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();

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

/**
Expand Down
Loading