// SPDX-License-Identifier: MIT import open, { apps } from "open"; import fs from "node:fs"; import { writeFile, mkdir, unlink } from "node:fs/promises"; import path from "node:path"; import { PublicClientApplication } from "@azure/msal-node"; import { getConfig, getConfigDir } from "@slawek/sk-tools"; import type { AccountInfo, AuthenticationResult, ICachePlugin, TokenCacheContext, } from "@azure/msal-node"; import type { ResourceName } from "../azure/index.ts"; import { RESOURCE_SCOPE_BY_NAME, DEFAULT_RESOURCES } from "../azure/index.ts"; import { translateResourceNamesToScopes } from "./index.ts"; const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login"; const BROWSER_KEYWORDS = Object.keys(apps).sort(); const OPEN_APPS = apps as Record; const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]); const SESSION_STATE_NAME = "session-state"; type SessionState = { activeAccountUpn: string | null; }; async function readSessionState(): Promise { const parsed = (await getConfig("sk-az-tools", SESSION_STATE_NAME)) as { activeAccountUpn?: unknown }; return { activeAccountUpn: typeof parsed?.activeAccountUpn === "string" ? parsed.activeAccountUpn : null, }; } async function writeSessionState(state: SessionState): Promise { const sessionPath = path.join(getConfigDir("sk-az-tools"), `${SESSION_STATE_NAME}.json`); await mkdir(path.dirname(sessionPath), { recursive: true }); await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8"); } async function clearSessionState(): Promise { try { const sessionPath = path.join(getConfigDir("sk-az-tools"), `${SESSION_STATE_NAME}.json`); await unlink(sessionPath); } catch (err) { if ((err as { code?: string } | null)?.code !== "ENOENT") { throw err; } } } function writeStderr(message: string): void { process.stderr.write(`${message}\n`); } function getBrowserAppName(browser?: string): string | readonly string[] | undefined { if (!browser || browser.trim() === "") { return undefined; } const keyword = BROWSER_KEYWORDS.find( (name) => name.toLowerCase() === browser.trim().toLowerCase(), ); if (!keyword) { throw new Error( `Invalid browser '${browser}'. Allowed: ${BROWSER_KEYWORDS.join(", ")}`, ); } return OPEN_APPS[keyword]; } function getBrowserKeyword(browser?: string): string { if (!browser || browser.trim() === "") { return ""; } const requested = browser.trim().toLowerCase(); const keyword = BROWSER_KEYWORDS.find((name) => name.toLowerCase() === requested); if (!keyword) { throw new Error( `Invalid browser '${browser}'. Allowed: ${BROWSER_KEYWORDS.join(", ")}`, ); } return keyword.toLowerCase(); } function getBrowserOpenOptions(browser?: string, browserProfile?: string): Parameters[1] { const browserName = getBrowserAppName(browser); if (!browserProfile || browserProfile.trim() === "") { return browserName ? { wait: false, app: { name: browserName } } : { wait: false }; } const browserKeyword = getBrowserKeyword(browser); if (!CHROMIUM_BROWSERS.has(browserKeyword)) { throw new Error( "--browser-profile is supported only with --browser-name edge|chrome|brave", ); } if (!browserName) { throw new Error("--browser-profile requires --browser-name"); } return { wait: false, app: { name: browserName, arguments: [`--profile-directory=${browserProfile.trim()}`], }, }; } function validateBrowserOptions(browser?: string, browserProfile?: string): void { if (browser && browser.trim() !== "") { getBrowserAppName(browser); } if (browserProfile && browserProfile.trim() !== "") { const browserKeyword = getBrowserKeyword(browser); if (!CHROMIUM_BROWSERS.has(browserKeyword)) { throw new Error( "--browser-profile is supported only with --browser-name edge|chrome|brave", ); } } } export function parseResources(resourcesInput?: string[]): ResourceName[] { if (!resourcesInput || resourcesInput.length === 0) { return [...DEFAULT_RESOURCES]; } const resources = resourcesInput .map((item) => item.trim().toLowerCase()) .filter(Boolean); const unique = [...new Set(resources)]; const invalid = unique.filter((name) => !Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, name)); if (invalid.length > 0) { throw new Error( `Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`, ); } return unique as ResourceName[]; } function fileCachePlugin(cachePath: string): ICachePlugin { return { beforeCacheAccess: async (ctx: TokenCacheContext) => { if (fs.existsSync(cachePath)) { ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8")); } }, afterCacheAccess: async (ctx: TokenCacheContext) => { if (!ctx.cacheHasChanged) return; fs.mkdirSync(path.dirname(cachePath), { recursive: true }); fs.writeFileSync(cachePath, ctx.tokenCache.serialize()); fs.chmodSync(cachePath, 0o600); }, }; } async function createPca(tenantId: string, clientId: string): Promise { const cacheRoot = getConfigDir("sk-az-tools"); const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`); let cachePlugin: ICachePlugin; try { const { DataProtectionScope, PersistenceCachePlugin, PersistenceCreator, } = await import("@azure/msal-node-extensions"); const persistence = await PersistenceCreator.createPersistence({ cachePath, dataProtectionScope: DataProtectionScope.CurrentUser, serviceName: "sk-az-tools", accountName: "msal-cache", usePlaintextFileOnLinux: true, }); cachePlugin = new PersistenceCachePlugin(persistence); } catch { // Fallback when msal-node-extensions/keytar/libsecret are unavailable. cachePlugin = fileCachePlugin(cachePath); } return new PublicClientApplication({ auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}`, }, cache: { cachePlugin, }, }); } async function acquireTokenWithCache( pca: PublicClientApplication, scopes: string[], account?: AccountInfo | null, ): Promise { if (account) { try { return await pca.acquireTokenSilent({ account, scopes, }); } catch { return null; } } const accounts = await pca.getTokenCache().getAllAccounts(); for (const cachedAccount of accounts) { try { return await pca.acquireTokenSilent({ account: cachedAccount, scopes, }); } catch { // Try next cached account. } } return null; } async function findAccountByUpn( pca: PublicClientApplication, upn: string, ): Promise { const normalized = upn.trim().toLowerCase(); if (!normalized) { return null; } const accounts = await pca.getTokenCache().getAllAccounts(); return ( accounts.find((account) => account.username.trim().toLowerCase() === normalized) ?? null ); } export async function loginInteractive( tenantId: string | undefined, clientId: string | undefined, scopes: string[], showAuthUrlOnly = false, browser?: string, browserProfile?: string, ): Promise { 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"); } validateBrowserOptions(browser, browserProfile); const pca = await createPca(tenantId, clientId); const cached = await acquireTokenWithCache(pca, scopes); if (cached) return cached; return pca.acquireTokenInteractive({ scopes, openBrowser: async (url: string) => { if (showAuthUrlOnly) { writeStderr(`Visit:\n${url}`); return; } const options = getBrowserOpenOptions(browser, browserProfile); await open(url, options).catch(() => { writeStderr(`Visit:\n${url}`); }); }, }); } export async function loginDeviceCode( tenantId: string, clientId: string, scopes: string[], ): Promise { 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 pca.acquireTokenByDeviceCode({ scopes, deviceCodeCallback: (response) => { writeStderr(response.message); }, }); } export async function login( tenantId: string, clientId: string, resourcesInput?: string[], useDeviceCode = false, noBrowser = false, browser?: string, browserProfile?: string, ): Promise<{ accountUpn: string | null; resources: Array<{ resource: string; expiresOn: string | null }>; flow: "device-code" | "interactive"; browserLaunchAttempted: boolean; }> { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); validateBrowserOptions(browser, browserProfile); const resources = parseResources(resourcesInput); const scopes = translateResourceNamesToScopes(resources) as string[]; const pca = await createPca(tenantId, clientId); const session = await readSessionState(); const preferredAccount = session.activeAccountUpn ? await findAccountByUpn(pca, session.activeAccountUpn) : null; const results: Array<{ resource: string; expiresOn: string | null }> = []; let selectedAccount: AccountInfo | null = preferredAccount; let token = await acquireTokenWithCache(pca, scopes, selectedAccount); if (token?.account) { selectedAccount = token.account; } if (!token) { if (useDeviceCode) { token = await pca.acquireTokenByDeviceCode({ scopes: scopes, deviceCodeCallback: (response) => { writeStderr(response.message); }, }); } else { token = await pca.acquireTokenInteractive({ scopes: scopes, openBrowser: async (url: string) => { if (noBrowser) { writeStderr(`Visit:\n${url}`); return; } const options = getBrowserOpenOptions(browser, browserProfile); await open(url, options).catch(() => { writeStderr(`Visit:\n${url}`); }); }, }); } if (token?.account) { selectedAccount = token.account; } results.push({ resource: resources.join(","), expiresOn: token?.expiresOn?.toISOString?.() ?? null, }); } if (!selectedAccount) { const accounts = await pca.getTokenCache().getAllAccounts(); selectedAccount = accounts[0] ?? null; } const activeAccountUpn = selectedAccount?.username ?? null; if (activeAccountUpn) { await writeSessionState({ activeAccountUpn }); } return { accountUpn: activeAccountUpn, resources: results, flow: useDeviceCode ? "device-code" : "interactive", browserLaunchAttempted: !useDeviceCode && !noBrowser, }; } export async function getTokenUsingMsal( tenantId: string, clientId: string, resources: string[], ): Promise { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); if (!resources || resources.length === 0) throw new Error("resources are required"); const session = await readSessionState(); if (!session.activeAccountUpn) { throw new Error(LOGIN_REQUIRED_MESSAGE); } const pca = await createPca(tenantId, clientId); const account = await findAccountByUpn(pca, session.activeAccountUpn); if (!account) { throw new Error(LOGIN_REQUIRED_MESSAGE); } // Convert short names of scopes to full resource scopes const scopes = resources.map((res) => RESOURCE_SCOPE_BY_NAME[res as ResourceName] || res); try { return await pca.acquireTokenSilent({ account, scopes, }); } catch { throw new Error(LOGIN_REQUIRED_MESSAGE); } } export async function logout( tenantId: string, clientId: string, clearAll = false, userPrincipalName?: string, ): Promise<{ clearedAll: boolean; signedOut: string[] }> { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); const pca = await createPca(tenantId, clientId); const tokenCache = pca.getTokenCache(); const accounts = await tokenCache.getAllAccounts(); const session = await readSessionState(); if (clearAll) { for (const account of accounts) { await tokenCache.removeAccount(account); } await clearSessionState(); return { clearedAll: true, signedOut: accounts.map((account) => account.username).filter((name): name is string => Boolean(name)), }; } const targetUpn = (typeof userPrincipalName === "string" ? userPrincipalName.trim().toLowerCase() : "") || (typeof session.activeAccountUpn === "string" ? session.activeAccountUpn.trim().toLowerCase() : ""); const accountToSignOut = accounts.find( (account) => account.username.trim().toLowerCase() === targetUpn, ); if (!accountToSignOut) { await clearSessionState(); return { clearedAll: false, signedOut: [] }; } await tokenCache.removeAccount(accountToSignOut); await clearSessionState(); return { clearedAll: false, signedOut: [accountToSignOut.username].filter((name): name is string => Boolean(name)), }; }