import { beforeEach, describe, expect, test } from 'bun:test'; import type { OAuthConfig, OAuthTokens } from '../client/config.js'; import { MCPOAuthClientProvider } from '../client/index.js'; import { MemoryStorage } from '../client/storage.js'; describe('MCPOAuthClientProvider Integration', () => { let config: OAuthConfig; let storage: MemoryStorage; beforeEach(() => { storage = new MemoryStorage(); config = { redirectUri: 'http://localhost:8080/callback', scope: 'openid profile email', storage, }; }); describe('initialization', () => { test('should create provider with minimal config', () => { const provider = new MCPOAuthClientProvider(config); expect(provider).toBeDefined(); expect(provider.redirectUrl).toBe(config.redirectUri ?? ''); }); test('should generate session ID if not provided', () => { const provider1 = new MCPOAuthClientProvider(config); const provider2 = new MCPOAuthClientProvider(config); // Each provider should have its own session ID // We can't directly access sessionId, but we can verify they work independently expect(provider1).not.toBe(provider2); }); test('should use provided session ID', () => { const sessionId = 'test-session-123'; const provider = new MCPOAuthClientProvider({ ...config, sessionId, }); expect(provider).toBeDefined(); }); test('should use provided client credentials', async () => { const provider = new MCPOAuthClientProvider({ ...config, clientId: 'test-client-id', clientSecret: 'test-secret', }); const clientInfo = await provider.clientInformation(); expect(clientInfo).toEqual({ client_id: 'test-client-id', client_secret: 'test-secret', }); }); }); describe('OAuth state management', () => { test('should generate unique state values', async () => { const provider = new MCPOAuthClientProvider(config); const state1 = await provider.state(); const state2 = await provider.state(); expect(state1).not.toBe(state2); expect(state1).toBeString(); expect(state2).toBeString(); }); }); describe('client metadata', () => { test('should provide default client metadata', () => { const provider = new MCPOAuthClientProvider(config); const metadata = provider.clientMetadata; expect(metadata).toHaveProperty('client_name'); expect(metadata).toHaveProperty('grant_types'); expect(metadata).toHaveProperty('response_types'); expect(metadata.redirect_uris).toContain(config.redirectUri!); }); test('should merge custom metadata with defaults', () => { const customMetadata = { client_name: 'Custom OAuth Client', }; const provider = new MCPOAuthClientProvider({ ...config, clientMetadata: customMetadata, }); const metadata = provider.clientMetadata; expect(metadata.client_name).toBe('Custom OAuth Client'); expect(metadata.grant_types).toBeDefined(); // Should still have defaults }); }); describe('token management', () => { test('should return undefined when no tokens exist', async () => { const provider = new MCPOAuthClientProvider(config); const tokens = await provider.tokens(); expect(tokens).toBeUndefined(); }); test('should save and retrieve tokens', async () => { const provider = new MCPOAuthClientProvider(config); const mockTokens = { access_token: 'test-access-token', token_type: 'Bearer' as const, expires_in: 3600, refresh_token: 'test-refresh-token', }; await provider.saveTokens(mockTokens); const retrieved = await provider.tokens(); expect(retrieved).toEqual(mockTokens); }); test('should clear tokens', async () => { const provider = new MCPOAuthClientProvider(config); const mockTokens = { access_token: 'test-access-token', token_type: 'Bearer' as const, }; await provider.saveTokens(mockTokens); await provider.invalidateCredentials('tokens'); const tokens = await provider.tokens(); expect(tokens).toBeUndefined(); }); test('should initialize tokens from config and allow updates', async () => { const initialTokens = { access_token: 'initial-token', token_type: 'Bearer' as const, refresh_token: 'initial-refresh', }; const provider = new MCPOAuthClientProvider({ ...config, tokens: initialTokens, }); // Should return initial tokens from config on first access const tokens1 = await provider.tokens(); expect(tokens1).toEqual(initialTokens); // Should be able to update tokens const newTokens = { access_token: 'new-token', token_type: 'Bearer' as const, refresh_token: 'new-refresh', }; await provider.saveTokens(newTokens); // Should return updated tokens (storage takes precedence after initialization) const tokens2 = await provider.tokens(); expect(tokens2).toEqual(newTokens); expect(tokens2?.access_token).not.toBe(initialTokens.access_token); }); }); describe('client information management', () => { test('should return undefined when no client info exists and no static credentials', async () => { const provider = new MCPOAuthClientProvider(config); const clientInfo = await provider.clientInformation(); expect(clientInfo).toBeUndefined(); }); test('should save and retrieve client information from dynamic registration', async () => { const provider = new MCPOAuthClientProvider(config); const clientInfo = { client_id: 'dynamic-client-id', client_secret: 'dynamic-secret', redirect_uris: [config.redirectUri!], client_id_issued_at: Date.now(), }; await provider.saveClientInformation(clientInfo); const retrieved = await provider.clientInformation(); expect(retrieved).toMatchObject({ client_id: clientInfo.client_id, client_secret: clientInfo.client_secret, }); }); test('should prefer static credentials over stored credentials', async () => { const staticCreds = { clientId: 'static-id', clientSecret: 'static-secret', }; const provider = new MCPOAuthClientProvider({ ...config, ...staticCreds, }); // Save dynamic credentials await provider.saveClientInformation({ client_id: 'dynamic-id', client_secret: 'dynamic-secret', redirect_uris: [config.redirectUri!], }); // Should return static credentials const clientInfo = await provider.clientInformation(); expect(clientInfo?.client_id).toBe(staticCreds.clientId); }); }); describe('storage isolation', () => { test('should isolate tokens between different sessions', async () => { const provider1 = new MCPOAuthClientProvider({ ...config, sessionId: 'session-1', }); const provider2 = new MCPOAuthClientProvider({ ...config, sessionId: 'session-2', }); const tokens1 = { access_token: 'token-1', token_type: 'Bearer' as const, }; const tokens2 = { access_token: 'token-2', token_type: 'Bearer' as const, }; await provider1.saveTokens(tokens1); await provider2.saveTokens(tokens2); const retrieved1 = await provider1.tokens(); const retrieved2 = await provider2.tokens(); expect(retrieved1?.access_token).toBe('token-1'); expect(retrieved2?.access_token).toBe('token-2'); }); }); describe('automatic token refresh', () => { test('should automatically refresh expired tokens when metadata is available', async () => { const provider = new MCPOAuthClientProvider({ ...config, clientId: 'test-client', clientSecret: 'test-secret', }); // Set authorization server metadata with token endpoint provider.authorizationServerMetadata = { issuer: 'https://auth.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], }; // Save expired tokens with refresh token const expiredTokens: OAuthTokens = { access_token: 'expired-token', token_type: 'Bearer', expires_in: 100, // Less than 5 minute buffer, will trigger refresh refresh_token: 'refresh-token-123', }; await provider.saveTokens(expiredTokens); // Mock the refresh (we can't easily test the actual HTTP call in unit test) // The tokens() method should detect expired tokens and attempt refresh const tokens = await provider.tokens(); // Should return the expired tokens (refresh will fail due to no network mock) // but the attempt to refresh should have been made expect(tokens).toBeDefined(); expect(tokens?.access_token).toBe('expired-token'); }); test('should return tokens immediately if not expired', async () => { const provider = new MCPOAuthClientProvider({ ...config, clientId: 'test-client', clientSecret: 'test-secret', }); // Save valid tokens that won't expire soon const validTokens: OAuthTokens = { access_token: 'valid-token', token_type: 'Bearer', expires_in: 3600, // 1 hour, well beyond 5 minute buffer refresh_token: 'refresh-token-123', }; await provider.saveTokens(validTokens); // Should return tokens without attempting refresh const tokens = await provider.tokens(); expect(tokens).toBeDefined(); expect(tokens?.access_token).toBe('valid-token'); }); test('should not attempt refresh if no refresh token available', async () => { const provider = new MCPOAuthClientProvider({ ...config, clientId: 'test-client', clientSecret: 'test-secret', }); provider.authorizationServerMetadata = { issuer: 'https://auth.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], }; // Save expired tokens WITHOUT refresh token const expiredTokens: OAuthTokens = { access_token: 'expired-token', token_type: 'Bearer', expires_in: 100, // Less than 5 minute buffer // No refresh_token }; await provider.saveTokens(expiredTokens); // Should return expired tokens without attempting refresh const tokens = await provider.tokens(); expect(tokens).toBeDefined(); expect(tokens?.access_token).toBe('expired-token'); }); test('should not attempt refresh if no metadata available', async () => { const provider = new MCPOAuthClientProvider({ ...config, clientId: 'test-client', clientSecret: 'test-secret', }); // No authorizationServerMetadata set // Save expired tokens with refresh token const expiredTokens: OAuthTokens = { access_token: 'expired-token', token_type: 'Bearer', expires_in: 100, // Less than 5 minute buffer refresh_token: 'refresh-token-123', }; await provider.saveTokens(expiredTokens); // Should return expired tokens without attempting refresh (no metadata) const tokens = await provider.tokens(); expect(tokens).toBeDefined(); expect(tokens?.access_token).toBe('expired-token'); }); }); });