Compare commits

..

40 Commits

Author SHA1 Message Date
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
52 changed files with 3470 additions and 1238 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/ node_modules/
dist/ dist/
artifacts/ artifacts/
package-lock.json

View File

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

View File

@@ -1,4 +1,36 @@
# A set of Azure related NodeJS modules # 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. 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 [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]`
**Options:**
- `--resources` <csv> - Comma-separated resources to authenticate. Allowed values: `graph`, `devops`, `arm`. Default is all three.
- `--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> - 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
```

1645
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", "name": "@slawek/sk-az-tools",
"version": "0.1.0", "version": "0.7.2",
"type": "module", "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": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
@@ -11,22 +24,31 @@
"@azure/msal-node": "^5.0.3", "@azure/msal-node": "^5.0.3",
"@azure/msal-node-extensions": "^1.2.0", "@azure/msal-node-extensions": "^1.2.0",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
"@slawek/sk-tools": ">=0.2.0",
"azure-devops-node-api": "^15.1.2", "azure-devops-node-api": "^15.1.2",
"jmespath": "^0.16.0", "commander": "^14.0.3",
"minimatch": "^10.1.2" "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": { "author": {
"name": "Sławomir Koszewski", "name": "Sławomir Koszewski",
"email": "slawek@koszewscy.waw.pl" "email": "slawek@koszewscy.waw.pl"
}, },
"bin": { "bin": {
"sk-az-tools": "./src/cli.js" "sk-az-tools": "./dist/cli.js"
}, },
"license": "MIT", "license": "MIT",
"exports": { "exports": {
".": "./src/index.js", ".": "./dist/index.js",
"./azure": "./src/azure/index.js", "./azure": "./dist/azure/index.js",
"./graph": "./src/graph/index.js", "./graph": "./dist/graph/index.js",
"./devops": "./src/devops/index.js" "./devops": "./dist/devops/index.js"
} }
} }

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

@@ -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";

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

@@ -0,0 +1,78 @@
// 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 { loadConfig } from "../index.ts";
// 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",
arm: "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", "arm"];
// 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]);
}
// Generic utility functions
export type AuthMode = "azure-identity" | "msal";
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);
}
}
// export function getAzureIdentityGraphAuthProvider(
// tenantId: string,
// clientId: string,
// ) {
// const credential = new DefaultAzureCredential({
// tenantId,
// managedIdentityClientId: clientId,
// });
// const getBearerToken = getBearerTokenProvider(
// credential,
// "https://graph.microsoft.com/.default",
// );
// return (done: (error: Error | null, accessToken: string | null) => void) => {
// void getBearerToken()
// .then((token) => done(null, token))
// .catch((err) => done(err as Error, null));
// };
// }

View File

@@ -2,83 +2,66 @@
import open, { apps } from "open"; import open, { apps } from "open";
import fs from "node:fs"; import fs from "node:fs";
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import { writeFile, mkdir, unlink } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { PublicClientApplication } from "@azure/msal-node"; import { PublicClientApplication } from "@azure/msal-node";
import os from "node:os"; import { getConfig, getConfigDir } from "@slawek/sk-tools";
import type {
AccountInfo,
AuthenticationResult,
ICachePlugin,
TokenCacheContext,
} from "@azure/msal-node";
const RESOURCE_SCOPE_BY_NAME = { import type { ResourceName } from "../azure/index.ts";
graph: "https://graph.microsoft.com/.default", import { RESOURCE_SCOPE_BY_NAME, DEFAULT_RESOURCES } from "../azure/index.ts";
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default", import { translateResourceNamesToScopes } from "./index.ts";
arm: "https://management.azure.com/.default",
};
const DEFAULT_RESOURCES = ["graph", "devops", "arm"];
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login"; const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
const BROWSER_KEYWORDS = Object.keys(apps).sort(); const BROWSER_KEYWORDS = Object.keys(apps).sort();
const OPEN_APPS = apps as Record<string, string | readonly string[]>;
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]); const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
const CONFIG_FILE_NAME = "config";
function getCacheRoot() { type SessionState = {
const isWindows = process.platform === "win32"; activeAccountUpn: string | null;
const userRoot = isWindows };
? process.env.LOCALAPPDATA || os.homedir()
: os.homedir();
return isWindows async function readSessionState(): Promise<SessionState> {
? path.join(userRoot, "sk-az-tools") const parsed = (await getConfig("sk-az-tools", CONFIG_FILE_NAME)) as { activeAccountUpn?: unknown };
: path.join(userRoot, ".config", "sk-az-tools"); return {
activeAccountUpn:
typeof parsed?.activeAccountUpn === "string"
? parsed.activeAccountUpn
: null,
};
} }
function getSessionFilePath() { async function writeSessionState(state: SessionState): Promise<void> {
return path.join(getCacheRoot(), "login-session.json"); const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.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 mkdir(path.dirname(sessionPath), { recursive: true });
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8"); await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
} }
async function clearSessionState() { async function clearSessionState(): Promise<void> {
try { try {
await unlink(getSessionFilePath()); const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`);
await unlink(sessionPath);
} catch (err) { } catch (err) {
if (err?.code !== "ENOENT") { if ((err as { code?: string } | null)?.code !== "ENOENT") {
throw err; throw err;
} }
} }
} }
function normalizeUpn(upn) { function writeStderr(message: string): void {
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
}
function writeStderr(message) {
process.stderr.write(`${message}\n`); process.stderr.write(`${message}\n`);
} }
function getBrowserAppName(browser) { function getBrowserAppName(browser?: string): string | readonly string[] | undefined {
if (!browser || browser.trim() === "") { if (!browser || browser.trim() === "") {
return null; return undefined;
} }
const keyword = BROWSER_KEYWORDS.find( const keyword = BROWSER_KEYWORDS.find(
@@ -90,10 +73,10 @@ function getBrowserAppName(browser) {
); );
} }
return apps[keyword]; return OPEN_APPS[keyword];
} }
function getBrowserKeyword(browser) { function getBrowserKeyword(browser?: string): string {
if (!browser || browser.trim() === "") { if (!browser || browser.trim() === "") {
return ""; return "";
} }
@@ -109,14 +92,13 @@ function getBrowserKeyword(browser) {
return keyword.toLowerCase(); return keyword.toLowerCase();
} }
function getBrowserOpenOptions({ browser, browserProfile }) { function getBrowserOpenOptions(browser?: string, browserProfile?: string): Parameters<typeof open>[1] {
const browserName = getBrowserAppName(browser); const browserName = getBrowserAppName(browser);
const options = browserName
? { wait: false, app: { name: browserName } }
: { wait: false };
if (!browserProfile || browserProfile.trim() === "") { if (!browserProfile || browserProfile.trim() === "") {
return options; return browserName
? { wait: false, app: { name: browserName } }
: { wait: false };
} }
const browserKeyword = getBrowserKeyword(browser); const browserKeyword = getBrowserKeyword(browser);
@@ -126,11 +108,20 @@ function getBrowserOpenOptions({ browser, browserProfile }) {
); );
} }
options.app.arguments = [`--profile-directory=${browserProfile.trim()}`]; if (!browserName) {
return options; throw new Error("--browser-profile requires --browser");
}
return {
wait: false,
app: {
name: browserName,
arguments: [`--profile-directory=${browserProfile.trim()}`],
},
};
} }
function validateBrowserOptions({ browser, browserProfile }) { function validateBrowserOptions(browser?: string, browserProfile?: string): void {
if (browser && browser.trim() !== "") { if (browser && browser.trim() !== "") {
getBrowserAppName(browser); getBrowserAppName(browser);
} }
@@ -145,7 +136,7 @@ function validateBrowserOptions({ browser, browserProfile }) {
} }
} }
export function parseResources(resourcesCsv) { export function parseResources(resourcesCsv?: string): ResourceName[] {
if (!resourcesCsv || resourcesCsv.trim() === "") { if (!resourcesCsv || resourcesCsv.trim() === "") {
return [...DEFAULT_RESOURCES]; return [...DEFAULT_RESOURCES];
} }
@@ -156,24 +147,24 @@ export function parseResources(resourcesCsv) {
.filter(Boolean); .filter(Boolean);
const unique = [...new Set(resources)]; const unique = [...new Set(resources)];
const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]); const invalid = unique.filter((name) => !Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, name));
if (invalid.length > 0) { if (invalid.length > 0) {
throw new Error( throw new Error(
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`, `Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
); );
} }
return unique; return unique as ResourceName[];
} }
function fileCachePlugin(cachePath) { function fileCachePlugin(cachePath: string): ICachePlugin {
return { return {
beforeCacheAccess: async (ctx) => { beforeCacheAccess: async (ctx: TokenCacheContext) => {
if (fs.existsSync(cachePath)) { if (fs.existsSync(cachePath)) {
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8")); ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
} }
}, },
afterCacheAccess: async (ctx) => { afterCacheAccess: async (ctx: TokenCacheContext) => {
if (!ctx.cacheHasChanged) return; if (!ctx.cacheHasChanged) return;
fs.mkdirSync(path.dirname(cachePath), { recursive: true }); fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, ctx.tokenCache.serialize()); fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
@@ -182,10 +173,10 @@ function fileCachePlugin(cachePath) {
}; };
} }
async function createPca({ tenantId, clientId }) { async function createPca(tenantId: string, clientId: string): Promise<PublicClientApplication> {
const cacheRoot = getCacheRoot(); const cacheRoot = getConfigDir("sk-az-tools");
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`); const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
let cachePlugin; let cachePlugin: ICachePlugin;
try { try {
const { const {
DataProtectionScope, DataProtectionScope,
@@ -201,7 +192,7 @@ async function createPca({ tenantId, clientId }) {
usePlaintextFileOnLinux: true, usePlaintextFileOnLinux: true,
}); });
cachePlugin = new PersistenceCachePlugin(persistence); cachePlugin = new PersistenceCachePlugin(persistence);
} catch (err) { } catch {
// Fallback when msal-node-extensions/keytar/libsecret are unavailable. // Fallback when msal-node-extensions/keytar/libsecret are unavailable.
cachePlugin = fileCachePlugin(cachePath); cachePlugin = fileCachePlugin(cachePath);
} }
@@ -217,7 +208,11 @@ async function createPca({ tenantId, clientId }) {
}); });
} }
async function acquireTokenWithCache({ pca, scopes, account }) { async function acquireTokenWithCache(
pca: PublicClientApplication,
scopes: string[],
account?: AccountInfo | null,
): Promise<AuthenticationResult | null> {
if (account) { if (account) {
try { try {
return await pca.acquireTokenSilent({ return await pca.acquireTokenSilent({
@@ -244,65 +239,74 @@ async function acquireTokenWithCache({ pca, scopes, account }) {
return null; return null;
} }
async function findAccountByUpn({ pca, upn }) { async function findAccountByUpn(
const normalized = normalizeUpn(upn); pca: PublicClientApplication,
upn: string,
): Promise<AccountInfo | null> {
const normalized = upn.trim().toLowerCase();
if (!normalized) { if (!normalized) {
return null; return null;
} }
const accounts = await pca.getTokenCache().getAllAccounts(); const accounts = await pca.getTokenCache().getAllAccounts();
return ( return (
accounts.find((account) => normalizeUpn(account?.username) === normalized) ?? accounts.find((account) => account.username.trim().toLowerCase() === normalized) ??
null null
); );
} }
export async function loginInteractive({ export async function loginInteractive(
tenantId, tenantId: string | undefined,
clientId, clientId: string | undefined,
scopes, scopes: string[],
showAuthUrlOnly = false, showAuthUrlOnly = false,
browser, browser?: string,
browserProfile, browserProfile?: string,
}) { ): Promise<AuthenticationResult | null> {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
if (!Array.isArray(scopes) || scopes.length === 0) if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error("scopes[] is required"); throw new Error("scopes[] is required");
validateBrowserOptions({ browser, browserProfile }); }
validateBrowserOptions(browser, browserProfile);
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const cached = await acquireTokenWithCache({ pca, scopes }); const cached = await acquireTokenWithCache(pca, scopes);
if (cached) return cached; if (cached) return cached;
return await pca.acquireTokenInteractive({ return pca.acquireTokenInteractive({
scopes, scopes,
openBrowser: async (url) => { openBrowser: async (url: string) => {
if (showAuthUrlOnly) { if (showAuthUrlOnly) {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
return; return;
} }
const options = getBrowserOpenOptions({ browser, browserProfile }); const options = getBrowserOpenOptions(browser, browserProfile);
return open(url, options).catch(() => { await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
}); });
}, },
}); });
} }
export async function loginDeviceCode({ tenantId, clientId, scopes }) { export async function loginDeviceCode(
tenantId: string,
clientId: string,
scopes: string[],
): Promise<AuthenticationResult | null> {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
if (!Array.isArray(scopes) || scopes.length === 0) if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error("scopes[] is required"); throw new Error("scopes[] is required");
}
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const cached = await acquireTokenWithCache({ pca, scopes }); const cached = await acquireTokenWithCache(pca, scopes);
if (cached) return cached; if (cached) return cached;
return await pca.acquireTokenByDeviceCode({ return pca.acquireTokenByDeviceCode({
scopes, scopes,
deviceCodeCallback: (response) => { deviceCodeCallback: (response) => {
writeStderr(response.message); writeStderr(response.message);
@@ -310,62 +314,58 @@ export async function loginDeviceCode({ tenantId, clientId, scopes }) {
}); });
} }
export async function login({ export async function login(
tenantId, tenantId: string,
clientId, clientId: string,
resourcesCsv, resourcesCsv?: string,
useDeviceCode = false, useDeviceCode = false,
noBrowser = false, noBrowser = false,
browser, browser?: string,
browserProfile, 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 (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
validateBrowserOptions({ browser, browserProfile }); validateBrowserOptions(browser, browserProfile);
const resources = parseResources(resourcesCsv); const resources = parseResources(resourcesCsv);
const scopes = resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]); const scopes = translateResourceNamesToScopes(resources);
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const session = await readSessionState(); const session = await readSessionState();
const preferredAccount = await findAccountByUpn({ const preferredAccount = session.activeAccountUpn
pca, ? await findAccountByUpn(pca, session.activeAccountUpn)
upn: session.activeAccountUpn, : null;
});
const results = []; const results: Array<{ resource: string; expiresOn: string | null }> = [];
let selectedAccount = preferredAccount; let selectedAccount: AccountInfo | null = preferredAccount;
for (let index = 0; index < resources.length; index += 1) { let token = await acquireTokenWithCache(pca, scopes, selectedAccount);
const resource = resources[index];
const scope = [scopes[index]];
let token = await acquireTokenWithCache({
pca,
scopes: scope,
account: selectedAccount,
});
if (!token) { if (!token) {
if (useDeviceCode) { if (useDeviceCode) {
token = await pca.acquireTokenByDeviceCode({ token = await pca.acquireTokenByDeviceCode({
scopes: scope, scopes: scopes,
deviceCodeCallback: (response) => { deviceCodeCallback: (response) => {
writeStderr(response.message); writeStderr(response.message);
}, },
}); });
} else { } else {
token = await pca.acquireTokenInteractive({ token = await pca.acquireTokenInteractive({
scopes: scope, scopes: scopes,
openBrowser: async (url) => { openBrowser: async (url: string) => {
if (noBrowser) { if (noBrowser) {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
return; return;
} }
const options = getBrowserOpenOptions({ browser, browserProfile }); const options = getBrowserOpenOptions(browser, browserProfile);
return open(url, options).catch(() => { await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
}); });
}, },
}); });
}
} }
if (token?.account) { if (token?.account) {
@@ -373,7 +373,7 @@ export async function login({
} }
results.push({ results.push({
resource, resource: resources.join(","),
expiresOn: token?.expiresOn?.toISOString?.() ?? null, expiresOn: token?.expiresOn?.toISOString?.() ?? null,
}); });
} }
@@ -391,54 +391,49 @@ export async function login({
}; };
} }
export async function acquireResourceTokenFromLogin({ export async function getTokenUsingMsal(
tenantId, tenantId: string,
clientId, clientId: string,
resource, resources: string[],
}) { ): Promise<AuthenticationResult | null> {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
if (!resource) throw new Error("resource is required"); if (!resources || resources.length === 0) throw new Error("resources are required");
const scope = RESOURCE_SCOPE_BY_NAME[resource];
if (!scope) {
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
}
const session = await readSessionState(); const session = await readSessionState();
if (!session.activeAccountUpn) { if (!session.activeAccountUpn) {
throw new Error(LOGIN_REQUIRED_MESSAGE); throw new Error(LOGIN_REQUIRED_MESSAGE);
} }
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const account = await findAccountByUpn({ const account = await findAccountByUpn(pca, session.activeAccountUpn);
pca,
upn: session.activeAccountUpn,
});
if (!account) { if (!account) {
throw new Error(LOGIN_REQUIRED_MESSAGE); throw new Error(LOGIN_REQUIRED_MESSAGE);
} }
// Convert short names of scopes to full resource scopes
const scopes = resources.map((res) => RESOURCE_SCOPE_BY_NAME[res as ResourceName] || res);
try { try {
return await pca.acquireTokenSilent({ return await pca.acquireTokenSilent({
account, account,
scopes: [scope], scopes,
}); });
} catch { } catch {
throw new Error(LOGIN_REQUIRED_MESSAGE); throw new Error(LOGIN_REQUIRED_MESSAGE);
} }
} }
export async function logout({ export async function logout(
tenantId, tenantId: string,
clientId, clientId: string,
clearAll = false, clearAll = false,
userPrincipalName, userPrincipalName?: string,
}) { ): Promise<{ clearedAll: boolean; signedOut: string[] }> {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
const pca = await createPca({ tenantId, clientId }); const pca = await createPca(tenantId, clientId);
const tokenCache = pca.getTokenCache(); const tokenCache = pca.getTokenCache();
const accounts = await tokenCache.getAllAccounts(); const accounts = await tokenCache.getAllAccounts();
const session = await readSessionState(); const session = await readSessionState();
@@ -450,13 +445,14 @@ export async function logout({
await clearSessionState(); await clearSessionState();
return { return {
clearedAll: true, clearedAll: true,
signedOut: accounts.map((account) => account.username).filter(Boolean), signedOut: accounts.map((account) => account.username).filter((name): name is string => Boolean(name)),
}; };
} }
const targetUpn = normalizeUpn(userPrincipalName) || normalizeUpn(session.activeAccountUpn); const targetUpn = (typeof userPrincipalName === "string" ? userPrincipalName.trim().toLowerCase() : "")
|| (typeof session.activeAccountUpn === "string" ? session.activeAccountUpn.trim().toLowerCase() : "");
const accountToSignOut = accounts.find( const accountToSignOut = accounts.find(
(account) => normalizeUpn(account.username) === targetUpn, (account) => account.username.trim().toLowerCase() === targetUpn,
); );
if (!accountToSignOut) { if (!accountToSignOut) {
@@ -468,6 +464,6 @@ export async function logout({
await clearSessionState(); await clearSessionState();
return { return {
clearedAll: false, clearedAll: false,
signedOut: [accountToSignOut.username].filter(Boolean), 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);
});

129
src/cli.ts Normal file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import { Command, Option } from "commander";
import { renderCliOutput } from "@slawek/sk-tools";
// 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 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")
.option("--resources <csv>", "Comma-separated resources: graph,devops,arm")
.option("--use-device-code", "Use device code flow")
.option("--no-browser", "Do not launch browser")
.option("--browser <name>", "Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox")
.option("--browser-profile <name>", "Chromium profile name")
.action(async (options) => {
const output = await runLoginCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns);
});
skAzTools
.command("logout")
.description("Sign out and clear login state")
.option("--all", "Clear login state and remove all cached accounts")
.action(async (options) => {
const output = await runLogoutCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns);
});
skAzTools
.command("get-token")
.description("Get access token (azurerm|devops)")
.addOption(new Option("-t, --type <value>", "Token type").choices(["azurerm", "devops"]))
.action(async (options) => {
const output = await runGetTokenCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns);
});
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`)
.action(async (url, options) => {
const output = await runRestCommand(url, options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().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) => {
const output = await runListAppsCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().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) => {
const output = await runListAppPermissionsCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns);
});
skAzTools
.command("list-app-grants")
.description("List OAuth2 grants for an app")
.option("-i, --app-id <appId>", "Application (client) ID")
.action(async (options) => {
const output = await runListAppGrantsCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().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) => {
const output = await runListResourcePermissionsCommand(options);
renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns);
});
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}`);
}
}

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

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

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT
import { getAccessToken } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadAuthConfig } from "../../index.ts";
type GetTokenOptions = {
type?: string;
};
export async function runGetTokenCommand(
options: GetTokenOptions,
): Promise<unknown> {
const tokenType = (options.type ?? "").toString().trim().toLowerCase();
if (!tokenType) {
throw new Error(
"--type is required for get-token (allowed: azurerm, devops)",
);
}
const config = await loadAuthConfig("public-config");
if (tokenType === "azurerm") {
const accessToken = await getAccessToken(config.tenantId, config.clientId, ["arm"]);
if (!accessToken) {
throw new Error("Failed to obtain AzureRM token");
}
return {
tokenType,
accessToken,
};
}
if (tokenType === "devops") {
const accessToken = await getDevOpsApiToken(
config.tenantId,
config.clientId,
);
return {
tokenType,
accessToken,
};
}
throw new Error(`Invalid --type '${options.type}'. Allowed: azurerm, devops`);
}

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;
}

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

@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
import { login } from "../../azure/index.ts";
import { loadAuthConfig } from "../../index.ts";
type LoginOptions = {
resources?: string;
useDeviceCode?: boolean;
noBrowser?: boolean;
browser?: string;
browserProfile?: string;
};
export async function runLoginCommand(options: LoginOptions): Promise<unknown> {
const config = await loadAuthConfig("public-config");
return login(
config.tenantId,
config.clientId,
options.resources,
Boolean(options.useDeviceCode),
Boolean(options.noBrowser),
options.browser,
options.browserProfile,
);
}

View File

@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
import { logout } from "../../azure/index.ts";
import { loadAuthConfig } from "../../index.ts";
type LogoutOptions = {
all?: boolean;
};
export async function runLogoutCommand(options: LogoutOptions): Promise<unknown> {
const config = await loadAuthConfig("public-config");
return logout(config.tenantId, config.clientId, Boolean(options.all));
}

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

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: MIT
import { getAccessToken } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadAuthConfig } from "../../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;
}
async function getAutoAuthorizationHeader(url: URL): Promise<string | null> {
const host = url.hostname.toLowerCase();
if (host !== "management.azure.com" && host !== "dev.azure.com") {
return null;
}
const config = await loadAuthConfig("public-config");
if (host === "management.azure.com") {
const accessToken = await getAccessToken(config.tenantId, config.clientId, ["arm"]);
if (!accessToken) {
throw new Error("Failed to obtain AzureRM token");
}
return `Bearer ${accessToken}`;
}
const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId);
return `Bearer ${accessToken}`;
}
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

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

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

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

View File

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

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

@@ -0,0 +1,54 @@
// 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,
) {
const credential = new DefaultAzureCredential({
tenantId,
managedIdentityClientId: clientId,
});
const getBearerToken = getBearerTokenProvider(
credential,
"https://graph.microsoft.com/.default",
);
return (done: (error: Error | null, accessToken: string | null) => void) => {
void getBearerToken()
.then((token) => done(null, token))
.catch((err) => done(err as Error, null));
};
}

View File

@@ -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";

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

@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
export * from "./auth.ts";
export * from "./app.ts";
export * from "./sp.ts";
import { loadAuthConfig, loadConfig } from "../index.ts";
import { Client } from "@microsoft/microsoft-graph-client";
import { getMsalAuthProvider, getAzureIdentityAuthProvider } from "./auth.ts";
export async function getGraphClient(): Promise<Client> {
const config = await loadConfig();
const authConfig = await loadAuthConfig("public-config");
const authProvider =
config.authMode === "azure-identity"
? getAzureIdentityAuthProvider(authConfig.tenantId, authConfig.clientId)
: 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"]
}