/**
* mysql-mcp - AuthorizationServerDiscovery Unit Tests
*
* Tests for RFC 8414 authorization server metadata discovery
* including caching behavior, error handling, and helper methods.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthorizationServerDiscovery, createAuthServerDiscovery } from '../AuthorizationServerDiscovery.js';
import { AuthServerDiscoveryError } from '../errors.js';
import type { AuthorizationServerMetadata } from '../types.js';
// Mock logger to avoid console output
vi.mock('../../utils/logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn()
}
}));
// Mock global fetch
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
describe('AuthorizationServerDiscovery', () => {
const mockMetadata: AuthorizationServerMetadata = {
issuer: 'https://auth.example.com',
token_endpoint: 'https://auth.example.com/token',
authorization_endpoint: 'https://auth.example.com/authorize',
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
registration_endpoint: 'https://auth.example.com/register',
grant_types_supported: ['authorization_code', 'client_credentials', 'refresh_token'],
scopes_supported: ['read', 'write', 'admin']
};
let discovery: AuthorizationServerDiscovery;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
discovery = new AuthorizationServerDiscovery({
authServerUrl: 'https://auth.example.com'
});
});
afterEach(() => {
vi.useRealTimers();
});
describe('constructor', () => {
it('should create instance with config', () => {
expect(discovery).toBeInstanceOf(AuthorizationServerDiscovery);
});
it('should use default cacheTtl when not provided', () => {
const disc = new AuthorizationServerDiscovery({
authServerUrl: 'https://auth.example.com'
});
expect(disc).toBeDefined();
});
it('should use custom cacheTtl when provided', () => {
const disc = new AuthorizationServerDiscovery({
authServerUrl: 'https://auth.example.com',
cacheTtl: 1800
});
expect(disc).toBeDefined();
});
it('should use custom timeout when provided', () => {
const disc = new AuthorizationServerDiscovery({
authServerUrl: 'https://auth.example.com',
timeout: 10000
});
expect(disc).toBeDefined();
});
});
describe('discover()', () => {
it('should fetch metadata from well-known endpoint', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata
});
const result = await discovery.discover();
expect(mockFetch).toHaveBeenCalledWith(
'https://auth.example.com/.well-known/oauth-authorization-server',
expect.objectContaining({
method: 'GET',
headers: { 'Accept': 'application/json' }
})
);
expect(result).toEqual(mockMetadata);
});
it('should cache metadata within TTL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata
});
// First call - fetches from network
const result1 = await discovery.discover();
expect(mockFetch).toHaveBeenCalledTimes(1);
// Advance time by 30 minutes (within default 1 hour TTL)
vi.advanceTimersByTime(30 * 60 * 1000);
// Second call - should use cache
const result2 = await discovery.discover();
expect(mockFetch).toHaveBeenCalledTimes(1); // No additional fetch
expect(result2).toEqual(result1);
});
it('should refresh cache after TTL expires', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockMetadata
});
// First call
await discovery.discover();
expect(mockFetch).toHaveBeenCalledTimes(1);
// Advance time past TTL (default 3600 seconds)
vi.advanceTimersByTime(3601 * 1000);
// Second call - should refetch
await discovery.discover();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should throw AuthServerDiscoveryError on HTTP error', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found'
});
await expect(discovery.discover()).rejects.toThrow(AuthServerDiscoveryError);
});
it('should throw AuthServerDiscoveryError on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
await expect(discovery.discover()).rejects.toThrow(AuthServerDiscoveryError);
});
it('should throw AuthServerDiscoveryError when metadata missing required fields', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ issuer: 'https://auth.example.com' }) // Missing token_endpoint
});
await expect(discovery.discover()).rejects.toThrow(AuthServerDiscoveryError);
});
it('should throw AuthServerDiscoveryError when issuer is missing', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ token_endpoint: 'https://auth.example.com/token' }) // Missing issuer
});
await expect(discovery.discover()).rejects.toThrow(AuthServerDiscoveryError);
});
});
describe('getJwksUri()', () => {
it('should return JWKS URI from metadata', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata
});
const jwksUri = await discovery.getJwksUri();
expect(jwksUri).toBe('https://auth.example.com/.well-known/jwks.json');
});
it('should throw when JWKS URI not in metadata', async () => {
const metadataWithoutJwks = {
issuer: 'https://auth.example.com',
token_endpoint: 'https://auth.example.com/token'
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => metadataWithoutJwks
});
await expect(discovery.getJwksUri()).rejects.toThrow(AuthServerDiscoveryError);
await expect(discovery.getJwksUri()).rejects.toThrow('does not include jwks_uri');
});
});
describe('getTokenEndpoint()', () => {
it('should return token endpoint from metadata', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata
});
const tokenEndpoint = await discovery.getTokenEndpoint();
expect(tokenEndpoint).toBe('https://auth.example.com/token');
});
});
describe('getRegistrationEndpoint()', () => {
it('should return registration endpoint when available', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata
});
const registrationEndpoint = await discovery.getRegistrationEndpoint();
expect(registrationEndpoint).toBe('https://auth.example.com/register');
});
it('should return undefined when registration endpoint not available', async () => {
const metadataWithoutRegistration = {
issuer: 'https://auth.example.com',
token_endpoint: 'https://auth.example.com/token'
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => metadataWithoutRegistration
});
const registrationEndpoint = await discovery.getRegistrationEndpoint();
expect(registrationEndpoint).toBeUndefined();
});
});
describe('supportsGrantType()', () => {
it('should return true for supported grant type', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata
});
const supports = await discovery.supportsGrantType('client_credentials');
expect(supports).toBe(true);
});
it('should return false for unsupported grant type', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata
});
const supports = await discovery.supportsGrantType('implicit');
expect(supports).toBe(false);
});
it('should return false when grant_types_supported not in metadata', async () => {
const metadataWithoutGrants = {
issuer: 'https://auth.example.com',
token_endpoint: 'https://auth.example.com/token'
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => metadataWithoutGrants
});
const supports = await discovery.supportsGrantType('authorization_code');
expect(supports).toBe(false);
});
});
describe('invalidateCache()', () => {
it('should clear cached metadata', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockMetadata
});
// First call populates cache
await discovery.discover();
expect(mockFetch).toHaveBeenCalledTimes(1);
// Invalidate cache
discovery.invalidateCache();
// Next call should refetch
await discovery.discover();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
describe('createAuthServerDiscovery()', () => {
it('should create AuthorizationServerDiscovery instance', () => {
const discovery = createAuthServerDiscovery({
authServerUrl: 'https://auth.example.com'
});
expect(discovery).toBeInstanceOf(AuthorizationServerDiscovery);
});
it('should pass all config options', () => {
const discovery = createAuthServerDiscovery({
authServerUrl: 'https://auth.example.com',
cacheTtl: 1800,
timeout: 10000
});
expect(discovery).toBeInstanceOf(AuthorizationServerDiscovery);
});
});