UNPKG

@davidmoore-io/ms-365-mcp-server

Version:

Microsoft 365 MCP Server - Fork with pre-authenticated token support

253 lines (252 loc) 10.1 kB
import { PublicClientApplication } from '@azure/msal-node'; import keytar from 'keytar'; import { fileURLToPath } from 'url'; import path from 'path'; import fs from 'fs'; import logger from './logger.js'; const endpoints = await import('./endpoints.json', { with: { type: 'json' }, }); const SERVICE_NAME = 'ms-365-mcp-server'; const TOKEN_CACHE_ACCOUNT = 'msal-token-cache'; const FALLBACK_DIR = path.dirname(fileURLToPath(import.meta.url)); const FALLBACK_PATH = path.join(FALLBACK_DIR, '..', '.token-cache.json'); const DEFAULT_CONFIG = { auth: { clientId: '084a3e9f-a9f4-43f7-89f9-d229cf97853e', authority: 'https://login.microsoftonline.com/common', }, }; const SCOPE_HIERARCHY = { 'Mail.ReadWrite': ['Mail.Read', 'Mail.Send'], 'Calendars.ReadWrite': ['Calendars.Read'], 'Files.ReadWrite': ['Files.Read'], 'Tasks.ReadWrite': ['Tasks.Read'], 'Contacts.ReadWrite': ['Contacts.Read'], }; function buildScopesFromEndpoints() { const scopesSet = new Set(); endpoints.default.forEach((endpoint) => { if (endpoint.scopes && Array.isArray(endpoint.scopes)) { endpoint.scopes.forEach((scope) => scopesSet.add(scope)); } }); Object.entries(SCOPE_HIERARCHY).forEach(([higherScope, lowerScopes]) => { if (lowerScopes.every((scope) => scopesSet.has(scope))) { lowerScopes.forEach((scope) => scopesSet.delete(scope)); scopesSet.add(higherScope); } }); return Array.from(scopesSet); } class AuthManager { constructor(config = DEFAULT_CONFIG, scopes = buildScopesFromEndpoints()) { logger.info(`And scopes are ${scopes.join(', ')}`, scopes); this.config = config; this.scopes = scopes; this.msalApp = new PublicClientApplication(this.config); this.accessToken = null; this.tokenExpiry = null; // Check for pre-authenticated token from environment if (process.env.MS365_ACCESS_TOKEN) { logger.info('Found MS365_ACCESS_TOKEN in environment, using pre-authenticated token'); this.accessToken = process.env.MS365_ACCESS_TOKEN; // Set expiry to 1 hour from now (we'll validate it when used) this.tokenExpiry = Date.now() + 3600000; } } async loadTokenCache() { try { let cacheData; try { const cachedData = await keytar.getPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT); if (cachedData) { cacheData = cachedData; } } catch (keytarError) { logger.warn(`Keychain access failed, falling back to file storage: ${keytarError.message}`); } if (!cacheData && fs.existsSync(FALLBACK_PATH)) { cacheData = fs.readFileSync(FALLBACK_PATH, 'utf8'); } if (cacheData) { this.msalApp.getTokenCache().deserialize(cacheData); } } catch (error) { logger.error(`Error loading token cache: ${error.message}`); } } async saveTokenCache() { try { const cacheData = this.msalApp.getTokenCache().serialize(); try { await keytar.setPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT, cacheData); } catch (keytarError) { logger.warn(`Keychain save failed, falling back to file storage: ${keytarError.message}`); fs.writeFileSync(FALLBACK_PATH, cacheData); } } catch (error) { logger.error(`Error saving token cache: ${error.message}`); } } async getToken(forceRefresh = false) { // First check if we have a pre-authenticated token from environment if (process.env.MS365_ACCESS_TOKEN && !forceRefresh) { // Validate the token is still valid by making a test request try { const response = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${process.env.MS365_ACCESS_TOKEN}`, }, }); if (response.ok) { logger.info('Environment token is valid'); this.accessToken = process.env.MS365_ACCESS_TOKEN; return this.accessToken; } else if (response.status === 401) { logger.warn('Environment token is expired or invalid, falling back to normal auth flow'); // Clear the invalid token this.accessToken = null; this.tokenExpiry = null; } } catch (error) { logger.warn(`Error validating environment token: ${error.message}`); } } if (this.accessToken && this.tokenExpiry && this.tokenExpiry > Date.now() && !forceRefresh) { return this.accessToken; } const accounts = await this.msalApp.getTokenCache().getAllAccounts(); if (accounts.length > 0) { const silentRequest = { account: accounts[0], scopes: this.scopes, }; try { const response = await this.msalApp.acquireTokenSilent(silentRequest); this.accessToken = response.accessToken; this.tokenExpiry = response.expiresOn ? new Date(response.expiresOn).getTime() : null; return this.accessToken; } catch (error) { logger.info('Silent token acquisition failed, using device code flow'); } } throw new Error('No valid token found'); } async acquireTokenByDeviceCode(hack) { const deviceCodeRequest = { scopes: this.scopes, deviceCodeCallback: (response) => { const text = ['\n', response.message, '\n'].join(''); if (hack) { hack(text + 'After login run the "verify login" command'); } else { console.log(text); } logger.info('Device code login initiated'); }, }; try { logger.info('Requesting device code...'); logger.info(`Scopes are: ${this.scopes.join(', ')}`); const response = await this.msalApp.acquireTokenByDeviceCode(deviceCodeRequest); logger.info('Device code login successful'); this.accessToken = response?.accessToken || null; this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null; await this.saveTokenCache(); return this.accessToken; } catch (error) { logger.error(`Error in device code flow: ${error.message}`); throw error; } } async testLogin() { try { logger.info('Testing login...'); const token = await this.getToken(); if (!token) { logger.error('Login test failed - no token received'); return { success: false, message: 'Login failed - no token received', }; } logger.info('Token retrieved successfully, testing Graph API access...'); try { const response = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${token}`, }, }); if (response.ok) { const userData = await response.json(); logger.info('Graph API user data fetch successful'); return { success: true, message: 'Login successful', userData: { displayName: userData.displayName, userPrincipalName: userData.userPrincipalName, }, }; } else { const errorText = await response.text(); logger.error(`Graph API user data fetch failed: ${response.status} - ${errorText}`); return { success: false, message: `Login successful but Graph API access failed: ${response.status}`, }; } } catch (graphError) { logger.error(`Error fetching user data: ${graphError.message}`); return { success: false, message: `Login successful but Graph API access failed: ${graphError.message}`, }; } } catch (error) { logger.error(`Login test failed: ${error.message}`); return { success: false, message: `Login failed: ${error.message}`, }; } } async logout() { try { const accounts = await this.msalApp.getTokenCache().getAllAccounts(); for (const account of accounts) { await this.msalApp.getTokenCache().removeAccount(account); } this.accessToken = null; this.tokenExpiry = null; try { await keytar.deletePassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT); } catch (keytarError) { logger.warn(`Keychain deletion failed: ${keytarError.message}`); } if (fs.existsSync(FALLBACK_PATH)) { fs.unlinkSync(FALLBACK_PATH); } return true; } catch (error) { logger.error(`Error during logout: ${error.message}`); throw error; } } } export default AuthManager;