ms365-mcp-server 1.1.23 ā 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +81 -31
- package/dist/index.js +59 -102
- package/dist/utils/ms365-operations.js +4 -9
- package/dist/utils/outlook-auth.js +614 -0
- package/dist/utils/outlook-credential-store.js +195 -0
- package/package.json +9 -5
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { ConfidentialClientApplication, PublicClientApplication, CryptoProvider } from '@azure/msal-node';
|
|
2
|
+
import { Client } from '@microsoft/microsoft-graph-client';
|
|
3
|
+
import * as http from 'http';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { URL } from 'url';
|
|
7
|
+
import open from 'open';
|
|
8
|
+
import { logger } from './api.js';
|
|
9
|
+
import { getConfigDirWithFallback } from './config-dir.js';
|
|
10
|
+
// OAuth callback port
|
|
11
|
+
const CALLBACK_PORT = 44005;
|
|
12
|
+
// Built-in fallback client ID (Microsoft Graph CLI Tools)
|
|
13
|
+
const BUILTIN_CLIENT_ID = '14d82eec-204b-4c2f-b7e8-296a70dab67e';
|
|
14
|
+
const DEFAULT_TENANT_ID = 'common';
|
|
15
|
+
// Use existing config directory (~/.ms365-mcp/)
|
|
16
|
+
const CONFIG_DIR = getConfigDirWithFallback();
|
|
17
|
+
const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
|
|
18
|
+
const TOKEN_FILE = path.join(CONFIG_DIR, 'token.json');
|
|
19
|
+
const MSAL_CACHE_FILE = path.join(CONFIG_DIR, 'msal-cache.json');
|
|
20
|
+
// Required scopes for Outlook operations
|
|
21
|
+
const OUTLOOK_SCOPES = [
|
|
22
|
+
'https://graph.microsoft.com/User.Read',
|
|
23
|
+
'https://graph.microsoft.com/Mail.Read',
|
|
24
|
+
'https://graph.microsoft.com/Mail.Send',
|
|
25
|
+
'https://graph.microsoft.com/Mail.ReadWrite',
|
|
26
|
+
'https://graph.microsoft.com/MailboxSettings.Read',
|
|
27
|
+
'https://graph.microsoft.com/Contacts.Read',
|
|
28
|
+
'offline_access'
|
|
29
|
+
];
|
|
30
|
+
// Token refresh buffer (10 minutes before expiry)
|
|
31
|
+
const TOKEN_REFRESH_BUFFER_MS = 10 * 60 * 1000;
|
|
32
|
+
/**
|
|
33
|
+
* Check if running in MCP context (non-interactive)
|
|
34
|
+
*/
|
|
35
|
+
function isInMcpContext() {
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
const isCliCommand = args.some(arg => ['--login', '--logout', '--verify-login', '--reset-auth', '--setup-auth'].includes(arg));
|
|
38
|
+
// If it's a CLI command, we're NOT in MCP context
|
|
39
|
+
if (isCliCommand)
|
|
40
|
+
return false;
|
|
41
|
+
// Check if stdin is not a TTY (piped input = MCP context)
|
|
42
|
+
// or if running via npx in a non-interactive way
|
|
43
|
+
return !process.stdin.isTTY || (process.env.npm_execpath?.includes('npx') ?? false);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Outlook OAuth2 Authentication Manager
|
|
47
|
+
* Implements OAuth redirect flow with local callback server
|
|
48
|
+
*/
|
|
49
|
+
export class OutlookAuth {
|
|
50
|
+
constructor() {
|
|
51
|
+
this.msalClient = null;
|
|
52
|
+
this.credentials = null;
|
|
53
|
+
this.callbackServer = null;
|
|
54
|
+
this.cryptoProvider = new CryptoProvider();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ensure config directory exists
|
|
58
|
+
*/
|
|
59
|
+
ensureConfigDir() {
|
|
60
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
61
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Load credentials from environment, stored file, or use fallback
|
|
66
|
+
*/
|
|
67
|
+
async loadCredentials() {
|
|
68
|
+
// Priority 1: Environment variables
|
|
69
|
+
if (process.env.OUTLOOK_CLIENT_ID && process.env.OUTLOOK_TENANT_ID) {
|
|
70
|
+
this.credentials = {
|
|
71
|
+
clientId: process.env.OUTLOOK_CLIENT_ID,
|
|
72
|
+
tenantId: process.env.OUTLOOK_TENANT_ID,
|
|
73
|
+
clientSecret: process.env.OUTLOOK_CLIENT_SECRET,
|
|
74
|
+
redirectUri: process.env.OUTLOOK_REDIRECT_URI || `http://localhost:${CALLBACK_PORT}/oauth2callback`,
|
|
75
|
+
authType: process.env.OUTLOOK_CLIENT_SECRET ? 'redirect' : 'device'
|
|
76
|
+
};
|
|
77
|
+
logger.log('Loaded credentials from environment variables');
|
|
78
|
+
return this.credentials;
|
|
79
|
+
}
|
|
80
|
+
// Priority 2: Stored credentials file (~/.ms365-mcp/credentials.json)
|
|
81
|
+
try {
|
|
82
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
83
|
+
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
|
84
|
+
this.credentials = JSON.parse(data);
|
|
85
|
+
logger.log('Loaded credentials from stored file');
|
|
86
|
+
return this.credentials;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
logger.error('Error loading credentials file:', error);
|
|
91
|
+
}
|
|
92
|
+
// Priority 3: Built-in fallback (no setup required)
|
|
93
|
+
this.credentials = {
|
|
94
|
+
clientId: BUILTIN_CLIENT_ID,
|
|
95
|
+
tenantId: DEFAULT_TENANT_ID,
|
|
96
|
+
redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
|
|
97
|
+
authType: 'redirect'
|
|
98
|
+
};
|
|
99
|
+
logger.log('Using built-in fallback credentials');
|
|
100
|
+
return this.credentials;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Initialize MSAL client with persistent cache
|
|
104
|
+
*/
|
|
105
|
+
async initializeMsalClient() {
|
|
106
|
+
if (this.msalClient) {
|
|
107
|
+
return this.msalClient;
|
|
108
|
+
}
|
|
109
|
+
if (!this.credentials) {
|
|
110
|
+
await this.loadCredentials();
|
|
111
|
+
}
|
|
112
|
+
const isConfidential = !!this.credentials.clientSecret;
|
|
113
|
+
// Create persistent token cache plugin using existing ~/.ms365-mcp/ location
|
|
114
|
+
const cachePlugin = {
|
|
115
|
+
beforeCacheAccess: async (cacheContext) => {
|
|
116
|
+
try {
|
|
117
|
+
if (fs.existsSync(MSAL_CACHE_FILE)) {
|
|
118
|
+
const cacheData = fs.readFileSync(MSAL_CACHE_FILE, 'utf8');
|
|
119
|
+
cacheContext.tokenCache.deserialize(cacheData);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
logger.error('Error loading MSAL cache:', error);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
afterCacheAccess: async (cacheContext) => {
|
|
127
|
+
try {
|
|
128
|
+
if (cacheContext.cacheHasChanged) {
|
|
129
|
+
this.ensureConfigDir();
|
|
130
|
+
const cacheData = cacheContext.tokenCache.serialize();
|
|
131
|
+
fs.writeFileSync(MSAL_CACHE_FILE, cacheData);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
logger.error('Error saving MSAL cache:', error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const config = {
|
|
140
|
+
auth: {
|
|
141
|
+
clientId: this.credentials.clientId,
|
|
142
|
+
authority: `https://login.microsoftonline.com/${this.credentials.tenantId}`,
|
|
143
|
+
...(isConfidential && { clientSecret: this.credentials.clientSecret })
|
|
144
|
+
},
|
|
145
|
+
cache: {
|
|
146
|
+
cachePlugin
|
|
147
|
+
},
|
|
148
|
+
system: {
|
|
149
|
+
loggerOptions: {
|
|
150
|
+
loggerCallback: (level, message, containsPii) => {
|
|
151
|
+
if (!containsPii) {
|
|
152
|
+
logger.log(`MSAL: ${message}`);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
piiLoggingEnabled: false,
|
|
156
|
+
logLevel: 3 // Error level
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
if (isConfidential) {
|
|
161
|
+
this.msalClient = new ConfidentialClientApplication(config);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
this.msalClient = new PublicClientApplication(config);
|
|
165
|
+
}
|
|
166
|
+
logger.log(`Initialized ${isConfidential ? 'Confidential' : 'Public'} MSAL client`);
|
|
167
|
+
return this.msalClient;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Start local callback server for OAuth redirect
|
|
171
|
+
*/
|
|
172
|
+
startCallbackServer(expectedState) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const server = http.createServer(async (req, res) => {
|
|
175
|
+
if (!req.url?.startsWith('/oauth2callback')) {
|
|
176
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
177
|
+
res.end('<html><body><h1>Outlook MCP OAuth2</h1><p>Waiting for authentication...</p></body></html>');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
|
|
182
|
+
const code = url.searchParams.get('code');
|
|
183
|
+
const state = url.searchParams.get('state');
|
|
184
|
+
const error = url.searchParams.get('error');
|
|
185
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
186
|
+
// Handle errors from OAuth provider
|
|
187
|
+
if (error) {
|
|
188
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
189
|
+
res.end(`<html><body><h1>Authentication Error</h1><p>${error}: ${errorDescription || 'Unknown error'}</p></body></html>`);
|
|
190
|
+
this.closeCallbackServer();
|
|
191
|
+
reject(new Error(`OAuth error: ${error} - ${errorDescription}`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Validate state parameter (CSRF protection)
|
|
195
|
+
if (state !== expectedState) {
|
|
196
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
197
|
+
res.end('<html><body><h1>Authentication Error</h1><p>Invalid state parameter. Possible CSRF attack.</p></body></html>');
|
|
198
|
+
this.closeCallbackServer();
|
|
199
|
+
reject(new Error('Invalid state parameter - possible CSRF attack'));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!code) {
|
|
203
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
204
|
+
res.end('<html><body><h1>Authentication Error</h1><p>No authorization code received.</p></body></html>');
|
|
205
|
+
this.closeCallbackServer();
|
|
206
|
+
reject(new Error('No authorization code received'));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Exchange code for tokens
|
|
210
|
+
const msalClient = await this.initializeMsalClient();
|
|
211
|
+
const tokenResponse = await msalClient.acquireTokenByCode({
|
|
212
|
+
code,
|
|
213
|
+
scopes: OUTLOOK_SCOPES,
|
|
214
|
+
redirectUri: this.credentials.redirectUri
|
|
215
|
+
});
|
|
216
|
+
if (!tokenResponse) {
|
|
217
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
218
|
+
res.end('<html><body><h1>Authentication Error</h1><p>Failed to exchange code for tokens.</p></body></html>');
|
|
219
|
+
this.closeCallbackServer();
|
|
220
|
+
reject(new Error('Failed to exchange authorization code for tokens'));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Success!
|
|
224
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
225
|
+
res.end(`
|
|
226
|
+
<html>
|
|
227
|
+
<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%);">
|
|
228
|
+
<div style="background: white; padding: 40px; border-radius: 10px; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
|
229
|
+
<h1 style="color: #22c55e; margin-bottom: 10px;">ā Authentication Successful!</h1>
|
|
230
|
+
<p style="color: #666;">You can close this window and return to the terminal.</p>
|
|
231
|
+
</div>
|
|
232
|
+
</body>
|
|
233
|
+
</html>
|
|
234
|
+
`);
|
|
235
|
+
this.closeCallbackServer();
|
|
236
|
+
resolve(tokenResponse);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
240
|
+
res.end(`<html><body><h1>Authentication Error</h1><p>${error}</p></body></html>`);
|
|
241
|
+
this.closeCallbackServer();
|
|
242
|
+
reject(error);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
server.on('error', (err) => {
|
|
246
|
+
reject(new Error(`Failed to start callback server: ${err.message}`));
|
|
247
|
+
});
|
|
248
|
+
server.listen(CALLBACK_PORT, () => {
|
|
249
|
+
logger.log(`OAuth callback server listening on port ${CALLBACK_PORT}`);
|
|
250
|
+
});
|
|
251
|
+
this.callbackServer = server;
|
|
252
|
+
// Timeout after 5 minutes
|
|
253
|
+
setTimeout(() => {
|
|
254
|
+
if (this.callbackServer) {
|
|
255
|
+
this.closeCallbackServer();
|
|
256
|
+
reject(new Error('Authentication timed out after 5 minutes'));
|
|
257
|
+
}
|
|
258
|
+
}, 5 * 60 * 1000);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Close callback server
|
|
263
|
+
*/
|
|
264
|
+
closeCallbackServer() {
|
|
265
|
+
if (this.callbackServer) {
|
|
266
|
+
this.callbackServer.close();
|
|
267
|
+
this.callbackServer = null;
|
|
268
|
+
logger.log('Closed OAuth callback server');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Generate authorization URL with PKCE and state
|
|
273
|
+
*/
|
|
274
|
+
async generateAuthUrl() {
|
|
275
|
+
const msalClient = await this.initializeMsalClient();
|
|
276
|
+
// Generate state for CSRF protection
|
|
277
|
+
const state = this.cryptoProvider.createNewGuid();
|
|
278
|
+
const authUrl = await msalClient.getAuthCodeUrl({
|
|
279
|
+
scopes: OUTLOOK_SCOPES,
|
|
280
|
+
redirectUri: this.credentials.redirectUri,
|
|
281
|
+
state,
|
|
282
|
+
prompt: 'select_account'
|
|
283
|
+
});
|
|
284
|
+
return { authUrl, state };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Main authentication entry point
|
|
288
|
+
* Handles both CLI and MCP contexts
|
|
289
|
+
*/
|
|
290
|
+
async authenticate() {
|
|
291
|
+
await this.loadCredentials();
|
|
292
|
+
// Check if we already have valid tokens
|
|
293
|
+
const existingToken = await this.getValidToken();
|
|
294
|
+
if (existingToken) {
|
|
295
|
+
logger.log('Using existing valid token');
|
|
296
|
+
return existingToken;
|
|
297
|
+
}
|
|
298
|
+
const { authUrl, state } = await this.generateAuthUrl();
|
|
299
|
+
if (isInMcpContext()) {
|
|
300
|
+
// In MCP context - throw error with auth URL for client to handle
|
|
301
|
+
const error = new Error('Authentication required');
|
|
302
|
+
error.authUrl = authUrl;
|
|
303
|
+
error.code = 'AUTH_REQUIRED';
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
// CLI context - open browser and wait for callback
|
|
307
|
+
console.log('\nš Microsoft 365 Authentication');
|
|
308
|
+
console.log('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
309
|
+
console.log('Opening browser for authentication...');
|
|
310
|
+
console.log(`\nIf browser doesn't open, visit:\n${authUrl}\n`);
|
|
311
|
+
console.log('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
312
|
+
// Start callback server first, then open browser
|
|
313
|
+
const tokenPromise = this.startCallbackServer(state);
|
|
314
|
+
try {
|
|
315
|
+
await open(authUrl);
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
console.log('Could not open browser automatically. Please visit the URL above.');
|
|
319
|
+
}
|
|
320
|
+
const tokenResponse = await tokenPromise;
|
|
321
|
+
// Save tokens
|
|
322
|
+
await this.saveTokens(tokenResponse);
|
|
323
|
+
console.log('\nā
Authentication successful!\n');
|
|
324
|
+
return tokenResponse;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Save tokens to existing storage location (~/.ms365-mcp/token.json)
|
|
328
|
+
*/
|
|
329
|
+
async saveTokens(tokenResponse) {
|
|
330
|
+
const tokens = {
|
|
331
|
+
accessToken: tokenResponse.accessToken,
|
|
332
|
+
refreshToken: undefined, // MSAL handles refresh tokens via cache
|
|
333
|
+
expiresOn: tokenResponse.expiresOn?.getTime() || Date.now() + 3600000,
|
|
334
|
+
account: tokenResponse.account ? {
|
|
335
|
+
username: tokenResponse.account.username,
|
|
336
|
+
homeAccountId: tokenResponse.account.homeAccountId,
|
|
337
|
+
environment: tokenResponse.account.environment,
|
|
338
|
+
tenantId: tokenResponse.account.tenantId,
|
|
339
|
+
localAccountId: tokenResponse.account.localAccountId
|
|
340
|
+
} : null,
|
|
341
|
+
authType: 'redirect'
|
|
342
|
+
};
|
|
343
|
+
try {
|
|
344
|
+
this.ensureConfigDir();
|
|
345
|
+
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
|
|
346
|
+
logger.log(`Tokens saved to ${TOKEN_FILE}`);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
logger.error('Error saving tokens:', error);
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Load tokens from existing storage location (~/.ms365-mcp/token.json)
|
|
355
|
+
*/
|
|
356
|
+
async loadStoredTokens() {
|
|
357
|
+
try {
|
|
358
|
+
if (!fs.existsSync(TOKEN_FILE)) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
const data = fs.readFileSync(TOKEN_FILE, 'utf8');
|
|
362
|
+
return JSON.parse(data);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
logger.error('Error loading tokens:', error);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Clear tokens from storage
|
|
371
|
+
*/
|
|
372
|
+
async clearTokens() {
|
|
373
|
+
try {
|
|
374
|
+
if (fs.existsSync(TOKEN_FILE)) {
|
|
375
|
+
fs.unlinkSync(TOKEN_FILE);
|
|
376
|
+
}
|
|
377
|
+
if (fs.existsSync(MSAL_CACHE_FILE)) {
|
|
378
|
+
fs.unlinkSync(MSAL_CACHE_FILE);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
logger.error('Error clearing tokens:', error);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Get valid token (checks expiry with buffer)
|
|
387
|
+
*/
|
|
388
|
+
async getValidToken() {
|
|
389
|
+
const storedTokens = await this.loadStoredTokens();
|
|
390
|
+
if (!storedTokens) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
// Check if token expires within buffer time
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
if (storedTokens.expiresOn > now + TOKEN_REFRESH_BUFFER_MS) {
|
|
396
|
+
// Token is still valid
|
|
397
|
+
return {
|
|
398
|
+
accessToken: storedTokens.accessToken,
|
|
399
|
+
expiresOn: new Date(storedTokens.expiresOn),
|
|
400
|
+
account: storedTokens.account,
|
|
401
|
+
scopes: OUTLOOK_SCOPES,
|
|
402
|
+
authority: `https://login.microsoftonline.com/${this.credentials?.tenantId || DEFAULT_TENANT_ID}`,
|
|
403
|
+
uniqueId: storedTokens.account?.localAccountId || '',
|
|
404
|
+
tenantId: storedTokens.account?.tenantId || '',
|
|
405
|
+
idToken: '',
|
|
406
|
+
idTokenClaims: {},
|
|
407
|
+
fromCache: true,
|
|
408
|
+
tokenType: 'Bearer',
|
|
409
|
+
correlationId: ''
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
// Token expired or expiring soon - try to refresh
|
|
413
|
+
logger.log('Token expired or expiring soon, attempting refresh...');
|
|
414
|
+
return await this.refreshToken();
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Refresh token using MSAL silent acquisition
|
|
418
|
+
*/
|
|
419
|
+
async refreshToken() {
|
|
420
|
+
try {
|
|
421
|
+
const storedTokens = await this.loadStoredTokens();
|
|
422
|
+
if (!storedTokens?.account) {
|
|
423
|
+
logger.log('No account info for token refresh');
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
await this.loadCredentials();
|
|
427
|
+
const msalClient = await this.initializeMsalClient();
|
|
428
|
+
// Try silent token acquisition
|
|
429
|
+
const tokenResponse = await msalClient.acquireTokenSilent({
|
|
430
|
+
scopes: OUTLOOK_SCOPES,
|
|
431
|
+
account: storedTokens.account
|
|
432
|
+
});
|
|
433
|
+
if (tokenResponse) {
|
|
434
|
+
await this.saveTokens(tokenResponse);
|
|
435
|
+
logger.log('Token refreshed successfully');
|
|
436
|
+
return tokenResponse;
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
logger.error('Token refresh failed:', error.message);
|
|
442
|
+
// If refresh fails, clear tokens so user can re-authenticate
|
|
443
|
+
if (error.errorCode === 'invalid_grant' ||
|
|
444
|
+
error.errorCode === 'interaction_required' ||
|
|
445
|
+
error.errorCode === 'consent_required') {
|
|
446
|
+
await this.clearTokens();
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Get authenticated Microsoft Graph client
|
|
453
|
+
*/
|
|
454
|
+
async getGraphClient() {
|
|
455
|
+
const token = await this.getValidToken();
|
|
456
|
+
if (!token) {
|
|
457
|
+
// Try to authenticate
|
|
458
|
+
const newToken = await this.authenticate();
|
|
459
|
+
return Client.init({
|
|
460
|
+
authProvider: (done) => {
|
|
461
|
+
done(null, newToken.accessToken);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return Client.init({
|
|
466
|
+
authProvider: (done) => {
|
|
467
|
+
done(null, token.accessToken);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Check if user is authenticated
|
|
473
|
+
*/
|
|
474
|
+
async isAuthenticated() {
|
|
475
|
+
const token = await this.getValidToken();
|
|
476
|
+
return token !== null;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Get authentication status
|
|
480
|
+
*/
|
|
481
|
+
async getAuthenticationStatus() {
|
|
482
|
+
const storedTokens = await this.loadStoredTokens();
|
|
483
|
+
await this.loadCredentials();
|
|
484
|
+
if (!storedTokens) {
|
|
485
|
+
return {
|
|
486
|
+
authenticated: false,
|
|
487
|
+
clientId: this.credentials?.clientId,
|
|
488
|
+
tenantId: this.credentials?.tenantId
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
const isValid = storedTokens.expiresOn > now;
|
|
493
|
+
const expiresIn = Math.max(0, Math.floor((storedTokens.expiresOn - now) / 1000 / 60));
|
|
494
|
+
return {
|
|
495
|
+
authenticated: isValid,
|
|
496
|
+
username: storedTokens.account?.username,
|
|
497
|
+
expiresAt: new Date(storedTokens.expiresOn).toLocaleString(),
|
|
498
|
+
expiresIn: expiresIn,
|
|
499
|
+
clientId: this.credentials?.clientId,
|
|
500
|
+
tenantId: this.credentials?.tenantId
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Logout - clear all stored credentials and tokens
|
|
505
|
+
*/
|
|
506
|
+
async logout() {
|
|
507
|
+
this.closeCallbackServer();
|
|
508
|
+
await this.clearTokens();
|
|
509
|
+
// Also clear credentials file
|
|
510
|
+
try {
|
|
511
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
512
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
logger.error('Error clearing credentials:', error);
|
|
517
|
+
}
|
|
518
|
+
this.msalClient = null;
|
|
519
|
+
this.credentials = null;
|
|
520
|
+
logger.log('Logged out successfully');
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Reset auth - clear everything
|
|
524
|
+
*/
|
|
525
|
+
async resetAuth() {
|
|
526
|
+
await this.logout();
|
|
527
|
+
console.log('ā
All authentication data cleared');
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Get current user info
|
|
531
|
+
*/
|
|
532
|
+
async getCurrentUser() {
|
|
533
|
+
const storedTokens = await this.loadStoredTokens();
|
|
534
|
+
if (storedTokens?.account?.username) {
|
|
535
|
+
return storedTokens.account.username;
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Save credentials to storage
|
|
541
|
+
*/
|
|
542
|
+
async saveCredentials(credentials) {
|
|
543
|
+
try {
|
|
544
|
+
this.ensureConfigDir();
|
|
545
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2));
|
|
546
|
+
logger.log(`Credentials saved to ${CREDENTIALS_FILE}`);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
logger.error('Error saving credentials:', error);
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Interactive credential setup
|
|
555
|
+
*/
|
|
556
|
+
async setupCredentials() {
|
|
557
|
+
const readline = await import('readline');
|
|
558
|
+
const rl = readline.createInterface({
|
|
559
|
+
input: process.stdin,
|
|
560
|
+
output: process.stdout
|
|
561
|
+
});
|
|
562
|
+
const question = (prompt) => {
|
|
563
|
+
return new Promise((resolve) => {
|
|
564
|
+
rl.question(prompt, resolve);
|
|
565
|
+
});
|
|
566
|
+
};
|
|
567
|
+
try {
|
|
568
|
+
console.log('\nš§ Outlook MCP Server Credential Setup\n');
|
|
569
|
+
console.log('Choose setup method:');
|
|
570
|
+
console.log('1. Use built-in credentials (Recommended - no Azure setup needed)');
|
|
571
|
+
console.log('2. Use custom Azure App credentials\n');
|
|
572
|
+
const choice = await question('Enter choice (1 or 2): ');
|
|
573
|
+
if (choice === '1') {
|
|
574
|
+
const credentials = {
|
|
575
|
+
clientId: BUILTIN_CLIENT_ID,
|
|
576
|
+
tenantId: DEFAULT_TENANT_ID,
|
|
577
|
+
redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
|
|
578
|
+
authType: 'redirect'
|
|
579
|
+
};
|
|
580
|
+
await this.saveCredentials(credentials);
|
|
581
|
+
console.log('\nā
Configured with built-in credentials!');
|
|
582
|
+
console.log('Run: ms365-mcp-server --login to authenticate\n');
|
|
583
|
+
}
|
|
584
|
+
else if (choice === '2') {
|
|
585
|
+
console.log('\nCustom Azure App Setup:');
|
|
586
|
+
console.log('1. Go to https://portal.azure.com');
|
|
587
|
+
console.log('2. Navigate to Azure Active Directory > App registrations');
|
|
588
|
+
console.log('3. Click "New registration"');
|
|
589
|
+
console.log(`4. Set redirect URI to: http://localhost:${CALLBACK_PORT}/oauth2callback`);
|
|
590
|
+
console.log('5. Grant required API permissions for Microsoft Graph\n');
|
|
591
|
+
const clientId = await question('Enter Client ID: ');
|
|
592
|
+
const tenantId = await question('Enter Tenant ID (or "common"): ');
|
|
593
|
+
const clientSecret = await question('Enter Client Secret (optional, press Enter to skip): ');
|
|
594
|
+
const credentials = {
|
|
595
|
+
clientId: clientId.trim(),
|
|
596
|
+
tenantId: tenantId.trim() || DEFAULT_TENANT_ID,
|
|
597
|
+
clientSecret: clientSecret.trim() || undefined,
|
|
598
|
+
redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
|
|
599
|
+
authType: 'redirect'
|
|
600
|
+
};
|
|
601
|
+
await this.saveCredentials(credentials);
|
|
602
|
+
console.log('\nā
Credentials saved!');
|
|
603
|
+
console.log('Run: ms365-mcp-server --login to authenticate\n');
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
console.log('Invalid choice. Setup cancelled.');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
rl.close();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
export const outlookAuth = new OutlookAuth();
|