UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

173 lines (172 loc) • 8.15 kB
import open from 'open'; import os from 'os'; import crypto from 'crypto'; import { captureRemote } from '../utils/capture.js'; import { observeServerDate } from './remote-channel.js'; const CLIENT_ID = 'mcp-device'; export class DeviceAuthenticator { constructor(baseServerUrl, deps = {}) { this.baseServerUrl = baseServerUrl; this.fetchFn = deps.fetch ?? fetch; this.openFn = deps.open ?? open; this.captureFn = deps.capture ?? captureRemote; this.monotonicNow = deps.monotonicNow ?? performance.now.bind(performance); } async authenticate(deviceId) { console.log('🔐 Connecting this computer...\n'); // Generate PKCE const pkce = this.generatePKCE(); // Step 1: Request device code const deviceAuth = await this.requestDeviceCode(pkce.challenge, deviceId); // Step 2: Display user instructions and open browser this.displayUserInstructions(deviceAuth); // Step 3: Poll for authorization const tokens = await this.pollForAuthorization(deviceAuth, pkce.verifier); console.log('✅ Device verified\n'); return tokens; } generatePKCE() { const verifier = crypto.randomBytes(32).toString('base64url'); const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); return { verifier, challenge }; } async requestDeviceCode(codeChallenge, deviceId) { console.log(' - 📡 Requesting device code...'); const startedAt = this.monotonicNow(); await this.captureFn('remote_device_auth_request_started', { has_existing_device_id: Boolean(deviceId), }); let response; try { response = await this.fetchFn(`${this.baseServerUrl}/device/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: CLIENT_ID, scope: 'mcp:tools', device_name: os.hostname(), device_type: 'mcp', device_id: deviceId, code_challenge: codeChallenge, code_challenge_method: 'S256', }), }); } catch (error) { await this.captureFn('remote_device_auth_request_network_error', { error, duration_ms: Math.round(this.monotonicNow() - startedAt), has_existing_device_id: Boolean(deviceId), }); throw error; } observeServerDate(response.headers.get('date')); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })); const errorMessage = error.error_description || 'Failed to start device flow'; await this.captureFn('remote_device_auth_request_failed', { error: errorMessage, duration_ms: Math.round(this.monotonicNow() - startedAt), has_existing_device_id: Boolean(deviceId), }); throw new Error(errorMessage); } const data = await response.json(); await this.captureFn('remote_device_auth_code_received', { duration_ms: Math.round(this.monotonicNow() - startedAt), has_existing_device_id: Boolean(deviceId), }); console.log(' - ✅ Device code received\n'); return data; } displayUserInstructions(deviceAuth) { console.log('📋 Please complete authentication:\n'); console.log(' 1. Verify this device in your browser:'); console.log(` ${deviceAuth.verification_uri_complete}\n`); console.log(' 2. Make sure the code matches:'); console.log(` ${deviceAuth.user_code}\n`); console.log(` Code expires in ${Math.floor(deviceAuth.expires_in / 60)} minutes.\n`); // Try to open browser automatically. This only confirms the OS accepted // the launch request; page-load completion is measured by the web event. void this.openFn(deviceAuth.verification_uri_complete) .then(() => this.captureFn('remote_device_browser_launch_succeeded')) .catch(async (error) => { await this.captureFn('remote_device_browser_launch_failed', { error }); console.log(' - Could not open browser automatically.'); console.log(` - Please visit: ${deviceAuth.verification_uri}\n`); }); console.log(' - ⏳ Waiting for authorization...\n'); } async pollForAuthorization(deviceAuth, codeVerifier) { const interval = (deviceAuth.interval || 5) * 1000; const maxAttempts = Math.floor(deviceAuth.expires_in / (deviceAuth.interval || 5)); let attempt = 0; while (attempt < maxAttempts) { attempt++; // Wait before polling await this.sleep(interval); // Only transport problems are retried here. The request, and a body // we cannot read, are the transient part; an answer the server // actually gave (access_denied, expired_token, ...) is final. Before, // the terminal throw sat inside this try and its own catch swallowed // it, so a denied or expired code kept the terminal on "Waiting for // authorization" until the whole code lifetime ran out. let response; let data; try { response = await this.fetchFn(`${this.baseServerUrl}/device/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_code: deviceAuth.device_code, client_id: CLIENT_ID, code_verifier: codeVerifier, }), }); // The response that delivers the session states the server's // time; correct the clock before that session is used. observeServerDate(response.headers.get('date')); // Parse response body exactly once. null = not a JSON answer // (e.g. a proxy's HTML 502), which is not a verdict on the code. data = await response.json().catch(() => null); } catch (fetchError) { if (attempt >= maxAttempts) { await this.captureFn('remote_device_auth_network_error', { error: fetchError }); throw fetchError; } continue; } // Successful authentication if (response.ok && data?.access_token) { return { device_id: data.device_id, access_token: data.access_token, refresh_token: data.refresh_token || null, }; } // Server-side trouble or an unreadable body: transient, keep polling. if (!data || response.status >= 500) { continue; } if (data.error === 'authorization_pending') { continue; } if (data.error === 'slow_down') { // Server requested slower polling await this.sleep(interval); continue; } // Terminal error: the server answered, and the answer is no. const errorMessage = data.error_description || data.error || 'Authorization failed'; await this.captureFn('remote_device_auth_failed', { error: errorMessage, error_code: data.error ?? null }); throw new Error(errorMessage); } const timeoutError = 'Authorization timeout - user did not authorize within the time limit'; await this.captureFn('remote_device_auth_timeout', { error: timeoutError }); throw new Error(timeoutError); } sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } }