commit d42e1bf8b87258a5a1bc60156e9d6250b799fd33 Author: Slawomir Koszewski Date: Sat Feb 14 19:31:23 2026 +0100 Added AI Generate Azure DevOps Task. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10d07d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.vsix +task/AzureFederatedAuth/dist/ +build/ +AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6bec3a --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Azure DevOps Azure Federated Auth Task + +Private Azure DevOps extension with a single task: `AzureFederatedAuth@1`. + +The task requests an OIDC token for a selected AzureRM service connection and exports: + +- `ARM_OIDC_TOKEN` (secret) +- `ARM_TENANT_ID` +- `ARM_CLIENT_ID` +- `GIT_ACCESS_TOKEN` (secret, optional) + +## Requirements + +- Linux agents (YAML pipelines) +- Job setting that exposes OAuth token (`System.AccessToken`) +- AzureRM service connection with workload identity federation +- Visual Studio Marketplace publisher account (required to publish/share this extension, even for private org-only usage) + +## Build + +```bash +./scripts/build.sh +``` + +This builds the TypeScript task and creates a `.vsix` extension package in `build/`. + +## Publish privately + +Publishing (CLI or Web UI) uses the same model: +- Upload extension version under a Visual Studio Marketplace publisher +- Share that published extension with your Azure DevOps organization(s) + +There is no direct local `.vsix` install path to an org that bypasses the publisher model. + +```bash +AZDO_PAT='' ./scripts/publish.sh +``` + +Example: + +```bash +AZDO_PAT="$AZDO_PAT" ./scripts/publish.sh ./build/private.azuredevops-get-oidc-token-task-1.0.0.vsix private org-a org-b org-c +``` + +### Manual publish (Web UI) + +You can publish the generated `.vsix` manually in the Visual Studio Marketplace publisher portal: + +1. Build/package first (`./scripts/build.sh`) and note the `.vsix` path. +2. Open your publisher in Visual Studio Marketplace. +3. Upload the `.vsix` as a new extension version. +4. Share the published extension with the target Azure DevOps organization(s). + +## YAML usage + +```yaml +- task: AzureFederatedAuth@1 + inputs: + serviceConnectionARM: 'my-arm-service-connection' + setGitAccessToken: true +``` + +See `examples/azure-pipelines-smoke.yml` for a full smoke validation pipeline. + +When `setGitAccessToken: true`, the task exchanges the OIDC assertion against Entra ID and requests scope `499b84ac-1321-427f-aa17-267ca6975798/.default`, then sets `GIT_ACCESS_TOKEN`. diff --git a/examples/azure-pipelines-smoke.yml b/examples/azure-pipelines-smoke.yml new file mode 100644 index 0000000..c50cb63 --- /dev/null +++ b/examples/azure-pipelines-smoke.yml @@ -0,0 +1,41 @@ +trigger: none +pr: none + +pool: + vmImage: ubuntu-latest + +steps: + - checkout: self + + - task: AzureFederatedAuth@1 + displayName: Get ARM OIDC token + inputs: + serviceConnectionARM: 'my-arm-service-connection' + setGitAccessToken: true + + - bash: | + set -euo pipefail + if [[ -z "${ARM_OIDC_TOKEN:-}" ]]; then + echo "ARM_OIDC_TOKEN is missing" + exit 1 + fi + + if [[ -z "${ARM_TENANT_ID:-}" ]]; then + echo "ARM_TENANT_ID is missing" + exit 1 + fi + + if [[ -z "${ARM_CLIENT_ID:-}" ]]; then + echo "ARM_CLIENT_ID is missing" + exit 1 + fi + + if [[ -z "${GIT_ACCESS_TOKEN:-}" ]]; then + echo "GIT_ACCESS_TOKEN is missing" + exit 1 + fi + + echo "ARM variables are populated." + echo "Tenant: $ARM_TENANT_ID" + echo "Client: $ARM_CLIENT_ID" + displayName: Validate exported variables diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000..dca403f Binary files /dev/null and b/images/icon.png differ diff --git a/images/icon.svg b/images/icon.svg new file mode 100644 index 0000000..695efbf --- /dev/null +++ b/images/icon.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..8af146c --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TASK_DIR="$ROOT_DIR/task/AzureFederatedAuth" +BUILD_DIR="$ROOT_DIR/build" + +cd "$TASK_DIR" +rm -rf dist node_modules +npm ci +npm run build + +cd "$ROOT_DIR" +mkdir -p "$BUILD_DIR" +npx tfx-cli extension create \ + --manifest-globs vss-extension.json \ + --output-path "$BUILD_DIR" diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..83717d8 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 3 ]]; then + echo "Usage: $0 [org2] [org3] ..." + echo "Requires environment variable AZDO_PAT to be set." + exit 1 +fi + +if [[ -z "${AZDO_PAT:-}" ]]; then + echo "AZDO_PAT is not set." + exit 1 +fi + +VSIX_PATH="$1" +PUBLISHER_ID="$2" +shift 2 + +for ORG in "$@"; do + echo "Publishing to organization: $ORG" + npx tfx-cli extension publish \ + --vsix "$VSIX_PATH" \ + --publisher "$PUBLISHER_ID" \ + --token "$AZDO_PAT" \ + --share-with "$ORG" +done diff --git a/task/AzureFederatedAuth/package-lock.json b/task/AzureFederatedAuth/package-lock.json new file mode 100644 index 0000000..6e825f0 --- /dev/null +++ b/task/AzureFederatedAuth/package-lock.json @@ -0,0 +1,454 @@ +{ + "name": "azure-federated-auth-task", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "azure-federated-auth-task", + "version": "1.0.0", + "dependencies": { + "azure-pipelines-task-lib": "^4.10.2" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/azure-pipelines-task-lib": { + "version": "4.17.3", + "resolved": "https://registry.npmjs.org/azure-pipelines-task-lib/-/azure-pipelines-task-lib-4.17.3.tgz", + "integrity": "sha512-UxfH5pk3uOHTi9TtLtdDyugQVkFES5A836ZEePjcs3jYyxm3EJ6IlFYq6gbfd6mNBhrM9fxG2u/MFYIJ+Z0cxQ==", + "license": "MIT", + "dependencies": { + "adm-zip": "^0.5.10", + "minimatch": "3.0.5", + "nodejs-file-downloader": "^4.11.1", + "q": "^1.5.1", + "semver": "^5.7.2", + "shelljs": "^0.8.5", + "uuid": "^3.0.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nodejs-file-downloader": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/nodejs-file-downloader/-/nodejs-file-downloader-4.13.0.tgz", + "integrity": "sha512-nI2fKnmJWWFZF6SgMPe1iBodKhfpztLKJTtCtNYGhm/9QXmWa/Pk9Sv00qHgzEvNLe1x7hjGDRor7gcm/ChaIQ==", + "license": "ISC", + "dependencies": { + "follow-redirects": "^1.15.6", + "https-proxy-agent": "^5.0.0", + "mime-types": "^2.1.27", + "sanitize-filename": "^1.6.3" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "license": "BSD-3-Clause", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "license": "(WTFPL OR MIT)" + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/task/AzureFederatedAuth/package.json b/task/AzureFederatedAuth/package.json new file mode 100644 index 0000000..79a5245 --- /dev/null +++ b/task/AzureFederatedAuth/package.json @@ -0,0 +1,18 @@ +{ + "name": "azure-federated-auth-task", + "version": "1.0.0", + "private": true, + "description": "Azure DevOps private task to fetch OIDC token for AzureRM service connection.", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist" + }, + "dependencies": { + "azure-pipelines-task-lib": "^4.10.2" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } +} diff --git a/task/AzureFederatedAuth/src/index.ts b/task/AzureFederatedAuth/src/index.ts new file mode 100644 index 0000000..4393835 --- /dev/null +++ b/task/AzureFederatedAuth/src/index.ts @@ -0,0 +1,182 @@ +import * as crypto from 'crypto'; +import * as tl from 'azure-pipelines-task-lib/task'; + +type OidcResponse = { + oidcToken?: string; +}; + +type EntraTokenResponse = { + access_token?: string; + error?: string; + error_description?: string; +}; + +const AZDO_APP_SCOPE = '499b84ac-1321-427f-aa17-267ca6975798/.default'; +const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + +function requireVariable(name: string): string { + const value = tl.getVariable(name); + if (!value) { + throw new Error(`Missing required pipeline variable: ${name}.`); + } + + return value; +} + +function getServiceConnectionMetadata(endpointId: string): { tenantId: string; clientId: string } { + const tenantId = + tl.getEndpointAuthorizationParameter(endpointId, 'tenantid', true) || + tl.getEndpointDataParameter(endpointId, 'tenantid', true); + + const clientId = + tl.getEndpointAuthorizationParameter(endpointId, 'serviceprincipalid', true) || + tl.getEndpointAuthorizationParameter(endpointId, 'clientid', true) || + tl.getEndpointDataParameter(endpointId, 'serviceprincipalid', true); + + if (!tenantId) { + throw new Error('Could not resolve tenant ID from the selected AzureRM service connection.'); + } + + if (!clientId) { + throw new Error('Could not resolve client ID from the selected AzureRM service connection.'); + } + + return { tenantId, clientId }; +} + +function buildOidcUrl(baseUrl: string, serviceConnectionId: string): string { + const url = new URL(baseUrl); + url.searchParams.set('api-version', '7.1'); + url.searchParams.set('serviceConnectionId', serviceConnectionId); + return url.toString(); +} + +function isJwtLike(value: string): boolean { + const parts = value.split('.'); + return parts.length === 3 && parts.every((part) => part.length > 0); +} + +async function requestOidcToken(requestUrl: string, accessToken: string): Promise { + const response = await fetch(requestUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Content-Length': '0' + } + }); + + if (!response.ok) { + const responseBody = await response.text(); + throw new Error( + `OIDC request failed with status ${response.status} ${response.statusText}. Response: ${responseBody}` + ); + } + + const data = (await response.json()) as OidcResponse; + const token = data.oidcToken?.trim(); + + if (!token) { + throw new Error('OIDC response did not include a non-empty oidcToken field.'); + } + + if (!isJwtLike(token)) { + throw new Error('OIDC token format is invalid (expected JWT).'); + } + + return token; +} + +async function exchangeOidcForAzureDevOpsToken( + tenantId: string, + clientId: string, + oidcToken: string +): Promise { + const tokenUrl = `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`; + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + scope: AZDO_APP_SCOPE, + client_assertion_type: CLIENT_ASSERTION_TYPE, + client_assertion: oidcToken + }); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: body.toString() + }); + + const rawBody = await response.text(); + let data: EntraTokenResponse = {}; + + if (rawBody.trim().length > 0) { + try { + data = JSON.parse(rawBody) as EntraTokenResponse; + } catch { + // Keep rawBody for error details when the response is not JSON. + } + } + + const token = data.access_token?.trim(); + + if (!response.ok) { + const errorDetails = + data.error_description || data.error || rawBody.trim() || 'Unknown token exchange error.'; + throw new Error( + `Failed to exchange OIDC token for Azure DevOps Git token (${response.status} ${response.statusText}): ${errorDetails}` + ); + } + + if (!token) { + throw new Error('Token exchange succeeded but no access_token was returned.'); + } + + return token; +} + +async function run(): Promise { + try { + const endpointId = tl.getInput('serviceConnectionARM', true); + const setGitAccessToken = tl.getBoolInput('setGitAccessToken', false); + if (!endpointId) { + throw new Error('Task input serviceConnectionARM is required.'); + } + + const oidcBaseUrl = requireVariable('System.OidcRequestUri'); + const accessToken = requireVariable('System.AccessToken'); + + console.log('Requesting OIDC token for ARM authentication...'); + + const requestUrl = buildOidcUrl(oidcBaseUrl, endpointId); + const token = await requestOidcToken(requestUrl, accessToken); + const metadata = getServiceConnectionMetadata(endpointId); + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + tl.setVariable('ARM_OIDC_TOKEN', token, true); + tl.setVariable('ARM_TENANT_ID', metadata.tenantId); + tl.setVariable('ARM_CLIENT_ID', metadata.clientId); + + console.log('Successfully retrieved OIDC token.'); + console.log(`OIDC Token SHA256: ${tokenHash}`); + + if (setGitAccessToken) { + console.log('Exchanging OIDC token for Azure DevOps scoped Git access token...'); + const gitToken = await exchangeOidcForAzureDevOpsToken(metadata.tenantId, metadata.clientId, token); + const gitTokenHash = crypto.createHash('sha256').update(gitToken).digest('hex'); + tl.setVariable('GIT_ACCESS_TOKEN', gitToken, true); + console.log(`GIT Access Token SHA256: ${gitTokenHash}`); + } + + tl.setResult(tl.TaskResult.Succeeded, 'ARM OIDC variables configured.'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + tl.error(message); + tl.setResult(tl.TaskResult.Failed, `Failed to configure ARM OIDC variables: ${message}`); + } +} + +void run(); diff --git a/task/AzureFederatedAuth/task.json b/task/AzureFederatedAuth/task.json new file mode 100644 index 0000000..16c59f4 --- /dev/null +++ b/task/AzureFederatedAuth/task.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://raw.githubusercontent.com/Microsoft/azure-pipelines-task-lib/master/tasks.schema.json", + "id": "11c532b8-f2bd-45f8-ac94-3b7e44608cc4", + "name": "AzureFederatedAuth", + "friendlyName": "Azure Federated Auth", + "description": "Requests an OIDC token for an AzureRM service connection and exports ARM_OIDC_TOKEN, ARM_TENANT_ID, ARM_CLIENT_ID, and optionally GIT_ACCESS_TOKEN for Azure DevOps Git HTTPS auth.", + "helpMarkDown": "Private task for YAML pipelines on Linux agents.", + "category": "Deploy", + "author": "private", + "version": { + "Major": 1, + "Minor": 0, + "Patch": 0 + }, + "instanceNameFormat": "Configure Azure federated auth for $(serviceConnectionARM)", + "inputs": [ + { + "name": "serviceConnectionARM", + "type": "connectedService:AzureRM", + "label": "Azure Resource Manager service connection", + "defaultValue": "", + "required": true, + "helpMarkDown": "AzureRM service connection used to request the OIDC token." + }, + { + "name": "setGitAccessToken", + "type": "boolean", + "label": "Also set GIT_ACCESS_TOKEN for Azure DevOps Git HTTPS auth", + "defaultValue": "false", + "required": false, + "helpMarkDown": "When enabled, exchanges the OIDC token for an Entra access token scoped to Azure DevOps (499b84ac-1321-427f-aa17-267ca6975798/.default) and sets secret variable GIT_ACCESS_TOKEN." + } + ], + "execution": { + "Node20_1": { + "target": "dist/index.js" + } + }, + "minimumAgentVersion": "3.225.0" +} diff --git a/task/AzureFederatedAuth/tsconfig.json b/task/AzureFederatedAuth/tsconfig.json new file mode 100644 index 0000000..b82293d --- /dev/null +++ b/task/AzureFederatedAuth/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/vss-extension.json b/vss-extension.json new file mode 100644 index 0000000..6071c5b --- /dev/null +++ b/vss-extension.json @@ -0,0 +1,41 @@ +{ + "manifestVersion": 1, + "id": "azuredevops-get-oidc-token-task", + "name": "Azure DevOps AzureFederatedAuth Task", + "version": "1.0.0", + "publisher": "private", + "targets": [ + { + "id": "Microsoft.VisualStudio.Services" + } + ], + "description": "Private Azure DevOps task to request an OIDC token for an AzureRM service connection and expose Terraform ARM variables.", + "categories": [ + "Azure Pipelines" + ], + "public": false, + "icons": { + "default": "images/icon.png" + }, + "files": [ + { + "path": "task/AzureFederatedAuth" + }, + { + "path": "images", + "addressable": true + } + ], + "contributions": [ + { + "id": "c5832ba7-adfc-4723-8bc6-ab6287c78b3d", + "type": "ms.vss-distributed-task.task", + "targets": [ + "ms.vss-distributed-task.tasks" + ], + "properties": { + "name": "task/AzureFederatedAuth" + } + } + ] +}