import { promises as fs } from "fs"; import path from "path"; import os from "os"; import crypto from "crypto"; export interface UpworkTokens { access_token: string; refresh_token: string; expires_in: number; token_type: string; obtained_at: string; } interface PersistedTokens extends UpworkTokens { cached_at: string; // When tokens were cached locally account_type: string; // 'freelancer' or 'agency' } export class CredentialManager { private readonly SERVICE_NAME = 'mcp-upwork'; private readonly CONFIG_DIR = path.join(os.homedir(), '.mcp-upwork'); private readonly CONFIG_FILE = path.join(this.CONFIG_DIR, 'tokens.json'); private readonly ENCRYPTION_KEY_FILE = path.join(this.CONFIG_DIR, '.key'); private keytar: any = null; private isKeytarAvailable = false; private initPromise: Promise; private encryptionKey: Buffer | null = null; constructor() { this.initPromise = this.initialize(); } private async initialize() { // Initialize keytar try { this.keytar = await import('keytar'); this.isKeytarAvailable = true; console.error('[CredentialManager] Keytar initialized successfully'); } catch (error) { console.error('[CredentialManager] Keytar not available, using file-based storage:', error); this.isKeytarAvailable = false; } // Ensure config directory exists try { await fs.mkdir(this.CONFIG_DIR, { recursive: true }); // Set directory permissions to be readable only by the user await fs.chmod(this.CONFIG_DIR, 0o700); } catch (error) { console.error('[CredentialManager] Failed to create config directory:', error); } // Initialize or load encryption key for file-based storage await this.initializeEncryptionKey(); } private async initializeEncryptionKey() { try { // Try to read existing key this.encryptionKey = await fs.readFile(this.ENCRYPTION_KEY_FILE); } catch (error) { // Generate new key if doesn't exist this.encryptionKey = crypto.randomBytes(32); try { await fs.writeFile(this.ENCRYPTION_KEY_FILE, this.encryptionKey); await fs.chmod(this.ENCRYPTION_KEY_FILE, 0o600); // Read/write for owner only } catch (writeError) { console.error('[CredentialManager] Failed to save encryption key:', writeError); } } } private encrypt(text: string): string { if (!this.encryptionKey) return text; // Fallback to plain text if no key const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); return iv.toString('hex') + ':' + encrypted; } private decrypt(encryptedText: string): string { if (!this.encryptionKey || !encryptedText.includes(':')) return encryptedText; try { const [ivHex, encrypted] = encryptedText.split(':'); const iv = Buffer.from(ivHex, 'hex'); const decipher = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, iv); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } catch (error) { console.error('[CredentialManager] Failed to decrypt:', error); return encryptedText; // Return as-is if decryption fails } } private async ensureInitialized() { await this.initPromise; } private getKeychainKey(accountType: 'freelancer' | 'agency'): string { return `upwork-${accountType}`; } async saveTokens(tokens: UpworkTokens, accountType: 'freelancer' | 'agency' = 'freelancer'): Promise { await this.ensureInitialized(); const persistedTokens: PersistedTokens = { ...tokens, cached_at: new Date().toISOString(), account_type: accountType }; // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { await this.keytar.setPassword( this.SERVICE_NAME, this.getKeychainKey(accountType), JSON.stringify(persistedTokens) ); console.error(`[CredentialManager] Tokens for ${accountType} saved to keychain`); return; // Success with keytar } catch (error) { console.error('[CredentialManager] Keytar save failed, falling back to file:', error); } } // Fallback to file-based storage await this.saveToFile(persistedTokens); } private async saveToFile(tokens: PersistedTokens): Promise { try { // Read existing tokens const allTokens = await this.loadFromFile(); // Encrypt sensitive data const encryptedTokens = { ...tokens, access_token: this.encrypt(tokens.access_token), refresh_token: this.encrypt(tokens.refresh_token) }; // Update or add tokens allTokens[tokens.account_type] = encryptedTokens; // Save back to file await fs.writeFile( this.CONFIG_FILE, JSON.stringify(allTokens, null, 2), 'utf8' ); await fs.chmod(this.CONFIG_FILE, 0o600); // Read/write for owner only console.error(`[CredentialManager] Tokens for ${tokens.account_type} saved to file`); } catch (error) { console.error('[CredentialManager] Failed to save tokens to file:', error); throw error; } } private async loadFromFile(): Promise> { try { const data = await fs.readFile(this.CONFIG_FILE, 'utf8'); return JSON.parse(data); } catch (error) { // File doesn't exist or is invalid return {}; } } async loadTokens(accountType: 'freelancer' | 'agency' = 'freelancer'): Promise { await this.ensureInitialized(); // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { const password = await this.keytar.getPassword(this.SERVICE_NAME, this.getKeychainKey(accountType)); if (password) { const persistedTokens = JSON.parse(password) as PersistedTokens; console.error(`[CredentialManager] Tokens for ${accountType} loaded from keychain`); return this.stripMetadata(persistedTokens); } } catch (error) { console.error(`[CredentialManager] Failed to get tokens for ${accountType} from keychain:`, error); } } // Fallback to file-based storage try { const allTokens = await this.loadFromFile(); const encryptedTokens = allTokens[accountType]; if (encryptedTokens) { // Decrypt sensitive data const tokens: PersistedTokens = { ...encryptedTokens, access_token: this.decrypt(encryptedTokens.access_token), refresh_token: this.decrypt(encryptedTokens.refresh_token) }; console.error(`[CredentialManager] Tokens for ${accountType} loaded from file`); return this.stripMetadata(tokens); } } catch (error) { console.error(`[CredentialManager] Failed to get tokens for ${accountType} from file:`, error); } return null; } private stripMetadata(persistedTokens: PersistedTokens): UpworkTokens { return { access_token: persistedTokens.access_token, refresh_token: persistedTokens.refresh_token, expires_in: persistedTokens.expires_in, token_type: persistedTokens.token_type, obtained_at: persistedTokens.obtained_at }; } async clearTokens(accountType: 'freelancer' | 'agency' = 'freelancer'): Promise { await this.ensureInitialized(); // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { await this.keytar.deletePassword(this.SERVICE_NAME, this.getKeychainKey(accountType)); console.error(`[CredentialManager] Tokens for ${accountType} deleted from keychain`); } catch (error) { console.error('[CredentialManager] Failed to delete from keychain:', error); } } // Also delete from file-based storage try { const allTokens = await this.loadFromFile(); delete allTokens[accountType]; await fs.writeFile( this.CONFIG_FILE, JSON.stringify(allTokens, null, 2), 'utf8' ); console.error(`[CredentialManager] Tokens for ${accountType} deleted from file`); } catch (error) { console.error('[CredentialManager] Failed to delete from file:', error); } } async isAvailable(): Promise { await this.ensureInitialized(); return this.isKeytarAvailable; } async getTokenAge(accountType: 'freelancer' | 'agency' = 'freelancer'): Promise { await this.ensureInitialized(); // Try to get cached_at timestamp let cachedAt: string | null = null; // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { const password = await this.keytar.getPassword(this.SERVICE_NAME, this.getKeychainKey(accountType)); if (password) { const persistedTokens = JSON.parse(password) as PersistedTokens; cachedAt = persistedTokens.cached_at; } } catch (error) { // Ignore keytar errors } } // Fallback to file if (!cachedAt) { try { const allTokens = await this.loadFromFile(); const tokens = allTokens[accountType]; if (tokens) { cachedAt = tokens.cached_at; } } catch (error) { // Ignore file errors } } if (!cachedAt) { return null; } const cacheTime = new Date(cachedAt).getTime(); const now = Date.now(); return Math.floor((now - cacheTime) / 1000); // Age in seconds } }