test-oauth-full-flow.jsโข17.8 kB
#!/usr/bin/env node
/**
* Full OAuth Flow Test Script for MCP Server
*
* This script:
* 1. Connects to the MCP server
* 2. Initiates OAuth flow and gets device code
* 3. Opens browser for user authentication
* 4. Waits for authentication completion
* 5. Tests authenticated GitHub API access
* 6. Retrieves repository data to verify OAuth works
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { spawn, exec } from 'child_process';
import open from 'open';
import chalk from 'chalk';
import { promisify } from 'util';
import readline from 'readline';
import { isTestMode, getAuthToken, validateToken } from './utils/github-auth.js';
const execAsync = promisify(exec);
/**
* WARNING: OAuth Testing Mode Support
* This script supports both PAT (testing) and OAuth device flow (production)
* - PAT Mode: Set TEST_GITHUB_TOKEN environment variable
* - OAuth Mode: Leave TEST_GITHUB_TOKEN unset
* See docs/development/OAUTH_TESTING_VS_PRODUCTION.md for critical differences
*/
class MCPOAuthTester {
constructor() {
this.client = null;
this.transport = null;
}
/**
* Mask sensitive data for logging while preserving debugging value
*/
maskSensitive(str, type = 'default') {
if (!str || typeof str !== 'string') return '***';
switch (type) {
case 'tool':
// For tool names: show first 3 chars and mask the rest
if (str.length <= 3) return '***';
return str.substring(0, 3) + '*'.repeat(Math.min(str.length - 3, 10));
case 'url':
// For URLs: mask domain but keep protocol and path structure
try {
const url = new URL(str);
const maskedHost = url.hostname.substring(0, 3) + '*'.repeat(Math.min(url.hostname.length - 3, 8));
return `${url.protocol}//${maskedHost}${url.pathname}`;
} catch {
// Fallback if not a valid URL
return this.maskSensitive(str, 'default');
}
case 'token':
// For tokens: show first 4 chars only
if (str.length <= 4) return '****';
return str.substring(0, 4) + '*'.repeat(Math.min(str.length - 4, 16));
default:
// General masking: show first 3 chars
if (str.length <= 3) return '***';
return str.substring(0, 3) + '*'.repeat(Math.min(str.length - 3, 10));
}
}
/**
* Sanitize error message while preserving useful debugging information
*/
sanitizeError(error, context = '') {
if (!error) return new Error('Unknown error');
const sanitizedError = new Error(`${context ? context + ': ' : ''}${error.message ? this.maskSensitive(error.message) : 'Unknown error'}`);
sanitizedError.code = error.code || 'UNKNOWN_ERROR';
sanitizedError.name = error.name || 'Error';
// Preserve non-sensitive properties
if (error.status) sanitizedError.status = error.status;
if (error.statusCode) sanitizedError.statusCode = error.statusCode;
return sanitizedError;
}
/**
* Create readline interface for user input
*/
createReadlineInterface() {
return readline.createInterface({
input: process.stdin,
output: process.stdout
});
}
/**
* Ask user a yes/no question
*/
async askYesNo(question) {
const rl = this.createReadlineInterface();
return new Promise((resolve) => {
rl.question(`${question} (y/n): `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y');
});
});
}
/**
* Wait for user to press Enter
*/
async waitForEnter(message = 'Press Enter to continue...') {
const rl = this.createReadlineInterface();
return new Promise((resolve) => {
rl.question(message, () => {
rl.close();
resolve();
});
});
}
/**
* Connect to the MCP server
*/
async connectToMCP() {
console.log(chalk.blue('๐ Connecting to MCP server...'));
try {
// Create transport and client (transport will spawn the process)
this.transport = new StdioClientTransport({
command: 'node',
args: ['dist/index.js'],
cwd: process.cwd()
});
this.client = new Client({
name: 'oauth-test-client',
version: '1.0.0'
}, {
capabilities: {}
});
await this.client.connect(this.transport);
console.log(chalk.green('โ
Connected to MCP server'));
// Discover available tools
const tools = await this.client.listTools();
console.log(chalk.blue(`\n๐ Discovered ${tools.tools.length} available tools`));
// Check for OAuth tools
const oauthTools = tools.tools.filter(tool =>
tool.name.includes('auth') || tool.name.includes('oauth')
);
if (oauthTools.length === 0) {
throw new Error('No OAuth tools found in MCP server');
}
// Use a safe, untainted count variable
const toolCount = Number(oauthTools.length);
// Log only the count - no iteration over tainted data
if (toolCount > 0) {
console.log(chalk.green(`โ
OAuth authentication tools detected (${toolCount} available)`));
} else {
console.log(chalk.red('โ No OAuth tools detected'));
}
return true;
} catch (error) {
console.log(chalk.red('โ Failed to connect to MCP server'));
// Sanitize error before rethrowing to avoid leaking sensitive information
const sanitizedError = this.sanitizeError(error, 'MCP Connection Failed');
throw sanitizedError;
}
}
/**
* Call an MCP tool
*/
async callTool(toolName, args = {}) {
try {
const result = await this.client.callTool({
name: toolName,
arguments: args
});
return result;
} catch (error) {
console.error(chalk.red(`Error calling tool ${this.maskSensitive(toolName, 'tool')}:`), this.maskSensitive(error.message || 'Unknown error'));
throw this.sanitizeError(error, 'Tool Call Failed');
}
}
/**
* Start OAuth authentication flow
*/
async startOAuthFlow() {
// WARNING: This code path differs for TESTING (PAT) vs PRODUCTION (OAuth)
// See docs/development/OAUTH_TESTING_VS_PRODUCTION.md
if (isTestMode()) {
console.log(chalk.blue('\n๐งช TEST MODE: Using Personal Access Token\n'));
console.log(chalk.yellow('โ ๏ธ Skipping OAuth device flow (used in production)'));
console.log(chalk.yellow(' To test the real OAuth flow, unset TEST_GITHUB_TOKEN'));
const token = await getAuthToken();
const validation = await validateToken(token);
if (!validation.valid) {
console.error(chalk.red('โ PAT validation failed:'), validation.error);
return false;
}
console.log(chalk.green('โ
PAT authenticated as:'), validation.user);
console.log(chalk.gray(' Scopes:'), validation.scopes.join(', '));
console.log(chalk.green('\nโ
Authentication ready for testing!'));
return true;
}
console.log(chalk.yellow('\n๐ Starting OAuth Authentication Flow...\n'));
// First check current auth status
console.log(chalk.blue('๐ Checking current authentication status...'));
const authStatus = await this.callTool('check_github_auth');
console.log(chalk.green('โ
Status check complete'));
if (authStatus.content?.[0]?.text?.includes('โ
')) {
console.log(chalk.green('โ
Already authenticated with GitHub!'));
return true;
}
// Start OAuth setup
console.log(chalk.blue('๐ Initiating GitHub OAuth...'));
const setupResult = await this.callTool('setup_github_auth');
console.log(chalk.green('โ
OAuth initiated'));
// Extract device code and verification URL
const resultText = setupResult.content?.[0]?.text || '';
const codeMatch = resultText.match(/Enter code:\s*\*\*([A-Z0-9]{4}-[A-Z0-9]{4})\*\*/) ||
resultText.match(/code:\s*\*\*([A-Z0-9]{4}-[A-Z0-9]{4})\*\*/i);
const urlMatch = resultText.match(/https:\/\/github\.com\/login\/device/);
if (!codeMatch || !urlMatch) {
console.error(chalk.red('Failed to extract device code from response'));
console.log('Response:', resultText);
return false;
}
const deviceCode = codeMatch[1];
const verificationUrl = 'https://github.com/login/device';
console.log(chalk.cyan('\n' + '='.repeat(60)));
console.log(chalk.bold.white('GitHub Device Authentication Required'));
console.log(chalk.cyan('='.repeat(60)));
console.log(chalk.yellow(`\n๐ User Code: ${chalk.bold.white(deviceCode)}`));
console.log(chalk.yellow(`๐ Verification URL: ${chalk.bold.white(verificationUrl)}`));
console.log(chalk.cyan('\n' + '='.repeat(60) + '\n'));
// Ask if user wants to open browser automatically
const shouldOpenBrowser = await this.askYesNo('Open browser automatically?');
if (shouldOpenBrowser) {
console.log(chalk.blue('Opening browser...'));
await open(verificationUrl);
console.log(chalk.green('โ
Browser opened'));
} else {
console.log(chalk.yellow(`\nPlease open your browser and go to:`));
console.log(chalk.bold.white(verificationUrl));
}
console.log(chalk.yellow(`\nEnter code: ${chalk.bold.white(deviceCode)}`));
console.log(chalk.gray('\nThe script will check for authentication every 5 seconds...'));
// Wait for authentication
return await this.waitForAuthentication();
}
/**
* Wait for user to complete authentication
*/
async waitForAuthentication() {
const maxAttempts = 30; // 5 minutes total with exponential backoff
let attempts = 0;
let delay = 2000; // Start with 2 seconds
console.log(chalk.blue('๐ Waiting for authentication...'));
while (attempts < maxAttempts) {
attempts++;
// Check auth status
const statusResult = await this.callTool('check_github_auth');
const statusText = statusResult.content?.[0]?.text || '';
if (statusText.includes('โ
') && statusText.includes('Authenticated')) {
console.log(chalk.green('โ
Authentication successful!'));
console.log(chalk.green('\nโ
GitHub OAuth authentication completed!'));
return true;
}
// Check helper status for more details
const helperStatus = await this.callTool('oauth_helper_status');
const helperText = helperStatus.content?.[0]?.text || '';
if (helperText.includes('โ') || helperText.includes('FAILED')) {
console.log(chalk.red('โ Authentication failed'));
console.log(chalk.red('\nโ Authentication failed or was cancelled'));
return false;
}
// Calculate exponential backoff with cap at 10 seconds
delay = Math.min(delay * 1.2, 10000);
const totalElapsed = attempts * (delay / 1000);
const remaining = Math.max(0, Math.floor((300 - totalElapsed) / 60));
process.stdout.write(`\r๐ Waiting for authentication... (${remaining}m remaining, checking every ${Math.round(delay/1000)}s)`);
// Wait with exponential backoff
await new Promise(resolve => setTimeout(resolve, delay));
}
console.log(chalk.red('\nโ Authentication timeout'));
console.log(chalk.red('\nโฑ๏ธ Authentication timed out after 5 minutes'));
return false;
}
/**
* Test authenticated GitHub API access
*/
async testGitHubAccess() {
console.log(chalk.yellow('\n๐งช Testing Authenticated GitHub API Access...\n'));
// Test 1: Check authentication status
console.log(chalk.blue('๐ Verifying authentication...'));
const authCheck = await this.callTool('check_github_auth');
console.log(chalk.green('โ
Authentication verified'));
// Test 2: Get user information (this would use OAuth if properly integrated)
console.log(chalk.blue('\n๐ Testing GitHub API Access through MCP...\n'));
// Try to access a repository through MCP tools
// Note: This assumes there are MCP tools that use GitHub API
// You may need to add specific tools to your MCP server for this
try {
// Example: Try to list repositories or access portfolio
const tools = await this.client.listTools();
// Look for GitHub-related tools
const githubTools = tools.tools.filter(tool =>
tool.name.includes('github') ||
tool.name.includes('repository') ||
tool.name.includes('portfolio')
);
if (githubTools.length > 0) {
console.log(chalk.green(`Found ${githubTools.length} GitHub-related tools`));
// Test tools with masked names for debugging purposes
let successCount = 0;
let failCount = 0;
console.log(chalk.blue('Testing GitHub tools...'));
for (const tool of githubTools) {
try {
await this.callTool(tool.name);
successCount++;
} catch (error) {
failCount++;
}
}
console.log(chalk.green(`\n Summary: ${successCount} tools executed successfully`));
if (failCount > 0) {
console.log(chalk.yellow(` ${failCount} tools failed`));
}
}
// Specific test: Try to access mickdarling/dollhouse-portfolio
console.log(chalk.blue('\n๐ฏ Attempting to access mickdarling/dollhouse-portfolio...\n'));
// This would need a specific MCP tool that uses GitHub API
// For now, we'll check if the OAuth token is stored properly
const helperStatus = await this.callTool('oauth_helper_status');
console.log(chalk.green('OAuth Helper Status:'));
console.log(helperStatus.content?.[0]?.text || 'No status available');
} catch (error) {
console.error(chalk.red('Error testing GitHub access:', error.message));
}
}
/**
* Test accessing specific repository data
*/
async testRepositoryAccess(owner, repo, path) {
console.log(chalk.blue(`\n๐ Testing access to ${owner}/${repo}/${path}...\n`));
// This would require specific MCP tools that use the OAuth token
// to make authenticated GitHub API calls
// For demonstration, we'll show what tools would be needed
console.log(chalk.yellow('To fully test OAuth repository access, the MCP server needs tools that:'));
console.log(' 1. Read the stored OAuth token');
console.log(' 2. Make authenticated GitHub API calls');
console.log(' 3. Return repository content');
console.log(chalk.cyan('\nExample MCP tool needed:'));
console.log(chalk.gray(`
async function getGitHubContent(owner, repo, path) {
const token = await getStoredOAuthToken();
const response = await fetch(
\`https://api.github.com/repos/\${owner}/\${repo}/contents/\${path}\`,
{
headers: {
'Authorization': \`token \${token}\`,
'Accept': 'application/vnd.github.v3+json'
}
}
);
return await response.json();
}
`));
}
/**
* Clean up and disconnect
*/
async cleanup() {
console.log(chalk.yellow('\n๐งน Cleaning up...'));
if (this.client) {
await this.client.close();
}
if (this.transport) {
await this.transport.close();
}
// Note: transport.close() handles process cleanup
// No need to kill serverProcess separately since we removed the redundant spawn
console.log(chalk.green('โ
Cleanup complete'));
}
/**
* Run the full test flow
*/
async run() {
console.log(chalk.bold.cyan('\n' + '='.repeat(60)));
const mode = isTestMode() ? '๐งช TEST MODE (PAT)' : '๐ PRODUCTION MODE (OAuth)';
console.log(chalk.bold.white(` MCP Server OAuth Full Flow Test - ${mode}`));
console.log(chalk.bold.cyan('='.repeat(60) + '\n'));
try {
// Step 1: Connect to MCP
await this.connectToMCP();
// Step 2: Start OAuth flow
const authSuccess = await this.startOAuthFlow();
if (!authSuccess) {
console.log(chalk.red('\nโ OAuth authentication failed'));
return;
}
// Step 3: Test authenticated access
await this.testGitHubAccess();
// Step 4: Test specific repository access
await this.testRepositoryAccess('mickdarling', 'dollhouse-portfolio', 'templates');
console.log(chalk.bold.green('\n' + '='.repeat(60)));
console.log(chalk.bold.white(' โ
OAuth Flow Test Complete!'));
console.log(chalk.bold.green('='.repeat(60) + '\n'));
// Show summary
console.log(chalk.cyan('Summary:'));
console.log(chalk.green(' โ
MCP server connection successful'));
if (isTestMode()) {
console.log(chalk.green(' โ
PAT authentication verified (TEST MODE)'));
console.log(chalk.yellow(' โ ๏ธ OAuth device flow NOT tested'));
} else {
console.log(chalk.green(' โ
OAuth authentication completed'));
console.log(chalk.green(' โ
OAuth functionality verified'));
}
console.log(chalk.yellow(' โ ๏ธ Full API access requires additional MCP tools'));
} catch (error) {
console.error(chalk.red('\nโ Test failed:'), error.message);
console.error(error.stack);
} finally {
await this.cleanup();
}
}
}
// Run the test
if (import.meta.url === `file://${process.argv[1]}`) {
const tester = new MCPOAuthTester();
tester.run().catch(error => {
console.error(chalk.red('Fatal error:'), error);
process.exit(1);
});
}
export default MCPOAuthTester;