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