482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
// 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";
|
|
|
|
const RESOURCE_SCOPE_BY_NAME = {
|
|
graph: "https://graph.microsoft.com/.default",
|
|
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
|
|
arm: "https://management.azure.com/.default",
|
|
} as const;
|
|
|
|
type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
|
|
|
|
const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"];
|
|
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
|
|
const BROWSER_KEYWORDS = Object.keys(apps).sort();
|
|
const OPEN_APPS = apps as Record<string, string | readonly string[]>;
|
|
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
|
|
const CONFIG_FILE_NAME = "config";
|
|
|
|
type SessionState = {
|
|
activeAccountUpn: string | null;
|
|
};
|
|
|
|
async function readSessionState(): Promise<SessionState> {
|
|
const parsed = (await getConfig("sk-az-tools", CONFIG_FILE_NAME)) as { activeAccountUpn?: unknown };
|
|
return {
|
|
activeAccountUpn:
|
|
typeof parsed?.activeAccountUpn === "string"
|
|
? parsed.activeAccountUpn
|
|
: null,
|
|
};
|
|
}
|
|
|
|
async function writeSessionState(state: SessionState): Promise<void> {
|
|
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`);
|
|
await mkdir(path.dirname(sessionPath), { recursive: true });
|
|
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
|
|
}
|
|
|
|
async function clearSessionState(): Promise<void> {
|
|
try {
|
|
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_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<typeof open>[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 edge|chrome|brave",
|
|
);
|
|
}
|
|
|
|
if (!browserName) {
|
|
throw new Error("--browser-profile requires --browser");
|
|
}
|
|
|
|
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 edge|chrome|brave",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function parseResources(resourcesCsv?: string): ResourceName[] {
|
|
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) => !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<PublicClientApplication> {
|
|
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<AuthenticationResult | null> {
|
|
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<AccountInfo | null> {
|
|
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<AuthenticationResult | null> {
|
|
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 | undefined,
|
|
clientId: string | undefined,
|
|
scopes: string[],
|
|
): Promise<AuthenticationResult | null> {
|
|
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 | undefined,
|
|
clientId: string | undefined,
|
|
resourcesCsv?: 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(resourcesCsv);
|
|
const scopes = resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]);
|
|
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;
|
|
for (let index = 0; index < resources.length; index += 1) {
|
|
const resource = resources[index];
|
|
const scope = [scopes[index]];
|
|
let token = await acquireTokenWithCache(pca, scope, 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: 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,
|
|
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 acquireResourceToken(
|
|
tenantId: string,
|
|
clientId: string,
|
|
resource: string,
|
|
): Promise<AuthenticationResult | null> {
|
|
if (!tenantId) throw new Error("tenantId is required");
|
|
if (!clientId) throw new Error("clientId is required");
|
|
if (!resource) throw new Error("resource is required");
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, resource)) {
|
|
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
|
|
}
|
|
|
|
const scope = RESOURCE_SCOPE_BY_NAME[resource as ResourceName];
|
|
|
|
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);
|
|
}
|
|
|
|
try {
|
|
return await pca.acquireTokenSilent({
|
|
account,
|
|
scopes: [scope],
|
|
});
|
|
} 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)),
|
|
};
|
|
}
|