auth.ts•16.3 kB
import type { AccountInfo, Configuration } from '@azure/msal-node';
import { PublicClientApplication } from '@azure/msal-node';
import logger from './logger.js';
import fs, { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
// Ok so this is a hack to lazily import keytar only when needed
// since --http mode may not need it at all, and keytar can be a pain to install (looking at you alpine)
let keytar: typeof import('keytar') | null = null;
async function getKeytar() {
if (keytar === undefined) {
return null;
}
if (keytar === null) {
try {
keytar = await import('keytar');
return keytar;
} catch (error) {
logger.info('keytar not available, using file-based credential storage');
keytar = undefined as any;
return null;
}
}
return keytar;
}
interface EndpointConfig {
pathPattern: string;
method: string;
toolName: string;
scopes?: string[];
workScopes?: string[];
llmTip?: string;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const endpointsData = JSON.parse(
readFileSync(path.join(__dirname, 'endpoints.json'), 'utf8')
) as EndpointConfig[];
const endpoints = {
default: endpointsData,
};
const SERVICE_NAME = 'ms-365-mcp-server';
const TOKEN_CACHE_ACCOUNT = 'msal-token-cache';
const SELECTED_ACCOUNT_KEY = 'selected-account';
const FALLBACK_DIR = path.dirname(fileURLToPath(import.meta.url));
const FALLBACK_PATH = path.join(FALLBACK_DIR, '..', '.token-cache.json');
const SELECTED_ACCOUNT_PATH = path.join(FALLBACK_DIR, '..', '.selected-account.json');
const DEFAULT_CONFIG: Configuration = {
auth: {
clientId: process.env.MS365_MCP_CLIENT_ID || '084a3e9f-a9f4-43f7-89f9-d229cf97853e',
authority: `https://login.microsoftonline.com/${process.env.MS365_MCP_TENANT_ID || 'common'}`,
},
};
interface ScopeHierarchy {
[key: string]: string[];
}
const SCOPE_HIERARCHY: ScopeHierarchy = {
'Mail.ReadWrite': ['Mail.Read'],
'Calendars.ReadWrite': ['Calendars.Read'],
'Files.ReadWrite': ['Files.Read'],
'Tasks.ReadWrite': ['Tasks.Read'],
'Contacts.ReadWrite': ['Contacts.Read'],
};
function buildScopesFromEndpoints(
includeWorkAccountScopes: boolean = false,
enabledToolsPattern?: string
): string[] {
const scopesSet = new Set<string>();
// Create regex for tool filtering if pattern is provided
let enabledToolsRegex: RegExp | undefined;
if (enabledToolsPattern) {
try {
enabledToolsRegex = new RegExp(enabledToolsPattern, 'i');
logger.info(`Building scopes with tool filter pattern: ${enabledToolsPattern}`);
} catch (error) {
logger.error(
`Invalid tool filter regex pattern: ${enabledToolsPattern}. Building scopes without filter.`
);
}
}
endpoints.default.forEach((endpoint) => {
// Skip endpoints that don't match the tool filter
if (enabledToolsRegex && !enabledToolsRegex.test(endpoint.toolName)) {
return;
}
// Skip endpoints that only have workScopes if not in work mode
if (!includeWorkAccountScopes && !endpoint.scopes && endpoint.workScopes) {
return;
}
// Add regular scopes
if (endpoint.scopes && Array.isArray(endpoint.scopes)) {
endpoint.scopes.forEach((scope) => scopesSet.add(scope));
}
// Add workScopes if in work mode
if (includeWorkAccountScopes && endpoint.workScopes && Array.isArray(endpoint.workScopes)) {
endpoint.workScopes.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);
}
});
const scopes = Array.from(scopesSet);
if (enabledToolsPattern) {
logger.info(`Built ${scopes.length} scopes for filtered tools: ${scopes.join(', ')}`);
}
return scopes;
}
interface LoginTestResult {
success: boolean;
message: string;
userData?: {
displayName: string;
userPrincipalName: string;
};
}
class AuthManager {
private config: Configuration;
private scopes: string[];
private msalApp: PublicClientApplication;
private accessToken: string | null;
private tokenExpiry: number | null;
private oauthToken: string | null;
private isOAuthMode: boolean;
private selectedAccountId: string | null;
constructor(
config: Configuration = DEFAULT_CONFIG,
scopes: string[] = 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;
this.selectedAccountId = null;
const oauthTokenFromEnv = process.env.MS365_MCP_OAUTH_TOKEN;
this.oauthToken = oauthTokenFromEnv ?? null;
this.isOAuthMode = oauthTokenFromEnv != null;
}
async loadTokenCache(): Promise<void> {
try {
let cacheData: string | undefined;
try {
const kt = await getKeytar();
if (kt) {
const cachedData = await kt.getPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT);
if (cachedData) {
cacheData = cachedData;
}
}
} catch (keytarError) {
logger.warn(
`Keychain access failed, falling back to file storage: ${(keytarError as Error).message}`
);
}
if (!cacheData && existsSync(FALLBACK_PATH)) {
cacheData = readFileSync(FALLBACK_PATH, 'utf8');
}
if (cacheData) {
this.msalApp.getTokenCache().deserialize(cacheData);
}
// Load selected account
await this.loadSelectedAccount();
} catch (error) {
logger.error(`Error loading token cache: ${(error as Error).message}`);
}
}
private async loadSelectedAccount(): Promise<void> {
try {
let selectedAccountData: string | undefined;
try {
const kt = await getKeytar();
if (kt) {
const cachedData = await kt.getPassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY);
if (cachedData) {
selectedAccountData = cachedData;
}
}
} catch (keytarError) {
logger.warn(
`Keychain access failed for selected account, falling back to file storage: ${(keytarError as Error).message}`
);
}
if (!selectedAccountData && existsSync(SELECTED_ACCOUNT_PATH)) {
selectedAccountData = readFileSync(SELECTED_ACCOUNT_PATH, 'utf8');
}
if (selectedAccountData) {
const parsed = JSON.parse(selectedAccountData);
this.selectedAccountId = parsed.accountId;
logger.info(`Loaded selected account: ${this.selectedAccountId}`);
}
} catch (error) {
logger.error(`Error loading selected account: ${(error as Error).message}`);
}
}
async saveTokenCache(): Promise<void> {
try {
const cacheData = this.msalApp.getTokenCache().serialize();
try {
const kt = await getKeytar();
if (kt) {
await kt.setPassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT, cacheData);
} else {
fs.writeFileSync(FALLBACK_PATH, cacheData);
}
} catch (keytarError) {
logger.warn(
`Keychain save failed, falling back to file storage: ${(keytarError as Error).message}`
);
fs.writeFileSync(FALLBACK_PATH, cacheData);
}
} catch (error) {
logger.error(`Error saving token cache: ${(error as Error).message}`);
}
}
private async saveSelectedAccount(): Promise<void> {
try {
const selectedAccountData = JSON.stringify({ accountId: this.selectedAccountId });
try {
const kt = await getKeytar();
if (kt) {
await kt.setPassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY, selectedAccountData);
} else {
fs.writeFileSync(SELECTED_ACCOUNT_PATH, selectedAccountData);
}
} catch (keytarError) {
logger.warn(
`Keychain save failed for selected account, falling back to file storage: ${(keytarError as Error).message}`
);
fs.writeFileSync(SELECTED_ACCOUNT_PATH, selectedAccountData);
}
} catch (error) {
logger.error(`Error saving selected account: ${(error as Error).message}`);
}
}
async setOAuthToken(token: string): Promise<void> {
this.oauthToken = token;
this.isOAuthMode = true;
}
async getToken(forceRefresh = false): Promise<string | null> {
if (this.isOAuthMode && this.oauthToken) {
return this.oauthToken;
}
if (this.accessToken && this.tokenExpiry && this.tokenExpiry > Date.now() && !forceRefresh) {
return this.accessToken;
}
const currentAccount = await this.getCurrentAccount();
if (currentAccount) {
const silentRequest = {
account: currentAccount,
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 {
logger.error('Silent token acquisition failed');
throw new Error('Silent token acquisition failed');
}
}
throw new Error('No valid token found');
}
async getCurrentAccount(): Promise<AccountInfo | null> {
const accounts = await this.msalApp.getTokenCache().getAllAccounts();
if (accounts.length === 0) {
return null;
}
// If a specific account is selected, find it
if (this.selectedAccountId) {
const selectedAccount = accounts.find(
(account: AccountInfo) => account.homeAccountId === this.selectedAccountId
);
if (selectedAccount) {
return selectedAccount;
}
logger.warn(
`Selected account ${this.selectedAccountId} not found, falling back to first account`
);
}
// Fall back to first account (backward compatibility)
return accounts[0];
}
async acquireTokenByDeviceCode(hack?: (message: string) => void): Promise<string | null> {
const deviceCodeRequest = {
scopes: this.scopes,
deviceCodeCallback: (response: { message: string }) => {
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(`Requesting scopes: ${this.scopes.join(', ')}`);
const response = await this.msalApp.acquireTokenByDeviceCode(deviceCodeRequest);
logger.info(`Granted scopes: ${response?.scopes?.join(', ') || 'none'}`);
logger.info('Device code login successful');
this.accessToken = response?.accessToken || null;
this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
// Set the newly authenticated account as selected if no account is currently selected
if (!this.selectedAccountId && response?.account) {
this.selectedAccountId = response.account.homeAccountId;
await this.saveSelectedAccount();
logger.info(`Auto-selected new account: ${response.account.username}`);
}
await this.saveTokenCache();
return this.accessToken;
} catch (error) {
logger.error(`Error in device code flow: ${(error as Error).message}`);
throw error;
}
}
async testLogin(): Promise<LoginTestResult> {
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 as Error).message}`);
return {
success: false,
message: `Login successful but Graph API access failed: ${(graphError as Error).message}`,
};
}
} catch (error) {
logger.error(`Login test failed: ${(error as Error).message}`);
return {
success: false,
message: `Login failed: ${(error as Error).message}`,
};
}
}
async logout(): Promise<boolean> {
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;
this.selectedAccountId = null;
try {
const kt = await getKeytar();
if (kt) {
await kt.deletePassword(SERVICE_NAME, TOKEN_CACHE_ACCOUNT);
await kt.deletePassword(SERVICE_NAME, SELECTED_ACCOUNT_KEY);
}
} catch (keytarError) {
logger.warn(`Keychain deletion failed: ${(keytarError as Error).message}`);
}
if (fs.existsSync(FALLBACK_PATH)) {
fs.unlinkSync(FALLBACK_PATH);
}
if (fs.existsSync(SELECTED_ACCOUNT_PATH)) {
fs.unlinkSync(SELECTED_ACCOUNT_PATH);
}
return true;
} catch (error) {
logger.error(`Error during logout: ${(error as Error).message}`);
throw error;
}
}
// Multi-account support methods
async listAccounts(): Promise<AccountInfo[]> {
return await this.msalApp.getTokenCache().getAllAccounts();
}
async selectAccount(accountId: string): Promise<boolean> {
const accounts = await this.listAccounts();
const account = accounts.find((acc: AccountInfo) => acc.homeAccountId === accountId);
if (!account) {
logger.error(`Account with ID ${accountId} not found`);
return false;
}
this.selectedAccountId = accountId;
await this.saveSelectedAccount();
// Clear cached tokens to force refresh with new account
this.accessToken = null;
this.tokenExpiry = null;
logger.info(`Selected account: ${account.username} (${accountId})`);
return true;
}
async removeAccount(accountId: string): Promise<boolean> {
const accounts = await this.listAccounts();
const account = accounts.find((acc: AccountInfo) => acc.homeAccountId === accountId);
if (!account) {
logger.error(`Account with ID ${accountId} not found`);
return false;
}
try {
await this.msalApp.getTokenCache().removeAccount(account);
// If this was the selected account, clear the selection
if (this.selectedAccountId === accountId) {
this.selectedAccountId = null;
await this.saveSelectedAccount();
this.accessToken = null;
this.tokenExpiry = null;
}
logger.info(`Removed account: ${account.username} (${accountId})`);
return true;
} catch (error) {
logger.error(`Failed to remove account ${accountId}: ${(error as Error).message}`);
return false;
}
}
getSelectedAccountId(): string | null {
return this.selectedAccountId;
}
}
export default AuthManager;
export { buildScopesFromEndpoints };