UNPKG

ms365-mcp-server

Version:

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

360 lines (359 loc) 14.1 kB
import { ConfidentialClientApplication } from '@azure/msal-node'; import { Client } from '@microsoft/microsoft-graph-client'; import * as fs from 'fs'; import * as path from 'path'; import { createServer } from 'http'; import { URL } from 'url'; import { logger } from './api.js'; import { createHash, randomBytes } from 'crypto'; import { getConfigDirWithFallback } from './config-dir.js'; // Scopes required for Microsoft 365 operations const SCOPES = [ 'https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send', 'https://graph.microsoft.com/MailboxSettings.Read', 'https://graph.microsoft.com/Contacts.Read', 'https://graph.microsoft.com/User.Read', 'offline_access' ]; // Configuration directory const CONFIG_DIR = getConfigDirWithFallback(); /** * Multi-user Microsoft 365 authentication manager */ export class MultiUserMS365Auth { constructor() { this.sessions = new Map(); this.credentials = null; this.authServers = new Map(); this.ensureConfigDir(); this.loadCredentials(); this.loadExistingSessions(); } ensureConfigDir() { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); } } /** * Load credentials from environment or file */ loadCredentials() { try { // Try environment variables first if (process.env.MS365_CLIENT_ID && process.env.MS365_CLIENT_SECRET && process.env.MS365_TENANT_ID) { this.credentials = { clientId: process.env.MS365_CLIENT_ID, clientSecret: process.env.MS365_CLIENT_SECRET, tenantId: process.env.MS365_TENANT_ID, redirectUri: process.env.MS365_REDIRECT_URI || 'http://localhost:44001/oauth2callback' }; logger.log('Loaded MS365 credentials from environment variables'); return; } // Try credentials file const credentialsFile = path.join(CONFIG_DIR, 'credentials.json'); if (fs.existsSync(credentialsFile)) { const credentialsData = fs.readFileSync(credentialsFile, 'utf8'); this.credentials = JSON.parse(credentialsData); logger.log('Loaded MS365 credentials from file'); } } catch (error) { logger.error('Error loading MS365 credentials:', error); } } /** * Load existing user sessions */ loadExistingSessions() { try { const sessionsFile = path.join(CONFIG_DIR, 'user-sessions.json'); if (fs.existsSync(sessionsFile)) { const sessionsData = fs.readFileSync(sessionsFile, 'utf8'); const sessions = JSON.parse(sessionsData); for (const [userId, session] of Object.entries(sessions)) { this.sessions.set(userId, session); } logger.log(`Loaded ${this.sessions.size} existing user sessions`); } } catch (error) { logger.error('Error loading existing sessions:', error); } } /** * Save user sessions to file */ saveUserSessions() { try { const sessionsFile = path.join(CONFIG_DIR, 'user-sessions.json'); const sessionsObj = Object.fromEntries(this.sessions); fs.writeFileSync(sessionsFile, JSON.stringify(sessionsObj, null, 2)); logger.log('Saved user sessions'); } catch (error) { logger.error('Error saving user sessions:', error); } } /** * Generate a unique user ID */ generateUserId(userEmail) { const timestamp = Date.now().toString(); const random = randomBytes(8).toString('hex'); if (userEmail) { const emailHash = createHash('sha256').update(userEmail).digest('hex').substring(0, 8); return `user_${emailHash}_${timestamp.substring(-6)}_${random.substring(0, 4)}`; } return `user_${timestamp}_${random}`; } /** * Create MSAL client for a user */ createMsalClient(redirectUri) { if (!this.credentials) { throw new Error('MS365 credentials not configured'); } const config = { auth: { clientId: this.credentials.clientId, clientSecret: this.credentials.clientSecret, authority: `https://login.microsoftonline.com/${this.credentials.tenantId}` }, system: { loggerOptions: { loggerCallback: (level, message, containsPii) => { if (!containsPii) { logger.log(`MSAL: ${message}`); } }, piiLoggingEnabled: false, logLevel: 3 // Error level } } }; return new ConfidentialClientApplication(config); } /** * Start authentication flow for a new user */ async authenticateNewUser(userEmail) { if (!this.credentials) { throw new Error('MS365 credentials not configured. Please set MS365_CLIENT_ID, MS365_CLIENT_SECRET, and MS365_TENANT_ID environment variables.'); } const userId = this.generateUserId(userEmail); // Find available port const port = await this.findAvailablePort(); const redirectUri = `http://localhost:${port}/oauth2callback`; // Create MSAL client with specific redirect URI const msalClient = this.createMsalClient(redirectUri); // Generate authentication URL const authUrl = await msalClient.getAuthCodeUrl({ scopes: SCOPES, redirectUri: redirectUri, prompt: 'consent', state: userId // Include user ID in state for identification }); // Start callback server for this user await this.startCallbackServer(userId, msalClient, port, redirectUri); logger.log(`Started authentication flow for user ${userId} on port ${port}`); return { userId, authUrl, port }; } /** * Find an available port for OAuth callback */ async findAvailablePort(startPort = 44001) { return new Promise((resolve) => { const server = createServer(); server.listen(startPort, () => { const port = server.address()?.port || startPort; server.close(); resolve(port); }); server.on('error', () => { resolve(this.findAvailablePort(startPort + 1)); }); }); } /** * Start callback server for OAuth2 authentication */ async startCallbackServer(userId, msalClient, port, redirectUri) { return new Promise((resolve, reject) => { const server = createServer(async (req, res) => { if (req.url?.startsWith('/oauth2callback')) { const url = new URL(req.url, `http://localhost:${port}`); const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { res.end(`<html><body><h1>Authentication Error</h1><p>${error}</p></body></html>`); server.close(); reject(new Error(`OAuth2 error: ${error}`)); return; } if (code && state === userId) { try { // Exchange code for token const tokenResponse = await msalClient.acquireTokenByCode({ code: code, scopes: SCOPES, redirectUri: redirectUri }); if (tokenResponse) { // Save user session const userSession = { userId: userId, userEmail: tokenResponse.account?.username, accessToken: tokenResponse.accessToken, refreshToken: '', // MSAL handles refresh tokens internally expiresOn: tokenResponse.expiresOn?.getTime() || 0, authenticated: true, account: tokenResponse.account }; this.sessions.set(userId, userSession); this.saveUserSessions(); res.end(`<html><body><h1>Authentication Successful!</h1><p>User ID: ${userId}<br/>You can close this window and return to the application.</p></body></html>`); server.close(); resolve(); return; } } catch (tokenError) { logger.error(`Token exchange failed for user ${userId}:`, tokenError); res.end(`<html><body><h1>Token Exchange Failed</h1><p>Please try again.</p></body></html>`); server.close(); reject(tokenError); return; } } res.end('<html><body><h1>Invalid Request</h1></body></html>'); } else { res.end('<html><body><h1>MS365 MCP Server OAuth2</h1><p>Waiting for authentication...</p></body></html>'); } }); this.authServers.set(userId, server); server.listen(port, () => { logger.log(`OAuth2 callback server started for user ${userId} on port ${port}`); // Set timeout for authentication setTimeout(() => { if (this.authServers.has(userId)) { server.close(); this.authServers.delete(userId); logger.log(`Authentication timeout for user ${userId}`); reject(new Error('Authentication timeout')); } }, 600000); // 10 minutes timeout }); server.on('error', (err) => { this.authServers.delete(userId); reject(err); }); server.on('close', () => { this.authServers.delete(userId); resolve(); }); }); } /** * Get Microsoft Graph client for a specific user */ async getGraphClientForUser(userId) { const session = this.sessions.get(userId); if (!session) { throw new Error(`User session not found: ${userId}`); } // Check if token is expired if (session.expiresOn < Date.now()) { await this.refreshUserToken(userId); } const client = Client.init({ authProvider: (done) => { const updatedSession = this.sessions.get(userId); done(null, updatedSession?.accessToken || ''); } }); return client; } /** * Refresh user token */ async refreshUserToken(userId) { const session = this.sessions.get(userId); if (!session?.account) { throw new Error(`No account information available for user: ${userId}`); } if (!this.credentials) { throw new Error('MS365 credentials not configured'); } const msalClient = this.createMsalClient(); try { const tokenResponse = await msalClient.acquireTokenSilent({ scopes: SCOPES, account: session.account }); if (!tokenResponse) { throw new Error('Failed to refresh token'); } // Update session session.accessToken = tokenResponse.accessToken; session.refreshToken = ''; // MSAL handles refresh tokens internally session.expiresOn = tokenResponse.expiresOn?.getTime() || 0; session.account = tokenResponse.account || session.account; this.sessions.set(userId, session); this.saveUserSessions(); logger.log(`Token refreshed for user ${userId}`); } catch (error) { logger.error(`Token refresh failed for user ${userId}:`, error); throw error; } } /** * Get user session information */ getUserSession(userId) { return this.sessions.get(userId) || null; } /** * Get all authenticated users */ getAuthenticatedUsers() { return Array.from(this.sessions.values()).filter(session => session.authenticated); } /** * Remove user session */ removeUser(userId) { const success = this.sessions.delete(userId); if (success) { this.saveUserSessions(); logger.log(`Removed user session: ${userId}`); } return success; } /** * Clear all user sessions */ clearAllSessions() { this.sessions.clear(); this.saveUserSessions(); logger.log('Cleared all user sessions'); } /** * Check if user is authenticated */ isUserAuthenticated(userId) { const session = this.sessions.get(userId); return session?.authenticated || false; } /** * Get user count */ getUserCount() { return this.sessions.size; } } export const multiUserMS365Auth = new MultiUserMS365Auth();