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
JavaScript
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();