Files
sk-az-tools/src/azure/pca-auth.ts

478 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";
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<string, string | readonly string[]>;
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
const SESSION_STATE_NAME = "session-state";
type SessionState = {
activeAccountUpn: string | null;
};
async function readSessionState(): Promise<SessionState> {
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<void> {
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<void> {
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<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-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<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,
clientId: string,
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,
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<AuthenticationResult | null> {
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)),
};
}