726 lines
22 KiB
TypeScript
726 lines
22 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import * as http from 'node:http';
|
|
import * as crypto from 'node:crypto';
|
|
import {
|
|
MCPOAuthProvider,
|
|
MCPOAuthConfig,
|
|
OAuthTokenResponse,
|
|
OAuthClientRegistrationResponse,
|
|
} from './oauth-provider.js';
|
|
import { MCPOAuthTokenStorage, MCPOAuthToken } from './oauth-token-storage.js';
|
|
|
|
// Mock dependencies
|
|
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
|
|
vi.mock('../utils/secure-browser-launcher.js', () => ({
|
|
openBrowserSecurely: mockOpenBrowserSecurely,
|
|
}));
|
|
vi.mock('node:crypto');
|
|
vi.mock('./oauth-token-storage.js');
|
|
|
|
// Mock fetch globally
|
|
const mockFetch = vi.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
// Define a reusable mock server with .listen, .close, and .on methods
|
|
const mockHttpServer = {
|
|
listen: vi.fn(),
|
|
close: vi.fn(),
|
|
on: vi.fn(),
|
|
};
|
|
vi.mock('node:http', () => ({
|
|
createServer: vi.fn(() => mockHttpServer),
|
|
}));
|
|
|
|
describe('MCPOAuthProvider', () => {
|
|
const mockConfig: MCPOAuthConfig = {
|
|
enabled: true,
|
|
clientId: 'test-client-id',
|
|
clientSecret: 'test-client-secret',
|
|
authorizationUrl: 'https://auth.example.com/authorize',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
scopes: ['read', 'write'],
|
|
redirectUri: 'http://localhost:7777/oauth/callback',
|
|
};
|
|
|
|
const mockToken: MCPOAuthToken = {
|
|
accessToken: 'access_token_123',
|
|
refreshToken: 'refresh_token_456',
|
|
tokenType: 'Bearer',
|
|
scope: 'read write',
|
|
expiresAt: Date.now() + 3600000,
|
|
};
|
|
|
|
const mockTokenResponse: OAuthTokenResponse = {
|
|
access_token: 'access_token_123',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
refresh_token: 'refresh_token_456',
|
|
scope: 'read write',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockOpenBrowserSecurely.mockClear();
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Mock crypto functions
|
|
vi.mocked(crypto.randomBytes).mockImplementation((size: number) => {
|
|
if (size === 32) return Buffer.from('code_verifier_mock_32_bytes_long');
|
|
if (size === 16) return Buffer.from('state_mock_16_by');
|
|
return Buffer.alloc(size);
|
|
});
|
|
|
|
vi.mocked(crypto.createHash).mockReturnValue({
|
|
update: vi.fn().mockReturnThis(),
|
|
digest: vi.fn().mockReturnValue('code_challenge_mock'),
|
|
} as unknown as crypto.Hash);
|
|
|
|
// Mock randomBytes to return predictable values for state
|
|
vi.mocked(crypto.randomBytes).mockImplementation((size) => {
|
|
if (size === 32) {
|
|
return Buffer.from('mock_code_verifier_32_bytes_long_string');
|
|
} else if (size === 16) {
|
|
return Buffer.from('mock_state_16_bytes');
|
|
}
|
|
return Buffer.alloc(size);
|
|
});
|
|
|
|
// Mock token storage
|
|
vi.mocked(MCPOAuthTokenStorage.saveToken).mockResolvedValue(undefined);
|
|
vi.mocked(MCPOAuthTokenStorage.getToken).mockResolvedValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('authenticate', () => {
|
|
it('should perform complete OAuth flow with PKCE', async () => {
|
|
// Mock HTTP server callback
|
|
let callbackHandler: unknown;
|
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
|
callbackHandler = handler;
|
|
return mockHttpServer as unknown as http.Server;
|
|
});
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
// Simulate OAuth callback
|
|
setTimeout(() => {
|
|
const mockReq = {
|
|
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
|
};
|
|
const mockRes = {
|
|
writeHead: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
|
mockReq,
|
|
mockRes,
|
|
);
|
|
}, 10);
|
|
});
|
|
|
|
// Mock token exchange
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTokenResponse),
|
|
});
|
|
|
|
const result = await MCPOAuthProvider.authenticate(
|
|
'test-server',
|
|
mockConfig,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
accessToken: 'access_token_123',
|
|
refreshToken: 'refresh_token_456',
|
|
tokenType: 'Bearer',
|
|
scope: 'read write',
|
|
expiresAt: expect.any(Number),
|
|
});
|
|
|
|
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
|
|
expect.stringContaining('authorize'),
|
|
);
|
|
expect(MCPOAuthTokenStorage.saveToken).toHaveBeenCalledWith(
|
|
'test-server',
|
|
expect.objectContaining({ accessToken: 'access_token_123' }),
|
|
'test-client-id',
|
|
'https://auth.example.com/token',
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should handle OAuth discovery when no authorization URL provided', async () => {
|
|
// Use a mutable config object
|
|
const configWithoutAuth: MCPOAuthConfig = { ...mockConfig };
|
|
delete configWithoutAuth.authorizationUrl;
|
|
delete configWithoutAuth.tokenUrl;
|
|
|
|
const mockResourceMetadata = {
|
|
authorization_servers: ['https://discovered.auth.com'],
|
|
};
|
|
|
|
const mockAuthServerMetadata = {
|
|
authorization_endpoint: 'https://discovered.auth.com/authorize',
|
|
token_endpoint: 'https://discovered.auth.com/token',
|
|
scopes_supported: ['read', 'write'],
|
|
};
|
|
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockResourceMetadata),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockAuthServerMetadata),
|
|
});
|
|
|
|
// Patch config after discovery
|
|
configWithoutAuth.authorizationUrl =
|
|
mockAuthServerMetadata.authorization_endpoint;
|
|
configWithoutAuth.tokenUrl = mockAuthServerMetadata.token_endpoint;
|
|
configWithoutAuth.scopes = mockAuthServerMetadata.scopes_supported;
|
|
|
|
// Setup callback handler
|
|
let callbackHandler: unknown;
|
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
|
callbackHandler = handler;
|
|
return mockHttpServer as unknown as http.Server;
|
|
});
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
setTimeout(() => {
|
|
const mockReq = {
|
|
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
|
};
|
|
const mockRes = {
|
|
writeHead: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
|
mockReq,
|
|
mockRes,
|
|
);
|
|
}, 10);
|
|
});
|
|
|
|
// Mock token exchange with discovered endpoint
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTokenResponse),
|
|
});
|
|
|
|
const result = await MCPOAuthProvider.authenticate(
|
|
'test-server',
|
|
configWithoutAuth,
|
|
'https://api.example.com',
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discovered.auth.com/token',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should perform dynamic client registration when no client ID provided', async () => {
|
|
const configWithoutClient = { ...mockConfig };
|
|
delete configWithoutClient.clientId;
|
|
|
|
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
|
|
client_id: 'dynamic_client_id',
|
|
client_secret: 'dynamic_client_secret',
|
|
redirect_uris: ['http://localhost:7777/oauth/callback'],
|
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
response_types: ['code'],
|
|
token_endpoint_auth_method: 'none',
|
|
};
|
|
|
|
const mockAuthServerMetadata = {
|
|
authorization_endpoint: 'https://auth.example.com/authorize',
|
|
token_endpoint: 'https://auth.example.com/token',
|
|
registration_endpoint: 'https://auth.example.com/register',
|
|
};
|
|
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockAuthServerMetadata),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockRegistrationResponse),
|
|
});
|
|
|
|
// Setup callback handler
|
|
let callbackHandler: unknown;
|
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
|
callbackHandler = handler;
|
|
return mockHttpServer as unknown as http.Server;
|
|
});
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
setTimeout(() => {
|
|
const mockReq = {
|
|
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
|
};
|
|
const mockRes = {
|
|
writeHead: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
|
mockReq,
|
|
mockRes,
|
|
);
|
|
}, 10);
|
|
});
|
|
|
|
// Mock token exchange
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTokenResponse),
|
|
});
|
|
|
|
const result = await MCPOAuthProvider.authenticate(
|
|
'test-server',
|
|
configWithoutClient,
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://auth.example.com/register',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle OAuth callback errors', async () => {
|
|
let callbackHandler: unknown;
|
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
|
callbackHandler = handler;
|
|
return mockHttpServer as unknown as http.Server;
|
|
});
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
setTimeout(() => {
|
|
const mockReq = {
|
|
url: '/oauth/callback?error=access_denied&error_description=User%20denied%20access',
|
|
};
|
|
const mockRes = {
|
|
writeHead: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
|
mockReq,
|
|
mockRes,
|
|
);
|
|
}, 10);
|
|
});
|
|
|
|
await expect(
|
|
MCPOAuthProvider.authenticate('test-server', mockConfig),
|
|
).rejects.toThrow('OAuth error: access_denied');
|
|
});
|
|
|
|
it('should handle state mismatch in callback', async () => {
|
|
let callbackHandler: unknown;
|
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
|
callbackHandler = handler;
|
|
return mockHttpServer as unknown as http.Server;
|
|
});
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
setTimeout(() => {
|
|
const mockReq = {
|
|
url: '/oauth/callback?code=auth_code_123&state=wrong_state',
|
|
};
|
|
const mockRes = {
|
|
writeHead: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
|
mockReq,
|
|
mockRes,
|
|
);
|
|
}, 10);
|
|
});
|
|
|
|
await expect(
|
|
MCPOAuthProvider.authenticate('test-server', mockConfig),
|
|
).rejects.toThrow('State mismatch - possible CSRF attack');
|
|
});
|
|
|
|
it('should handle token exchange failure', async () => {
|
|
let callbackHandler: unknown;
|
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
|
callbackHandler = handler;
|
|
return mockHttpServer as unknown as http.Server;
|
|
});
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
setTimeout(() => {
|
|
const mockReq = {
|
|
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
|
};
|
|
const mockRes = {
|
|
writeHead: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
|
mockReq,
|
|
mockRes,
|
|
);
|
|
}, 10);
|
|
});
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
text: () => Promise.resolve('Invalid grant'),
|
|
});
|
|
|
|
await expect(
|
|
MCPOAuthProvider.authenticate('test-server', mockConfig),
|
|
).rejects.toThrow('Token exchange failed: 400 - Invalid grant');
|
|
});
|
|
|
|
it('should handle callback timeout', async () => {
|
|
vi.mocked(http.createServer).mockImplementation(
|
|
() => mockHttpServer as unknown as http.Server,
|
|
);
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
// Don't trigger callback - simulate timeout
|
|
});
|
|
|
|
// Mock setTimeout to trigger timeout immediately
|
|
const originalSetTimeout = global.setTimeout;
|
|
global.setTimeout = vi.fn((callback, delay) => {
|
|
if (delay === 5 * 60 * 1000) {
|
|
// 5 minute timeout
|
|
callback();
|
|
}
|
|
return originalSetTimeout(callback, 0);
|
|
}) as unknown as typeof setTimeout;
|
|
|
|
await expect(
|
|
MCPOAuthProvider.authenticate('test-server', mockConfig),
|
|
).rejects.toThrow('OAuth callback timeout');
|
|
|
|
global.setTimeout = originalSetTimeout;
|
|
});
|
|
});
|
|
|
|
describe('refreshAccessToken', () => {
|
|
it('should refresh token successfully', async () => {
|
|
const refreshResponse = {
|
|
access_token: 'new_access_token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
refresh_token: 'new_refresh_token',
|
|
};
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(refreshResponse),
|
|
});
|
|
|
|
const result = await MCPOAuthProvider.refreshAccessToken(
|
|
mockConfig,
|
|
'old_refresh_token',
|
|
'https://auth.example.com/token',
|
|
);
|
|
|
|
expect(result).toEqual(refreshResponse);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://auth.example.com/token',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: expect.stringContaining('grant_type=refresh_token'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include client secret in refresh request when available', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTokenResponse),
|
|
});
|
|
|
|
await MCPOAuthProvider.refreshAccessToken(
|
|
mockConfig,
|
|
'refresh_token',
|
|
'https://auth.example.com/token',
|
|
);
|
|
|
|
const fetchCall = mockFetch.mock.calls[0];
|
|
expect(fetchCall[1].body).toContain('client_secret=test-client-secret');
|
|
});
|
|
|
|
it('should handle refresh token failure', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
text: () => Promise.resolve('Invalid refresh token'),
|
|
});
|
|
|
|
await expect(
|
|
MCPOAuthProvider.refreshAccessToken(
|
|
mockConfig,
|
|
'invalid_refresh_token',
|
|
'https://auth.example.com/token',
|
|
),
|
|
).rejects.toThrow('Token refresh failed: 400 - Invalid refresh token');
|
|
});
|
|
});
|
|
|
|
describe('getValidToken', () => {
|
|
it('should return valid token when not expired', async () => {
|
|
const validCredentials = {
|
|
serverName: 'test-server',
|
|
token: mockToken,
|
|
clientId: 'test-client-id',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
vi.mocked(MCPOAuthTokenStorage.getToken).mockResolvedValue(
|
|
validCredentials,
|
|
);
|
|
vi.mocked(MCPOAuthTokenStorage.isTokenExpired).mockReturnValue(false);
|
|
|
|
const result = await MCPOAuthProvider.getValidToken(
|
|
'test-server',
|
|
mockConfig,
|
|
);
|
|
|
|
expect(result).toBe('access_token_123');
|
|
});
|
|
|
|
it('should refresh expired token and return new token', async () => {
|
|
const expiredCredentials = {
|
|
serverName: 'test-server',
|
|
token: { ...mockToken, expiresAt: Date.now() - 3600000 },
|
|
clientId: 'test-client-id',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
vi.mocked(MCPOAuthTokenStorage.getToken).mockResolvedValue(
|
|
expiredCredentials,
|
|
);
|
|
vi.mocked(MCPOAuthTokenStorage.isTokenExpired).mockReturnValue(true);
|
|
|
|
const refreshResponse = {
|
|
access_token: 'new_access_token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
refresh_token: 'new_refresh_token',
|
|
};
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(refreshResponse),
|
|
});
|
|
|
|
const result = await MCPOAuthProvider.getValidToken(
|
|
'test-server',
|
|
mockConfig,
|
|
);
|
|
|
|
expect(result).toBe('new_access_token');
|
|
expect(MCPOAuthTokenStorage.saveToken).toHaveBeenCalledWith(
|
|
'test-server',
|
|
expect.objectContaining({ accessToken: 'new_access_token' }),
|
|
'test-client-id',
|
|
'https://auth.example.com/token',
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should return null when no credentials exist', async () => {
|
|
vi.mocked(MCPOAuthTokenStorage.getToken).mockResolvedValue(null);
|
|
|
|
const result = await MCPOAuthProvider.getValidToken(
|
|
'test-server',
|
|
mockConfig,
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should handle refresh failure and remove invalid token', async () => {
|
|
const expiredCredentials = {
|
|
serverName: 'test-server',
|
|
token: { ...mockToken, expiresAt: Date.now() - 3600000 },
|
|
clientId: 'test-client-id',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
vi.mocked(MCPOAuthTokenStorage.getToken).mockResolvedValue(
|
|
expiredCredentials,
|
|
);
|
|
vi.mocked(MCPOAuthTokenStorage.isTokenExpired).mockReturnValue(true);
|
|
vi.mocked(MCPOAuthTokenStorage.removeToken).mockResolvedValue(undefined);
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
text: () => Promise.resolve('Invalid refresh token'),
|
|
});
|
|
|
|
const result = await MCPOAuthProvider.getValidToken(
|
|
'test-server',
|
|
mockConfig,
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
expect(MCPOAuthTokenStorage.removeToken).toHaveBeenCalledWith(
|
|
'test-server',
|
|
);
|
|
expect(console.error).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to refresh token'),
|
|
);
|
|
});
|
|
|
|
it('should return null for token without refresh capability', async () => {
|
|
const tokenWithoutRefresh = {
|
|
serverName: 'test-server',
|
|
token: {
|
|
...mockToken,
|
|
refreshToken: undefined,
|
|
expiresAt: Date.now() - 3600000,
|
|
},
|
|
clientId: 'test-client-id',
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
vi.mocked(MCPOAuthTokenStorage.getToken).mockResolvedValue(
|
|
tokenWithoutRefresh,
|
|
);
|
|
vi.mocked(MCPOAuthTokenStorage.isTokenExpired).mockReturnValue(true);
|
|
|
|
const result = await MCPOAuthProvider.getValidToken(
|
|
'test-server',
|
|
mockConfig,
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('PKCE parameter generation', () => {
|
|
it('should generate valid PKCE parameters', async () => {
|
|
// Test is implicit in the authenticate flow tests, but we can verify
|
|
// the crypto mocks are called correctly
|
|
let callbackHandler: unknown;
|
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
|
callbackHandler = handler;
|
|
return mockHttpServer as unknown as http.Server;
|
|
});
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
setTimeout(() => {
|
|
const mockReq = {
|
|
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
|
};
|
|
const mockRes = {
|
|
writeHead: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
|
mockReq,
|
|
mockRes,
|
|
);
|
|
}, 10);
|
|
});
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTokenResponse),
|
|
});
|
|
|
|
await MCPOAuthProvider.authenticate('test-server', mockConfig);
|
|
|
|
expect(crypto.randomBytes).toHaveBeenCalledWith(32); // code verifier
|
|
expect(crypto.randomBytes).toHaveBeenCalledWith(16); // state
|
|
expect(crypto.createHash).toHaveBeenCalledWith('sha256');
|
|
});
|
|
});
|
|
|
|
describe('Authorization URL building', () => {
|
|
it('should build correct authorization URL with all parameters', async () => {
|
|
// Mock to capture the URL that would be opened
|
|
let capturedUrl: string | undefined;
|
|
mockOpenBrowserSecurely.mockImplementation((url: string) => {
|
|
capturedUrl = url;
|
|
return Promise.resolve();
|
|
});
|
|
|
|
let callbackHandler: unknown;
|
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
|
callbackHandler = handler;
|
|
return mockHttpServer as unknown as http.Server;
|
|
});
|
|
|
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
|
callback?.();
|
|
setTimeout(() => {
|
|
const mockReq = {
|
|
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
|
};
|
|
const mockRes = {
|
|
writeHead: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
|
mockReq,
|
|
mockRes,
|
|
);
|
|
}, 10);
|
|
});
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTokenResponse),
|
|
});
|
|
|
|
await MCPOAuthProvider.authenticate('test-server', mockConfig);
|
|
|
|
expect(capturedUrl).toBeDefined();
|
|
expect(capturedUrl!).toContain('response_type=code');
|
|
expect(capturedUrl!).toContain('client_id=test-client-id');
|
|
expect(capturedUrl!).toContain('code_challenge=code_challenge_mock');
|
|
expect(capturedUrl!).toContain('code_challenge_method=S256');
|
|
expect(capturedUrl!).toContain('scope=read+write');
|
|
expect(capturedUrl!).toContain('resource=https%3A%2F%2Fauth.example.com');
|
|
});
|
|
});
|
|
});
|