import * as msal from "@azure/msal-node";
import * as fs from "fs";
import * as path from "path";
const CACHE_PATH =
process.env.M365_CALENDAR_TOKEN_CACHE_PATH ||
path.join(
process.env.HOME || process.env.USERPROFILE || ".",
".m365calendar-mcp-token-cache.json"
);
const CALENDAR_SCOPES = [
"Calendars.ReadWrite",
"Calendars.Read",
"User.Read",
"offline_access",
];
export interface AuthConfig {
clientId: string;
tenantId?: string;
}
export class AuthManager {
private pca: msal.PublicClientApplication;
private accessToken: string | null = null;
private tokenExpiry: number | null = null;
private accountInfo: msal.AccountInfo | null = null;
private config: AuthConfig;
constructor(config: AuthConfig) {
this.config = config;
const authority = config.tenantId
? `https://login.microsoftonline.com/${config.tenantId}`
: "https://login.microsoftonline.com/common";
const msalConfig: msal.Configuration = {
auth: {
clientId: config.clientId,
authority,
},
cache: {
cachePlugin: this.createCachePlugin(),
},
};
this.pca = new msal.PublicClientApplication(msalConfig);
}
private createCachePlugin(): msal.ICachePlugin {
const beforeCacheAccess = async (
cacheContext: msal.TokenCacheContext
): Promise<void> => {
try {
if (fs.existsSync(CACHE_PATH)) {
const data = fs.readFileSync(CACHE_PATH, "utf-8");
cacheContext.tokenCache.deserialize(data);
}
} catch {
// Cache file doesn't exist or is corrupted, start fresh
}
};
const afterCacheAccess = async (
cacheContext: msal.TokenCacheContext
): Promise<void> => {
if (cacheContext.cacheHasChanged) {
try {
fs.writeFileSync(CACHE_PATH, cacheContext.tokenCache.serialize(), {
mode: 0o600,
});
} catch {
// Silently fail if we can't write cache
}
}
};
return { beforeCacheAccess, afterCacheAccess };
}
async login(): Promise<void> {
const deviceCodeRequest: msal.DeviceCodeRequest = {
scopes: CALENDAR_SCOPES,
deviceCodeCallback: (response) => {
console.error(`\nTo sign in, use a web browser to open the page ${response.verificationUri} and enter the code ${response.userCode} to authenticate.\n`);
},
};
const response = await this.pca.acquireTokenByDeviceCode(deviceCodeRequest);
if (response) {
this.accessToken = response.accessToken;
this.tokenExpiry = response.expiresOn
? new Date(response.expiresOn).getTime()
: null;
this.accountInfo = response.account;
console.error("Successfully authenticated!");
} else {
throw new Error("Authentication failed - no response received");
}
}
async getAccessToken(): Promise<string> {
// If we have a valid token, return it
if (this.accessToken && this.tokenExpiry && Date.now() < this.tokenExpiry) {
return this.accessToken;
}
// Try silent token acquisition
const accounts = await this.pca.getTokenCache().getAllAccounts();
if (accounts.length > 0) {
const account = this.accountInfo || accounts[0];
try {
const silentRequest: msal.SilentFlowRequest = {
scopes: CALENDAR_SCOPES,
account,
};
const response = await this.pca.acquireTokenSilent(silentRequest);
if (response) {
this.accessToken = response.accessToken;
this.tokenExpiry = response.expiresOn
? new Date(response.expiresOn).getTime()
: null;
this.accountInfo = response.account;
return response.accessToken;
}
} catch {
// Silent acquisition failed, need interactive login
}
}
// Fall back to device code flow
await this.login();
if (!this.accessToken) {
throw new Error("Failed to acquire access token");
}
return this.accessToken;
}
async isAuthenticated(): Promise<boolean> {
try {
const accounts = await this.pca.getTokenCache().getAllAccounts();
return accounts.length > 0;
} catch {
return false;
}
}
async logout(): Promise<void> {
try {
const accounts = await this.pca.getTokenCache().getAllAccounts();
for (const account of accounts) {
await this.pca.getTokenCache().removeAccount(account);
}
if (fs.existsSync(CACHE_PATH)) {
fs.unlinkSync(CACHE_PATH);
}
this.accessToken = null;
this.tokenExpiry = null;
this.accountInfo = null;
console.error("Successfully logged out.");
} catch (error) {
throw new Error(`Logout failed: ${error}`);
}
}
}