diff --git a/package.json b/package.json index d6b2377..5de6ddf 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dependencies": { "@azure/identity": "^4.13.0", "@azure/msal-node": "^5.0.3", + "@azure/msal-node-extensions": "^1.2.0", "@microsoft/microsoft-graph-client": "^3.0.7", "azure-devops-node-api": "^15.1.2" }, diff --git a/src/azure/pca-auth.js b/src/azure/pca-auth.js index 97e66ee..d39e9ba 100644 --- a/src/azure/pca-auth.js +++ b/src/azure/pca-auth.js @@ -1,39 +1,56 @@ -import http from "node:http"; -import { URL } from "node:url"; import open, { apps } from "open"; -import crypto from "node:crypto"; -import fs from "node:fs"; import path from "node:path"; -import os from "node:os"; import { PublicClientApplication } from "@azure/msal-node"; +import { + DataProtectionScope, + Environment, + PersistenceCachePlugin, + PersistenceCreator, +} from "@azure/msal-node-extensions"; -function fileCachePlugin(cachePath) { - return { - beforeCacheAccess: async (ctx) => { - if (fs.existsSync(cachePath)) { - ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8")); - } +async function createPca({ tenantId, clientId }) { + const cachePath = path.join( + Environment.getUserRootDirectory(), + "sk-az-tools", + `${clientId}-msal.cache`, + ); + + const persistence = await PersistenceCreator.createPersistence({ + cachePath, + dataProtectionScope: DataProtectionScope.CurrentUser, + serviceName: "sk-az-tools", + accountName: "msal-cache", + usePlaintextFileOnLinux: false, + }); + + return new PublicClientApplication({ + auth: { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, }, - afterCacheAccess: async (ctx) => { - if (ctx.cacheHasChanged) { - fs.mkdirSync(path.dirname(cachePath), { recursive: true }); - fs.writeFileSync(cachePath, ctx.tokenCache.serialize()); - fs.chmodSync(cachePath, 0o600); // Owner read/write only - } + cache: { + cachePlugin: new PersistenceCachePlugin(persistence), }, - }; + }); } -function generatePkce() { - const verifier = crypto.randomBytes(32).toString("base64url"); // 43 chars, valid - const challenge = crypto - .createHash("sha256") - .update(verifier) - .digest("base64url"); +async function acquireTokenWithCache({ pca, scopes }) { + const accounts = await pca.getTokenCache().getAllAccounts(); - return { verifier, challenge, challengeMethod: "S256" }; + if (accounts.length > 0) { + try { + return await pca.acquireTokenSilent({ + account: accounts[0], + scopes, + }); + } catch (e) { + // proceed to interactive/device code login + } + } + + return null; } export async function loginInteractive({ tenantId, clientId, scopes }) { @@ -42,105 +59,71 @@ export async function loginInteractive({ tenantId, clientId, scopes }) { if (!Array.isArray(scopes) || scopes.length === 0) throw new Error("scopes[] is required"); - const cachePath = path.join( - os.homedir(), - `.config/sk-az-tools`, - `${clientId}-token-cache.json`, - ); + const pca = await createPca({ tenantId, clientId }); - const pca = new PublicClientApplication({ - auth: { - clientId, - authority: `https://login.microsoftonline.com/${tenantId}`, - }, - cache: { - cachePlugin: fileCachePlugin(cachePath), - }, - }); + const cached = await acquireTokenWithCache({ pca, scopes }); + if (cached) return cached; - const accounts = await pca.getTokenCache().getAllAccounts(); - - if (accounts.length > 0) { - try { - const silentResult = await pca.acquireTokenSilent({ - account: accounts[0], - scopes, - }); - return silentResult; - } catch (e) { - // proceed to interactive login - } - } - - const pkce = generatePkce(); - - return new Promise((resolve, reject) => { - let redirectUri; - - const server = http.createServer(async (req, res) => { + return await pca.acquireTokenInteractive({ + scopes, + openBrowser: async (url) => { try { - const url = new URL(req.url ?? "/", `http://${req.headers.host}`); - - if (url.pathname !== "/callback") { - res.writeHead(404).end(); - return; - } - - const code = url.searchParams.get("code"); - if (!code) { - res.writeHead(400).end("Missing authorization code"); - server.close(); - reject(new Error("Missing authorization code")); - return; - } - - res.end("Authentication complete. You may close this tab."); - server.close(); - - const token = await pca.acquireTokenByCode({ - code, - scopes, - redirectUri, - codeVerifier: pkce.verifier, - }); - - resolve(token); - } catch (e) { - try { - server.close(); - } catch {} - reject(e); + await open(url, { wait: false }); + // To enforce Microsoft Edge instead of the default browser, use: + // await open(url, { + // wait: false, + // app: { + // name: apps.edge, + // }, + // }); + } catch { + // If auto-open fails, provide URL for manual copy/paste. + console.log("Visit:\n" + url); } - }); - - server.listen(0, "127.0.0.1", async () => { - try { - const { port } = server.address(); - redirectUri = `http://localhost:${port}/callback`; - console.log("Using redirectUri:", redirectUri); - - const authUrl = await pca.getAuthCodeUrl({ - scopes, - redirectUri, - codeChallenge: pkce.challenge, - codeChallengeMethod: pkce.challengeMethod, - }); - - try { - await open(authUrl, { - wait: false, - app: { - name: apps.edge, // Enforce using Microsoft Edge browser - }, - }); - } catch {} - console.log("Visit:\n" + authUrl); - } catch (e) { - try { - server.close(); - } catch {} - reject(e); - } - }); + }, + }); +} + +export async function loginDeviceCode({ tenantId, clientId, scopes }) { + if (!tenantId) throw new Error("tenantId is required"); + if (!clientId) throw new Error("clientId is required"); + if (!Array.isArray(scopes) || scopes.length === 0) + throw new Error("scopes[] is required"); + + const pca = await createPca({ tenantId, clientId }); + + const cached = await acquireTokenWithCache({ pca, scopes }); + if (cached) return cached; + + return await pca.acquireTokenByDeviceCode({ + scopes, + deviceCodeCallback: (response) => { + console.log(response.message); + }, + }); +} + +export async function logout({ tenantId, clientId, userPrincipalName }) { + if (!tenantId) throw new Error("tenantId is required"); + if (!clientId) throw new Error("clientId is required"); + if (!userPrincipalName) + throw new Error("userPrincipalName is required"); + + const pca = await createPca({ tenantId, clientId }); + + const accounts = await pca.getTokenCache().getAllAccounts(); + const accountToSignOut = accounts.find( + (acct) => + acct.username?.toLowerCase() === userPrincipalName.toLowerCase(), + ); + + if (!accountToSignOut) return false; + + pca.signOut({ account: accountToSignOut }).then(() => { + console.log(`Signed out ${userPrincipalName}`); + return true; + }).catch((err) => { + console.error(`Failed to sign out ${userPrincipalName}:`, err); + return false; }); }