UNPKG

ms365-mcp-server

Version:

Microsoft 365 MCP Server for managing Microsoft 365 email through natural language interactions with full OAuth2 authentication support

259 lines (258 loc) 9.11 kB
import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { logger } from './api.js'; // Service name for keychain storage const SERVICE_NAME = 'ms365-mcp-server'; // Fallback directory for file-based storage const FALLBACK_DIR = path.join(os.homedir(), '.ms365-mcp'); /** * Secure credential store that uses OS keychain when available, * with encrypted file fallback for cross-platform compatibility. */ export class CredentialStore { constructor() { this.useKeychain = false; this.keytar = null; this.initialized = false; this.initPromise = null; this.ensureFallbackDir(); this.initPromise = this.initializeKeychain(); } /** * Ensure the credential store is initialized */ async ensureInitialized() { if (!this.initialized && this.initPromise) { await this.initPromise; this.initialized = true; } } /** * Initialize keychain support if available */ async initializeKeychain() { try { // Try to load keytar for secure OS keychain storage // Use eval to prevent TypeScript from checking the import at compile time const keytarModule = await eval('import("keytar")'); this.keytar = keytarModule.default || keytarModule; this.useKeychain = true; logger.log('OS keychain support enabled'); } catch (error) { // Keytar might not be available on all systems logger.log('OS keychain not available, using file fallback'); this.useKeychain = false; } } /** * Ensure fallback directory exists */ ensureFallbackDir() { if (!fs.existsSync(FALLBACK_DIR)) { fs.mkdirSync(FALLBACK_DIR, { recursive: true }); } } /** * Store credentials securely */ async setCredentials(account, credentials) { await this.ensureInitialized(); const credentialString = JSON.stringify(credentials); if (this.useKeychain && this.keytar) { try { await this.keytar.setPassword(SERVICE_NAME, account, credentialString); logger.log(`Stored credentials for ${account} in OS keychain`); return; } catch (error) { logger.error(`Failed to store in keychain for ${account}:`, error); // Fall through to file storage } } // Fallback to file storage await this.setCredentialsFile(account, credentials); } /** * Retrieve credentials securely */ async getCredentials(account) { await this.ensureInitialized(); if (this.useKeychain && this.keytar) { try { const credentialString = await this.keytar.getPassword(SERVICE_NAME, account); if (credentialString) { logger.log(`Retrieved credentials for ${account} from OS keychain`); return JSON.parse(credentialString); } } catch (error) { logger.error(`Failed to retrieve from keychain for ${account}:`, error); // Fall through to file storage } } // Fallback to file storage return await this.getCredentialsFile(account); } /** * Delete credentials securely */ async deleteCredentials(account) { await this.ensureInitialized(); let deleted = false; if (this.useKeychain && this.keytar) { try { deleted = await this.keytar.deletePassword(SERVICE_NAME, account); if (deleted) { logger.log(`Deleted credentials for ${account} from OS keychain`); } } catch (error) { logger.error(`Failed to delete from keychain for ${account}:`, error); } } // Also try file storage const fileDeleted = await this.deleteCredentialsFile(account); return deleted || fileDeleted; } /** * List all stored accounts */ async listAccounts() { await this.ensureInitialized(); const accounts = new Set(); // Get accounts from keychain (if available) if (this.useKeychain && this.keytar) { try { const keychainAccounts = await this.keytar.findCredentials(SERVICE_NAME); keychainAccounts.forEach((cred) => accounts.add(cred.account)); } catch (error) { logger.error('Failed to list keychain accounts:', error); } } // Get accounts from file storage const fileAccounts = await this.listFileAccounts(); fileAccounts.forEach(account => accounts.add(account)); return Array.from(accounts); } /** * File-based credential storage (fallback) */ async setCredentialsFile(account, credentials) { try { const filePath = path.join(FALLBACK_DIR, `token_${this.sanitizeFilename(account)}.json`); const encryptedData = this.simpleEncrypt(JSON.stringify(credentials)); fs.writeFileSync(filePath, encryptedData); logger.log(`Stored credentials for ${account} in file`); } catch (error) { logger.error(`Failed to store credentials in file for ${account}:`, error); throw error; } } /** * File-based credential retrieval (fallback) */ async getCredentialsFile(account) { try { const filePath = path.join(FALLBACK_DIR, `token_${this.sanitizeFilename(account)}.json`); if (!fs.existsSync(filePath)) { return null; } const encryptedData = fs.readFileSync(filePath, 'utf8'); const decryptedData = this.simpleDecrypt(encryptedData); logger.log(`Retrieved credentials for ${account} from file`); return JSON.parse(decryptedData); } catch (error) { logger.error(`Failed to retrieve credentials from file for ${account}:`, error); return null; } } /** * File-based credential deletion (fallback) */ async deleteCredentialsFile(account) { try { const filePath = path.join(FALLBACK_DIR, `token_${this.sanitizeFilename(account)}.json`); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); logger.log(`Deleted credentials for ${account} from file`); return true; } return false; } catch (error) { logger.error(`Failed to delete credentials from file for ${account}:`, error); return false; } } /** * List accounts from file storage */ async listFileAccounts() { try { const files = fs.readdirSync(FALLBACK_DIR); return files .filter(file => file.startsWith('token_') && file.endsWith('.json')) .map(file => file.replace('token_', '').replace('.json', '')) .map(filename => this.unsanitizeFilename(filename)); } catch (error) { logger.error('Failed to list file accounts:', error); return []; } } /** * Sanitize account name for file storage */ sanitizeFilename(account) { return account.replace(/[^a-zA-Z0-9._-]/g, '_'); } /** * Reverse filename sanitization */ unsanitizeFilename(filename) { // This is a simplified reverse - in practice, we'd need a more robust mapping return filename.replace(/_/g, '@'); } /** * Simple encryption for file storage (base64 + basic obfuscation) * Note: This is not cryptographically secure, just obfuscation */ simpleEncrypt(data) { const key = 'ms365-mcp-server-key'; let encrypted = ''; for (let i = 0; i < data.length; i++) { encrypted += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length)); } return Buffer.from(encrypted).toString('base64'); } /** * Simple decryption for file storage */ simpleDecrypt(encryptedData) { const key = 'ms365-mcp-server-key'; const encrypted = Buffer.from(encryptedData, 'base64').toString(); let decrypted = ''; for (let i = 0; i < encrypted.length; i++) { decrypted += String.fromCharCode(encrypted.charCodeAt(i) ^ key.charCodeAt(i % key.length)); } return decrypted; } /** * Check if keychain is available */ isKeychainAvailable() { return this.useKeychain && this.keytar !== null; } /** * Get storage method being used */ getStorageMethod() { return this.useKeychain ? 'OS Keychain' : 'Encrypted File'; } } export const credentialStore = new CredentialStore();