Added AI Generate Azure DevOps Task.

This commit is contained in:
2026-02-14 19:31:23 +01:00
commit d42e1bf8b8
13 changed files with 942 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
*.vsix
task/AzureFederatedAuth/dist/
build/
AGENTS.md

65
README.md Normal file
View File

@@ -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='<your-pat>' ./scripts/publish.sh <vsix-path> <publisher-id> <org1> <org2> <org3>
```
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`.

View File

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

BIN
images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

39
images/icon.svg Normal file
View File

@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-label="Azure Git Federation">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0078D4"/>
<stop offset="100%" stop-color="#004E8C"/>
</linearGradient>
<linearGradient id="azureFacet" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#9AD7FF"/>
<stop offset="100%" stop-color="#59B9FF"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="240" height="240" rx="40" fill="url(#bg)"/>
<rect x="8" y="8" width="240" height="240" rx="40" fill="none" stroke="#9ED8FF" stroke-opacity="0.35" stroke-width="2"/>
<path d="M48 196 L100 56 L130 56 L78 196 Z" fill="url(#azureFacet)"/>
<path d="M114 196 L164 76 L208 196 Z" fill="#D9F1FF" fill-opacity="0.92"/>
<!-- Federation links moved up to avoid overlap with Git branches -->
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M84 128 C84 114 96 104 110 104 L120 104" stroke="#FFD9A3" stroke-width="12"/>
<path d="M172 128 C172 114 160 104 146 104 L136 104" stroke="#FFD9A3" stroke-width="12"/>
<path d="M84 128 C84 142 96 152 110 152 L120 152" stroke="#FFC266" stroke-width="12"/>
<path d="M172 128 C172 142 160 152 146 152 L136 152" stroke="#FFC266" stroke-width="12"/>
</g>
<g stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" fill="none">
<path d="M70 208 V170 H102"/>
<path d="M186 208 V170 H154"/>
<path d="M128 208 V178"/>
</g>
<g fill="#FFB347" stroke="#FFFFFF" stroke-width="5">
<circle cx="70" cy="208" r="10"/>
<circle cx="128" cy="208" r="10"/>
<circle cx="186" cy="208" r="10"/>
<circle cx="128" cy="178" r="10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

17
scripts/build.sh Executable file
View File

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

26
scripts/publish.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 3 ]]; then
echo "Usage: $0 <vsix-path> <publisher-id> <org1> [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

454
task/AzureFederatedAuth/package-lock.json generated Normal file
View File

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

View File

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

View File

@@ -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<string> {
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<string> {
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<void> {
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();

View File

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

View File

@@ -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"]
}

41
vss-extension.json Normal file
View File

@@ -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"
}
}
]
}