outlook-mcp/auth/token-manager.js
Seton Carmichael a7886b5b2b Initial commit: Outlook MCP Server v1.0.0
MCP server providing Microsoft Outlook integration via Graph API:
- Email: list, search, read, send, thread reconstruction
- Calendar: list, create, decline, cancel, delete events
- Folders: list, create, move emails
- Inbox rules: list, create, reorder
- MSAL device code flow auth with persistent token cache
- Test mode with mock data
- Comprehensive README with setup, config, and tool reference

20 MCP tools across 5 modules. Node.js >= 14. MIT license.
2026-06-21 19:39:31 -04:00

166 lines
4.8 KiB
JavaScript

/**
* Token management using @azure/msal-node
*/
const msal = require('@azure/msal-node');
const fs = require('fs');
const path = require('path');
const config = require('../config');
// Persist MSAL's internal token cache to disk so tokens survive process restarts.
const cachePlugin = {
beforeCacheAccess: async (cacheContext) => {
try {
if (fs.existsSync(config.AUTH_CONFIG.tokenStorePath)) {
cacheContext.tokenCache.deserialize(
fs.readFileSync(config.AUTH_CONFIG.tokenStorePath, 'utf8')
);
}
} catch (e) {
console.error('[TokenManager] Cache read error:', e.message);
}
},
afterCacheAccess: async (cacheContext) => {
if (cacheContext.cacheHasChanged) {
try {
const dir = path.dirname(config.AUTH_CONFIG.tokenStorePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
config.AUTH_CONFIG.tokenStorePath,
cacheContext.tokenCache.serialize()
);
console.error('[TokenManager] Cache saved to:', config.AUTH_CONFIG.tokenStorePath);
} catch (e) {
console.error('[TokenManager] Cache write error:', e.message);
}
}
}
};
// Singleton PublicClientApplication — device code flow requires a public client.
// "Allow public client flows" must be enabled in the app registration.
let _pca = null;
function getPca() {
if (!_pca) {
_pca = new msal.PublicClientApplication({
auth: {
clientId: config.AUTH_CONFIG.clientId,
authority: 'https://login.microsoftonline.com/organizations'
},
cache: { cachePlugin },
system: {
loggerOptions: {
logLevel: msal.LogLevel.Warning,
piiLoggingEnabled: false,
loggerCallback: (level, message, containsPii) => {
if (!containsPii && config.DEBUG_MODE) {
console.error(`[MSAL] ${message}`);
}
}
}
}
});
}
return _pca;
}
/**
* Returns the first cached account from MSAL's token cache, or null.
*/
async function getCachedAccount() {
try {
const accounts = await getPca().getTokenCache().getAllAccounts();
return accounts && accounts.length > 0 ? accounts[0] : null;
} catch (e) {
console.error('[TokenManager] getAllAccounts error:', e.message);
return null;
}
}
/**
* Returns a valid access token via MSAL silent flow (handles refresh automatically).
* Returns null if no cached account or silent acquire fails.
*/
async function getAccessToken() {
// Test mode shortcut
if (config.USE_TEST_MODE) {
try {
const raw = fs.readFileSync(config.AUTH_CONFIG.tokenStorePath, 'utf8');
const data = JSON.parse(raw);
if (data._testMode) return data.access_token;
} catch { /* fall through */ }
return null;
}
try {
const account = await getCachedAccount();
if (!account) return null;
const result = await getPca().acquireTokenSilent({
scopes: config.AUTH_CONFIG.scopes,
account
});
return result ? result.accessToken : null;
} catch (e) {
console.error('[TokenManager] Silent token acquire failed:', e.message);
return null;
}
}
/**
* Initiates the device code flow.
* Returns { deviceCodeInfo, tokenPromise } immediately after the code is issued.
* tokenPromise resolves when the user completes sign-in; MSAL handles all polling.
*/
async function initiateDeviceCodeFlow() {
const pca = getPca();
let resolveDeviceCode, rejectDeviceCode;
const deviceCodeInfoPromise = new Promise((resolve, reject) => {
resolveDeviceCode = resolve;
rejectDeviceCode = reject;
});
const tokenPromise = pca.acquireTokenByDeviceCode({
scopes: config.AUTH_CONFIG.scopes,
deviceCodeCallback: (response) => {
console.error('[TokenManager] Device code issued:', response.userCode);
resolveDeviceCode(response);
}
}).catch((err) => {
// If the flow fails before the callback fires, reject the code promise too
rejectDeviceCode(err);
throw err;
});
const deviceCodeInfo = await deviceCodeInfoPromise;
return { deviceCodeInfo, tokenPromise };
}
/**
* Silent refresh — with MSAL this is handled transparently inside getAccessToken().
*/
async function refreshAccessToken() {
return getAccessToken();
}
/**
* Creates test tokens for USE_TEST_MODE.
*/
function createTestTokens() {
const testData = {
_testMode: true,
access_token: 'test_access_token_' + Date.now(),
expires_at: Date.now() + 3600 * 1000
};
const dir = path.dirname(config.AUTH_CONFIG.tokenStorePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(config.AUTH_CONFIG.tokenStorePath, JSON.stringify(testData, null, 2));
}
module.exports = {
getPca,
getCachedAccount,
getAccessToken,
initiateDeviceCodeFlow,
refreshAccessToken,
createTestTokens
};