Compare commits

..

51 Commits

Author SHA1 Message Date
4dd3056b2f fix: missed incorrect AI autocomplete. 2026-03-11 12:12:36 +01:00
3e2b54ba3c fix: validation for bump type in check-package-version script 2026-03-11 12:11:30 +01:00
0269313516 chore: update package version to 0.8.0 in package.json and package-lock.json
All checks were successful
build / build (push) Successful in 15s
2026-03-11 11:59:24 +01:00
ade8f065e0 feat: enhance package version management in check-package-version script 2026-03-11 11:59:20 +01:00
1bae7d8f85 chore: update @slawek/sk-tools version to ^0.4.1 in package.json and package-lock.json 2026-03-11 11:48:09 +01:00
0714fc5c1b feat: add script to check and update package versions for sk-tools 2026-03-11 11:48:00 +01:00
d8d72be7e9 chore: update file permissions for make-mermaid-func-deps.mjs 2026-03-11 11:47:53 +01:00
b678dd5ace Authentication refactoring. 2026-03-11 10:41:42 +01:00
d69402a33d Updated package versions.
All checks were successful
build / build (push) Successful in 22s
2026-03-10 20:36:01 +01:00
2fa8fcfc3c Fix: Do not require PCA to be configured for Azure Identity default authentication. 2026-03-10 20:31:09 +01:00
059fc3c1da fix: update getAzureIdentityAuthProvider and make tenant id and client id optional. 2026-03-10 20:28:41 +01:00
97f7011f97 Fix: dependencies.
All checks were successful
build / build (push) Successful in 14s
2026-03-10 19:52:24 +01:00
dda13b7e2a chore: bump version to 0.7.1 in package.json
All checks were successful
build / build (push) Successful in 14s
2026-03-10 19:49:26 +01:00
5265e5300c refactor: remove unused usage functions and migrate argument parsing to commander.js 2026-03-10 19:48:02 +01:00
9fd770999b Migrated from parseArgs from node:util to commander.js.
All checks were successful
build / build (push) Successful in 14s
2026-03-10 07:15:00 +01:00
a98c77cd2e Refactor of authentication code. Added configuration file selectable authentication method. Selectable from built-in Azure Identity, and custom PCA using MSAL.
Some checks failed
build / build (push) Failing after 14s
2026-03-08 19:07:10 +01:00
0829b35113 refactor: remove unused imports and function for cleaner code
Some checks failed
build / build (push) Failing after 13s
2026-03-08 07:56:45 +01:00
63eb9c3cad Updated sk-tools to version 0.3.0.
All checks were successful
build / build (push) Successful in 13s
2026-03-07 21:51:18 +01:00
cd119c90c2 Fix: resolved package-lock issues.
All checks were successful
build / build (push) Successful in 13s
2026-03-07 19:14:19 +01:00
88ac901222 update: adopted new output options. Various optimizations.
Some checks failed
build / build (push) Failing after 15s
2026-03-07 18:49:15 +01:00
059590fde4 fix: removed unefficient AI generated call pattern using one-use object types created for 2-3 variable passing.
Some checks failed
build / build (push) Failing after 13s
2026-03-07 16:33:13 +01:00
63029d1119 refactor: streamline session state management using configuration functions 2026-03-07 15:36:48 +01:00
aa6f9e24f8 Refactored configuration loading function. 2026-03-07 15:18:46 +01:00
67dd2045e3 refactor: moved bump-patch.mjs to the generic sk-tools package. 2026-03-07 11:06:10 +01:00
94a573f1e1 refactor: simplify bump function and improve version handling 2026-03-07 11:04:23 +01:00
ed18cb535a Cleaned up docs. 2026-03-07 10:17:56 +01:00
9c2aea491c feat: bump version to 0.4.3 and add bump-patch script for automated versioning
All checks were successful
build / build (push) Successful in 15s
2026-03-07 10:13:47 +01:00
d39fdb3e33 feat: add make-deps script for function dependency graph generation
Some checks failed
build / build (push) Failing after 15s
2026-03-07 10:01:04 +01:00
2a0b49effe refactor: remove omitPermissionGuidColumns utility and implement omitRecords function 2026-03-07 08:02:58 +01:00
fff80047c2 fix: restored accidentialy removed sk-tools dependency.
All checks were successful
build / build (push) Successful in 17s
2026-03-06 23:11:10 +01:00
a629a3a32d Fix incorrect handling of table output.
Some checks failed
build / build (push) Failing after 12s
2026-03-06 23:03:19 +01:00
2fa9462657 Refactor CLI commands: remove table command and related utilities; update dependencies and version
All checks were successful
build / build (push) Successful in 17s
2026-03-06 19:05:34 +01:00
03fb55d97f Add Azure CLI Impersonation documentation 2026-03-06 14:51:29 +01:00
a53d2896b1 Add create and delete scripts creating Public Client Application (Remove unused JavaScript version). 2026-03-06 14:51:23 +01:00
3b37b26571 Add package publishing step to build workflow 2026-03-06 12:24:32 +01:00
350577420b update command usage formatting for consistency
All checks were successful
build / build (push) Successful in 12s
2026-03-06 12:11:06 +01:00
8cbd1d6399 Add support for CSV and TSV input formats in table command
All checks were successful
build / build (push) Successful in 12s
2026-03-06 11:43:47 +01:00
21b6a51330 Add comprehensive command documentation for SK Azure Tools
All checks were successful
build / build (push) Successful in 50s
2026-03-06 06:20:42 +01:00
9b9aefc9a5 Fix usageTable command help text for header option 2026-03-06 06:20:36 +01:00
849a8505a2 Bump version to 0.3.1 in package.json and package-lock.json
All checks were successful
build / build (push) Successful in 12s
2026-03-05 23:29:45 +01:00
ba7bacbe12 Update: Refactored commands into their own source files. 2026-03-05 23:28:08 +01:00
9581ee1a31 Add REST command support with method and URL options
All checks were successful
build / build (push) Successful in 12s
2026-03-05 23:18:47 +01:00
9f023d44cc Fix badge link in README to use correct YAML file extension 2026-03-05 22:57:09 +01:00
d74d133a60 Add build status badge to README 2026-03-05 22:56:15 +01:00
05d517709b Refactor code structure for improved readability and maintainability
All checks were successful
build / build (push) Successful in 12s
2026-03-05 22:51:36 +01:00
e70e668432 Add workflow_dispatch trigger to build workflow 2026-03-05 22:49:31 +01:00
e246657740 Add build workflow for CI/CD pipeline 2026-03-05 22:48:27 +01:00
71ec95b52b Bump version to 0.3.0 and add get-token command for Azure token retrieval 2026-03-05 22:39:42 +01:00
21b8179d40 Update version to 0.2.1 and add create-pca script for Azure app management 2026-03-05 22:03:10 +01:00
b88b35cb90 Bump version to 0.2.0 and update build script to make CLI executable 2026-03-05 21:36:52 +01:00
cb41e7dec1 Migrated to TypeScript. 2026-03-05 21:29:58 +01:00
55 changed files with 3901 additions and 1542 deletions

View File

@@ -0,0 +1,30 @@
name: build
on:
push:
paths:
- 'src/**'
- package.json
- package-lock.json
- tsconfig.json
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Publish package
run: |
npm config set @slawek:registry=https://gitea.koszewscy.waw.pl/api/packages/slawek/npm/
npm config set -- '//gitea.koszewscy.waw.pl/api/packages/slawek/npm/:_authToken' "${{ secrets.CI_TOKEN }}"
npm publish --access public

1
.gitignore vendored
View File

@@ -2,4 +2,3 @@
node_modules/
dist/
artifacts/
package-lock.json

View File

@@ -1,3 +1,11 @@
docs
src/
*.ts
tsconfig.json
scripts/
Dockerfile
.git
.gitignore
artifacts/
node_modules
package-lock.json
package-lock.json
docs

View File

@@ -1,4 +1,36 @@
# A set of Azure related NodeJS modules
[![test](https://gitea.koszewscy.waw.pl/slawek/sk-az-tools/actions/workflows/build.yml/badge.svg)](https://gitea.koszewscy.waw.pl/slawek/sk-az-tools/actions?workflow=build.yml)
This repository contains a collection of NodeJS modules that facilitate interaction with Azure and Graph authentication and management of selected Entra ID objects and Azure resources.
## Installation
```bash
npm install @slawek/sk-az-tools
```
## Development
### Build from TypeScript
```bash
npm run build
```
### Watch mode
```bash
npm run build:watch
```
### CLI smoke check
```bash
node dist/cli.js --help
```
## Publishing
The package is published from compiled output in `dist/`. See `docs/PACKAGING.md` for the complete release workflow.

View File

@@ -0,0 +1,34 @@
# Azure CLI Impersonation
To use `sk-az-tools` module or commands, you need to register Public Client Application and assign it appropriate permissions for full functionality.
Some commands may work with limited functionality without dedicated Public Client Application. You can use Azure CLI public client application for that purpose.
The Client ID of Azure CLI public client application is `04b07795-8ddb-461a-bbee-02f9e1bf7b46`.
Create a configuration file `$HOME/.config/sk-az-tools/public-config.json` with the following content:
```json
{
"tenantId": "<tenant-id>",
"clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
}
```
You can obtain tenant ID using `az account show --query tenantId -o tsv` command.
Confirm Client ID of the Azure CLI by locating Azure CLI installation and looking into the following file that lives in the Azure CLI embedded Python distribution:
`<installation_path>/lib/python3.<minor_version>/site-packages/azure/cli/core/auth/constants.py`:
```python
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46'
ACCESS_TOKEN = 'access_token'
EXPIRES_IN = "expires_in"
```

137
docs/Commands.md Normal file
View File

@@ -0,0 +1,137 @@
# SK Azure Tools Commands
The `sk-az-tools` package may act as a CLI tool that provides various commands for working with various services. Currently implemented commands support services related to:
- Microsoft Entra ID
- Azure Resource Manager
- Azure DevOps Services
## Global Options
These options apply to all commands unless stated otherwise:
- `--query`, `-q` <jmespath> - Apply JMESPath filter before output rendering.
- `--output`, `-o` <format> - Output format: `table|t|alignedtable|at|prettytable|pt|tsv`.
- `--columns`, `-C` <definition> - Column selection for table outputs:
- `col1` - Select column (case-insensitive match), keep raw header label.
- `col1:` - Select column (case-insensitive match), use auto-generated header label.
- `col1: Label 1` - Select column (case-insensitive match), use custom header label.
- Prefix token with `=` for exact column-name match: `=col1`, `=col1:`, `=col1:Label`.
- Tokens are comma-separated and rendered in the specified order.
- `--help`, `-h` - Show command help.
Note: `rest --header` is a command-specific HTTP header option and is unrelated to `--columns`.
## Login
**Command name:** `login`
**Usage:** `sk-az-tools login [resource...] [--use-device-code] [--no-browser] [--browser-name <name>] [--browser-profile <profile>] [global options]`
**Options:**
- `[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.
- `--no-browser` - Do not launch browser automatically. Print the sign-in URL to stderr.
- `--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`).
**Description:** The `login` command authenticates user sign-in for selected resource audiences and caches tokens for subsequent commands.
## Logout
**Command name:** `logout`
**Usage:** `sk-az-tools logout [--all] [global options]`
**Options:**
- `--all` - Clear login state and remove all cached accounts.
**Description:** The `logout` command signs out from cached user sessions. By default it signs out the active account; with `--all` it clears all cached accounts.
## Get Token
**Command name:** `get-token`
**Usage:** `sk-az-tools get-token --type|-t <azurerm|devops> [global options]`
**Options:**
- `--type`, `-t` <azurerm|devops> - Token audience to retrieve.
**Description:** The `get-token` command returns an access token for either Azure Resource Manager (`azurerm`) or Azure DevOps (`devops`) using the current login context.
## REST
**Command name:** `rest`
**Usage:** `sk-az-tools rest [--method <httpMethod>] --url <url> [--header <name: value>] [global options]`
**Options:**
- `--method` <httpMethod> - HTTP method to use. Default: `GET`.
- `--url` <url> - Full URL to call.
- `--header` <name: value> - Extra request header to include (for example: `Content-Type: application/json`).
**Description:** The `rest` command performs an HTTP request and returns response metadata and body. If `Authorization` is not provided explicitly, a `Bearer` token is added automatically for supported hosts:
- `management.azure.com` - uses Azure Resource Manager token
- `dev.azure.com` - uses Azure DevOps token
## List Apps
**Command name:** `list-apps`
**Usage:** `sk-az-tools list-apps [--display-name|-n <name>] [--app-id|-i <appId>] [--filter|-f <glob>] [global options]`
**Options:**
- `--display-name`, `-n` <name> - Find applications by display name.
- `--app-id`, `-i` <appId> - Find application by application (client) ID.
- `--filter`, `-f` <glob> - Filter results by display name using glob pattern.
**Description:** The `list-apps` command queries Microsoft Entra applications and returns matching app registrations.
## List App Permissions
**Command name:** `list-app-permissions`
**Usage:** `sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options]`
**Options:**
- `--app-id`, `-i` <appId> - Application (client) ID. Required.
- `--resolve`, `-r` - Resolve permission GUIDs to human-readable names.
- `--short`, `-s` - Output compact result (omits permission GUID columns in output formatting step).
- `--filter`, `-f` <glob> - Filter permissions by name using glob pattern.
**Description:** The `list-app-permissions` command returns required API permissions declared by the target app registration.
## List App Grants
**Command name:** `list-app-grants`
**Usage:** `sk-az-tools list-app-grants --app-id|-i <appId> [global options]`
**Options:**
- `--app-id`, `-i` <appId> - Application (client) ID. Required.
**Description:** The `list-app-grants` command lists OAuth2 delegated permission grants associated with the specified app.
## List Resource Permissions
**Command name:** `list-resource-permissions`
**Usage:** `sk-az-tools list-resource-permissions [--app-id|-i <appId> | --display-name|-n <name>] [--filter|-f <glob>] [global options]`
**Options:**
- `--app-id`, `-i` <appId> - Resource application ID.
- `--display-name`, `-n` <name> - Resource application display name.
- `--filter`, `-f` <glob> - Filter permissions by name using glob pattern.
**Description:** The `list-resource-permissions` command returns available delegated and application permissions exposed by a resource app.

View File

@@ -1,94 +0,0 @@
# Developing `hello-world` (ESM) Summary
## Minimal Layout
- `package.json`, `README.md`, `src/index.js` (ESM only).
- `package.json` uses `"type": "module"` and explicit `exports`.
- `files` allow-list to control shipped content.
Example `package.json`:
```json
{
"name": "hello-world",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.js"
},
"files": ["src", "README.md", "package.json"],
"engines": {
"node": ">=18"
}
}
```
Example `src/index.js`:
```js
export function helloWorld() {
console.log("Hello World!!!");
}
```
## Sub-modules (Subpath Exports)
- Expose sub-modules using explicit subpaths in `exports`.
- Keep public API small and intentional.
Example:
```json
{
"exports": {
".": "./src/index.js",
"./greetings": "./src/greetings.js",
"./callouts": "./src/callouts.js"
}
}
```
## `exports` vs `files`
- `exports` defines the public import surface (what consumers can import).
- `files` defines what gets packaged; supports globs and negation.
Example:
```json
{
"files": ["dist/**", "README.md", "!dist/**/*.map"]
}
```
## Dev Workflow (Separate Repo, Live Updates)
- Use `npm link` for live-edit development across repos.
Publisher repo:
```bash
npm link
```
Consumer repo:
```bash
npm link hello-world
```
Notes:
- If you build to `dist/`, run a watch build so the consumer sees updates.
- Unlink when done:
```bash
npm unlink hello-world
```
## Distribution as a `.tgz` Artifact
- Create a tarball with `npm pack` and distribute the `.tgz` file.
- Install directly from the tarball path.
- Use `npm pack --dry-run` to verify contents before sharing.
- The `.tgz` is written to the current working directory where `npm pack` is run.
- You can redirect output with `--pack-destination` (or `pack-destination` config).
- Ship `package.json` in the artifact, but exclude `package-lock.json` (keep it in the repo for development only).
Commands:
```bash
npm pack
npm install ./hello-world-1.0.0.tgz
```
Example with output directory:
```bash
npm pack --pack-destination ./artifacts
```

1646
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,20 @@
{
"name": "@slawek/sk-az-tools",
"version": "0.1.0",
"version": "0.8.0",
"type": "module",
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "rm -rf dist && tsc && chmod +x dist/cli.js",
"build:watch": "tsc --watch",
"create-pca": "node dist/create-pca.js",
"bump-patch": "node scripts/bump-patch.mjs",
"make-deps": "node scripts/make-mermaid-func-deps.mjs",
"clean": "rm -rf dist"
},
"engines": {
"node": ">=24.0.0"
},
@@ -11,22 +24,31 @@
"@azure/msal-node": "^5.0.3",
"@azure/msal-node-extensions": "^1.2.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@slawek/sk-tools": "^0.4.1",
"azure-devops-node-api": "^15.1.2",
"jmespath": "^0.16.0",
"minimatch": "^10.1.2"
"commander": "^14.0.3",
"minimatch": "^10.1.2",
"open": "^10.1.0",
"semver": "^7.7.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/node": ">=24.0.0",
"ts-morph": ">=27.0.0",
"typescript": ">=5.8.2"
},
"author": {
"name": "Sławomir Koszewski",
"email": "slawek@koszewscy.waw.pl"
},
"bin": {
"sk-az-tools": "./src/cli.js"
"sk-az-tools": "./dist/cli.js"
},
"license": "MIT",
"exports": {
".": "./src/index.js",
"./azure": "./src/azure/index.js",
"./graph": "./src/graph/index.js",
"./devops": "./src/devops/index.js"
".": "./dist/index.js",
"./azure": "./dist/azure/index.js",
"./graph": "./dist/graph/index.js",
"./devops": "./dist/devops/index.js"
}
}

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 (values.bump !== undefined && !["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 !== skAzToolsPackageLock.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"}`);
}

139
scripts/create-pca.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -uo pipefail
usage() {
cat <<EOF
Usage: $(basename "$0") [options] <app-name>
Options:
-c, --config <path> Write JSON config to file (optional)
-h, --help Show this help message and exit
EOF
}
CONFIG_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-c|--config)
if [[ $# -le 0 ]]; then
echo "Error: Missing value for --config" >&2
exit 1
fi
CONFIG_PATH="$2"
shift 2
;;
-*)
echo "Error: Unknown option: $1" >&2
exit 1
;;
*)
break
;;
esac
done
APP_NAME="${1:-}"
if [[ -z "$APP_NAME" ]]; then
echo "Error: Application name is required." >&2
usage >&2
exit 1
fi
APP_ID="$(az ad app list --display-name "$APP_NAME" | jq -r '[.[].appId] | join(",")')"
if [[ "$APP_ID" =~ "," ]]; then
echo "Error: The application name '$APP_NAME' is not unique." >&2
exit 1
fi
if [[ -z "$APP_ID" ]]; then
APP_ID="$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)"
if [[ -z "$APP_ID" ]]; then
echo "Error: Failed to create application '$APP_NAME'." >&2
exit 1
fi
echo "Created application '$APP_NAME' with appId '$APP_ID'."
else
printf "Application '%s' already exists. Update it? [y/N]: " "$APP_NAME" >&2
read -r ANSWER
if [[ ! "$ANSWER" =~ ^[Yy]([Ee][Ss])*$ ]]; then
echo "Canceled." >&2
exit 0
fi
fi
RESOURCE_ACCESS='[
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"resourceAccess": [
{ "id": "0e263e50-5827-48a4-b97c-d940288653c7", "type": "Scope" }
]
},
{
"resourceAppId": "499b84ac-1321-427f-aa17-267ca6975798",
"resourceAccess": [
{ "id": "ee69721e-6c3a-468f-a9ec-302d16a4c599", "type": "Scope" }
]
},
{
"resourceAppId": "797f4846-ba00-4fd7-ba43-dac1f8f63013",
"resourceAccess": [
{ "id": "41094075-9dad-400e-a0bd-54e686782033", "type": "Scope" }
]
}
]'
if ! az ad app update \
--id "$APP_ID" \
--sign-in-audience AzureADMyOrg \
--is-fallback-public-client true \
--required-resource-accesses "$RESOURCE_ACCESS" \
--public-client-redirect-uris http://localhost "msal${APP_ID}://auth" \
--enable-access-token-issuance true \
--enable-id-token-issuance true \
>/dev/null 2>&1; then
echo "Error: Failed to configure application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
SP_ID="$(az ad sp show --id "$APP_ID" --query id -o tsv)"
if [[ -z "$SP_ID" ]]; then
SP_ID="$(az ad sp create --id "$APP_ID" --query id -o tsv)"
if [[ -z "$SP_ID" ]]; then
echo "Error: Failed to create service principal for application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
else
echo "Service principal for application '$APP_NAME' already exists with id '$SP_ID'."
fi
az ad app permission admin-consent --id "$APP_ID"
if [[ $? -ne 0 ]]; then
echo "Error: Failed to grant admin consent for application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
TENANT_ID="$(az account show --query tenantId -o tsv)"
if [[ -z "$TENANT_ID" ]]; then
echo "Error: Failed to resolve tenantId from current Azure CLI context." >&2
exit 1
fi
CONFIG="{
\"tenantId\": \"$TENANT_ID\",
\"clientId\": \"$APP_ID\"
}
"
if [[ -n "$CONFIG_PATH" ]]; then
mkdir -p "$(dirname "$CONFIG_PATH")"
else
CONFIG_PATH="/dev/null"
fi
echo "$CONFIG" | tee "$CONFIG_PATH"

44
scripts/delete-pca.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -uo pipefail
APP_NAME="${1:-}"
if [[ -z "$APP_NAME" ]]; then
echo "Error: Application name is required." >&2
echo "Usage: $(basename "$0") <app-name>" >&2
exit 1
fi
APP_ID="$(az ad app list --display-name "$APP_NAME" | jq -r '[.[].appId] | join(",")')"
if [[ "$APP_ID" =~ "," ]]; then
echo "Error: The application name '$APP_NAME' is not unique." >&2
exit 1
fi
if [[ -z "$APP_ID" ]]; then
echo "Error: No application found with name '$APP_NAME'." >&2
exit 1
fi
SP_ID="$(az ad sp show --id "$APP_ID" --query id -o tsv)"
if [[ -z "$SP_ID" ]]; then
echo "No service principal found for application '$APP_NAME' ($APP_ID)."
fi
# Get confirmation from user before deleting
read -p "Are you sure you want to delete application '$APP_NAME' with appId '$APP_ID' and its service principal? (y/N) " -n 1 -r
echo
if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
echo "Aborting deletion."
exit 0
fi
if [[ -n "$SP_ID" ]]; then
az ad sp delete --id "$SP_ID"
echo "Deleted service principal with id '$SP_ID' for application '$APP_NAME' ($APP_ID)."
fi
az ad app delete --id "$APP_ID"
echo "Deleted application '$APP_NAME' with appId '$APP_ID'."

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import fs from "node:fs";
import path from "node:path";
import { parseArgs } from "node:util";
import { Node, Project, SyntaxKind } from "ts-morph";
const projectRoot = process.cwd();
const MAX_EDGES = Number(process.env.MAX_GRAPH_EDGES ?? "450");
const {
values: { source: sourceArgsRaw, output: outputArg },
} = parseArgs({
options: {
source: {
type: "string",
multiple: true,
},
output: {
type: "string",
},
},
});
function collectFunctionEntries(sourceFile) {
const entries = [];
const namesSeen = new Set();
for (const fn of sourceFile.getFunctions()) {
const name = fn.getName();
if (!name || namesSeen.has(name)) {
continue;
}
namesSeen.add(name);
entries.push({ name, node: fn });
}
for (const declaration of sourceFile.getVariableDeclarations()) {
const name = declaration.getName();
if (namesSeen.has(name)) {
continue;
}
const initializer = declaration.getInitializer();
if (!initializer) {
continue;
}
if (!Node.isArrowFunction(initializer) && !Node.isFunctionExpression(initializer)) {
continue;
}
namesSeen.add(name);
entries.push({ name, node: initializer });
}
return entries;
}
function nodeId(filePath, functionName) {
return `${filePath}__${functionName}`.replace(/[^A-Za-z0-9_]/g, "_");
}
function normalizeRelativePath(filePath) {
return filePath.replaceAll("\\", "/").replace(/^\.\//, "");
}
function normalizeSourceArg(sourceArg) {
const projectRelative = path.isAbsolute(sourceArg)
? path.relative(projectRoot, sourceArg)
: sourceArg;
return normalizeRelativePath(projectRelative).replace(/\/+$/, "");
}
const functionsByFile = new Map();
const allFunctionNames = new Set();
const functionEntriesByFile = new Map();
const project = new Project({
tsConfigFilePath: path.join(projectRoot, "tsconfig.json"),
});
const sourceFiles = project
.getSourceFiles()
.filter((sourceFile) => !sourceFile.isDeclarationFile())
.sort((a, b) => a.getFilePath().localeCompare(b.getFilePath()));
const sourceContext = sourceFiles.map((sourceFile) => ({
sourceFile,
relativePath: normalizeRelativePath(path.relative(projectRoot, sourceFile.getFilePath())),
}));
const filteredSourceFiles = (() => {
if (!sourceArgsRaw || sourceArgsRaw.length === 0) {
return sourceContext.map((item) => item.sourceFile);
}
const sourceArgs = sourceArgsRaw.map((sourceArg) => normalizeSourceArg(sourceArg));
const matchedSourceFiles = new Set();
for (const sourceArg of sourceArgs) {
const hasMatch = sourceContext.some((item) => (
item.relativePath === sourceArg
|| item.relativePath.startsWith(`${sourceArg}/`)
));
if (!hasMatch) {
throw new Error(`No source file matched --source=${sourceArg}`);
}
for (const item of sourceContext) {
if (item.relativePath === sourceArg || item.relativePath.startsWith(`${sourceArg}/`)) {
matchedSourceFiles.add(item.sourceFile);
}
}
}
return [...matchedSourceFiles];
})();
for (const sourceFile of filteredSourceFiles) {
const absolutePath = sourceFile.getFilePath();
const relativePath = path.relative(projectRoot, absolutePath).replaceAll("\\", "/");
const functionEntries = collectFunctionEntries(sourceFile);
const functionNames = functionEntries.map((entry) => entry.name);
functionsByFile.set(relativePath, functionNames);
functionEntriesByFile.set(relativePath, functionEntries);
for (const functionName of functionNames) {
allFunctionNames.add(functionName);
}
}
const firstNodeForFunction = new Map();
for (const [relativePath, functionNames] of functionsByFile.entries()) {
for (const functionName of functionNames) {
if (!firstNodeForFunction.has(functionName)) {
firstNodeForFunction.set(functionName, nodeId(relativePath, functionName));
}
}
}
const edgeSet = new Set();
for (const functionEntries of functionEntriesByFile.values()) {
for (const { name: sourceName, node } of functionEntries) {
const body = node.getBody();
if (!body) {
continue;
}
const sourceNode = firstNodeForFunction.get(sourceName);
if (!sourceNode) {
continue;
}
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const expression = call.getExpression();
let targetName;
if (Node.isIdentifier(expression)) {
targetName = expression.getText();
} else if (Node.isPropertyAccessExpression(expression)) {
targetName = expression.getName();
}
if (!targetName || targetName === sourceName || !allFunctionNames.has(targetName)) {
continue;
}
const targetNode = firstNodeForFunction.get(targetName);
if (targetNode) {
edgeSet.add(`${sourceNode} --> ${targetNode}`);
}
}
}
}
let diagram = "%% Function Dependency Graph (`src/**/*.ts`)\n";
diagram += "%% Generated from current package source files.\n";
diagram += "flowchart LR\n";
for (const [relativePath, functionNames] of functionsByFile.entries()) {
if (functionNames.length === 0) {
continue;
}
const subgraphId = relativePath.replace(/[^A-Za-z0-9_]/g, "_");
diagram += ` subgraph ${subgraphId}[\"${relativePath}\"]\n`;
for (const functionName of functionNames) {
diagram += ` ${nodeId(relativePath, functionName)}[\"${functionName}\"]\n`;
}
diagram += " end\n";
}
const sortedEdges = [...edgeSet].sort();
const emittedEdges = sortedEdges.slice(0, MAX_EDGES);
for (const edge of emittedEdges) {
diagram += ` ${edge}\n`;
}
if (sortedEdges.length > MAX_EDGES) {
diagram += `%% Note: showing ${MAX_EDGES} of ${sortedEdges.length} detected edges to stay within Mermaid default edge limits.\n`;
}
if (outputArg) {
const outputPath = path.resolve(projectRoot, outputArg);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, diagram, "utf8");
} else {
process.stdout.write(diagram);
}

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

@@ -1,39 +0,0 @@
// SPDX-License-Identifier: MIT
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
export async function getCredential(credentialType, options) {
switch (credentialType) {
case "d":
case "default":
return new DefaultAzureCredential();
case "cs":
case "clientSecret":
if (!options.tenantId || !options.clientId || !options.clientSecret) {
throw new Error(
"tenantId, clientId, and clientSecret are required for ClientSecretCredential",
);
}
return new ClientSecretCredential(
options.tenantId,
options.clientId,
options.clientSecret,
);
case "dc":
case "deviceCode":
if (!options.tenantId || !options.clientId) {
throw new Error(
"tenantId and clientId are required for DeviceCodeCredential",
);
}
return new DeviceCodeCredential({
tenantId: options.tenantId,
clientId: options.clientId,
userPromptCallback: (info) => {
console.log(info.message);
},
});
default:
throw new Error(`Unsupported credential type: ${credentialType}`);
}
}

85
src/azure/client-auth.ts Normal file
View File

@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT
import {
DefaultAzureCredential,
ClientSecretCredential,
DeviceCodeCredential,
getBearerTokenProvider,
} from "@azure/identity";
import type { TokenCredential } from "@azure/core-auth";
import { SkAzureCredential } from "./sk-credential.ts";
import { translateResourceNamesToScopes } from "./index.ts";
type CredentialType =
| "d"
| "default"
| "cs"
| "clientSecret"
| "dc"
| "deviceCode"
| "sk"
| "skCredential";
export function getCredential(
credentialType: CredentialType,
tenantId?: string,
clientId?: string,
clientSecret?: string,
): TokenCredential {
switch (credentialType) {
case "d":
case "default":
return new DefaultAzureCredential();
case "cs":
case "clientSecret":
if (!tenantId || !clientId || !clientSecret) {
throw new Error(
"tenantId, clientId, and clientSecret are required for ClientSecretCredential",
);
}
return new ClientSecretCredential(tenantId, clientId, clientSecret);
case "dc":
case "deviceCode":
if (!tenantId || !clientId) {
throw new Error(
"tenantId and clientId are required for DeviceCodeCredential",
);
}
return new DeviceCodeCredential({
tenantId,
clientId,
userPromptCallback: (info) => {
console.log(info.message);
},
});
case "sk":
case "skCredential":
if (!tenantId || !clientId) {
throw new Error(
"tenantId and clientId are required for SkAzureCredential",
);
}
return new SkAzureCredential(tenantId, clientId);
default:
throw new Error(`Unsupported credential type: ${credentialType}`);
}
}
export async function getTokenUsingAzureIdentity(
tenantId: string,
clientId: string,
resources: string[],
): Promise<string> {
const scopes = translateResourceNamesToScopes(resources);
const credential = getCredential("default", tenantId, clientId);
const getBearerToken = getBearerTokenProvider(credential, scopes);
const accessToken = await getBearerToken();
if (!accessToken) {
throw new Error("Failed to acquire access token with Azure Identity.");
}
return accessToken;
}

View File

@@ -1 +0,0 @@
//

View File

@@ -1,18 +0,0 @@
// SPDX-License-Identifier: MIT
/**
* @module azure
*
* This module provides authentication functionalities for Azure services.
*
*/
export { getCredential } from "./client-auth.js";
export {
loginInteractive,
loginDeviceCode,
login,
logout,
parseResources,
acquireResourceTokenFromLogin,
} from "./pca-auth.js";

81
src/azure/index.ts Normal file
View File

@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MIT
/**
* @module azure
*
* This module provides authentication functionalities for Azure services.
*/
import { getTokenUsingMsal } from "./pca-auth.ts";
import { getTokenUsingAzureIdentity } from "./client-auth.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
export {
loginInteractive,
loginDeviceCode,
login,
logout,
parseResources,
} from "./pca-auth.ts";
export { getCredential } from "./client-auth.ts";
export const RESOURCE_SCOPE_BY_NAME = {
graph: "https://graph.microsoft.com/.default",
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
azurerm: "https://management.azure.com/.default",
openai: "https://cognitiveservices.azure.com/.default",
} as const;
export type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
export const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "azurerm"];
// A helper function to translate short resource names to their corresponding scopes
export function translateResourceNamesToScopes(resourceNames: string[]): string[] {
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
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(
tenantId: string,
clientId: string,
resources: string[]
): Promise<string> {
const config = await loadConfig();
if (config.authMode === "msal") {
const result = await getTokenUsingMsal(tenantId, clientId, resources);
if (!result?.accessToken) {
throw new Error("Failed to acquire access token with MSAL.");
}
return result.accessToken;
} else {
return getTokenUsingAzureIdentity(tenantId, clientId, resources);
}
}

View File

@@ -1,473 +0,0 @@
// SPDX-License-Identifier: MIT
import open, { apps } from "open";
import fs from "node:fs";
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import path from "node:path";
import { PublicClientApplication } from "@azure/msal-node";
import os from "node:os";
const RESOURCE_SCOPE_BY_NAME = {
graph: "https://graph.microsoft.com/.default",
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
arm: "https://management.azure.com/.default",
};
const DEFAULT_RESOURCES = ["graph", "devops", "arm"];
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
const BROWSER_KEYWORDS = Object.keys(apps).sort();
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
function getCacheRoot() {
const isWindows = process.platform === "win32";
const userRoot = isWindows
? process.env.LOCALAPPDATA || os.homedir()
: os.homedir();
return isWindows
? path.join(userRoot, "sk-az-tools")
: path.join(userRoot, ".config", "sk-az-tools");
}
function getSessionFilePath() {
return path.join(getCacheRoot(), "login-session.json");
}
async function readSessionState() {
try {
const sessionJson = await readFile(getSessionFilePath(), "utf8");
const parsed = JSON.parse(sessionJson);
return {
activeAccountUpn:
typeof parsed?.activeAccountUpn === "string"
? parsed.activeAccountUpn
: null,
};
} catch (err) {
if (err?.code === "ENOENT") {
return { activeAccountUpn: null };
}
throw err;
}
}
async function writeSessionState(state) {
const sessionPath = getSessionFilePath();
await mkdir(path.dirname(sessionPath), { recursive: true });
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
}
async function clearSessionState() {
try {
await unlink(getSessionFilePath());
} catch (err) {
if (err?.code !== "ENOENT") {
throw err;
}
}
}
function normalizeUpn(upn) {
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
}
function writeStderr(message) {
process.stderr.write(`${message}\n`);
}
function getBrowserAppName(browser) {
if (!browser || browser.trim() === "") {
return null;
}
const keyword = BROWSER_KEYWORDS.find(
(name) => name.toLowerCase() === browser.trim().toLowerCase(),
);
if (!keyword) {
throw new Error(
`Invalid browser '${browser}'. Allowed: ${BROWSER_KEYWORDS.join(", ")}`,
);
}
return apps[keyword];
}
function getBrowserKeyword(browser) {
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, browserProfile }) {
const browserName = getBrowserAppName(browser);
const options = browserName
? { wait: false, app: { name: browserName } }
: { wait: false };
if (!browserProfile || browserProfile.trim() === "") {
return options;
}
const browserKeyword = getBrowserKeyword(browser);
if (!CHROMIUM_BROWSERS.has(browserKeyword)) {
throw new Error(
"--browser-profile is supported only with --browser edge|chrome|brave",
);
}
options.app.arguments = [`--profile-directory=${browserProfile.trim()}`];
return options;
}
function validateBrowserOptions({ browser, browserProfile }) {
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) {
if (!resourcesCsv || resourcesCsv.trim() === "") {
return [...DEFAULT_RESOURCES];
}
const resources = resourcesCsv
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
const unique = [...new Set(resources)];
const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]);
if (invalid.length > 0) {
throw new Error(
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
);
}
return unique;
}
function fileCachePlugin(cachePath) {
return {
beforeCacheAccess: async (ctx) => {
if (fs.existsSync(cachePath)) {
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
}
},
afterCacheAccess: async (ctx) => {
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, clientId }) {
const cacheRoot = getCacheRoot();
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
let cachePlugin;
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 (err) {
// 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, scopes, account }) {
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, upn }) {
const normalized = normalizeUpn(upn);
if (!normalized) {
return null;
}
const accounts = await pca.getTokenCache().getAllAccounts();
return (
accounts.find((account) => normalizeUpn(account?.username) === normalized) ??
null
);
}
export async function loginInteractive({
tenantId,
clientId,
scopes,
showAuthUrlOnly = false,
browser,
browserProfile,
}) {
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 await pca.acquireTokenInteractive({
scopes,
openBrowser: async (url) => {
if (showAuthUrlOnly) {
writeStderr(`Visit:\n${url}`);
return;
}
const options = getBrowserOpenOptions({ browser, browserProfile });
return open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`);
});
},
});
}
export async function loginDeviceCode({ tenantId, clientId, scopes }) {
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 await pca.acquireTokenByDeviceCode({
scopes,
deviceCodeCallback: (response) => {
writeStderr(response.message);
},
});
}
export async function login({
tenantId,
clientId,
resourcesCsv,
useDeviceCode = false,
noBrowser = false,
browser,
browserProfile,
}) {
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 = await findAccountByUpn({
pca,
upn: session.activeAccountUpn,
});
const results = [];
let selectedAccount = preferredAccount;
for (let index = 0; index < resources.length; index += 1) {
const resource = resources[index];
const scope = [scopes[index]];
let token = await acquireTokenWithCache({
pca,
scopes: scope,
account: selectedAccount,
});
if (!token) {
if (useDeviceCode) {
token = await pca.acquireTokenByDeviceCode({
scopes: scope,
deviceCodeCallback: (response) => {
writeStderr(response.message);
},
});
} else {
token = await pca.acquireTokenInteractive({
scopes: scope,
openBrowser: async (url) => {
if (noBrowser) {
writeStderr(`Visit:\n${url}`);
return;
}
const options = getBrowserOpenOptions({ browser, browserProfile });
return open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`);
});
},
});
}
}
if (token?.account) {
selectedAccount = token.account;
}
results.push({
resource,
expiresOn: token?.expiresOn?.toISOString?.() ?? null,
});
}
const activeAccountUpn = selectedAccount?.username ?? null;
if (activeAccountUpn) {
await writeSessionState({ activeAccountUpn });
}
return {
accountUpn: activeAccountUpn,
resources: results,
flow: useDeviceCode ? "device-code" : "interactive",
browserLaunchAttempted: !useDeviceCode && !noBrowser,
};
}
export async function acquireResourceTokenFromLogin({
tenantId,
clientId,
resource,
}) {
if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required");
if (!resource) throw new Error("resource is required");
const scope = RESOURCE_SCOPE_BY_NAME[resource];
if (!scope) {
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
}
const session = await readSessionState();
if (!session.activeAccountUpn) {
throw new Error(LOGIN_REQUIRED_MESSAGE);
}
const pca = await createPca({ tenantId, clientId });
const account = await findAccountByUpn({
pca,
upn: 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,
clientId,
clearAll = false,
userPrincipalName,
}) {
if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required");
const pca = await createPca({ tenantId, clientId });
const tokenCache = pca.getTokenCache();
const accounts = await tokenCache.getAllAccounts();
const session = await readSessionState();
if (clearAll) {
for (const account of accounts) {
await tokenCache.removeAccount(account);
}
await clearSessionState();
return {
clearedAll: true,
signedOut: accounts.map((account) => account.username).filter(Boolean),
};
}
const targetUpn = normalizeUpn(userPrincipalName) || normalizeUpn(session.activeAccountUpn);
const accountToSignOut = accounts.find(
(account) => normalizeUpn(account.username) === targetUpn,
);
if (!accountToSignOut) {
await clearSessionState();
return { clearedAll: false, signedOut: [] };
}
await tokenCache.removeAccount(accountToSignOut);
await clearSessionState();
return {
clearedAll: false,
signedOut: [accountToSignOut.username].filter(Boolean),
};
}

477
src/azure/pca-auth.ts Normal file
View File

@@ -0,0 +1,477 @@
// 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)),
};
}

View File

@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MIT
import type { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
import { getTokenUsingMsal } from "./pca-auth.ts";
export class SkAzureCredential implements TokenCredential {
constructor(
private tenantId: string,
private clientId: string,
) {}
async getToken(
scopes: string | string[],
_options?: GetTokenOptions,
): Promise<AccessToken | null> {
const resources = Array.isArray(scopes) ? scopes : [scopes];
const result = await getTokenUsingMsal(this.tenantId, this.clientId, resources);
if (!result?.accessToken) {
return null;
}
return {
token: result.accessToken,
expiresOnTimestamp: result.expiresOn
? result.expiresOn.getTime()
: Date.now() + 55 * 60 * 1000,
};
}
}

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import { parseArgs } from "node:util";
import { runCommand } from "./cli/commands.js";
import {
normalizeOutputFormat,
omitPermissionGuidColumns,
outputFiltered,
parseHeaderSpec,
renderOutput,
} from "./cli/utils.js";
function usage() {
return `Usage: sk-az-tools <command> [options]
Commands:
login Authenticate selected resources
logout Sign out and clear login state
list-apps List Entra applications
list-app-permissions List required permissions for an app
list-app-grants List OAuth2 grants for an app
list-resource-permissions List available permissions for a resource app
table Render stdin JSON as Markdown table
Global options (all commands):
-q, --query <jmespath>
-o, --output <format> table|t|alignedtable|at|prettytable|pt|tsv
-h, --help
Use: sk-az-tools --help <command>
or: sk-az-tools <command> --help`;
}
function usageListApps() {
return `Usage: sk-az-tools list-apps [--display-name|-n <name>] [--app-id|-i <appId>] [--filter|-f <glob>] [global options]
Options:
-n, --display-name <name> Get app by name
-i, --app-id <appId> Get app by id
-f, --filter <glob> Filter by app display name glob`;
}
function usageLogin() {
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]
Options:
--resources <csv> Comma-separated resources: graph,devops,arm (default: all)
--use-device-code Use device code flow instead of interactive flow
--no-browser Do not launch browser; print interactive URL to stderr
--browser <name> Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox
--browser-profile <name> Chromium profile name (e.g. Default, "Profile 1")`;
}
function usageLogout() {
return `Usage: sk-az-tools logout [--all] [global options]
Options:
--all Clear login state and remove all cached accounts`;
}
function usageListAppPermissions() {
return `Usage: sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options]
Options:
-i, --app-id <appId> Application (client) ID (required)
-r, --resolve Resolve permission GUIDs to human-readable values
-s, --short Makes output more compact
-f, --filter <glob> Filter by permission name glob`;
}
function usageListAppGrants() {
return `Usage: sk-az-tools list-app-grants --app-id|-i <appId> [global options]
Options:
-i, --app-id <appId> Application (client) ID (required)`;
}
function usageListResourcePermissions() {
return `Usage: sk-az-tools list-resource-permissions [--app-id|-i <appId> | --display-name|-n <name>] [--filter|-f <glob>] [global options]
Options:
-i, --app-id <appId> Resource app ID
-n, --display-name <name> Resource app display name
-f, --filter <glob> Filter by permission name glob`;
}
function usageTable() {
return `Usage: sk-az-tools table [--header|-H <spec|auto|a|original|o>] [global options]
Options:
-H, --header <value> Header mode/spec: auto|a (default), original|o, OR "col1, col2" OR "key1: Label 1, key2: Label 2"`;
}
function usageCommand(command) {
switch (command) {
case "login":
return usageLogin();
case "list-apps":
return usageListApps();
case "logout":
return usageLogout();
case "list-app-permissions":
return usageListAppPermissions();
case "list-app-grants":
return usageListAppGrants();
case "list-resource-permissions":
return usageListResourcePermissions();
case "table":
return usageTable();
default:
return `Unknown command: ${command}\n\n${usage()}`;
}
}
async function main() {
const argv = process.argv.slice(2);
const command = argv[0];
if (!command) {
console.log(usage());
process.exit(0);
}
if (command === "-h" || command === "--help") {
const helpCommand = argv[1];
console.log(helpCommand ? usageCommand(helpCommand) : usage());
process.exit(0);
}
const { values } = parseArgs({
args: argv.slice(1),
options: {
help: { type: "boolean", short: "h" },
"display-name": { type: "string", short: "n" },
"app-id": { type: "string", short: "i" },
resources: { type: "string" },
"use-device-code": { type: "boolean" },
"no-browser": { type: "boolean" },
browser: { type: "string" },
"browser-profile": { type: "string" },
all: { type: "boolean" },
resolve: { type: "boolean", short: "r" },
short: { type: "boolean", short: "s" },
filter: { type: "string", short: "f" },
query: { type: "string", short: "q" },
header: { type: "string", short: "H" },
output: { type: "string", short: "o" },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
console.log(usageCommand(command));
process.exit(0);
}
const outputFormat = normalizeOutputFormat(values.output);
const result = await runCommand(command, values);
const filtered = outputFiltered(result, values.query);
const output = command === "list-app-permissions" && values.short
? omitPermissionGuidColumns(filtered)
: filtered;
const headerSpec = parseHeaderSpec(values.header);
renderOutput(command, output, outputFormat, headerSpec);
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
console.error(usage());
process.exit(1);
});

141
src/cli.ts Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import { Argument, Command, Option } from "commander";
import { renderCliOutput } from "@slawek/sk-tools";
import { supportedResourceNames, ResourceName } from "./azure/index.ts";
// Commands
import { runGetTokenCommand } from "./cli/commands/get-token.ts";
import { runListAppGrantsCommand } from "./cli/commands/list-app-grants.ts";
import { runListAppPermissionsCommand } from "./cli/commands/list-app-permissions.ts";
import { runListAppsCommand } from "./cli/commands/list-apps.ts";
import { runListResourcePermissionsCommand } from "./cli/commands/list-resource-permissions.ts";
import { runLoginCommand } from "./cli/commands/login.ts";
import { runLogoutCommand } from "./cli/commands/logout.ts";
import { runRestCommand } from "./cli/commands/rest.ts";
import { runTestCommand } from "./cli/commands/test-command.ts";
import pkg from "../package.json" with { type: "json" };
const { version: packageVersion } = pkg;
async function main(): Promise<void> {
const skAzTools = new Command();
skAzTools
.name("sk-az-tools")
.description("A collection of tools for Azure and Microsoft Entra management")
.version(packageVersion)
.option("-q, --query <jmespath>", "JMESPath query to filter output")
.option("-C, --columns <definition>", "Column tokens: col (raw), col: (auto), col:Label (custom), exact via = prefix")
.addOption(new Option("-o, --output <format>", "Output format: table|t|alignedtable|at|prettytable|pt|tsv")
.choices(["table", "t", "alignedtable", "at", "prettytable", "pt", "tsv"])
);
skAzTools
.command("login")
.description("Authenticate selected resources")
.addArgument(
new Argument("[resource...]", "Resources: graph|devops|azurerm")
.choices(supportedResourceNames())
.default(["azurerm"]),
)
.option("--use-device-code", "Use device code flow")
.option("--no-browser", "Do not launch browser")
.option("--browser-name <name>", "Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox")
.option("--browser-profile <name>", "Chromium profile name")
.action(runLoginCommand);
skAzTools
.command("logout")
.description("Sign out and clear login state")
.option("--all", "Clear login state and remove all cached accounts")
.action(runLogoutCommand);
skAzTools
.command("get-token")
.description("Get an access token for a resource or resources.")
.addArgument(new Argument("<type>", "Token type.").choices(supportedResourceNames()))
.action(runGetTokenCommand);
skAzTools
.command("rest")
.description("Call REST API endpoint")
.argument("<url>", "Full URL to call")
.addOption(new Option("-X, --method <httpMethod>", "HTTP method")
.choices(["GET", "POST", "PUT", "PATCH", "DELETE"]))
.option("-H, --header <name: value>", "Extra request header")
.addHelpText("after", `
Authorization is added automatically for:
management.azure.com Uses azurerm token
dev.azure.com Uses devops token
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 allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
skAzTools
.command("list-apps")
.description("List Entra applications")
.option("-n, --display-name <name>", "Get app by display name")
.option("-i, --app-id <id>", "Get app by id")
.option("-f, --filter <pattern>", "Filter display name glob")
.action(async (options, command) => {
const output = await runListAppsCommand(options);
const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
skAzTools
.command("list-app-permissions")
.description("List required permissions for an app")
.option("-i, --app-id <appId>", "Application (client) ID")
.option("-r, --resolve", "Resolve permission GUIDs to human-readable values")
.option("-s, --short", "Makes output more compact")
.option("-f, --filter <glob>", "Filter by permission name glob")
.action(async (options, command) => {
const output = await runListAppPermissionsCommand(options);
const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
skAzTools
.command("list-app-grants")
.description("List OAuth2 grants for an app")
.option("-i, --app-id <appId>", "Application (client) ID")
.action(async (options, command) => {
const output = await runListAppGrantsCommand(options);
const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
skAzTools
.command("list-resource-permissions")
.description("List available permissions for a resource app")
.option("-i, --app-id <appId>", "Resource app ID")
.option("-n, --display-name <name>", "Resource app display name")
.option("-f, --filter <glob>", "Filter by permission name glob")
.action(async (options, command) => {
const output = await runListResourcePermissionsCommand(options);
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();
}
main().catch((err: unknown) => {
const error = err as Error;
console.error(`Error: ${error.message}`);
process.exit(1);
});

View File

@@ -1,141 +0,0 @@
// SPDX-License-Identifier: MIT
import { minimatch } from "minimatch";
import { loadPublicConfig } from "../index.js";
import { getGraphClient } from "../graph/auth.js";
import { login, logout } from "../azure/index.js";
import {
listApps,
listAppPermissions,
listAppPermissionsResolved,
listAppGrants,
listResourcePermissions,
} from "../graph/app.js";
import { readJsonFromStdin } from "./utils.js";
function filterByPermissionName(rows, pattern) {
return rows.filter((item) =>
minimatch(item.permissionValue ?? "", pattern, { nocase: true })
|| minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true })
);
}
function filterByDisplayName(rows, pattern) {
return rows.filter((item) =>
minimatch(item.displayName ?? "", pattern, { nocase: true })
);
}
async function getGraphClientFromPublicConfig() {
const config = await loadPublicConfig();
return getGraphClient({
tenantId: config.tenantId,
clientId: config.clientId,
});
}
async function runTableCommand() {
return readJsonFromStdin();
}
async function runLoginCommand(values) {
const config = await loadPublicConfig();
return login({
tenantId: config.tenantId,
clientId: config.clientId,
resourcesCsv: values.resources,
useDeviceCode: Boolean(values["use-device-code"]),
noBrowser: Boolean(values["no-browser"]),
browser: values.browser,
browserProfile: values["browser-profile"],
});
}
async function runLogoutCommand(values) {
const config = await loadPublicConfig();
return logout({
tenantId: config.tenantId,
clientId: config.clientId,
clearAll: Boolean(values.all),
});
}
async function runListAppsCommand(values) {
const { client } = await getGraphClientFromPublicConfig();
let result = await listApps(client, {
displayName: values["display-name"],
appId: values["app-id"],
});
if (values["app-id"] && result.length > 1) {
throw new Error(`Expected a single app for --app-id ${values["app-id"]}, but got ${result.length}`);
}
if (values.filter) {
result = filterByDisplayName(result, values.filter);
}
return result;
}
async function runListAppPermissionsCommand(values) {
if (!values["app-id"]) {
throw new Error("--app-id is required for list-app-permissions");
}
const { client } = await getGraphClientFromPublicConfig();
let result = values.resolve || values.filter
? await listAppPermissionsResolved(client, values["app-id"])
: await listAppPermissions(client, values["app-id"]);
if (values.filter) {
result = filterByPermissionName(result, values.filter);
}
return result;
}
async function runListAppGrantsCommand(values) {
if (!values["app-id"]) {
throw new Error("--app-id is required for list-app-grants");
}
const { client } = await getGraphClientFromPublicConfig();
return listAppGrants(client, values["app-id"]);
}
async function runListResourcePermissionsCommand(values) {
if (!values["app-id"] && !values["display-name"]) {
throw new Error("--app-id or --display-name is required for list-resource-permissions");
}
if (values["app-id"] && values["display-name"]) {
throw new Error("Use either --app-id or --display-name for list-resource-permissions, not both");
}
const { client } = await getGraphClientFromPublicConfig();
let result = await listResourcePermissions(client, {
appId: values["app-id"],
displayName: values["display-name"],
});
if (values.filter) {
result = filterByPermissionName(result, values.filter);
}
return result;
}
export async function runCommand(command, values) {
switch (command) {
case "login":
return runLoginCommand(values);
case "logout":
return runLogoutCommand(values);
case "table":
return runTableCommand();
case "list-apps":
return runListAppsCommand(values);
case "list-app-permissions":
return runListAppPermissionsCommand(values);
case "list-app-grants":
return runListAppGrantsCommand(values);
case "list-resource-permissions":
return runListResourcePermissionsCommand(values);
default:
throw new Error(`Unknown command: ${command}`);
}
}

View File

@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
import { RESOURCE_SCOPE_BY_NAME, ResourceName, supportedResourceNames, getTokenCredential } from "../../azure/index.ts";
export async function runGetTokenCommand(
type: ResourceName,
): Promise<void> {
if (!type || !supportedResourceNames().includes(type)) {
throw new Error(`Token type is required for get-token (allowed: ${supportedResourceNames().join(", ")})`);
}
const credential = await getTokenCredential();
const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME[type]);
if (!accessToken) {
throw new Error("Failed to obtain access token.");
}
// Output only the token string for easy consumption in scripts
console.log(accessToken.token);
}

View File

@@ -0,0 +1,17 @@
// SPDX-License-Identifier: MIT
import { listAppGrants } from "../../graph/app.ts";
import { getGraphClient } from "../../graph/index.ts";
type ListAppGrantsOptions = {
appId?: string;
};
export async function runListAppGrantsCommand(options: ListAppGrantsOptions): Promise<unknown> {
if (!options.appId) {
throw new Error("--app-id is required for list-app-grants");
}
const client = await getGraphClient();
return listAppGrants(client, options.appId);
}

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT
import { listAppPermissions, listAppPermissionsResolved } from "../../graph/app.ts";
import { filterByPermissionName } from "./shared.ts";
import { getGraphClient } from "../../graph/index.ts";
type ListAppPermissionsOptions = {
appId?: string;
resolve?: boolean;
short?: boolean;
filter?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function omitColumns(input: unknown, names: string[]): unknown {
if (!Array.isArray(input) || !input.every(isRecord)) {
return input;
}
const namesSet = new Set(names);
return input.map((record) =>
Object.fromEntries(
Object.entries(record).filter(([key]) => !namesSet.has(key)),
)
);
}
export async function runListAppPermissionsCommand(options: ListAppPermissionsOptions): Promise<unknown> {
if (!options.appId) {
throw new Error("--app-id is required for list-app-permissions");
}
const client = await getGraphClient();
let result: unknown = options.resolve || options.filter
? await listAppPermissionsResolved(client, options.appId)
: await listAppPermissions(client, options.appId);
if (options.short) {
result = omitColumns(result, ["resourceAppId", "permissionId"]);
}
if (options.filter) {
result = filterByPermissionName(result as Array<Record<string, unknown>>, options.filter);
}
return result;
}

View File

@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
import { listApps } from "../../graph/app.ts";
import { filterByDisplayName } from "./shared.ts";
import { getGraphClient } from "../../graph/index.ts";
type ListAppsOptions = {
displayName?: string;
appId?: string;
filter?: string;
};
export async function runListAppsCommand(options: ListAppsOptions): Promise<unknown> {
const client = await getGraphClient();
let result = await listApps(client, options.displayName, options.appId);
if (options.appId && result.length > 1) {
throw new Error(`Expected a single app for --app-id ${options.appId}, but got ${result.length}`);
}
if (options.filter) {
result = filterByDisplayName(result, options.filter);
}
return result;
}

View File

@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
import { listResourcePermissions } from "../../graph/app.ts";
import { getGraphClient } from "../../graph/index.ts";
import { filterByPermissionName } from "./shared.ts";
type ListResourcePermissionsOptions = {
appId?: string;
displayName?: string;
filter?: string;
};
export async function runListResourcePermissionsCommand(options: ListResourcePermissionsOptions): Promise<unknown> {
if (!options.appId && !options.displayName) {
throw new Error("--app-id or --display-name is required for list-resource-permissions");
}
if (options.appId && options.displayName) {
throw new Error("Use either --app-id or --display-name for list-resource-permissions, not both");
}
const client = await getGraphClient();
let result = await listResourcePermissions(
client,
options.appId,
options.displayName,
);
if (options.filter) {
result = filterByPermissionName(result, options.filter);
}
return result;
}

35
src/cli/commands/login.ts Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
import { login } from "../../azure/index.ts";
import type { ResourceName } from "../../azure/index.ts";
import { loadAuthConfig } from "../../index.ts";
type LoginOptions = {
useDeviceCode?: boolean;
noBrowser?: boolean;
browserName?: string;
browserProfile?: string;
};
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 result = await login(
config.tenantId,
config.clientId,
resources,
Boolean(options.useDeviceCode),
Boolean(options.noBrowser),
options.browserName,
options.browserProfile,
) as LoginResult;
console.log(`Logged in as ${result.accountUpn ?? "<unknown>"} using ${result.flow} flow for resources: ${resources.join(",")}`);
}

View File

@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
import { logout } from "../../azure/index.ts";
import { loadAuthConfig } from "../../index.ts";
type LogoutOptions = {
all?: boolean;
};
type LogoutResult = {
clearedAll: boolean;
signedOut: string[];
};
export async function runLogoutCommand(options: LogoutOptions): Promise<void> {
const config = await loadAuthConfig("public-config");
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(", ")}`);
}

125
src/cli/commands/rest.ts Normal file
View File

@@ -0,0 +1,125 @@
// SPDX-License-Identifier: MIT
import { RESOURCE_SCOPE_BY_NAME, ResourceName, getTokenCredential } from "../../azure/index.ts";
function parseHeaderLine(
header?: string,
): { name: string; value: string } | null {
if (!header || header.trim() === "") {
return null;
}
const separatorIndex = header.indexOf(":");
if (separatorIndex < 1) {
throw new Error("--header must be in the format 'Name: Value'");
}
const name = header.slice(0, separatorIndex).trim();
const value = header.slice(separatorIndex + 1).trim();
if (!name || !value) {
throw new Error("--header must be in the format 'Name: Value'");
}
return { name, value };
}
function hasAuthorizationHeader(headers: Headers): boolean {
for (const headerName of headers.keys()) {
if (headerName.toLowerCase() === "authorization") {
return true;
}
}
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> {
const host = url.hostname.toLowerCase();
const resourceName = resolveResourceNameForHost(host);
if (!resourceName) {
return null;
}
const credential = await getTokenCredential();
const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME[resourceName]);
if (!accessToken?.token) {
throw new Error(`Failed to obtain ${resourceName} token`);
}
return `Bearer ${accessToken.token}`;
}
type httpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type restOptions = {
method?: httpMethod;
header?: string;
};
export async function runRestCommand(url: string, options: restOptions): Promise<unknown> {
const method = options.method || "GET";
const urlValue = (url ?? "").toString().trim();
if (!urlValue) {
throw new Error("URL is required for rest");
}
let targetUrl: URL;
try {
targetUrl = new URL(urlValue);
} catch {
throw new Error(`Invalid URL '${urlValue}'`);
}
const headers = new Headers();
const customHeader = parseHeaderLine(options.header);
if (customHeader) {
headers.set(customHeader.name, customHeader.value);
}
if (!hasAuthorizationHeader(headers)) {
const authorization = await getAutoAuthorizationHeader(targetUrl);
if (authorization) {
headers.set("Authorization", authorization);
}
}
const response = await fetch(targetUrl, {
method,
headers,
});
const contentType = response.headers.get("content-type") ?? "";
let body: string;
if (contentType.toLowerCase().includes("application/json")) {
body = JSON.stringify(await response.json());
} else {
body = await response.text();
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
body,
};
}

View File

@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
import { minimatch } from "minimatch";
type PermissionRow = {
permissionValue?: string | null;
permissionDisplayName?: string | null;
};
type DisplayNameRow = {
displayName?: string | null;
};
export function filterByPermissionName<T extends PermissionRow>(rows: T[], pattern: string): T[] {
return rows.filter((item) =>
minimatch(item.permissionValue ?? "", pattern, { nocase: true })
|| minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true }),
);
}
export function filterByDisplayName<T extends DisplayNameRow>(rows: T[], pattern: string): T[] {
return rows.filter((item) =>
minimatch(item.displayName ?? "", pattern, { nocase: true }),
);
}

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

@@ -1,176 +0,0 @@
// SPDX-License-Identifier: MIT
import jmespath from "jmespath";
import { toMarkdownTable } from "../markdown.js";
export function outputFiltered(object, query) {
return query
? jmespath.search(object, query)
: object;
}
export function parseHeaderSpec(headerValue) {
if (!headerValue) {
return { mode: "auto" };
}
const raw = headerValue.trim();
if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") {
return { mode: "auto" };
}
if (raw.toLowerCase() === "original" || raw.toLowerCase() === "o") {
return { mode: "original" };
}
const parts = raw.split(",").map((p) => p.trim()).filter(Boolean);
const isMap = parts.some((p) => p.includes(":"));
if (!isMap) {
return { mode: "list", labels: parts };
}
const map = {};
for (const part of parts) {
const idx = part.indexOf(":");
if (idx < 0) {
throw new Error(`Invalid --header mapping segment: '${part}'`);
}
const key = part.slice(0, idx).trim();
const label = part.slice(idx + 1).trim();
if (!key || !label) {
throw new Error(`Invalid --header mapping segment: '${part}'`);
}
map[key] = label;
}
return { mode: "map", map };
}
export function normalizeOutputFormat(outputValue) {
if (outputValue == null) {
return "json";
}
const raw = outputValue.toLowerCase();
if (raw === "json") {
throw new Error("JSON is the default output. Omit --output to use it.");
}
if (raw === "j") {
throw new Error("JSON is the default output. Omit --output to use it.");
}
if (raw === "table" || raw === "t") return "table";
if (raw === "alignedtable" || raw === "at") return "alignedtable";
if (raw === "prettytable" || raw === "pt") return "prettytable";
if (raw === "tsv") return "tsv";
throw new Error("--output must be one of: table|t, alignedtable|at, prettytable|pt, tsv");
}
function getScalarRowsAndHeaders(value) {
let rows;
if (Array.isArray(value)) {
rows = value.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? item
: { value: item },
);
} else if (value && typeof value === "object") {
rows = [value];
} else {
rows = [{ value }];
}
if (rows.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
.filter((key) =>
rows.every((row) => {
const v = row[key];
return v == null || typeof v !== "object";
}),
);
if (headers.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
return { headers, rows };
}
function toTsv(value) {
const { headers, rows } = getScalarRowsAndHeaders(value);
const lines = rows.map((row) =>
headers
.map((header) => (row[header] == null ? "" : String(row[header]).replaceAll("\t", " ").replaceAll("\n", " ")))
.join("\t"),
);
return lines.join("\n");
}
export function omitPermissionGuidColumns(value) {
if (Array.isArray(value)) {
return value.map((item) => omitPermissionGuidColumns(item));
}
if (!value || typeof value !== "object") {
return value;
}
const { resourceAppId, permissionId, ...rest } = value;
return rest;
}
export async function readJsonFromStdin() {
const input = await new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => {
resolve(data);
});
process.stdin.on("error", (err) => {
reject(err);
});
});
if (!input.trim()) {
throw new Error("No JSON input provided on stdin");
}
try {
return JSON.parse(input);
} catch (err) {
throw new Error(`Invalid JSON input on stdin: ${err.message}`);
}
}
export function renderOutput(command, output, outputFormat, headerSpec) {
if (outputFormat === "tsv") {
console.log(toTsv(output));
return;
}
if (command === "table") {
console.log(toMarkdownTable(
output,
outputFormat === "alignedtable" || outputFormat === "prettytable",
outputFormat === "prettytable",
headerSpec,
));
} else if (outputFormat === "alignedtable") {
console.log(toMarkdownTable(output, true, false, headerSpec));
} else if (outputFormat === "prettytable") {
console.log(toMarkdownTable(output, true, true, headerSpec));
} else if (outputFormat === "table") {
console.log(toMarkdownTable(output, false, false, headerSpec));
} else {
console.log(JSON.stringify(output, null, 2));
}
}

87
scripts/create-pca.js → src/create-pca.ts Executable file → Normal file
View File

@@ -1,18 +1,23 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { spawnSync } from "node:child_process";
import { parseArgs } from "node:util";
import { Command } from "commander";
function runAz(args, options = {}) {
type RunAzResult = {
status: number;
stdout: string;
stderr: string;
};
function runAz(args: string[], quiet = false, allowFailure = false): RunAzResult {
const result = spawnSync("az", args, {
encoding: "utf8",
stdio: options.quiet
stdio: quiet
? ["ignore", "ignore", "ignore"]
: ["ignore", "pipe", "pipe"],
});
@@ -21,7 +26,7 @@ function runAz(args, options = {}) {
throw result.error;
}
if (result.status !== 0 && options.allowFailure !== true) {
if (result.status !== 0 && allowFailure !== true) {
throw new Error(
(result.stderr || "").trim() || `az ${args.join(" ")} failed`,
);
@@ -34,50 +39,18 @@ function runAz(args, options = {}) {
};
}
async function main() {
const usageText = `Usage: ${path.basename(process.argv[1])} [options] <app-name>
Options:
-c, --config <path> Write JSON config to file (optional)
-h, --help Show this help message and exit`;
let values;
let positionals;
try {
({ values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
help: { type: "boolean", short: "h" },
config: { type: "string", short: "c" },
},
strict: true,
allowPositionals: true,
}));
} catch (err) {
console.error(`Error: ${err.message}`);
console.error(usageText);
process.exit(1);
}
async function main(): Promise<void> {
const program = new Command(path.basename(process.argv[1]));
program
.description("Create or update public client app and print config template")
.argument("<app-name>", "Application name")
.option("-c, --config <path>", "Write JSON config to file (optional)")
.allowExcessArguments(false)
.parse(process.argv);
if (values.help) {
console.log(usageText);
process.exit(0);
}
if (positionals.length > 1) {
console.error(
"Error: Too many positional arguments. Only one app name positional argument is allowed.",
);
console.error(usageText);
process.exit(1);
}
const appName = positionals[0] || "";
const configPath = values.config || "";
if (!appName) {
console.error("Error: Application name is required.");
console.error(usageText);
process.exit(1);
}
const appName = program.args[0] as string;
const options = program.opts<{ config?: string }>();
const configPath = options.config ?? "";
let appId = runAz([
"ad",
@@ -96,12 +69,12 @@ Options:
input: process.stdin,
output: process.stderr,
});
const answer = await new Promise((resolve) => {
const answer = await new Promise<string>((resolve) => {
rl.question(
`Application '${appName}' already exists. Update it? [y/N]: `,
(answer) => {
(answerValue) => {
rl.close();
resolve(answer.trim());
resolve(answerValue.trim());
},
);
});
@@ -187,7 +160,7 @@ Options:
"--enable-id-token-issuance",
"true",
],
{ quiet: true },
true,
);
} catch {
console.error(
@@ -199,14 +172,12 @@ Options:
fs.rmSync(tempDir, { recursive: true, force: true });
}
runAz(["ad", "sp", "create", "--id", appId], {
quiet: true,
allowFailure: true,
});
runAz(["ad", "sp", "create", "--id", appId], true, true);
const adminConsentResult = runAz(
["ad", "app", "permission", "admin-consent", "--id", appId],
{ quiet: true, allowFailure: true },
true,
true,
);
if (adminConsentResult.status !== 0) {
console.warn(
@@ -251,6 +222,6 @@ Options:
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
});

View File

@@ -1 +0,0 @@
//

View File

@@ -1,55 +0,0 @@
// SPDX-License-Identifier: MIT
/**
* A DevOps helpers module.
*/
import { loginInteractive } from "../azure/index.js";
import * as azdev from "azure-devops-node-api";
const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"];
/**
* Get Azure DevOps API token.
*
* @param { string } tenantId - The Azure AD tenant ID
* @param { string } clientId - The Azure AD client ID
* @returns { Promise<string> } Azure DevOps API access token
*/
export async function getDevOpsApiToken(tenantId, clientId) {
const result = await loginInteractive({
tenantId,
clientId,
scopes: AZURE_DEVOPS_SCOPES,
});
const accessToken = result?.accessToken;
if(!accessToken) {
throw new Error("Failed to obtain Azure DevOps API token");
}
return accessToken;
}
/**
* Get Azure DevOps clients - Core and Git.
*
* @param { string } orgUrl - The Azure DevOps organization URL
* @param { string } tenantId - The Azure AD tenant ID
* @param { string } clientId - The Azure AD client ID
* @returns { Promise<{ coreClient: Object, gitClient: Object }> }
*/
export async function getDevOpsClients(orgUrl, tenantId, clientId) {
const accessToken = await getDevOpsApiToken(tenantId, clientId);
const authHandler = azdev.getBearerHandler(accessToken);
const connection = new azdev.WebApi(orgUrl, authHandler);
const coreClient = await connection.getCoreApi();
const gitClient = await connection.getGitApi();
return { coreClient, gitClient };
}

30
src/devops/index.ts Normal file
View File

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
/**
* A DevOps helpers module.
*/
import { RESOURCE_SCOPE_BY_NAME, getTokenCredential } from "../azure/index.ts";
import * as azdev from "azure-devops-node-api";
export type DevOpsClients = {
coreClient: Awaited<ReturnType<azdev.WebApi["getCoreApi"]>>;
gitClient: Awaited<ReturnType<azdev.WebApi["getGitApi"]>>;
};
export async function getDevOpsClients(orgUrl: string, tenantId?: string, clientId?: string): Promise<DevOpsClients> {
const credential = await getTokenCredential(tenantId, clientId);
const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME.devops);
if (!accessToken?.token) {
throw new Error("Failed to obtain Azure DevOps API token");
}
const authHandler = azdev.getBearerHandler(accessToken.token);
const connection = new azdev.WebApi(orgUrl, authHandler);
const coreClient = await connection.getCoreApi();
const gitClient = await connection.getGitApi();
return { coreClient, gitClient };
}

View File

@@ -1,51 +1,70 @@
// SPDX-License-Identifier: MIT
/**
* Get an Azure application by its display name.
*
* @param { Object } client
* @param { string } displayName
* @returns { Promise<Object|null> }
*/
export async function getApp(client, displayName) {
type GraphObject = Record<string, unknown>;
type GraphResult<T = GraphObject> = {
value?: T[];
};
type RequiredResourceAccessItem = {
type?: string;
id?: string;
};
type RequiredResourceAccess = {
resourceAppId?: string;
resourceAccess?: RequiredResourceAccessItem[];
};
type GraphPermission = {
id?: string;
value?: string;
displayName?: string;
adminConsentDisplayName?: string;
userConsentDisplayName?: string;
isEnabled?: boolean;
};
type ServicePrincipal = {
id?: string;
appId?: string;
displayName?: string;
oauth2PermissionScopes?: GraphPermission[];
appRoles?: GraphPermission[];
};
export async function getApp(client: any, displayName: string): Promise<GraphObject | null> {
const result = await client
.api("/applications")
.filter(`displayName eq '${displayName}'`)
.get();
.get() as GraphResult;
// Return the first application found or null if none exists
return result.value.length > 0 ? result.value[0] : null;
return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
}
export async function createApp(client, displayName) {
export async function createApp(client: any, displayName: string): Promise<GraphObject> {
const app = await client.api("/applications").post({
displayName,
});
}) as GraphObject;
if (!app || !app.appId) {
if (!app || typeof app.appId !== "string") {
throw new Error("Failed to create application");
}
return app;
}
export async function deleteApp(client, appObjectId) {
export async function deleteApp(client: any, appObjectId: string): Promise<void> {
await client.api(`/applications/${appObjectId}`).delete();
}
/**
* List Azure applications, optionally filtered by display name and/or app ID.
*
* @param { Object } client
* @param { Object } [options]
* @param { string } [options.displayName]
* @param { string } [options.appId]
* @returns { Promise<Array> }
*/
export async function listApps(client, options = {}) {
const { displayName, appId } = options;
export async function listApps(
client: any,
displayName?: string,
appId?: string,
): Promise<GraphObject[]> {
let request = client.api("/applications");
const filters = [];
const filters: string[] = [];
if (displayName) {
filters.push(`displayName eq '${displayName}'`);
@@ -58,18 +77,11 @@ export async function listApps(client, options = {}) {
request = request.filter(filters.join(" and "));
}
const result = await request.get();
const result = await request.get() as GraphResult;
return Array.isArray(result?.value) ? result.value : [];
}
/**
* List required resource access configuration for an application by appId.
*
* @param { Object } client
* @param { string } appId
* @returns { Promise<Array> }
*/
export async function listAppPermissions(client, appId) {
export async function listAppPermissions(client: any, appId: string): Promise<RequiredResourceAccess[]> {
if (!appId) {
throw new Error("appId is required");
}
@@ -78,7 +90,7 @@ export async function listAppPermissions(client, appId) {
.api("/applications")
.filter(`appId eq '${appId}'`)
.select("id,appId,displayName,requiredResourceAccess")
.get();
.get() as GraphResult<GraphObject>;
const app = Array.isArray(result?.value) && result.value.length > 0
? result.value[0]
@@ -88,19 +100,13 @@ export async function listAppPermissions(client, appId) {
return [];
}
return Array.isArray(app.requiredResourceAccess)
? app.requiredResourceAccess
const requiredResourceAccess = app.requiredResourceAccess;
return Array.isArray(requiredResourceAccess)
? requiredResourceAccess as RequiredResourceAccess[]
: [];
}
/**
* List required resource access in a resolved, human-readable form.
*
* @param { Object } client
* @param { string } appId
* @returns { Promise<Array> }
*/
export async function listAppPermissionsResolved(client, appId) {
export async function listAppPermissionsResolved(client: any, appId: string): Promise<Array<Record<string, unknown>>> {
const requiredResourceAccess = await listAppPermissions(client, appId);
if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) {
return [];
@@ -109,7 +115,7 @@ export async function listAppPermissionsResolved(client, appId) {
const resourceAppIds = [...new Set(
requiredResourceAccess
.map((entry) => entry?.resourceAppId)
.filter(Boolean),
.filter((value): value is string => typeof value === "string" && value.length > 0),
)];
const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => {
@@ -117,17 +123,21 @@ export async function listAppPermissionsResolved(client, appId) {
.api("/servicePrincipals")
.filter(`appId eq '${resourceAppId}'`)
.select("appId,displayName,oauth2PermissionScopes,appRoles")
.get();
.get() as GraphResult<ServicePrincipal>;
const sp = Array.isArray(result?.value) && result.value.length > 0
? result.value[0]
: null;
const scopesById = new Map(
(sp?.oauth2PermissionScopes ?? []).map((scope) => [scope.id, scope]),
(sp?.oauth2PermissionScopes ?? [])
.filter((scope) => typeof scope.id === "string")
.map((scope) => [scope.id as string, scope]),
);
const rolesById = new Map(
(sp?.appRoles ?? []).map((role) => [role.id, role]),
(sp?.appRoles ?? [])
.filter((role) => typeof role.id === "string")
.map((role) => [role.id as string, role]),
);
return {
@@ -142,9 +152,10 @@ export async function listAppPermissionsResolved(client, appId) {
resourceDefinitions.map((entry) => [entry.resourceAppId, entry]),
);
const rows = [];
const rows: Array<Record<string, unknown>> = [];
for (const resourceEntry of requiredResourceAccess) {
const resourceMeta = byResourceAppId.get(resourceEntry.resourceAppId);
const resourceAppId = resourceEntry.resourceAppId ?? "";
const resourceMeta = byResourceAppId.get(resourceAppId);
const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess)
? resourceEntry.resourceAccess
: [];
@@ -153,8 +164,8 @@ export async function listAppPermissionsResolved(client, appId) {
const permissionType = item?.type ?? null;
const permissionId = item?.id ?? null;
const resolved = permissionType === "Scope"
? resourceMeta?.scopesById.get(permissionId)
: resourceMeta?.rolesById.get(permissionId);
? resourceMeta?.scopesById.get(permissionId ?? "")
: resourceMeta?.rolesById.get(permissionId ?? "");
rows.push({
resourceAppId: resourceEntry.resourceAppId ?? null,
@@ -174,14 +185,7 @@ export async function listAppPermissionsResolved(client, appId) {
return rows;
}
/**
* List delegated OAuth2 permission grants for an application by appId.
*
* @param { Object } client
* @param { string } appId
* @returns { Promise<Array> }
*/
export async function listAppGrants(client, appId) {
export async function listAppGrants(client: any, appId: string): Promise<GraphObject[]> {
if (!appId) {
throw new Error("appId is required");
}
@@ -190,7 +194,7 @@ export async function listAppGrants(client, appId) {
.api("/servicePrincipals")
.filter(`appId eq '${appId}'`)
.select("id,appId,displayName")
.get();
.get() as GraphResult<ServicePrincipal>;
const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0
? spResult.value[0]
@@ -203,22 +207,16 @@ export async function listAppGrants(client, appId) {
const grantsResult = await client
.api("/oauth2PermissionGrants")
.filter(`clientId eq '${servicePrincipal.id}'`)
.get();
.get() as GraphResult;
return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
}
/**
* List available delegated scopes and app roles for a resource app.
*
* @param { Object } client
* @param { Object } options
* @param { string } [options.appId]
* @param { string } [options.displayName]
* @returns { Promise<Array> }
*/
export async function listResourcePermissions(client, options = {}) {
const { appId, displayName } = options;
export async function listResourcePermissions(
client: any,
appId?: string,
displayName?: string,
): Promise<Array<Record<string, unknown>>> {
if (!appId && !displayName) {
throw new Error("appId or displayName is required");
}
@@ -233,9 +231,9 @@ export async function listResourcePermissions(client, options = {}) {
request = request.filter(`displayName eq '${displayName}'`);
}
const result = await request.get();
const result = await request.get() as GraphResult<ServicePrincipal>;
const servicePrincipals = Array.isArray(result?.value) ? result.value : [];
const rows = [];
const rows: Array<Record<string, unknown>> = [];
for (const sp of servicePrincipals) {
for (const scope of sp?.oauth2PermissionScopes ?? []) {

View File

@@ -1,29 +0,0 @@
// SPDX-License-Identifier: MIT
import { Client } from "@microsoft/microsoft-graph-client";
import { acquireResourceTokenFromLogin } from "../azure/index.js";
/**
* Initialize and return a Microsoft Graph client
* along with the authentication token.
*
* @param { Object } options - Options for authentication
* @param { string } options.tenantId - The Azure AD tenant ID
* @param { string } options.clientId - The Azure AD client ID
* @returns { Promise<{ graphApiToken: Object, client: Object }> } An object containing the Graph API token and client
*/
export async function getGraphClient({ tenantId, clientId }) {
const graphApiToken = await acquireResourceTokenFromLogin({
tenantId,
clientId,
resource: "graph",
});
const client = Client.init({
authProvider: (done) => {
done(null, graphApiToken.accessToken);
},
});
return { graphApiToken, client };
}

55
src/graph/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
// SPDX-License-Identifier: MIT
import { Client } from "@microsoft/microsoft-graph-client";
import { getAccessToken } from "../azure/index.ts";
import { DefaultAzureCredential, getBearerTokenProvider } from "@azure/identity";
// export async function getGraphClientUsingMsal(
// tenantId: string,
// clientId: string,
// ): Promise<Client> {
// const graphApiToken = await getAccessToken(tenantId, clientId, ["graph"]);
// return Client.init({
// authProvider: (done) => {
// done(null, graphApiToken);
// },
// });
// }
type GraphAuthProvider = (
done: (error: Error | null, accessToken: string | null) => void
) => void;
export function getMsalAuthProvider(
tenantId: string,
clientId: string,
): GraphAuthProvider {
return (done) => {
void getAccessToken(tenantId, clientId, ["graph"])
.then((accessToken) => done(null, accessToken))
.catch((err) => done(err as Error, null));
};
}
export function getAzureIdentityAuthProvider(tenantId?: string, clientId?: string) : GraphAuthProvider {
const credentialOptions =
tenantId && clientId
? { tenantId, managedIdentityClientId: clientId }
: undefined;
const credential = credentialOptions
? new DefaultAzureCredential(credentialOptions)
: new DefaultAzureCredential();
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

@@ -1 +0,0 @@
//

View File

@@ -1,5 +0,0 @@
// SPDX-License-Identifier: MIT
export * from "./auth.js";
export * from "./app.js";
export * from "./sp.js";

27
src/graph/index.ts Normal file
View File

@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
export * from "./auth.ts";
export * from "./app.ts";
export * from "./sp.ts";
import { loadAuthConfig, loadConfig } from "../index.ts";
import { Client, AuthProvider } from "@microsoft/microsoft-graph-client";
import { getMsalAuthProvider, getAzureIdentityAuthProvider } from "./auth.ts";
export async function getGraphClient(): Promise<Client> {
const config = await loadConfig();
let authProvider: AuthProvider;
if (config.authMode === "azure-identity") {
authProvider = getAzureIdentityAuthProvider();
} else {
const authConfig = await loadAuthConfig("public-config");
authProvider = getMsalAuthProvider(authConfig.tenantId, authConfig.clientId);
}
return Client.init({
authProvider: authProvider,
});
}

View File

@@ -1,27 +0,0 @@
// SPDX-License-Identifier: MIT
export async function getServicePrincipal(client, appId) {
const result = await client
.api("/servicePrincipals")
.filter(`appId eq '${appId}'`)
.get();
// Return the first service principal found or null if none exists
return result.value.length > 0 ? result.value[0] : null;
}
export async function createSp(client, appId) {
const sp = await client.api("/servicePrincipals").post({
appId,
});
if (!sp || !sp.id) {
throw new Error("Failed to create service principal");
}
return sp;
}
export async function deleteSp(client, spId) {
await client.api(`/servicePrincipals/${spId}`).delete();
}

30
src/graph/sp.ts Normal file
View File

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
type GraphResult<T = Record<string, unknown>> = {
value?: T[];
};
export async function getServicePrincipal(client: any, appId: string): Promise<Record<string, unknown> | null> {
const result = await client
.api("/servicePrincipals")
.filter(`appId eq '${appId}'`)
.get() as GraphResult;
return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
}
export async function createSp(client: any, appId: string): Promise<Record<string, unknown>> {
const sp = await client.api("/servicePrincipals").post({
appId,
}) as Record<string, unknown>;
if (!sp || typeof sp.id !== "string") {
throw new Error("Failed to create service principal");
}
return sp;
}
export async function deleteSp(client: any, spId: string): Promise<void> {
await client.api(`/servicePrincipals/${spId}`).delete();
}

1
src/index.d.ts vendored
View File

@@ -1 +0,0 @@
//

View File

@@ -1,48 +0,0 @@
// SPDX-License-Identifier: MIT
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export function getUserConfigDir() {
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) {
if (typeof configFileName !== "string" || configFileName.trim() === "") {
throw new Error(
'Invalid config file name. Expected a non-empty string like "public-config.json" or "confidential-config.json".',
);
}
const config = {
tenantId: process.env.AZURE_TENANT_ID,
clientId: process.env.AZURE_CLIENT_ID,
};
const configPath = path.join(getUserConfigDir(), "sk-az-tools", configFileName);
return readFile(configPath, "utf8")
.then((configJson) => JSON.parse(configJson))
.catch((err) => {
if (err?.code === "ENOENT") {
return {};
}
throw err;
})
.then((json) => ({
tenantId: json.tenantId || config.tenantId,
clientId: json.clientId || config.clientId,
}));
}
export function loadPublicConfig() {
return loadConfig("public-config.json");
}
export function loadConfidentialConfig() {
return loadConfig("confidential-config.json");
}

42
src/index.ts Normal file
View File

@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
import { validate as validateUuid } from "uuid";
import { getConfig } from "@slawek/sk-tools";
import type { AuthConfig, Config } from "./types.ts";
export async function loadAuthConfig(configName: string): Promise<AuthConfig> {
if (configName.trim() === "") {
throw new Error(
'Invalid config name. Expected a non-empty string like "public-config" or "confidential-config".',
);
}
const envConfig = {
tenantId: process.env.AZURE_TENANT_ID,
clientId: process.env.AZURE_CLIENT_ID,
};
const json = (await getConfig("sk-az-tools", configName)) as Record<string, unknown>;
const tenantId = (typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId) ?? "";
const clientId = (typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId) ?? "";
if (!validateUuid(tenantId ?? "") || !validateUuid(clientId ?? "")) {
throw new Error("tenantId and clientId must be valid GUIDs.");
}
return {
tenantId,
clientId,
};
}
export async function loadConfig(): Promise<Config> {
const json = (await getConfig("sk-az-tools", "config")) as Record<string, unknown>;
return {
activeAccountUpn: typeof json.activeAccountUpn === "string" ? json.activeAccountUpn : undefined,
authMode: typeof json.authMode === "string" ? json.authMode : "msal"
};
}

View File

@@ -1,113 +0,0 @@
// SPDX-License-Identifier: MIT
function formatCell(value) {
const text = value == null
? ""
: String(value);
return text.replaceAll("|", "\\|").replaceAll("\n", "<br>");
}
function isGuid(value) {
return typeof value === "string"
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
}
function toAutoHeaderLabel(key) {
const withSpaces = String(key)
.replace(/[_-]+/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
return withSpaces
.split(" ")
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
}
function getScalarRowsAndHeaders(value) {
let rows;
if (Array.isArray(value)) {
rows = value.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? item
: { value: item }
);
} else if (value && typeof value === "object") {
rows = [value];
} else {
rows = [{ value }];
}
if (rows.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
.filter((key) =>
rows.every((row) => {
const value = row[key];
return value == null || typeof value !== "object";
})
);
if (headers.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
return { headers, rows };
}
export function toMarkdownTable(value, pretty = false, quoteGuids = false) {
const headerSpec = arguments[3] ?? { mode: "default" };
const { headers, rows } = getScalarRowsAndHeaders(value);
const headerDefinitions = headers.map((key, idx) => {
let label = key;
if (headerSpec?.mode === "auto") {
label = toAutoHeaderLabel(key);
} else if (headerSpec?.mode === "list" && Array.isArray(headerSpec.labels) && headerSpec.labels[idx]) {
label = headerSpec.labels[idx];
} else if (headerSpec?.mode === "map" && headerSpec.map && headerSpec.map[key]) {
label = headerSpec.map[key];
}
return { key, label };
});
const renderCell = (raw) => {
const text = formatCell(raw);
return quoteGuids && isGuid(raw) ? `\`${text}\`` : text;
};
if (!pretty) {
const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`;
const separatorLine = `| ${headerDefinitions.map(() => "---").join(" | ")} |`;
const rowLines = rows.map((row) =>
`| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |`
);
return [headerLine, separatorLine, ...rowLines].join("\n");
}
const widths = headerDefinitions.map((header, idx) =>
Math.max(
header.label.length,
...rows.map((row) => renderCell(row[headerDefinitions[idx].key]).length),
)
);
const renderRow = (values) =>
`| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`;
const headerLine = renderRow(headerDefinitions.map((h) => h.label));
const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`;
const rowLines = rows.map((row) =>
renderRow(headerDefinitions.map((header) => renderCell(row[header.key])))
);
return [headerLine, separatorLine, ...rowLines].join("\n");
}

11
src/types.ts Normal file
View File

@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
export type AuthConfig = {
tenantId: string;
clientId: string;
};
export type Config = {
activeAccountUpn: string | undefined;
authMode: string;
};

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2024"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"rewriteRelativeImportExtensions": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}