@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
173 lines (172 loc) • 8.15 kB
JavaScript
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));
}
}