Compare commits

..

3 Commits

18 changed files with 223 additions and 321 deletions

36
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "@slawek/sk-az-tools", "name": "@slawek/sk-az-tools",
"version": "0.4.3", "version": "0.4.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -16,7 +16,9 @@
"@slawek/sk-tools": ">=0.1.0", "@slawek/sk-tools": ">=0.1.0",
"azure-devops-node-api": "^15.1.2", "azure-devops-node-api": "^15.1.2",
"minimatch": "^10.1.2", "minimatch": "^10.1.2",
"open": "^10.1.0" "open": "^10.1.0",
"semver": "^7.7.2",
"uuid": "^11.1.0"
}, },
"bin": { "bin": {
"sk-az-tools": "dist/cli.js" "sk-az-tools": "dist/cli.js"
@@ -154,6 +156,15 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/@azure/identity/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@azure/logger": { "node_modules/@azure/logger": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz",
@@ -233,6 +244,15 @@
"node": ">=0.8.0" "node": ">=0.8.0"
} }
}, },
"node_modules/@azure/msal-node/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
@@ -1576,12 +1596,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/esm/bin/uuid"
} }
}, },
"node_modules/wrappy": { "node_modules/wrappy": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@slawek/sk-az-tools", "name": "@slawek/sk-az-tools",
"version": "0.4.3", "version": "0.4.5",
"type": "module", "type": "module",
"files": [ "files": [
"dist", "dist",
@@ -27,7 +27,8 @@
"azure-devops-node-api": "^15.1.2", "azure-devops-node-api": "^15.1.2",
"minimatch": "^10.1.2", "minimatch": "^10.1.2",
"open": "^10.1.0", "open": "^10.1.0",
"semver": "^7.7.2" "semver": "^7.7.2",
"uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": ">=24.0.0", "@types/node": ">=24.0.0",

View File

@@ -1,18 +1,16 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity"; import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
import type { AuthenticationResult } from "@azure/msal-node";
import { acquireResourceToken as acquireResourceTokenPca } from "./pca-auth.ts";
type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode"; type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode";
type CredentialOptions = {
tenantId?: string;
clientId?: string;
clientSecret?: string;
};
export async function getCredential( export async function getCredential(
credentialType: CredentialType, credentialType: CredentialType,
options: CredentialOptions, tenantId?: string,
clientId?: string,
clientSecret?: string,
): Promise<DefaultAzureCredential | ClientSecretCredential | DeviceCodeCredential> { ): Promise<DefaultAzureCredential | ClientSecretCredential | DeviceCodeCredential> {
switch (credentialType) { switch (credentialType) {
case "d": case "d":
@@ -20,26 +18,26 @@ export async function getCredential(
return new DefaultAzureCredential(); return new DefaultAzureCredential();
case "cs": case "cs":
case "clientSecret": case "clientSecret":
if (!options.tenantId || !options.clientId || !options.clientSecret) { if (!tenantId || !clientId || !clientSecret) {
throw new Error( throw new Error(
"tenantId, clientId, and clientSecret are required for ClientSecretCredential", "tenantId, clientId, and clientSecret are required for ClientSecretCredential",
); );
} }
return new ClientSecretCredential( return new ClientSecretCredential(
options.tenantId, tenantId,
options.clientId, clientId,
options.clientSecret, clientSecret,
); );
case "dc": case "dc":
case "deviceCode": case "deviceCode":
if (!options.tenantId || !options.clientId) { if (!tenantId || !clientId) {
throw new Error( throw new Error(
"tenantId and clientId are required for DeviceCodeCredential", "tenantId and clientId are required for DeviceCodeCredential",
); );
} }
return new DeviceCodeCredential({ return new DeviceCodeCredential({
tenantId: options.tenantId, tenantId,
clientId: options.clientId, clientId,
userPromptCallback: (info) => { userPromptCallback: (info) => {
console.log(info.message); console.log(info.message);
}, },
@@ -48,3 +46,11 @@ export async function getCredential(
throw new Error(`Unsupported credential type: ${credentialType}`); throw new Error(`Unsupported credential type: ${credentialType}`);
} }
} }
export async function acquireResourceToken(
tenantId: string,
clientId: string,
resource: string,
): Promise<AuthenticationResult | null> {
return acquireResourceTokenPca(tenantId, clientId, resource);
}

View File

@@ -7,11 +7,19 @@
*/ */
export { getCredential } from "./client-auth.ts"; export { getCredential } from "./client-auth.ts";
import { acquireResourceToken as acquireResourceTokenPca } from "./pca-auth.ts";
export { export {
loginInteractive, loginInteractive,
loginDeviceCode, loginDeviceCode,
login, login,
logout, logout,
parseResources, parseResources,
acquireResourceTokenFromLogin,
} from "./pca-auth.ts"; } from "./pca-auth.ts";
export async function acquireResourceToken(
tenantId: string,
clientId: string,
resource: string,
) {
return acquireResourceTokenPca(tenantId, clientId, resource);
}

View File

@@ -2,17 +2,17 @@
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 { 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 { getConfig, getConfigDir } from "@slawek/sk-tools";
import type { import type {
AccountInfo, AccountInfo,
AuthenticationResult, AuthenticationResult,
ICachePlugin, ICachePlugin,
TokenCacheContext, TokenCacheContext,
} from "@azure/msal-node"; } from "@azure/msal-node";
import os from "node:os";
const RESOURCE_SCOPE_BY_NAME = { const RESOURCE_SCOPE_BY_NAME = {
graph: "https://graph.microsoft.com/.default", graph: "https://graph.microsoft.com/.default",
@@ -27,96 +27,32 @@ const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
const BROWSER_KEYWORDS = Object.keys(apps).sort(); const BROWSER_KEYWORDS = Object.keys(apps).sort();
const OPEN_APPS = apps as Record<string, string | readonly string[]>; const OPEN_APPS = apps as Record<string, string | readonly string[]>;
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]); const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
const CONFIG_FILE_NAME = "config";
type SessionState = { type SessionState = {
activeAccountUpn: string | null; activeAccountUpn: string | null;
}; };
type BrowserOptions = {
browser?: string;
browserProfile?: string;
};
type LoginInteractiveOptions = {
tenantId?: string;
clientId?: string;
scopes: string[];
showAuthUrlOnly?: boolean;
browser?: string;
browserProfile?: string;
};
type LoginDeviceCodeOptions = {
tenantId?: string;
clientId?: string;
scopes: string[];
};
type LoginOptions = {
tenantId?: string;
clientId?: string;
resourcesCsv?: string;
useDeviceCode?: boolean;
noBrowser?: boolean;
browser?: string;
browserProfile?: string;
};
type AcquireResourceTokenOptions = {
tenantId?: string;
clientId?: string;
resource?: string;
};
type LogoutOptions = {
tenantId?: string;
clientId?: string;
clearAll?: boolean;
userPrincipalName?: string;
};
function getCacheRoot(): string {
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(): string {
return path.join(getCacheRoot(), "login-session.json");
}
async function readSessionState(): Promise<SessionState> { async function readSessionState(): Promise<SessionState> {
try { const parsed = (await getConfig("sk-az-tools", CONFIG_FILE_NAME)) as { activeAccountUpn?: unknown };
const sessionJson = await readFile(getSessionFilePath(), "utf8");
const parsed = JSON.parse(sessionJson) as { activeAccountUpn?: unknown };
return { return {
activeAccountUpn: activeAccountUpn:
typeof parsed?.activeAccountUpn === "string" typeof parsed?.activeAccountUpn === "string"
? parsed.activeAccountUpn ? parsed.activeAccountUpn
: null, : null,
}; };
} catch (err) {
if ((err as { code?: string } | null)?.code === "ENOENT") {
return { activeAccountUpn: null };
}
throw err;
}
} }
async function writeSessionState(state: SessionState): Promise<void> { async function writeSessionState(state: SessionState): Promise<void> {
const sessionPath = getSessionFilePath(); const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`);
await mkdir(path.dirname(sessionPath), { recursive: true }); await mkdir(path.dirname(sessionPath), { recursive: true });
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8"); await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
} }
async function clearSessionState(): Promise<void> { async function clearSessionState(): Promise<void> {
try { try {
await unlink(getSessionFilePath()); const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`);
await unlink(sessionPath);
} catch (err) { } catch (err) {
if ((err as { code?: string } | null)?.code !== "ENOENT") { if ((err as { code?: string } | null)?.code !== "ENOENT") {
throw err; throw err;
@@ -124,10 +60,6 @@ async function clearSessionState(): Promise<void> {
} }
} }
function normalizeUpn(upn: unknown): string {
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
}
function writeStderr(message: string): void { function writeStderr(message: string): void {
process.stderr.write(`${message}\n`); process.stderr.write(`${message}\n`);
} }
@@ -165,7 +97,7 @@ function getBrowserKeyword(browser?: string): string {
return keyword.toLowerCase(); return keyword.toLowerCase();
} }
function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Parameters<typeof open>[1] { function getBrowserOpenOptions(browser?: string, browserProfile?: string): Parameters<typeof open>[1] {
const browserName = getBrowserAppName(browser); const browserName = getBrowserAppName(browser);
if (!browserProfile || browserProfile.trim() === "") { if (!browserProfile || browserProfile.trim() === "") {
@@ -194,7 +126,7 @@ function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Par
}; };
} }
function validateBrowserOptions({ browser, browserProfile }: BrowserOptions): void { function validateBrowserOptions(browser?: string, browserProfile?: string): void {
if (browser && browser.trim() !== "") { if (browser && browser.trim() !== "") {
getBrowserAppName(browser); getBrowserAppName(browser);
} }
@@ -246,8 +178,8 @@ function fileCachePlugin(cachePath: string): ICachePlugin {
}; };
} }
async function createPca({ tenantId, clientId }: { tenantId: string; clientId: string }): Promise<PublicClientApplication> { async function createPca(tenantId: string, clientId: string): Promise<PublicClientApplication> {
const cacheRoot = getCacheRoot(); const cacheRoot = getConfigDir("sk-az-tools");
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`); const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
let cachePlugin: ICachePlugin; let cachePlugin: ICachePlugin;
try { try {
@@ -281,15 +213,11 @@ async function createPca({ tenantId, clientId }: { tenantId: string; clientId: s
}); });
} }
async function acquireTokenWithCache({ async function acquireTokenWithCache(
pca, pca: PublicClientApplication,
scopes, scopes: string[],
account, account?: AccountInfo | null,
}: { ): Promise<AuthenticationResult | null> {
pca: PublicClientApplication;
scopes: string[];
account?: AccountInfo | null;
}): Promise<AuthenticationResult | null> {
if (account) { if (account) {
try { try {
return await pca.acquireTokenSilent({ return await pca.acquireTokenSilent({
@@ -316,43 +244,40 @@ async function acquireTokenWithCache({
return null; return null;
} }
async function findAccountByUpn({ async function findAccountByUpn(
pca, pca: PublicClientApplication,
upn, upn: string,
}: { ): Promise<AccountInfo | null> {
pca: PublicClientApplication; const normalized = upn.trim().toLowerCase();
upn: string | null;
}): Promise<AccountInfo | null> {
const normalized = normalizeUpn(upn);
if (!normalized) { if (!normalized) {
return null; return null;
} }
const accounts = await pca.getTokenCache().getAllAccounts(); const accounts = await pca.getTokenCache().getAllAccounts();
return ( return (
accounts.find((account) => normalizeUpn(account?.username) === normalized) ?? accounts.find((account) => account.username.trim().toLowerCase() === normalized) ??
null null
); );
} }
export async function loginInteractive({ export async function loginInteractive(
tenantId, tenantId: string | undefined,
clientId, clientId: string | undefined,
scopes, scopes: string[],
showAuthUrlOnly = false, showAuthUrlOnly = false,
browser, browser?: string,
browserProfile, browserProfile?: string,
}: LoginInteractiveOptions): Promise<AuthenticationResult | null> { ): Promise<AuthenticationResult | null> {
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 (!Array.isArray(scopes) || scopes.length === 0) { if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error("scopes[] is required"); throw new Error("scopes[] is required");
} }
validateBrowserOptions({ browser, browserProfile }); validateBrowserOptions(browser, browserProfile);
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const cached = await acquireTokenWithCache({ pca, scopes }); const cached = await acquireTokenWithCache(pca, scopes);
if (cached) return cached; if (cached) return cached;
return pca.acquireTokenInteractive({ return pca.acquireTokenInteractive({
@@ -362,7 +287,7 @@ export async function loginInteractive({
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
return; return;
} }
const options = getBrowserOpenOptions({ browser, browserProfile }); const options = getBrowserOpenOptions(browser, browserProfile);
await open(url, options).catch(() => { await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
}); });
@@ -370,16 +295,20 @@ export async function loginInteractive({
}); });
} }
export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDeviceCodeOptions): Promise<AuthenticationResult | null> { export async function loginDeviceCode(
tenantId: string | undefined,
clientId: string | undefined,
scopes: string[],
): Promise<AuthenticationResult | null> {
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 (!Array.isArray(scopes) || scopes.length === 0) { if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error("scopes[] is required"); throw new Error("scopes[] is required");
} }
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const cached = await acquireTokenWithCache({ pca, scopes }); const cached = await acquireTokenWithCache(pca, scopes);
if (cached) return cached; if (cached) return cached;
return pca.acquireTokenByDeviceCode({ return pca.acquireTokenByDeviceCode({
@@ -390,15 +319,15 @@ export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDevic
}); });
} }
export async function login({ export async function login(
tenantId, tenantId: string | undefined,
clientId, clientId: string | undefined,
resourcesCsv, resourcesCsv?: string,
useDeviceCode = false, useDeviceCode = false,
noBrowser = false, noBrowser = false,
browser, browser?: string,
browserProfile, browserProfile?: string,
}: LoginOptions): Promise<{ ): Promise<{
accountUpn: string | null; accountUpn: string | null;
resources: Array<{ resource: string; expiresOn: string | null }>; resources: Array<{ resource: string; expiresOn: string | null }>;
flow: "device-code" | "interactive"; flow: "device-code" | "interactive";
@@ -406,27 +335,22 @@ export async function login({
}> { }> {
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");
validateBrowserOptions({ browser, browserProfile }); validateBrowserOptions(browser, browserProfile);
const resources = parseResources(resourcesCsv); const resources = parseResources(resourcesCsv);
const scopes = resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]); const scopes = resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]);
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const session = await readSessionState(); const session = await readSessionState();
const preferredAccount = await findAccountByUpn({ const preferredAccount = session.activeAccountUpn
pca, ? await findAccountByUpn(pca, session.activeAccountUpn)
upn: session.activeAccountUpn, : null;
});
const results: Array<{ resource: string; expiresOn: string | null }> = []; const results: Array<{ resource: string; expiresOn: string | null }> = [];
let selectedAccount: AccountInfo | null = preferredAccount; let selectedAccount: AccountInfo | null = preferredAccount;
for (let index = 0; index < resources.length; index += 1) { for (let index = 0; index < resources.length; index += 1) {
const resource = resources[index]; const resource = resources[index];
const scope = [scopes[index]]; const scope = [scopes[index]];
let token = await acquireTokenWithCache({ let token = await acquireTokenWithCache(pca, scope, selectedAccount);
pca,
scopes: scope,
account: selectedAccount,
});
if (!token) { if (!token) {
if (useDeviceCode) { if (useDeviceCode) {
@@ -444,7 +368,7 @@ export async function login({
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
return; return;
} }
const options = getBrowserOpenOptions({ browser, browserProfile }); const options = getBrowserOpenOptions(browser, browserProfile);
await open(url, options).catch(() => { await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
}); });
@@ -476,11 +400,11 @@ export async function login({
}; };
} }
export async function acquireResourceTokenFromLogin({ export async function acquireResourceToken(
tenantId, tenantId: string,
clientId, clientId: string,
resource, resource: string,
}: AcquireResourceTokenOptions): Promise<AuthenticationResult | null> { ): Promise<AuthenticationResult | null> {
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 (!resource) throw new Error("resource is required"); if (!resource) throw new Error("resource is required");
@@ -496,11 +420,8 @@ export async function acquireResourceTokenFromLogin({
throw new Error(LOGIN_REQUIRED_MESSAGE); throw new Error(LOGIN_REQUIRED_MESSAGE);
} }
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const account = await findAccountByUpn({ const account = await findAccountByUpn(pca, session.activeAccountUpn);
pca,
upn: session.activeAccountUpn,
});
if (!account) { if (!account) {
throw new Error(LOGIN_REQUIRED_MESSAGE); throw new Error(LOGIN_REQUIRED_MESSAGE);
} }
@@ -515,16 +436,16 @@ export async function acquireResourceTokenFromLogin({
} }
} }
export async function logout({ export async function logout(
tenantId, tenantId: string,
clientId, clientId: string,
clearAll = false, clearAll = false,
userPrincipalName, userPrincipalName?: string,
}: LogoutOptions): Promise<{ clearedAll: boolean; signedOut: string[] }> { ): Promise<{ clearedAll: boolean; signedOut: string[] }> {
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");
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const tokenCache = pca.getTokenCache(); const tokenCache = pca.getTokenCache();
const accounts = await tokenCache.getAllAccounts(); const accounts = await tokenCache.getAllAccounts();
const session = await readSessionState(); const session = await readSessionState();
@@ -540,9 +461,10 @@ export async function logout({
}; };
} }
const targetUpn = normalizeUpn(userPrincipalName) || normalizeUpn(session.activeAccountUpn); const targetUpn = (typeof userPrincipalName === "string" ? userPrincipalName.trim().toLowerCase() : "")
|| (typeof session.activeAccountUpn === "string" ? session.activeAccountUpn.trim().toLowerCase() : "");
const accountToSignOut = accounts.find( const accountToSignOut = accounts.find(
(account) => normalizeUpn(account.username) === targetUpn, (account) => account.username.trim().toLowerCase() === targetUpn,
); );
if (!accountToSignOut) { if (!accountToSignOut) {

1
src/cli/commands/auth.ts Normal file
View File

@@ -0,0 +1 @@
// SPDX-License-Identifier: MIT

View File

@@ -1,8 +1,8 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { acquireResourceTokenFromLogin } from "../../azure/index.ts"; import { acquireResourceToken } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts"; import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadPublicConfig } from "../../index.ts"; import { loadConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts"; import type { CommandValues } from "./types.ts";
@@ -19,20 +19,14 @@ export async function runGetTokenCommand(values: CommandValues): Promise<unknown
throw new Error("--type is required for get-token (allowed: azurerm, devops)"); throw new Error("--type is required for get-token (allowed: azurerm, devops)");
} }
const config = await loadPublicConfig(); const config = await loadConfig("public-config");
if (!config.tenantId) {
throw new Error("tenantId is required");
}
if (!config.clientId) {
throw new Error("clientId is required");
}
if (tokenType === "azurerm") { if (tokenType === "azurerm") {
const result = await acquireResourceTokenFromLogin({ const result = await acquireResourceToken(
tenantId: config.tenantId, config.tenantId,
clientId: config.clientId, config.clientId,
resource: "arm", "arm",
}); );
const accessToken = result?.accessToken; const accessToken = result?.accessToken;
if (!accessToken) { if (!accessToken) {

View File

@@ -16,10 +16,7 @@ Options:
export async function runListAppsCommand(values: CommandValues): Promise<unknown> { export async function runListAppsCommand(values: CommandValues): Promise<unknown> {
const { client } = await getGraphClientFromPublicConfig(); const { client } = await getGraphClientFromPublicConfig();
let result = await listApps(client, { let result = await listApps(client, values["display-name"], values["app-id"]);
displayName: values["display-name"],
appId: values["app-id"],
});
if (values["app-id"] && result.length > 1) { if (values["app-id"] && result.length > 1) {
throw new Error(`Expected a single app for --app-id ${values["app-id"]}, but got ${result.length}`); throw new Error(`Expected a single app for --app-id ${values["app-id"]}, but got ${result.length}`);
} }

View File

@@ -23,10 +23,11 @@ export async function runListResourcePermissionsCommand(values: CommandValues):
} }
const { client } = await getGraphClientFromPublicConfig(); const { client } = await getGraphClientFromPublicConfig();
let result = await listResourcePermissions(client, { let result = await listResourcePermissions(
appId: values["app-id"], client,
displayName: values["display-name"], values["app-id"],
}); values["display-name"],
);
if (values.filter) { if (values.filter) {
result = filterByPermissionName(result, values.filter); result = filterByPermissionName(result, values.filter);
} }

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { login } from "../../azure/index.ts"; import { login } from "../../azure/index.ts";
import { loadPublicConfig } from "../../index.ts"; import { loadConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts"; import type { CommandValues } from "./types.ts";
@@ -17,14 +17,14 @@ Options:
} }
export async function runLoginCommand(values: CommandValues): Promise<unknown> { export async function runLoginCommand(values: CommandValues): Promise<unknown> {
const config = await loadPublicConfig(); const config = await loadConfig("public-config");
return login({ return login(
tenantId: config.tenantId, config.tenantId,
clientId: config.clientId, config.clientId,
resourcesCsv: values.resources, values.resources,
useDeviceCode: Boolean(values["use-device-code"]), Boolean(values["use-device-code"]),
noBrowser: Boolean(values["no-browser"]), Boolean(values["no-browser"]),
browser: values.browser, values.browser,
browserProfile: values["browser-profile"], values["browser-profile"],
}); );
} }

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { logout } from "../../azure/index.ts"; import { logout } from "../../azure/index.ts";
import { loadPublicConfig } from "../../index.ts"; import { loadConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts"; import type { CommandValues } from "./types.ts";
@@ -13,10 +13,6 @@ Options:
} }
export async function runLogoutCommand(values: CommandValues): Promise<unknown> { export async function runLogoutCommand(values: CommandValues): Promise<unknown> {
const config = await loadPublicConfig(); const config = await loadConfig("public-config");
return logout({ return logout(config.tenantId, config.clientId, Boolean(values.all));
tenantId: config.tenantId,
clientId: config.clientId,
clearAll: Boolean(values.all),
});
} }

View File

@@ -1,8 +1,8 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { acquireResourceTokenFromLogin } from "../../azure/index.ts"; import { acquireResourceToken } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts"; import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadPublicConfig } from "../../index.ts"; import { loadConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts"; import type { CommandValues } from "./types.ts";
@@ -54,20 +54,14 @@ async function getAutoAuthorizationHeader(url: URL): Promise<string | null> {
return null; return null;
} }
const config = await loadPublicConfig(); const config = await loadConfig("public-config");
if (!config.tenantId) {
throw new Error("tenantId is required");
}
if (!config.clientId) {
throw new Error("clientId is required");
}
if (host === "management.azure.com") { if (host === "management.azure.com") {
const result = await acquireResourceTokenFromLogin({ const result = await acquireResourceToken(
tenantId: config.tenantId, config.tenantId,
clientId: config.clientId, config.clientId,
resource: "arm", "arm",
}); );
const accessToken = result?.accessToken; const accessToken = result?.accessToken;
if (!accessToken) { if (!accessToken) {
throw new Error("Failed to obtain AzureRM token"); throw new Error("Failed to obtain AzureRM token");

View File

@@ -2,7 +2,7 @@
import { minimatch } from "minimatch"; import { minimatch } from "minimatch";
import { loadPublicConfig } from "../../index.ts"; import { loadConfig } from "../../index.ts";
import { getGraphClient } from "../../graph/auth.ts"; import { getGraphClient } from "../../graph/auth.ts";
type PermissionRow = { type PermissionRow = {
@@ -28,9 +28,6 @@ export function filterByDisplayName<T extends DisplayNameRow>(rows: T[], pattern
} }
export async function getGraphClientFromPublicConfig(): Promise<{ client: any }> { export async function getGraphClientFromPublicConfig(): Promise<{ client: any }> {
const config = await loadPublicConfig(); const config = await loadConfig("public-config");
return getGraphClient({ return getGraphClient(config.tenantId, config.clientId);
tenantId: config.tenantId,
clientId: config.clientId,
});
} }

View File

@@ -8,21 +8,16 @@ import readline from "node:readline";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
type RunAzOptions = {
quiet?: boolean;
allowFailure?: boolean;
};
type RunAzResult = { type RunAzResult = {
status: number; status: number;
stdout: string; stdout: string;
stderr: string; stderr: string;
}; };
function runAz(args: string[], options: RunAzOptions = {}): RunAzResult { function runAz(args: string[], quiet = false, allowFailure = false): RunAzResult {
const result = spawnSync("az", args, { const result = spawnSync("az", args, {
encoding: "utf8", encoding: "utf8",
stdio: options.quiet stdio: quiet
? ["ignore", "ignore", "ignore"] ? ["ignore", "ignore", "ignore"]
: ["ignore", "pipe", "pipe"], : ["ignore", "pipe", "pipe"],
}); });
@@ -31,7 +26,7 @@ function runAz(args: string[], options: RunAzOptions = {}): RunAzResult {
throw result.error; throw result.error;
} }
if (result.status !== 0 && options.allowFailure !== true) { if (result.status !== 0 && allowFailure !== true) {
throw new Error( throw new Error(
(result.stderr || "").trim() || `az ${args.join(" ")} failed`, (result.stderr || "").trim() || `az ${args.join(" ")} failed`,
); );
@@ -198,7 +193,7 @@ Options:
"--enable-id-token-issuance", "--enable-id-token-issuance",
"true", "true",
], ],
{ quiet: true }, true,
); );
} catch { } catch {
console.error( console.error(
@@ -210,14 +205,12 @@ Options:
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
} }
runAz(["ad", "sp", "create", "--id", appId], { runAz(["ad", "sp", "create", "--id", appId], true, true);
quiet: true,
allowFailure: true,
});
const adminConsentResult = runAz( const adminConsentResult = runAz(
["ad", "app", "permission", "admin-consent", "--id", appId], ["ad", "app", "permission", "admin-consent", "--id", appId],
{ quiet: true, allowFailure: true }, true,
true,
); );
if (adminConsentResult.status !== 0) { if (adminConsentResult.status !== 0) {
console.warn( console.warn(

View File

@@ -14,11 +14,11 @@ type LoginInteractiveResult = {
}; };
export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise<string> { export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise<string> {
const result = await loginInteractive({ const result = await loginInteractive(
tenantId, tenantId,
clientId, clientId,
scopes: AZURE_DEVOPS_SCOPES, AZURE_DEVOPS_SCOPES,
}) as LoginInteractiveResult; ) as LoginInteractiveResult;
const accessToken = result?.accessToken; const accessToken = result?.accessToken;

View File

@@ -6,11 +6,6 @@ type GraphResult<T = GraphObject> = {
value?: T[]; value?: T[];
}; };
type AppQueryOptions = {
displayName?: string;
appId?: string;
};
type RequiredResourceAccessItem = { type RequiredResourceAccessItem = {
type?: string; type?: string;
id?: string; id?: string;
@@ -38,11 +33,6 @@ type ServicePrincipal = {
appRoles?: GraphPermission[]; appRoles?: GraphPermission[];
}; };
type ResourcePermissionsOptions = {
appId?: string;
displayName?: string;
};
export async function getApp(client: any, displayName: string): Promise<GraphObject | null> { export async function getApp(client: any, displayName: string): Promise<GraphObject | null> {
const result = await client const result = await client
.api("/applications") .api("/applications")
@@ -68,8 +58,11 @@ export async function deleteApp(client: any, appObjectId: string): Promise<void>
await client.api(`/applications/${appObjectId}`).delete(); await client.api(`/applications/${appObjectId}`).delete();
} }
export async function listApps(client: any, options: AppQueryOptions = {}): Promise<GraphObject[]> { export async function listApps(
const { displayName, appId } = options; client: any,
displayName?: string,
appId?: string,
): Promise<GraphObject[]> {
let request = client.api("/applications"); let request = client.api("/applications");
const filters: string[] = []; const filters: string[] = [];
@@ -219,8 +212,11 @@ export async function listAppGrants(client: any, appId: string): Promise<GraphOb
return Array.isArray(grantsResult?.value) ? grantsResult.value : []; return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
} }
export async function listResourcePermissions(client: any, options: ResourcePermissionsOptions = {}): Promise<Array<Record<string, unknown>>> { export async function listResourcePermissions(
const { appId, displayName } = options; client: any,
appId?: string,
displayName?: string,
): Promise<Array<Record<string, unknown>>> {
if (!appId && !displayName) { if (!appId && !displayName) {
throw new Error("appId or displayName is required"); throw new Error("appId or displayName is required");
} }

View File

@@ -1,24 +1,18 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { Client } from "@microsoft/microsoft-graph-client"; import { Client } from "@microsoft/microsoft-graph-client";
import { acquireResourceTokenFromLogin } from "../azure/index.ts"; import { acquireResourceToken } from "../azure/index.ts";
type GraphClientOptions = {
tenantId?: string;
clientId?: string;
};
type GraphApiToken = { type GraphApiToken = {
accessToken: string; accessToken: string;
[key: string]: unknown; [key: string]: unknown;
}; };
export async function getGraphClient({ tenantId, clientId }: GraphClientOptions): Promise<{ graphApiToken: GraphApiToken; client: any }> { export async function getGraphClient(
const graphApiToken = await acquireResourceTokenFromLogin({ tenantId: string,
tenantId, clientId: string,
clientId, ): Promise<{ graphApiToken: GraphApiToken; client: any }> {
resource: "graph", const graphApiToken = await acquireResourceToken(tenantId, clientId, "graph") as GraphApiToken;
}) as GraphApiToken;
const client = Client.init({ const client = Client.init({
authProvider: (done) => { authProvider: (done) => {

View File

@@ -1,58 +1,36 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { readFile } from "node:fs/promises"; import { validate as validateUuid } from "uuid";
import os from "node:os"; import { getConfig } from "@slawek/sk-tools";
import path from "node:path";
type Config = { type Config = {
tenantId?: string; tenantId: string;
clientId?: string; clientId: string;
}; };
type ConfigCandidate = { export async function loadConfig(configName: string): Promise<Config> {
tenantId?: unknown; if (typeof configName !== "string" || configName.trim() === "") {
clientId?: unknown;
};
export function getUserConfigDir(): string {
if (process.platform === "win32") {
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
}
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
}
async function loadConfig(configFileName: string): Promise<Config> {
if (typeof configFileName !== "string" || configFileName.trim() === "") {
throw new Error( throw new Error(
'Invalid config file name. Expected a non-empty string like "public-config.json" or "confidential-config.json".', 'Invalid config name. Expected a non-empty string like "public-config" or "confidential-config".',
); );
} }
const envConfig: Config = { const envConfig = {
tenantId: process.env.AZURE_TENANT_ID, tenantId: process.env.AZURE_TENANT_ID,
clientId: process.env.AZURE_CLIENT_ID, clientId: process.env.AZURE_CLIENT_ID,
}; };
const configPath = path.join(getUserConfigDir(), "sk-az-tools", configFileName); const json = (await getConfig("sk-az-tools", configName)) as Record<string, unknown>;
return readFile(configPath, "utf8")
.then((configJson) => JSON.parse(configJson) as ConfigCandidate) const tenantId = (typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId) ?? "";
.catch((err: unknown) => { const clientId = (typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId) ?? "";
if ((err as { code?: string } | null)?.code === "ENOENT") {
return {} as ConfigCandidate; if (!validateUuid(tenantId ?? "") || !validateUuid(clientId ?? "")) {
} throw new Error("tenantId and clientId must be valid GUIDs.");
throw err;
})
.then((json) => ({
tenantId: typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId,
clientId: typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId,
}));
} }
export function loadPublicConfig(): Promise<Config> { return {
return loadConfig("public-config.json"); tenantId,
} clientId,
};
export function loadConfidentialConfig(): Promise<Config> {
return loadConfig("confidential-config.json");
} }