/** * 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 };