Compare commits

...

6 Commits

16 changed files with 283 additions and 160 deletions

View File

@@ -26,14 +26,14 @@ Note: `rest --header` is a command-specific HTTP header option and is unrelated
**Command name:** `login` **Command name:** `login`
**Usage:** `sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]` **Usage:** `sk-az-tools login [resource...] [--use-device-code] [--no-browser] [--browser-name <name>] [--browser-profile <profile>] [global options]`
**Options:** **Options:**
- `--resources` <csv> - Comma-separated resources to authenticate. Allowed values: `graph`, `devops`, `arm`. Default is all three. - `[resource...]` - One or more resources to authenticate. Allowed values: `graph`, `devops`, `azurerm`. Default is `azurerm`.
- `--use-device-code` - Use device code flow instead of browser-based interactive flow. - `--use-device-code` - Use device code flow instead of browser-based interactive flow.
- `--no-browser` - Do not launch browser automatically. Print the sign-in URL to stderr. - `--no-browser` - Do not launch browser automatically. Print the sign-in URL to stderr.
- `--browser` <name> - Browser keyword used for interactive sign-in. Allowed values: `brave`, `browser`, `browserPrivate`, `chrome`, `edge`, `firefox`. - `--browser-name` <name> - Browser keyword used for interactive sign-in. Allowed values: `brave`, `browser`, `browserPrivate`, `chrome`, `edge`, `firefox`.
- `--browser-profile` <name> - Chromium profile name (for example: `Default`, `Profile 1`). - `--browser-profile` <name> - Chromium profile name (for example: `Default`, `Profile 1`).
**Description:** The `login` command authenticates user sign-in for selected resource audiences and caches tokens for subsequent commands. **Description:** The `login` command authenticates user sign-in for selected resource audiences and caches tokens for subsequent commands.

13
package-lock.json generated
View File

@@ -1,19 +1,19 @@
{ {
"name": "@slawek/sk-az-tools", "name": "@slawek/sk-az-tools",
"version": "0.7.3", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@slawek/sk-az-tools", "name": "@slawek/sk-az-tools",
"version": "0.7.3", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@azure/identity": "^4.13.0", "@azure/identity": "^4.13.0",
"@azure/msal-node": "^5.0.3", "@azure/msal-node": "^5.0.3",
"@azure/msal-node-extensions": "^1.2.0", "@azure/msal-node-extensions": "^1.2.0",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
"@slawek/sk-tools": ">=0.2.0", "@slawek/sk-tools": "^0.4.1",
"azure-devops-node-api": "^15.1.2", "azure-devops-node-api": "^15.1.2",
"commander": "^14.0.3", "commander": "^14.0.3",
"minimatch": "^10.1.2", "minimatch": "^10.1.2",
@@ -291,11 +291,12 @@
} }
}, },
"node_modules/@slawek/sk-tools": { "node_modules/@slawek/sk-tools": {
"version": "0.3.0", "version": "0.4.1",
"resolved": "https://gitea.koszewscy.waw.pl/api/packages/slawek/npm/%40slawek%2Fsk-tools/-/0.3.0/sk-tools-0.3.0.tgz", "resolved": "https://gitea.koszewscy.waw.pl/api/packages/slawek/npm/%40slawek%2Fsk-tools/-/0.4.1/sk-tools-0.4.1.tgz",
"integrity": "sha512-DqcpCwsH0noRNxq9lIwOLn9pmu6LNB6NSmXBe3r0CIMP+NW88iYwgvDeALWxLZbb1pN6rHc1R/ea+fjVW0Bkgw==", "integrity": "sha512-rTw/m6ZK72HGELcCC+ze1sNcqt4LM5dBUdJ3c5UsOT95qTZAGauVBKsslpVd4Kotf24vNlGFQ1fpVDcT5sluwQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"commander": "^14.0.3",
"d3-dsv": "^3.0.1", "d3-dsv": "^3.0.1",
"jmespath": "^0.16.0", "jmespath": "^0.16.0",
"semver": "^7.7.4", "semver": "^7.7.4",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@slawek/sk-az-tools", "name": "@slawek/sk-az-tools",
"version": "0.7.3", "version": "0.8.0",
"type": "module", "type": "module",
"files": [ "files": [
"dist", "dist",
@@ -24,7 +24,7 @@
"@azure/msal-node": "^5.0.3", "@azure/msal-node": "^5.0.3",
"@azure/msal-node-extensions": "^1.2.0", "@azure/msal-node-extensions": "^1.2.0",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
"@slawek/sk-tools": ">=0.2.0", "@slawek/sk-tools": "^0.4.1",
"azure-devops-node-api": "^15.1.2", "azure-devops-node-api": "^15.1.2",
"commander": "^14.0.3", "commander": "^14.0.3",
"minimatch": "^10.1.2", "minimatch": "^10.1.2",

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import { inc } from "semver";
const skAzToolsPackagePath = resolve("package.json");
const skAzToolsPackageLockPath = resolve("package-lock.json");
const skToolsPackagePath = resolve("../sk-tools", "package.json");
const skAzToolsPackage = JSON.parse(readFileSync(skAzToolsPackagePath, "utf-8"));
const skAzToolsPackageLock = JSON.parse(readFileSync(skAzToolsPackageLockPath, "utf-8"));
const skToolsPackage = JSON.parse(readFileSync(skToolsPackagePath, "utf-8"));
const { values } = parseArgs({
options: {
update: { type: "boolean", short: "u", description: "Update @slawek/sk-tools to the latest version." },
bump: { type: "string", short: "b", description: "Bump the version of @slawek/sk-az-tools in package.json. Allowed values: major, minor, patch." }
}
});
if (!["major", "minor", "patch"].includes(values.bump)) {
console.error(`Invalid bump type: ${values.bump}. Allowed values are: major, minor, patch.`);
process.exit(1);
}
// Package versions
console.log(`SK Tools version: ${skToolsPackage.version}`);
console.log(`SK Azure Tools version: ${skAzToolsPackage.version}\n`);
if (values.bump) {
const newVersion = inc(skAzToolsPackage.version, values.bump);
if (!newVersion) {
console.error(`Failed to bump version: ${skAzToolsPackage.version}`);
process.exit(1);
}
skAzToolsPackage.version = newVersion;
writeFileSync(skAzToolsPackagePath, JSON.stringify(skAzToolsPackage, null, 4));
console.log(`Bumped SK Azure Tools version to: ${newVersion}`);
}
console.log(`SK Azure Tools Locked version: ${skAzToolsPackageLock.version}`);
// Update package.json if --update flag is set
// or if the version of @slawek/sk-az-tools in package.json
// is different than the version in package-lock.json.
if (values.update || skAzToolsPackage.version !== skToolsPackage.version) {
console.log(`Updating package.json...`);
skAzToolsPackage.dependencies["@slawek/sk-tools"] = `>=${skToolsPackage.version}`;
writeFileSync(skAzToolsPackagePath, JSON.stringify(skAzToolsPackage, null, 4));
// Install and link the updated package
spawnSync("npm", ["install", "@slawek/sk-tools"], { stdio: "inherit" });
spawnSync("npm", ["link", "@slawek/sk-tools"], { stdio: "inherit" });
// Show the updated dependency tree
spawnSync("npm", ["ls"], { stdio: "inherit" });
} else {
console.log(`\nSK Tools version requested: ${skAzToolsPackage.dependencies["@slawek/sk-tools"] ?? "not found"}`);
}

0
scripts/make-mermaid-func-deps.mjs Normal file → Executable file
View File

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Hardcode variables.
SUBSCRIPTION_ID="c885a276-c882-483f-b216-42f73715161d"
ACCESS_TOKEN=$(sk-az-tools get-token graph)
# List Azure resource groups via Azure Resource Manager API
echo "Azure Resource Groups in subscription '$SUBSCRIPTION_ID':"
curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourcegroups?api-version=2021-04-01" |
jq '.value[] | {id, name, location}'
echo "---"
# Get current user ('me') via Microsoft Graph
echo "Current User (me):"
curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://graph.microsoft.com/v1.0/me" |
jq '{id, displayName, userPrincipalName}'
echo "---"
# List Azure DevOps projects in the given org
echo "Azure DevOps Projects in org 'skoszewski':"
curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://dev.azure.com/skoszewski/_apis/projects?api-version=7.1" |
jq '.value[] | {id, name, state}'

View File

@@ -8,7 +8,10 @@
import { getTokenUsingMsal } from "./pca-auth.ts"; import { getTokenUsingMsal } from "./pca-auth.ts";
import { getTokenUsingAzureIdentity } from "./client-auth.ts"; import { getTokenUsingAzureIdentity } from "./client-auth.ts";
import { loadConfig } from "../index.ts"; import { loadAuthConfig, loadConfig } from "../index.ts";
import { SkAzureCredential } from "./sk-credential.ts";
import { DefaultAzureCredential } from "@azure/identity";
import type { TokenCredential } from "@azure/core-auth";
// Reexporting functions and types from submodules // Reexporting functions and types from submodules
export { export {
@@ -24,21 +27,42 @@ export { getCredential } from "./client-auth.ts";
export const RESOURCE_SCOPE_BY_NAME = { export const RESOURCE_SCOPE_BY_NAME = {
graph: "https://graph.microsoft.com/.default", graph: "https://graph.microsoft.com/.default",
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default", devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
arm: "https://management.azure.com/.default", azurerm: "https://management.azure.com/.default",
openai: "https://cognitiveservices.azure.com/.default", openai: "https://cognitiveservices.azure.com/.default",
} as const; } as const;
export type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME; export type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
export const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"]; export const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "azurerm"];
// A helper function to translate short resource names to their corresponding scopes // A helper function to translate short resource names to their corresponding scopes
export function translateResourceNamesToScopes(resourceNames: string[]): string[] { export function translateResourceNamesToScopes(resourceNames: string[]): string[] {
return resourceNames.map((name) => RESOURCE_SCOPE_BY_NAME[name as ResourceName]); return resourceNames.map((name) => RESOURCE_SCOPE_BY_NAME[name as ResourceName]);
} }
export function supportedResourceNames(): ResourceName[] {
return Object.keys(RESOURCE_SCOPE_BY_NAME) as ResourceName[];
}
// Generic utility functions // Generic utility functions
export type AuthMode = "azure-identity" | "msal"; export type AuthMode = "azure-identity" | "msal";
export async function getTokenCredential(
tenantId?: string,
clientId?: string,
): Promise<TokenCredential> {
const config = await loadConfig();
if (config.authMode === "azure-identity") {
return new DefaultAzureCredential();
}
const authConfig = await loadAuthConfig("public-config");
return new SkAzureCredential(
tenantId || authConfig.tenantId,
clientId || authConfig.clientId,
);
}
export async function getAccessToken( export async function getAccessToken(
tenantId: string, tenantId: string,
clientId: string, clientId: string,
@@ -55,24 +79,3 @@ export async function getAccessToken(
return getTokenUsingAzureIdentity(tenantId, clientId, resources); return getTokenUsingAzureIdentity(tenantId, clientId, resources);
} }
} }
// export function getAzureIdentityGraphAuthProvider(
// tenantId: string,
// clientId: string,
// ) {
// const credential = new DefaultAzureCredential({
// tenantId,
// managedIdentityClientId: clientId,
// });
// const getBearerToken = getBearerTokenProvider(
// credential,
// "https://graph.microsoft.com/.default",
// );
// return (done: (error: Error | null, accessToken: string | null) => void) => {
// void getBearerToken()
// .then((token) => done(null, token))
// .catch((err) => done(err as Error, null));
// };
// }

View File

@@ -22,14 +22,14 @@ 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"; const SESSION_STATE_NAME = "session-state";
type SessionState = { type SessionState = {
activeAccountUpn: string | null; activeAccountUpn: string | null;
}; };
async function readSessionState(): Promise<SessionState> { async function readSessionState(): Promise<SessionState> {
const parsed = (await getConfig("sk-az-tools", CONFIG_FILE_NAME)) as { activeAccountUpn?: unknown }; const parsed = (await getConfig("sk-az-tools", SESSION_STATE_NAME)) as { activeAccountUpn?: unknown };
return { return {
activeAccountUpn: activeAccountUpn:
typeof parsed?.activeAccountUpn === "string" typeof parsed?.activeAccountUpn === "string"
@@ -39,14 +39,14 @@ async function readSessionState(): Promise<SessionState> {
} }
async function writeSessionState(state: SessionState): Promise<void> { async function writeSessionState(state: SessionState): Promise<void> {
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`); const sessionPath = path.join(getConfigDir("sk-az-tools"), `${SESSION_STATE_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 {
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`); const sessionPath = path.join(getConfigDir("sk-az-tools"), `${SESSION_STATE_NAME}.json`);
await unlink(sessionPath); await unlink(sessionPath);
} catch (err) { } catch (err) {
if ((err as { code?: string } | null)?.code !== "ENOENT") { if ((err as { code?: string } | null)?.code !== "ENOENT") {
@@ -104,19 +104,19 @@ function getBrowserOpenOptions(browser?: string, browserProfile?: string): Param
const browserKeyword = getBrowserKeyword(browser); const browserKeyword = getBrowserKeyword(browser);
if (!CHROMIUM_BROWSERS.has(browserKeyword)) { if (!CHROMIUM_BROWSERS.has(browserKeyword)) {
throw new Error( throw new Error(
"--browser-profile is supported only with --browser edge|chrome|brave", "--browser-profile is supported only with --browser-name edge|chrome|brave",
); );
} }
if (!browserName) { if (!browserName) {
throw new Error("--browser-profile requires --browser"); throw new Error("--browser-profile requires --browser-name");
} }
return { return {
wait: false, wait: false,
app: { app: {
name: browserName, name: browserName,
arguments: [`--profile-directory=${browserProfile.trim()}`], arguments: [`--profile-directory=${browserProfile.trim()}`],
}, },
}; };
} }
@@ -130,19 +130,18 @@ function validateBrowserOptions(browser?: string, browserProfile?: string): void
const browserKeyword = getBrowserKeyword(browser); const browserKeyword = getBrowserKeyword(browser);
if (!CHROMIUM_BROWSERS.has(browserKeyword)) { if (!CHROMIUM_BROWSERS.has(browserKeyword)) {
throw new Error( throw new Error(
"--browser-profile is supported only with --browser edge|chrome|brave", "--browser-profile is supported only with --browser-name edge|chrome|brave",
); );
} }
} }
} }
export function parseResources(resourcesCsv?: string): ResourceName[] { export function parseResources(resourcesInput?: string[]): ResourceName[] {
if (!resourcesCsv || resourcesCsv.trim() === "") { if (!resourcesInput || resourcesInput.length === 0) {
return [...DEFAULT_RESOURCES]; return [...DEFAULT_RESOURCES];
} }
const resources = resourcesCsv const resources = resourcesInput
.split(",")
.map((item) => item.trim().toLowerCase()) .map((item) => item.trim().toLowerCase())
.filter(Boolean); .filter(Boolean);
@@ -317,7 +316,7 @@ export async function loginDeviceCode(
export async function login( export async function login(
tenantId: string, tenantId: string,
clientId: string, clientId: string,
resourcesCsv?: string, resourcesInput?: string[],
useDeviceCode = false, useDeviceCode = false,
noBrowser = false, noBrowser = false,
browser?: string, browser?: string,
@@ -332,8 +331,8 @@ export async function login(
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(resourcesInput);
const scopes = translateResourceNamesToScopes(resources); const scopes = translateResourceNamesToScopes(resources) as string[];
const pca = await createPca(tenantId, clientId); const pca = await createPca(tenantId, clientId);
const session = await readSessionState(); const session = await readSessionState();
const preferredAccount = session.activeAccountUpn const preferredAccount = session.activeAccountUpn
@@ -344,6 +343,10 @@ export async function login(
let selectedAccount: AccountInfo | null = preferredAccount; let selectedAccount: AccountInfo | null = preferredAccount;
let token = await acquireTokenWithCache(pca, scopes, selectedAccount); let token = await acquireTokenWithCache(pca, scopes, selectedAccount);
if (token?.account) {
selectedAccount = token.account;
}
if (!token) { if (!token) {
if (useDeviceCode) { if (useDeviceCode) {
token = await pca.acquireTokenByDeviceCode({ token = await pca.acquireTokenByDeviceCode({
@@ -378,6 +381,11 @@ export async function login(
}); });
} }
if (!selectedAccount) {
const accounts = await pca.getTokenCache().getAllAccounts();
selectedAccount = accounts[0] ?? null;
}
const activeAccountUpn = selectedAccount?.username ?? null; const activeAccountUpn = selectedAccount?.username ?? null;
if (activeAccountUpn) { if (activeAccountUpn) {
await writeSessionState({ activeAccountUpn }); await writeSessionState({ activeAccountUpn });

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { Command, Option } from "commander"; import { Argument, Command, Option } from "commander";
import { renderCliOutput } from "@slawek/sk-tools"; import { renderCliOutput } from "@slawek/sk-tools";
import { supportedResourceNames, ResourceName } from "./azure/index.ts";
// Commands // Commands
import { runGetTokenCommand } from "./cli/commands/get-token.ts"; import { runGetTokenCommand } from "./cli/commands/get-token.ts";
@@ -13,6 +14,7 @@ import { runListResourcePermissionsCommand } from "./cli/commands/list-resource-
import { runLoginCommand } from "./cli/commands/login.ts"; import { runLoginCommand } from "./cli/commands/login.ts";
import { runLogoutCommand } from "./cli/commands/logout.ts"; import { runLogoutCommand } from "./cli/commands/logout.ts";
import { runRestCommand } from "./cli/commands/rest.ts"; import { runRestCommand } from "./cli/commands/rest.ts";
import { runTestCommand } from "./cli/commands/test-command.ts";
import pkg from "../package.json" with { type: "json" }; import pkg from "../package.json" with { type: "json" };
const { version: packageVersion } = pkg; const { version: packageVersion } = pkg;
@@ -33,33 +35,28 @@ async function main(): Promise<void> {
skAzTools skAzTools
.command("login") .command("login")
.description("Authenticate selected resources") .description("Authenticate selected resources")
.option("--resources <csv>", "Comma-separated resources: graph,devops,arm") .addArgument(
new Argument("[resource...]", "Resources: graph|devops|azurerm")
.choices(supportedResourceNames())
.default(["azurerm"]),
)
.option("--use-device-code", "Use device code flow") .option("--use-device-code", "Use device code flow")
.option("--no-browser", "Do not launch browser") .option("--no-browser", "Do not launch browser")
.option("--browser <name>", "Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox") .option("--browser-name <name>", "Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox")
.option("--browser-profile <name>", "Chromium profile name") .option("--browser-profile <name>", "Chromium profile name")
.action(async (options) => { .action(runLoginCommand);
const output = await runLoginCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns);
});
skAzTools skAzTools
.command("logout") .command("logout")
.description("Sign out and clear login state") .description("Sign out and clear login state")
.option("--all", "Clear login state and remove all cached accounts") .option("--all", "Clear login state and remove all cached accounts")
.action(async (options) => { .action(runLogoutCommand);
const output = await runLogoutCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns);
});
skAzTools skAzTools
.command("get-token") .command("get-token")
.description("Get access token (azurerm|devops)") .description("Get an access token for a resource or resources.")
.addOption(new Option("-t, --type <value>", "Token type").choices(["azurerm", "devops"])) .addArgument(new Argument("<type>", "Token type.").choices(supportedResourceNames()))
.action(async (options) => { .action(runGetTokenCommand);
const output = await runGetTokenCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns);
});
skAzTools skAzTools
.command("rest") .command("rest")
@@ -71,10 +68,14 @@ async function main(): Promise<void> {
.addHelpText("after", ` .addHelpText("after", `
Authorization is added automatically for: Authorization is added automatically for:
management.azure.com Uses azurerm token management.azure.com Uses azurerm token
dev.azure.com Uses devops token`) dev.azure.com Uses devops token
.action(async (url, options) => { graph.microsoft.com Uses graph token
cognitiveservices.azure.com Uses openai token
*.openai.azure.com Uses openai token`)
.action(async (url, options, command) => {
const output = await runRestCommand(url, options); const output = await runRestCommand(url, options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
}); });
skAzTools skAzTools
@@ -83,9 +84,10 @@ Authorization is added automatically for:
.option("-n, --display-name <name>", "Get app by display name") .option("-n, --display-name <name>", "Get app by display name")
.option("-i, --app-id <id>", "Get app by id") .option("-i, --app-id <id>", "Get app by id")
.option("-f, --filter <pattern>", "Filter display name glob") .option("-f, --filter <pattern>", "Filter display name glob")
.action(async (options) => { .action(async (options, command) => {
const output = await runListAppsCommand(options); const output = await runListAppsCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
}); });
skAzTools skAzTools
@@ -95,18 +97,20 @@ Authorization is added automatically for:
.option("-r, --resolve", "Resolve permission GUIDs to human-readable values") .option("-r, --resolve", "Resolve permission GUIDs to human-readable values")
.option("-s, --short", "Makes output more compact") .option("-s, --short", "Makes output more compact")
.option("-f, --filter <glob>", "Filter by permission name glob") .option("-f, --filter <glob>", "Filter by permission name glob")
.action(async (options) => { .action(async (options, command) => {
const output = await runListAppPermissionsCommand(options); const output = await runListAppPermissionsCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
}); });
skAzTools skAzTools
.command("list-app-grants") .command("list-app-grants")
.description("List OAuth2 grants for an app") .description("List OAuth2 grants for an app")
.option("-i, --app-id <appId>", "Application (client) ID") .option("-i, --app-id <appId>", "Application (client) ID")
.action(async (options) => { .action(async (options, command) => {
const output = await runListAppGrantsCommand(options); const output = await runListAppGrantsCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
}); });
skAzTools skAzTools
@@ -115,10 +119,18 @@ Authorization is added automatically for:
.option("-i, --app-id <appId>", "Resource app ID") .option("-i, --app-id <appId>", "Resource app ID")
.option("-n, --display-name <name>", "Resource app display name") .option("-n, --display-name <name>", "Resource app display name")
.option("-f, --filter <glob>", "Filter by permission name glob") .option("-f, --filter <glob>", "Filter by permission name glob")
.action(async (options) => { .action(async (options, command) => {
const output = await runListResourcePermissionsCommand(options); const output = await runListResourcePermissionsCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
}); });
// Hidden test command for development purposes
skAzTools
.command("test", { hidden: true })
.description("Test command for development")
.action(runTestCommand);
await skAzTools.parseAsync(); await skAzTools.parseAsync();
} }

View File

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

View File

@@ -1,48 +1,21 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { getAccessToken } from "../../azure/index.ts"; import { RESOURCE_SCOPE_BY_NAME, ResourceName, supportedResourceNames, getTokenCredential } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadAuthConfig } from "../../index.ts";
type GetTokenOptions = {
type?: string;
};
export async function runGetTokenCommand( export async function runGetTokenCommand(
options: GetTokenOptions, type: ResourceName,
): Promise<unknown> { ): Promise<void> {
const tokenType = (options.type ?? "").toString().trim().toLowerCase(); if (!type || !supportedResourceNames().includes(type)) {
if (!tokenType) { throw new Error(`Token type is required for get-token (allowed: ${supportedResourceNames().join(", ")})`);
throw new Error(
"--type is required for get-token (allowed: azurerm, devops)",
);
} }
const config = await loadAuthConfig("public-config"); const credential = await getTokenCredential();
if (tokenType === "azurerm") { const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME[type]);
const accessToken = await getAccessToken(config.tenantId, config.clientId, ["arm"]); if (!accessToken) {
throw new Error("Failed to obtain access token.");
if (!accessToken) {
throw new Error("Failed to obtain AzureRM token");
}
return {
tokenType,
accessToken,
};
} }
if (tokenType === "devops") { // Output only the token string for easy consumption in scripts
const accessToken = await getDevOpsApiToken( console.log(accessToken.token);
config.tenantId,
config.clientId,
);
return {
tokenType,
accessToken,
};
}
throw new Error(`Invalid --type '${options.type}'. Allowed: azurerm, devops`);
} }

View File

@@ -1,25 +1,35 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { login } from "../../azure/index.ts"; import { login } from "../../azure/index.ts";
import type { ResourceName } from "../../azure/index.ts";
import { loadAuthConfig } from "../../index.ts"; import { loadAuthConfig } from "../../index.ts";
type LoginOptions = { type LoginOptions = {
resources?: string;
useDeviceCode?: boolean; useDeviceCode?: boolean;
noBrowser?: boolean; noBrowser?: boolean;
browser?: string; browserName?: string;
browserProfile?: string; browserProfile?: string;
}; };
export async function runLoginCommand(options: LoginOptions): Promise<unknown> { type LoginResult = {
accountUpn: string | null;
resources: Array<{ resource: string; expiresOn: string | null }>;
flow: "device-code" | "interactive";
browserLaunchAttempted: boolean;
};
export async function runLoginCommand(resources: ResourceName[], options: LoginOptions): Promise<void> {
const config = await loadAuthConfig("public-config"); const config = await loadAuthConfig("public-config");
return login(
const result = await login(
config.tenantId, config.tenantId,
config.clientId, config.clientId,
options.resources, resources,
Boolean(options.useDeviceCode), Boolean(options.useDeviceCode),
Boolean(options.noBrowser), Boolean(options.noBrowser),
options.browser, options.browserName,
options.browserProfile, options.browserProfile,
); ) as LoginResult;
console.log(`Logged in as ${result.accountUpn ?? "<unknown>"} using ${result.flow} flow for resources: ${resources.join(",")}`);
} }

View File

@@ -7,7 +7,28 @@ type LogoutOptions = {
all?: boolean; all?: boolean;
}; };
export async function runLogoutCommand(options: LogoutOptions): Promise<unknown> { type LogoutResult = {
clearedAll: boolean;
signedOut: string[];
};
export async function runLogoutCommand(options: LogoutOptions): Promise<void> {
const config = await loadAuthConfig("public-config"); const config = await loadAuthConfig("public-config");
return logout(config.tenantId, config.clientId, Boolean(options.all)); const result = await logout(config.tenantId, config.clientId, Boolean(options.all)) as LogoutResult;
if (result.signedOut.length === 0) {
console.log(
result.clearedAll
? "Cleared all cached accounts."
: "No active account to sign out.",
);
return;
}
if (result.clearedAll) {
console.log(`Cleared all cached accounts: ${result.signedOut.join(", ")}`);
return;
}
console.log(`Signed out: ${result.signedOut.join(", ")}`);
} }

View File

@@ -1,8 +1,6 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { getAccessToken } from "../../azure/index.ts"; import { RESOURCE_SCOPE_BY_NAME, ResourceName, getTokenCredential } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadAuthConfig } from "../../index.ts";
function parseHeaderLine( function parseHeaderLine(
header?: string, header?: string,
@@ -35,24 +33,39 @@ function hasAuthorizationHeader(headers: Headers): boolean {
return false; return false;
} }
function resolveResourceNameForHost(host: string): ResourceName | null {
switch (host) {
case "management.azure.com":
return "azurerm";
case "dev.azure.com":
return "devops";
case "graph.microsoft.com":
return "graph";
case "cognitiveservices.azure.com":
return "openai";
default:
if (host.endsWith(".openai.azure.com")) {
return "openai";
}
return null;
}
}
async function getAutoAuthorizationHeader(url: URL): Promise<string | null> { async function getAutoAuthorizationHeader(url: URL): Promise<string | null> {
const host = url.hostname.toLowerCase(); const host = url.hostname.toLowerCase();
if (host !== "management.azure.com" && host !== "dev.azure.com") { const resourceName = resolveResourceNameForHost(host);
if (!resourceName) {
return null; return null;
} }
const config = await loadAuthConfig("public-config"); const credential = await getTokenCredential();
if (host === "management.azure.com") { const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME[resourceName]);
const accessToken = await getAccessToken(config.tenantId, config.clientId, ["arm"]); if (!accessToken?.token) {
if (!accessToken) { throw new Error(`Failed to obtain ${resourceName} token`);
throw new Error("Failed to obtain AzureRM token");
}
return `Bearer ${accessToken}`;
} }
const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId); return `Bearer ${accessToken.token}`;
return `Bearer ${accessToken}`;
} }
type httpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; type httpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

View File

@@ -0,0 +1,6 @@
// SPDX-License-Identifier: MIT
// Hidden test command for development purposes
export async function runTestCommand(): Promise<void> {
console.log("Test command executed.");
}

View File

@@ -4,35 +4,23 @@
* A DevOps helpers module. * A DevOps helpers module.
*/ */
import { loginInteractive } from "../azure/index.ts"; import { RESOURCE_SCOPE_BY_NAME, getTokenCredential } from "../azure/index.ts";
import * as azdev from "azure-devops-node-api"; import * as azdev from "azure-devops-node-api";
const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"]; export type DevOpsClients = {
coreClient: Awaited<ReturnType<azdev.WebApi["getCoreApi"]>>;
type LoginInteractiveResult = { gitClient: Awaited<ReturnType<azdev.WebApi["getGitApi"]>>;
accessToken?: string;
}; };
export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise<string> { export async function getDevOpsClients(orgUrl: string, tenantId?: string, clientId?: string): Promise<DevOpsClients> {
const result = await loginInteractive( const credential = await getTokenCredential(tenantId, clientId);
tenantId,
clientId,
AZURE_DEVOPS_SCOPES,
) as LoginInteractiveResult;
const accessToken = result?.accessToken; const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME.devops);
if (!accessToken?.token) {
if (!accessToken) {
throw new Error("Failed to obtain Azure DevOps API token"); throw new Error("Failed to obtain Azure DevOps API token");
} }
return accessToken; const authHandler = azdev.getBearerHandler(accessToken.token);
}
export async function getDevOpsClients(orgUrl: string, tenantId: string, clientId: string): Promise<{ coreClient: unknown; gitClient: unknown }> {
const accessToken = await getDevOpsApiToken(tenantId, clientId);
const authHandler = azdev.getBearerHandler(accessToken);
const connection = new azdev.WebApi(orgUrl, authHandler); const connection = new azdev.WebApi(orgUrl, authHandler);
const coreClient = await connection.getCoreApi(); const coreClient = await connection.getCoreApi();