Add explicit login/logout flows and browser selection

This commit is contained in:
2026-02-08 20:35:36 +01:00
parent 5888cb4d1a
commit 2180d5aa4c
6 changed files with 392 additions and 54 deletions

23
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -11,5 +11,8 @@ export { getCredential } from "./client-auth.js";
export { export {
loginInteractive, loginInteractive,
loginDeviceCode, loginDeviceCode,
login,
logout, logout,
parseResources,
acquireResourceTokenFromLogin,
} from "./pca-auth.js"; } from "./pca-auth.js";

View File

@@ -1,13 +1,125 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import open, { apps } from "open"; import open, { apps } from "open";
import fs from "node:fs"; import fs from "node:fs";
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { PublicClientApplication } from "@azure/msal-node"; import { PublicClientApplication } from "@azure/msal-node";
import os from "node:os"; 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) { function fileCachePlugin(cachePath) {
return { return {
beforeCacheAccess: async (ctx) => { beforeCacheAccess: async (ctx) => {
@@ -25,14 +137,7 @@ function fileCachePlugin(cachePath) {
} }
async function createPca({ tenantId, clientId }) { async function createPca({ tenantId, clientId }) {
const isWindows = process.platform === "win32"; const cacheRoot = getCacheRoot();
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 cachePath = path.join(cacheRoot, `${clientId}-msal.cache`); const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
let cachePlugin; let cachePlugin;
try { try {
@@ -66,28 +171,52 @@ async function createPca({ tenantId, clientId }) {
}); });
} }
async function acquireTokenWithCache({ pca, scopes }) { async function acquireTokenWithCache({ pca, scopes, account }) {
const accounts = await pca.getTokenCache().getAllAccounts(); if (account) {
if (accounts.length > 0) {
try { try {
return await pca.acquireTokenSilent({ return await pca.acquireTokenSilent({
account: accounts[0], account,
scopes, scopes,
}); });
} catch (e) { } catch {
// proceed to interactive/device code login 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; 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({ export async function loginInteractive({
tenantId, tenantId,
clientId, clientId,
scopes, scopes,
showAuthUrlOnly = false, showAuthUrlOnly = false,
browser,
}) { }) {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
@@ -103,26 +232,17 @@ export async function loginInteractive({
scopes, scopes,
openBrowser: async (url) => { openBrowser: async (url) => {
if (showAuthUrlOnly) { if (showAuthUrlOnly) {
console.log("Visit:\n" + url); writeStderr(`Visit:\n${url}`);
return; return;
} }
const browserName = getBrowserAppName(browser);
// To enforce Microsoft Edge instead of the default browser, use: const options = browserName
// return open(url, { ? { wait: false, app: { name: browserName } }
// wait: false, : { wait: false };
// app: { return open(url, options).catch(() => {
// name: apps.edge, writeStderr(`Visit:\n${url}`);
// },
// }).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);
}); });
} },
}); });
} }
@@ -140,32 +260,170 @@ export async function loginDeviceCode({ tenantId, clientId, scopes }) {
return await pca.acquireTokenByDeviceCode({ return await pca.acquireTokenByDeviceCode({
scopes, scopes,
deviceCodeCallback: (response) => { 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 (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId 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 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( const accountToSignOut = accounts.find(
(acct) => (account) => normalizeUpn(account.username) === targetUpn,
acct.username?.toLowerCase() === userPrincipalName.toLowerCase(),
); );
if (!accountToSignOut) return false; if (!accountToSignOut) {
await clearSessionState();
return { clearedAll: false, signedOut: [] };
}
pca.signOut({ account: accountToSignOut }).then(() => { await tokenCache.removeAccount(accountToSignOut);
console.log(`Signed out ${userPrincipalName}`); await clearSessionState();
return true; return {
}).catch((err) => { clearedAll: false,
console.error(`Failed to sign out ${userPrincipalName}:`, err); signedOut: [accountToSignOut.username].filter(Boolean),
return false; };
});
} }

View File

@@ -16,6 +16,8 @@ function usage() {
return `Usage: sk-az-tools <command> [options] return `Usage: sk-az-tools <command> [options]
Commands: Commands:
login Authenticate selected resources
logout Sign out and clear login state
list-apps List Entra applications list-apps List Entra applications
list-app-permissions List required permissions for an app list-app-permissions List required permissions for an app
list-app-grants List OAuth2 grants for an app list-app-grants List OAuth2 grants for an app
@@ -40,6 +42,23 @@ Options:
-f, --filter <glob> Filter by app display name glob`; -f, --filter <glob> Filter by app display name glob`;
} }
function usageLogin() {
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [global options]
Options:
--resources <csv> 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 <name> 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() { function usageListAppPermissions() {
return `Usage: sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options] return `Usage: sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options]
@@ -75,8 +94,12 @@ Options:
function usageCommand(command) { function usageCommand(command) {
switch (command) { switch (command) {
case "login":
return usageLogin();
case "list-apps": case "list-apps":
return usageListApps(); return usageListApps();
case "logout":
return usageLogout();
case "list-app-permissions": case "list-app-permissions":
return usageListAppPermissions(); return usageListAppPermissions();
case "list-app-grants": case "list-app-grants":
@@ -109,6 +132,11 @@ async function main() {
help: { type: "boolean", short: "h" }, help: { type: "boolean", short: "h" },
"display-name": { type: "string", short: "n" }, "display-name": { type: "string", short: "n" },
"app-id": { type: "string", short: "i" }, "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" }, resolve: { type: "boolean", short: "r" },
short: { type: "boolean", short: "s" }, short: { type: "boolean", short: "s" },
filter: { type: "string", short: "f" }, filter: { type: "string", short: "f" },

View File

@@ -4,6 +4,7 @@ import { minimatch } from "minimatch";
import { loadPublicConfig } from "../index.js"; import { loadPublicConfig } from "../index.js";
import { getGraphClient } from "../graph/auth.js"; import { getGraphClient } from "../graph/auth.js";
import { login, logout } from "../azure/index.js";
import { import {
listApps, listApps,
listAppPermissions, listAppPermissions,
@@ -38,6 +39,27 @@ async function runTableCommand() {
return readJsonFromStdin(); 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) { async function runListAppsCommand(values) {
const { client } = await getGraphClientFromPublicConfig(); const { client } = await getGraphClientFromPublicConfig();
let result = await listApps(client, { let result = await listApps(client, {
@@ -98,6 +120,10 @@ async function runListResourcePermissionsCommand(values) {
export async function runCommand(command, values) { export async function runCommand(command, values) {
switch (command) { switch (command) {
case "login":
return runLoginCommand(values);
case "logout":
return runLogoutCommand(values);
case "table": case "table":
return runTableCommand(); return runTableCommand();
case "list-apps": case "list-apps":

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { loginInteractive } from "../azure/index.js";
import { Client } from "@microsoft/microsoft-graph-client"; import { Client } from "@microsoft/microsoft-graph-client";
import { acquireResourceTokenFromLogin } from "../azure/index.js";
/** /**
* Initialize and return a Microsoft Graph client * 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 * @returns { Promise<{ graphApiToken: Object, client: Object }> } An object containing the Graph API token and client
*/ */
export async function getGraphClient({ tenantId, clientId }) { export async function getGraphClient({ tenantId, clientId }) {
const graphApiToken = await loginInteractive({ const graphApiToken = await acquireResourceTokenFromLogin({
tenantId, tenantId,
clientId, clientId,
scopes: ["https://graph.microsoft.com/.default"], resource: "graph",
}); });
const client = Client.init({ const client = Client.init({