From 2180d5aa4c56c83408d8fae1c144785e6e7630f9 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 8 Feb 2026 20:35:36 +0100 Subject: [PATCH] Add explicit login/logout flows and browser selection --- Dockerfile | 23 +++ src/azure/index.js | 9 +- src/azure/pca-auth.js | 354 ++++++++++++++++++++++++++++++++++++------ src/cli.js | 28 ++++ src/cli/commands.js | 26 ++++ src/graph/auth.js | 6 +- 6 files changed, 392 insertions(+), 54 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ed7be1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:24-alpine AS package + +WORKDIR /package + +COPY package.json ./ +COPY src ./src +COPY README.md LICENSE ./ + +RUN npm pack --silent + + +FROM node:24-alpine + +WORKDIR /work + +COPY --from=package /package/*.tgz /tmp/sk-az-tools.tgz + +RUN npm install --global /tmp/sk-az-tools.tgz \ + && rm /tmp/sk-az-tools.tgz \ + && npm cache clean --force + +ENTRYPOINT ["sk-az-tools"] +CMD ["--help"] diff --git a/src/azure/index.js b/src/azure/index.js index 33680f4..3bb69c5 100644 --- a/src/azure/index.js +++ b/src/azure/index.js @@ -9,7 +9,10 @@ export { getCredential } from "./client-auth.js"; export { - loginInteractive, - loginDeviceCode, - logout, + loginInteractive, + loginDeviceCode, + login, + logout, + parseResources, + acquireResourceTokenFromLogin, } from "./pca-auth.js"; diff --git a/src/azure/pca-auth.js b/src/azure/pca-auth.js index 6d36c87..9836ab2 100644 --- a/src/azure/pca-auth.js +++ b/src/azure/pca-auth.js @@ -1,13 +1,125 @@ // SPDX-License-Identifier: MIT - import open, { apps } from "open"; import fs from "node:fs"; +import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import path from "node:path"; import { PublicClientApplication } from "@azure/msal-node"; import os from "node:os"; +const RESOURCE_SCOPE_BY_NAME = { + graph: "https://graph.microsoft.com/.default", + devops: "499b84ac-1321-427f-aa17-267ca6975798/.default", + arm: "https://management.azure.com/.default", +}; + +const DEFAULT_RESOURCES = ["graph", "devops", "arm"]; +const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login"; +const BROWSER_KEYWORDS = Object.keys(apps).sort(); + +function getCacheRoot() { + const isWindows = process.platform === "win32"; + const userRoot = isWindows + ? process.env.LOCALAPPDATA || os.homedir() + : os.homedir(); + + return isWindows + ? path.join(userRoot, "sk-az-tools") + : path.join(userRoot, ".config", "sk-az-tools"); +} + +function getSessionFilePath() { + return path.join(getCacheRoot(), "login-session.json"); +} + +async function readSessionState() { + try { + const sessionJson = await readFile(getSessionFilePath(), "utf8"); + const parsed = JSON.parse(sessionJson); + return { + activeAccountUpn: + typeof parsed?.activeAccountUpn === "string" + ? parsed.activeAccountUpn + : null, + }; + } catch (err) { + if (err?.code === "ENOENT") { + return { activeAccountUpn: null }; + } + throw err; + } +} + +async function writeSessionState(state) { + const sessionPath = getSessionFilePath(); + await mkdir(path.dirname(sessionPath), { recursive: true }); + await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8"); +} + +async function clearSessionState() { + try { + await unlink(getSessionFilePath()); + } catch (err) { + if (err?.code !== "ENOENT") { + throw err; + } + } +} + +function normalizeUpn(upn) { + return typeof upn === "string" ? upn.trim().toLowerCase() : ""; +} + +function writeStderr(message) { + process.stderr.write(`${message}\n`); +} + +function getBrowserAppName(browser) { + if (!browser || browser.trim() === "") { + return null; + } + + const requested = browser.trim().toLowerCase(); + if (requested === "default") { + return null; + } + + const keyword = BROWSER_KEYWORDS.find((name) => name.toLowerCase() === requested); + if (!keyword) { + throw new Error( + `Invalid browser '${browser}'. Allowed: default, ${BROWSER_KEYWORDS.join(", ")}`, + ); + } + + return apps[keyword]; +} + +export function parseResources(resourcesCsv) { + if (!resourcesCsv || resourcesCsv.trim() === "") { + return [...DEFAULT_RESOURCES]; + } + + const resources = resourcesCsv + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); + + const unique = [...new Set(resources)]; + const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]); + if (invalid.length > 0) { + throw new Error( + `Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`, + ); + } + + return unique; +} + +function getScopesForResources(resources) { + return resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]); +} + function fileCachePlugin(cachePath) { return { beforeCacheAccess: async (ctx) => { @@ -25,14 +137,7 @@ function fileCachePlugin(cachePath) { } async function createPca({ tenantId, clientId }) { - const isWindows = process.platform === "win32"; - const userRoot = isWindows - ? process.env.LOCALAPPDATA || os.homedir() - : os.homedir(); - const cacheRoot = isWindows - ? path.join(userRoot, "sk-az-tools") - : path.join(userRoot, ".config", "sk-az-tools"); - + const cacheRoot = getCacheRoot(); const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`); let cachePlugin; try { @@ -66,28 +171,52 @@ async function createPca({ tenantId, clientId }) { }); } -async function acquireTokenWithCache({ pca, scopes }) { - const accounts = await pca.getTokenCache().getAllAccounts(); - - if (accounts.length > 0) { +async function acquireTokenWithCache({ pca, scopes, account }) { + if (account) { try { return await pca.acquireTokenSilent({ - account: accounts[0], + account, scopes, }); - } catch (e) { - // proceed to interactive/device code login + } 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, upn }) { + const normalized = normalizeUpn(upn); + if (!normalized) { + return null; + } + + const accounts = await pca.getTokenCache().getAllAccounts(); + return ( + accounts.find((account) => normalizeUpn(account?.username) === normalized) ?? + null + ); +} + export async function loginInteractive({ tenantId, clientId, scopes, showAuthUrlOnly = false, + browser, }) { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); @@ -103,26 +232,17 @@ export async function loginInteractive({ scopes, openBrowser: async (url) => { if (showAuthUrlOnly) { - console.log("Visit:\n" + url); + writeStderr(`Visit:\n${url}`); return; } - - // To enforce Microsoft Edge instead of the default browser, use: - // return open(url, { - // wait: false, - // app: { - // name: apps.edge, - // }, - // }).catch(() => { - // // If auto-open fails, provide URL for manual copy/paste. - // console.log("Visit:\n" + url); - // }); - - return open(url, { wait: false }).catch(() => { - // If auto-open fails, provide URL for manual copy/paste. - console.log("Visit:\n" + url); + const browserName = getBrowserAppName(browser); + const options = browserName + ? { wait: false, app: { name: browserName } } + : { wait: false }; + return open(url, options).catch(() => { + writeStderr(`Visit:\n${url}`); }); - } + }, }); } @@ -140,32 +260,170 @@ export async function loginDeviceCode({ tenantId, clientId, scopes }) { return await pca.acquireTokenByDeviceCode({ scopes, deviceCodeCallback: (response) => { - console.log(response.message); + writeStderr(response.message); }, }); } -export async function logout({ tenantId, clientId, userPrincipalName }) { +export async function login({ + tenantId, + clientId, + resourcesCsv, + useDeviceCode = false, + noBrowser = false, + browser, +}) { 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 resources = parseResources(resourcesCsv); + const scopes = getScopesForResources(resources); + const pca = await createPca({ tenantId, clientId }); + const session = await readSessionState(); + const preferredAccount = await findAccountByUpn({ + pca, + upn: session.activeAccountUpn, + }); + + const results = []; + let selectedAccount = preferredAccount; + for (let index = 0; index < resources.length; index += 1) { + const resource = resources[index]; + const scope = [scopes[index]]; + let token = await acquireTokenWithCache({ + pca, + scopes: scope, + account: selectedAccount, + }); + + if (!token) { + if (useDeviceCode) { + token = await pca.acquireTokenByDeviceCode({ + scopes: scope, + deviceCodeCallback: (response) => { + writeStderr(response.message); + }, + }); + } else { + token = await pca.acquireTokenInteractive({ + scopes: scope, + openBrowser: async (url) => { + if (noBrowser) { + writeStderr(`Visit:\n${url}`); + return; + } + const browserName = getBrowserAppName(browser); + const options = browserName + ? { wait: false, app: { name: browserName } } + : { wait: false }; + return open(url, options).catch(() => { + writeStderr(`Visit:\n${url}`); + }); + }, + }); + } + } + + if (token?.account) { + selectedAccount = token.account; + } + + results.push({ + resource, + expiresOn: token?.expiresOn?.toISOString?.() ?? 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 acquireResourceTokenFromLogin({ + tenantId, + clientId, + resource, +}) { + if (!tenantId) throw new Error("tenantId is required"); + if (!clientId) throw new Error("clientId is required"); + if (!resource) throw new Error("resource is required"); + + const scope = RESOURCE_SCOPE_BY_NAME[resource]; + if (!scope) { + throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`); + } + + const session = await readSessionState(); + if (!session.activeAccountUpn) { + throw new Error(LOGIN_REQUIRED_MESSAGE); + } const pca = await createPca({ tenantId, clientId }); + const account = await findAccountByUpn({ + pca, + upn: session.activeAccountUpn, + }); + if (!account) { + throw new Error(LOGIN_REQUIRED_MESSAGE); + } - const accounts = await pca.getTokenCache().getAllAccounts(); + try { + return await pca.acquireTokenSilent({ + account, + scopes: [scope], + }); + } catch { + throw new Error(LOGIN_REQUIRED_MESSAGE); + } +} + +export async function logout({ + tenantId, + clientId, + clearAll = false, + userPrincipalName, +}) { + 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(Boolean), + }; + } + + const targetUpn = normalizeUpn(userPrincipalName) || normalizeUpn(session.activeAccountUpn); const accountToSignOut = accounts.find( - (acct) => - acct.username?.toLowerCase() === userPrincipalName.toLowerCase(), + (account) => normalizeUpn(account.username) === targetUpn, ); - if (!accountToSignOut) return false; + if (!accountToSignOut) { + await clearSessionState(); + return { clearedAll: false, signedOut: [] }; + } - pca.signOut({ account: accountToSignOut }).then(() => { - console.log(`Signed out ${userPrincipalName}`); - return true; - }).catch((err) => { - console.error(`Failed to sign out ${userPrincipalName}:`, err); - return false; - }); + await tokenCache.removeAccount(accountToSignOut); + await clearSessionState(); + return { + clearedAll: false, + signedOut: [accountToSignOut.username].filter(Boolean), + }; } diff --git a/src/cli.js b/src/cli.js index 0cade4d..596f229 100755 --- a/src/cli.js +++ b/src/cli.js @@ -16,6 +16,8 @@ function usage() { return `Usage: sk-az-tools [options] Commands: + login Authenticate selected resources + logout Sign out and clear login state list-apps List Entra applications list-app-permissions List required permissions for an app list-app-grants List OAuth2 grants for an app @@ -40,6 +42,23 @@ Options: -f, --filter Filter by app display name glob`; } +function usageLogin() { + return `Usage: sk-az-tools login [--resources ] [--use-device-code] [--no-browser] [--browser ] [global options] + +Options: + --resources Comma-separated resources: graph,devops,arm (default: all) + --use-device-code Use device code flow instead of interactive flow + --no-browser Do not launch browser; print interactive URL to stderr + --browser Browser keyword: default|brave|browser|browserPrivate|chrome|edge|firefox`; +} + +function usageLogout() { + return `Usage: sk-az-tools logout [--all] [global options] + +Options: + --all Clear login state and remove all cached accounts`; +} + function usageListAppPermissions() { return `Usage: sk-az-tools list-app-permissions --app-id|-i [--resolve|-r] [--short|-s] [--filter|-f ] [global options] @@ -75,8 +94,12 @@ Options: function usageCommand(command) { switch (command) { + case "login": + return usageLogin(); case "list-apps": return usageListApps(); + case "logout": + return usageLogout(); case "list-app-permissions": return usageListAppPermissions(); case "list-app-grants": @@ -109,6 +132,11 @@ async function main() { help: { type: "boolean", short: "h" }, "display-name": { type: "string", short: "n" }, "app-id": { type: "string", short: "i" }, + resources: { type: "string" }, + "use-device-code": { type: "boolean" }, + "no-browser": { type: "boolean" }, + browser: { type: "string" }, + all: { type: "boolean" }, resolve: { type: "boolean", short: "r" }, short: { type: "boolean", short: "s" }, filter: { type: "string", short: "f" }, diff --git a/src/cli/commands.js b/src/cli/commands.js index 11a7ea0..3f94425 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -4,6 +4,7 @@ import { minimatch } from "minimatch"; import { loadPublicConfig } from "../index.js"; import { getGraphClient } from "../graph/auth.js"; +import { login, logout } from "../azure/index.js"; import { listApps, listAppPermissions, @@ -38,6 +39,27 @@ async function runTableCommand() { return readJsonFromStdin(); } +async function runLoginCommand(values) { + const config = await loadPublicConfig(); + return login({ + tenantId: config.tenantId, + clientId: config.clientId, + resourcesCsv: values.resources, + useDeviceCode: Boolean(values["use-device-code"]), + noBrowser: Boolean(values["no-browser"]), + browser: values.browser, + }); +} + +async function runLogoutCommand(values) { + const config = await loadPublicConfig(); + return logout({ + tenantId: config.tenantId, + clientId: config.clientId, + clearAll: Boolean(values.all), + }); +} + async function runListAppsCommand(values) { const { client } = await getGraphClientFromPublicConfig(); let result = await listApps(client, { @@ -98,6 +120,10 @@ async function runListResourcePermissionsCommand(values) { export async function runCommand(command, values) { switch (command) { + case "login": + return runLoginCommand(values); + case "logout": + return runLogoutCommand(values); case "table": return runTableCommand(); case "list-apps": diff --git a/src/graph/auth.js b/src/graph/auth.js index c346e30..5abdd9e 100644 --- a/src/graph/auth.js +++ b/src/graph/auth.js @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -import { loginInteractive } from "../azure/index.js"; import { Client } from "@microsoft/microsoft-graph-client"; +import { acquireResourceTokenFromLogin } from "../azure/index.js"; /** * Initialize and return a Microsoft Graph client @@ -13,10 +13,10 @@ import { Client } from "@microsoft/microsoft-graph-client"; * @returns { Promise<{ graphApiToken: Object, client: Object }> } An object containing the Graph API token and client */ export async function getGraphClient({ tenantId, clientId }) { - const graphApiToken = await loginInteractive({ + const graphApiToken = await acquireResourceTokenFromLogin({ tenantId, clientId, - scopes: ["https://graph.microsoft.com/.default"], + resource: "graph", }); const client = Client.init({