UNPKG

ms365-mcp-server

Version:

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

615 lines (614 loc) • 25.1 kB
import { ConfidentialClientApplication, PublicClientApplication, CryptoProvider } from '@azure/msal-node'; import { Client } from '@microsoft/microsoft-graph-client'; import * as http from 'http'; import * as fs from 'fs'; import * as path from 'path'; import { URL } from 'url'; import open from 'open'; import { logger } from './api.js'; import { getConfigDirWithFallback } from './config-dir.js'; // OAuth callback port const CALLBACK_PORT = 44005; // Built-in fallback client ID (Microsoft Graph CLI Tools) const BUILTIN_CLIENT_ID = '14d82eec-204b-4c2f-b7e8-296a70dab67e'; const DEFAULT_TENANT_ID = 'common'; // Use existing config directory (~/.ms365-mcp/) const CONFIG_DIR = getConfigDirWithFallback(); const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json'); const TOKEN_FILE = path.join(CONFIG_DIR, 'token.json'); const MSAL_CACHE_FILE = path.join(CONFIG_DIR, 'msal-cache.json'); // Required scopes for Outlook operations const OUTLOOK_SCOPES = [ 'https://graph.microsoft.com/User.Read', 'https://graph.microsoft.com/Mail.Read', 'https://graph.microsoft.com/Mail.Send', 'https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/MailboxSettings.Read', 'https://graph.microsoft.com/Contacts.Read', 'offline_access' ]; // Token refresh buffer (10 minutes before expiry) const TOKEN_REFRESH_BUFFER_MS = 10 * 60 * 1000; /** * Check if running in MCP context (non-interactive) */ function isInMcpContext() { const args = process.argv.slice(2); const isCliCommand = args.some(arg => ['--login', '--logout', '--verify-login', '--reset-auth', '--setup-auth'].includes(arg)); // If it's a CLI command, we're NOT in MCP context if (isCliCommand) return false; // Check if stdin is not a TTY (piped input = MCP context) // or if running via npx in a non-interactive way return !process.stdin.isTTY || (process.env.npm_execpath?.includes('npx') ?? false); } /** * Outlook OAuth2 Authentication Manager * Implements OAuth redirect flow with local callback server */ export class OutlookAuth { constructor() { this.msalClient = null; this.credentials = null; this.callbackServer = null; this.cryptoProvider = new CryptoProvider(); } /** * Ensure config directory exists */ ensureConfigDir() { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); } } /** * Load credentials from environment, stored file, or use fallback */ async loadCredentials() { // Priority 1: Environment variables if (process.env.OUTLOOK_CLIENT_ID && process.env.OUTLOOK_TENANT_ID) { this.credentials = { clientId: process.env.OUTLOOK_CLIENT_ID, tenantId: process.env.OUTLOOK_TENANT_ID, clientSecret: process.env.OUTLOOK_CLIENT_SECRET, redirectUri: process.env.OUTLOOK_REDIRECT_URI || `http://localhost:${CALLBACK_PORT}/oauth2callback`, authType: process.env.OUTLOOK_CLIENT_SECRET ? 'redirect' : 'device' }; logger.log('Loaded credentials from environment variables'); return this.credentials; } // Priority 2: Stored credentials file (~/.ms365-mcp/credentials.json) try { if (fs.existsSync(CREDENTIALS_FILE)) { const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8'); this.credentials = JSON.parse(data); logger.log('Loaded credentials from stored file'); return this.credentials; } } catch (error) { logger.error('Error loading credentials file:', error); } // Priority 3: Built-in fallback (no setup required) this.credentials = { clientId: BUILTIN_CLIENT_ID, tenantId: DEFAULT_TENANT_ID, redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`, authType: 'redirect' }; logger.log('Using built-in fallback credentials'); return this.credentials; } /** * Initialize MSAL client with persistent cache */ async initializeMsalClient() { if (this.msalClient) { return this.msalClient; } if (!this.credentials) { await this.loadCredentials(); } const isConfidential = !!this.credentials.clientSecret; // Create persistent token cache plugin using existing ~/.ms365-mcp/ location const cachePlugin = { beforeCacheAccess: async (cacheContext) => { try { if (fs.existsSync(MSAL_CACHE_FILE)) { const cacheData = fs.readFileSync(MSAL_CACHE_FILE, 'utf8'); cacheContext.tokenCache.deserialize(cacheData); } } catch (error) { logger.error('Error loading MSAL cache:', error); } }, afterCacheAccess: async (cacheContext) => { try { if (cacheContext.cacheHasChanged) { this.ensureConfigDir(); const cacheData = cacheContext.tokenCache.serialize(); fs.writeFileSync(MSAL_CACHE_FILE, cacheData); } } catch (error) { logger.error('Error saving MSAL cache:', error); } } }; const config = { auth: { clientId: this.credentials.clientId, authority: `https://login.microsoftonline.com/${this.credentials.tenantId}`, ...(isConfidential && { clientSecret: this.credentials.clientSecret }) }, cache: { cachePlugin }, system: { loggerOptions: { loggerCallback: (level, message, containsPii) => { if (!containsPii) { logger.log(`MSAL: ${message}`); } }, piiLoggingEnabled: false, logLevel: 3 // Error level } } }; if (isConfidential) { this.msalClient = new ConfidentialClientApplication(config); } else { this.msalClient = new PublicClientApplication(config); } logger.log(`Initialized ${isConfidential ? 'Confidential' : 'Public'} MSAL client`); return this.msalClient; } /** * Start local callback server for OAuth redirect */ startCallbackServer(expectedState) { return new Promise((resolve, reject) => { const server = http.createServer(async (req, res) => { if (!req.url?.startsWith('/oauth2callback')) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('<html><body><h1>Outlook MCP OAuth2</h1><p>Waiting for authentication...</p></body></html>'); return; } try { const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`); const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); const errorDescription = url.searchParams.get('error_description'); // Handle errors from OAuth provider if (error) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(`<html><body><h1>Authentication Error</h1><p>${error}: ${errorDescription || 'Unknown error'}</p></body></html>`); this.closeCallbackServer(); reject(new Error(`OAuth error: ${error} - ${errorDescription}`)); return; } // Validate state parameter (CSRF protection) if (state !== expectedState) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('<html><body><h1>Authentication Error</h1><p>Invalid state parameter. Possible CSRF attack.</p></body></html>'); this.closeCallbackServer(); reject(new Error('Invalid state parameter - possible CSRF attack')); return; } if (!code) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('<html><body><h1>Authentication Error</h1><p>No authorization code received.</p></body></html>'); this.closeCallbackServer(); reject(new Error('No authorization code received')); return; } // Exchange code for tokens const msalClient = await this.initializeMsalClient(); const tokenResponse = await msalClient.acquireTokenByCode({ code, scopes: OUTLOOK_SCOPES, redirectUri: this.credentials.redirectUri }); if (!tokenResponse) { res.writeHead(500, { 'Content-Type': 'text/html' }); res.end('<html><body><h1>Authentication Error</h1><p>Failed to exchange code for tokens.</p></body></html>'); this.closeCallbackServer(); reject(new Error('Failed to exchange authorization code for tokens')); return; } // Success! res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);"> <div style="background: white; padding: 40px; border-radius: 10px; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"> <h1 style="color: #22c55e; margin-bottom: 10px;">āœ“ Authentication Successful!</h1> <p style="color: #666;">You can close this window and return to the terminal.</p> </div> </body> </html> `); this.closeCallbackServer(); resolve(tokenResponse); } catch (error) { res.writeHead(500, { 'Content-Type': 'text/html' }); res.end(`<html><body><h1>Authentication Error</h1><p>${error}</p></body></html>`); this.closeCallbackServer(); reject(error); } }); server.on('error', (err) => { reject(new Error(`Failed to start callback server: ${err.message}`)); }); server.listen(CALLBACK_PORT, () => { logger.log(`OAuth callback server listening on port ${CALLBACK_PORT}`); }); this.callbackServer = server; // Timeout after 5 minutes setTimeout(() => { if (this.callbackServer) { this.closeCallbackServer(); reject(new Error('Authentication timed out after 5 minutes')); } }, 5 * 60 * 1000); }); } /** * Close callback server */ closeCallbackServer() { if (this.callbackServer) { this.callbackServer.close(); this.callbackServer = null; logger.log('Closed OAuth callback server'); } } /** * Generate authorization URL with PKCE and state */ async generateAuthUrl() { const msalClient = await this.initializeMsalClient(); // Generate state for CSRF protection const state = this.cryptoProvider.createNewGuid(); const authUrl = await msalClient.getAuthCodeUrl({ scopes: OUTLOOK_SCOPES, redirectUri: this.credentials.redirectUri, state, prompt: 'select_account' }); return { authUrl, state }; } /** * Main authentication entry point * Handles both CLI and MCP contexts */ async authenticate() { await this.loadCredentials(); // Check if we already have valid tokens const existingToken = await this.getValidToken(); if (existingToken) { logger.log('Using existing valid token'); return existingToken; } const { authUrl, state } = await this.generateAuthUrl(); if (isInMcpContext()) { // In MCP context - throw error with auth URL for client to handle const error = new Error('Authentication required'); error.authUrl = authUrl; error.code = 'AUTH_REQUIRED'; throw error; } // CLI context - open browser and wait for callback console.log('\nšŸ” Microsoft 365 Authentication'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('Opening browser for authentication...'); console.log(`\nIf browser doesn't open, visit:\n${authUrl}\n`); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); // Start callback server first, then open browser const tokenPromise = this.startCallbackServer(state); try { await open(authUrl); } catch (error) { console.log('Could not open browser automatically. Please visit the URL above.'); } const tokenResponse = await tokenPromise; // Save tokens await this.saveTokens(tokenResponse); console.log('\nāœ… Authentication successful!\n'); return tokenResponse; } /** * Save tokens to existing storage location (~/.ms365-mcp/token.json) */ async saveTokens(tokenResponse) { const tokens = { accessToken: tokenResponse.accessToken, refreshToken: undefined, // MSAL handles refresh tokens via cache expiresOn: tokenResponse.expiresOn?.getTime() || Date.now() + 3600000, account: tokenResponse.account ? { username: tokenResponse.account.username, homeAccountId: tokenResponse.account.homeAccountId, environment: tokenResponse.account.environment, tenantId: tokenResponse.account.tenantId, localAccountId: tokenResponse.account.localAccountId } : null, authType: 'redirect' }; try { this.ensureConfigDir(); fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2)); logger.log(`Tokens saved to ${TOKEN_FILE}`); } catch (error) { logger.error('Error saving tokens:', error); throw error; } } /** * Load tokens from existing storage location (~/.ms365-mcp/token.json) */ async loadStoredTokens() { try { if (!fs.existsSync(TOKEN_FILE)) { return null; } const data = fs.readFileSync(TOKEN_FILE, 'utf8'); return JSON.parse(data); } catch (error) { logger.error('Error loading tokens:', error); return null; } } /** * Clear tokens from storage */ async clearTokens() { try { if (fs.existsSync(TOKEN_FILE)) { fs.unlinkSync(TOKEN_FILE); } if (fs.existsSync(MSAL_CACHE_FILE)) { fs.unlinkSync(MSAL_CACHE_FILE); } } catch (error) { logger.error('Error clearing tokens:', error); } } /** * Get valid token (checks expiry with buffer) */ async getValidToken() { const storedTokens = await this.loadStoredTokens(); if (!storedTokens) { return null; } // Check if token expires within buffer time const now = Date.now(); if (storedTokens.expiresOn > now + TOKEN_REFRESH_BUFFER_MS) { // Token is still valid return { accessToken: storedTokens.accessToken, expiresOn: new Date(storedTokens.expiresOn), account: storedTokens.account, scopes: OUTLOOK_SCOPES, authority: `https://login.microsoftonline.com/${this.credentials?.tenantId || DEFAULT_TENANT_ID}`, uniqueId: storedTokens.account?.localAccountId || '', tenantId: storedTokens.account?.tenantId || '', idToken: '', idTokenClaims: {}, fromCache: true, tokenType: 'Bearer', correlationId: '' }; } // Token expired or expiring soon - try to refresh logger.log('Token expired or expiring soon, attempting refresh...'); return await this.refreshToken(); } /** * Refresh token using MSAL silent acquisition */ async refreshToken() { try { const storedTokens = await this.loadStoredTokens(); if (!storedTokens?.account) { logger.log('No account info for token refresh'); return null; } await this.loadCredentials(); const msalClient = await this.initializeMsalClient(); // Try silent token acquisition const tokenResponse = await msalClient.acquireTokenSilent({ scopes: OUTLOOK_SCOPES, account: storedTokens.account }); if (tokenResponse) { await this.saveTokens(tokenResponse); logger.log('Token refreshed successfully'); return tokenResponse; } return null; } catch (error) { logger.error('Token refresh failed:', error.message); // If refresh fails, clear tokens so user can re-authenticate if (error.errorCode === 'invalid_grant' || error.errorCode === 'interaction_required' || error.errorCode === 'consent_required') { await this.clearTokens(); } return null; } } /** * Get authenticated Microsoft Graph client */ async getGraphClient() { const token = await this.getValidToken(); if (!token) { // Try to authenticate const newToken = await this.authenticate(); return Client.init({ authProvider: (done) => { done(null, newToken.accessToken); } }); } return Client.init({ authProvider: (done) => { done(null, token.accessToken); } }); } /** * Check if user is authenticated */ async isAuthenticated() { const token = await this.getValidToken(); return token !== null; } /** * Get authentication status */ async getAuthenticationStatus() { const storedTokens = await this.loadStoredTokens(); await this.loadCredentials(); if (!storedTokens) { return { authenticated: false, clientId: this.credentials?.clientId, tenantId: this.credentials?.tenantId }; } const now = Date.now(); const isValid = storedTokens.expiresOn > now; const expiresIn = Math.max(0, Math.floor((storedTokens.expiresOn - now) / 1000 / 60)); return { authenticated: isValid, username: storedTokens.account?.username, expiresAt: new Date(storedTokens.expiresOn).toLocaleString(), expiresIn: expiresIn, clientId: this.credentials?.clientId, tenantId: this.credentials?.tenantId }; } /** * Logout - clear all stored credentials and tokens */ async logout() { this.closeCallbackServer(); await this.clearTokens(); // Also clear credentials file try { if (fs.existsSync(CREDENTIALS_FILE)) { fs.unlinkSync(CREDENTIALS_FILE); } } catch (error) { logger.error('Error clearing credentials:', error); } this.msalClient = null; this.credentials = null; logger.log('Logged out successfully'); } /** * Reset auth - clear everything */ async resetAuth() { await this.logout(); console.log('āœ… All authentication data cleared'); } /** * Get current user info */ async getCurrentUser() { const storedTokens = await this.loadStoredTokens(); if (storedTokens?.account?.username) { return storedTokens.account.username; } return null; } /** * Save credentials to storage */ async saveCredentials(credentials) { try { this.ensureConfigDir(); fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2)); logger.log(`Credentials saved to ${CREDENTIALS_FILE}`); } catch (error) { logger.error('Error saving credentials:', error); throw error; } } /** * Interactive credential setup */ async setupCredentials() { const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const question = (prompt) => { return new Promise((resolve) => { rl.question(prompt, resolve); }); }; try { console.log('\nšŸ”§ Outlook MCP Server Credential Setup\n'); console.log('Choose setup method:'); console.log('1. Use built-in credentials (Recommended - no Azure setup needed)'); console.log('2. Use custom Azure App credentials\n'); const choice = await question('Enter choice (1 or 2): '); if (choice === '1') { const credentials = { clientId: BUILTIN_CLIENT_ID, tenantId: DEFAULT_TENANT_ID, redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`, authType: 'redirect' }; await this.saveCredentials(credentials); console.log('\nāœ… Configured with built-in credentials!'); console.log('Run: ms365-mcp-server --login to authenticate\n'); } else if (choice === '2') { console.log('\nCustom Azure App Setup:'); console.log('1. Go to https://portal.azure.com'); console.log('2. Navigate to Azure Active Directory > App registrations'); console.log('3. Click "New registration"'); console.log(`4. Set redirect URI to: http://localhost:${CALLBACK_PORT}/oauth2callback`); console.log('5. Grant required API permissions for Microsoft Graph\n'); const clientId = await question('Enter Client ID: '); const tenantId = await question('Enter Tenant ID (or "common"): '); const clientSecret = await question('Enter Client Secret (optional, press Enter to skip): '); const credentials = { clientId: clientId.trim(), tenantId: tenantId.trim() || DEFAULT_TENANT_ID, clientSecret: clientSecret.trim() || undefined, redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`, authType: 'redirect' }; await this.saveCredentials(credentials); console.log('\nāœ… Credentials saved!'); console.log('Run: ms365-mcp-server --login to authenticate\n'); } else { console.log('Invalid choice. Setup cancelled.'); } } finally { rl.close(); } } } export const outlookAuth = new OutlookAuth();