feat: refactored common OIDC code and moved to shared sources directory.

This commit is contained in:
2026-02-25 07:16:56 +01:00
parent 41aee35e73
commit 1ef0999a3e
13 changed files with 243 additions and 349 deletions

View File

@@ -1,81 +1,61 @@
# Azure DevOps Federated Auth Toolkit
# SK Azure DevOps Toolkit
Azure DevOps extension with two tasks:
Developer README for the Azure DevOps extension codebase.
For administrator-facing installation and usage guidance, see `overview.md`.
## Tasks in this extension
- `AzureFederatedAuth@1`
- `CopyBlob@1`
`AzureFederatedAuth@1` requests an OIDC token for a selected AzureRM service connection and exports:
- Requests an OIDC token for a selected AzureRM service connection (workload identity federation).
- Exports:
- `ARM_OIDC_TOKEN` (secret)
- `ARM_TENANT_ID`
- `ARM_CLIENT_ID`
- `GIT_ACCESS_TOKEN` (secret, optional)
- `CopyBlob@1`
- Copies a blob between Azure Storage accounts/containers using the selected AzureRM service connection.
## Requirements
## Repository layout
- 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, including org-only usage)
- `task/AzureFederatedAuth` - task implementation and manifest
- `task/CopyBlob` - task implementation and manifest
- `task/_shared` - shared OIDC/auth helpers used by tasks
- `scripts/build.sh` - builds tasks and packages the extension
- `examples/azure-pipelines-smoke.yml` - smoke pipeline example
## Build
## Local development
Prerequisites:
- Node.js (LTS)
- npm
Install dependencies (per task):
```bash
cd task/AzureFederatedAuth && npm install
cd ../CopyBlob && npm install
```
Build and package extension:
```bash
./scripts/build.sh
```
This builds the TypeScript task and creates a `.vsix` extension package in `build/`.
Build output:
## Publish privately
- Task JavaScript output in each task's `dist/`
- Extension package (`.vsix`) in `build/`
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)
## Validation pipeline
There is no direct local `.vsix` install path to an org that bypasses the publisher model.
Use `examples/azure-pipelines-smoke.yml` to validate task execution end-to-end in Azure Pipelines.
```bash
AZDO_PAT='<your-pat>' ./scripts/publish.sh <vsix-path> <publisher-id> <org1> <org2> <org3>
```
## Publishing notes (maintainers)
Example:
```bash
AZDO_PAT="$AZDO_PAT" ./scripts/publish.sh ./build/skoszewski-lab.azuredevops-get-oidc-token-task-1.0.5.vsix skoszewski-lab 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
printTokenHashes: false
- task: CopyBlob@1
inputs:
serviceConnectionARM: 'my-arm-service-connection'
srcStorageAccountName: 'srcaccount'
dstStorageAccountName: 'dstaccount'
srcContainerName: 'tfstate'
dstContainerName: 'tfstate-backup'
blobName: 'lz.tfstate'
```
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`.
Publishing requires a Visual Studio Marketplace publisher and sharing the published extension with target Azure DevOps organizations.
## Author

View File

@@ -1,12 +1,12 @@
{
"name": "azure-federated-auth-task",
"version": "1.0.6",
"version": "1.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "azure-federated-auth-task",
"version": "1.0.6",
"version": "1.0.7",
"license": "MIT",
"dependencies": {
"azure-pipelines-task-lib": "^5.2.6"

View File

@@ -1,6 +1,6 @@
{
"name": "azure-federated-auth-task",
"version": "1.0.6",
"version": "1.0.7",
"private": true,
"author": "Slawomir Koszewski",
"license": "MIT",

View File

@@ -1,159 +1,34 @@
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;
};
import {
buildOidcUrl,
exchangeOidcForScopedToken,
getServiceConnectionMetadata,
requireInput,
requestOidcToken,
requireVariable
} from '../../_shared/src/oidc';
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 endpointId = requireInput('serviceConnectionARM', tl.getInput);
const setGitAccessToken = tl.getBoolInput('setGitAccessToken', false);
const printTokenHashes = tl.getBoolInput('printTokenHashes', false);
if (!endpointId) {
throw new Error('Task input serviceConnectionARM is required.');
}
const oidcBaseUrl = requireVariable('System.OidcRequestUri');
const accessToken = requireVariable('System.AccessToken');
const oidcBaseUrl = requireVariable('System.OidcRequestUri', tl.getVariable);
const accessToken = requireVariable('System.AccessToken', tl.getVariable);
console.log('Requesting OIDC token for ARM authentication...');
const requestUrl = buildOidcUrl(oidcBaseUrl, endpointId);
const token = await requestOidcToken(requestUrl, accessToken);
const metadata = getServiceConnectionMetadata(endpointId);
const token = await requestOidcToken(requestUrl, accessToken, true);
const metadata = getServiceConnectionMetadata(
endpointId,
tl.getEndpointAuthorizationParameter,
tl.getEndpointDataParameter
);
tl.setVariable('ARM_OIDC_TOKEN', token, true);
tl.setVariable('ARM_TENANT_ID', metadata.tenantId);
@@ -167,7 +42,7 @@ async function run(): Promise<void> {
if (setGitAccessToken) {
console.log('Exchanging OIDC token for Azure DevOps scoped Git access token...');
const gitToken = await exchangeOidcForAzureDevOpsToken(metadata.tenantId, metadata.clientId, token);
const gitToken = await exchangeOidcForScopedToken(metadata.tenantId, metadata.clientId, token, AZDO_APP_SCOPE);
tl.setVariable('GIT_ACCESS_TOKEN', gitToken, true);
if (printTokenHashes) {
const gitTokenHash = crypto.createHash('sha256').update(gitToken).digest('hex');

View File

@@ -41,7 +41,7 @@
],
"execution": {
"Node20_1": {
"target": "dist/index.js"
"target": "dist/AzureFederatedAuth/src/index.js"
}
},
"minimumAgentVersion": "3.225.0"

View File

@@ -8,7 +8,7 @@
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": ".."
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,12 +1,12 @@
{
"name": "copy-blob-task",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "copy-blob-task",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"azure-pipelines-task-lib": "^5.2.6"

View File

@@ -1,6 +1,6 @@
{
"name": "copy-blob-task",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"author": "Slawomir Koszewski",
"license": "MIT",

View File

@@ -1,90 +1,14 @@
import * as tl from 'azure-pipelines-task-lib/task';
type OidcResponse = {
oidcToken?: string;
};
type TokenResponse = {
access_token?: string;
error?: string;
error_description?: string;
};
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
import {
buildOidcUrl,
exchangeOidcForScopedToken,
getServiceConnectionMetadata,
requireInput,
requestOidcToken,
requireVariable
} from '../../_shared/src/oidc';
const STORAGE_SCOPE = 'https://storage.azure.com/.default';
function requireInput(name: string): string {
const value = tl.getInput(name, true);
if (!value) {
throw new Error(`Task input ${name} is required.`);
}
return value.trim();
}
function requireVariable(name: string): string {
const value = tl.getVariable(name);
if (!value) {
throw new Error(`Missing required variable: ${name}.`);
}
return value.trim();
}
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();
}
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.');
}
return token;
}
function buildBlobUrl(accountName: string, containerName: string, blobName: string): string {
const trimmedBlobName = blobName.replace(/^\/+/, '');
const encodedContainer = encodeURIComponent(containerName);
@@ -96,48 +20,6 @@ function buildBlobUrl(accountName: string, containerName: string, blobName: stri
return `https://${accountName}.blob.core.windows.net/${encodedContainer}/${encodedBlobName}`;
}
async function exchangeOidcForStorageToken(tenantId: string, clientId: string, oidcToken: string): Promise<string> {
const tokenUrl = `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
const body = new URLSearchParams({
client_id: clientId,
scope: STORAGE_SCOPE,
grant_type: 'client_credentials',
client_assertion_type: CLIENT_ASSERTION_TYPE,
client_assertion: oidcToken
}).toString();
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body
});
const rawBody = await response.text();
let parsed: TokenResponse = {};
if (rawBody.trim()) {
try {
parsed = JSON.parse(rawBody) as TokenResponse;
} catch {
parsed = {};
}
}
if (!response.ok) {
const details = parsed.error_description || parsed.error || rawBody || 'Unknown token exchange error.';
throw new Error(`Token request failed (${response.status} ${response.statusText}): ${details}`);
}
const token = parsed.access_token?.trim();
if (!token) {
throw new Error('Token exchange succeeded but access_token is missing.');
}
return token;
}
async function copyBlob(
sourceUrl: string,
destinationUrl: string,
@@ -171,28 +53,32 @@ async function copyBlob(
async function run(): Promise<void> {
try {
const endpointId = requireInput('serviceConnectionARM');
const srcStorageAccountName = requireInput('srcStorageAccountName');
const dstStorageAccountName = requireInput('dstStorageAccountName');
const srcContainerName = requireInput('srcContainerName');
const endpointId = requireInput('serviceConnectionARM', tl.getInput);
const srcStorageAccountName = requireInput('srcStorageAccountName', tl.getInput);
const dstStorageAccountName = requireInput('dstStorageAccountName', tl.getInput);
const srcContainerName = requireInput('srcContainerName', tl.getInput);
const dstContainerNameInput = tl.getInput('dstContainerName', false)?.trim() || '';
const blobName = requireInput('blobName');
const blobName = requireInput('blobName', tl.getInput);
const oidcBaseUrl = requireVariable('System.OidcRequestUri');
const systemAccessToken = requireVariable('System.AccessToken');
const oidcBaseUrl = requireVariable('System.OidcRequestUri', tl.getVariable);
const systemAccessToken = requireVariable('System.AccessToken', tl.getVariable);
const metadata = getServiceConnectionMetadata(endpointId);
const metadata = getServiceConnectionMetadata(
endpointId,
tl.getEndpointAuthorizationParameter,
tl.getEndpointDataParameter
);
const oidcRequestUrl = buildOidcUrl(oidcBaseUrl, endpointId);
console.log('Requesting OIDC token for ARM authentication...');
const oidcToken = await requestOidcToken(oidcRequestUrl, systemAccessToken);
const oidcToken = await requestOidcToken(oidcRequestUrl, systemAccessToken, false);
const dstContainerName = dstContainerNameInput || srcContainerName;
const srcUrl = buildBlobUrl(srcStorageAccountName, srcContainerName, blobName);
const dstUrl = buildBlobUrl(dstStorageAccountName, dstContainerName, blobName);
console.log('Requesting storage access token from Microsoft Entra ID...');
const accessToken = await exchangeOidcForStorageToken(metadata.tenantId, metadata.clientId, oidcToken);
const accessToken = await exchangeOidcForScopedToken(metadata.tenantId, metadata.clientId, oidcToken, STORAGE_SCOPE);
console.log(`Copying blob ${srcStorageAccountName}/${srcContainerName}/${blobName} -> ${dstStorageAccountName}/${dstContainerName}/${blobName}...`);
const copyResult = await copyBlob(srcUrl, dstUrl, accessToken);

View File

@@ -65,7 +65,7 @@
],
"execution": {
"Node20_1": {
"target": "dist/index.js"
"target": "dist/CopyBlob/src/index.js"
}
},
"minimumAgentVersion": "3.225.0"

View File

@@ -8,7 +8,7 @@
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": ".."
},
"include": ["src/**/*.ts"]
}

153
task/_shared/src/oidc.ts Normal file
View File

@@ -0,0 +1,153 @@
export type ServiceConnectionMetadata = {
tenantId: string;
clientId: string;
};
export type TokenResponse = {
access_token?: string;
error?: string;
error_description?: string;
};
export const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
type VariableProvider = (name: string) => string | undefined;
type EndpointParamProvider = (endpointId: string, key: string, optional: boolean) => string | undefined;
type InputProvider = (name: string, required?: boolean) => string | undefined;
type OidcResponse = {
oidcToken?: string;
};
export function requireVariable(name: string, getVariable: VariableProvider): string {
const value = getVariable(name);
if (!value) {
throw new Error(`Missing required pipeline variable: ${name}.`);
}
return value.trim();
}
export function requireInput(name: string, getInput: InputProvider): string {
const value = getInput(name, true);
if (!value) {
throw new Error(`Task input ${name} is required.`);
}
return value.trim();
}
export function getServiceConnectionMetadata(
endpointId: string,
getEndpointAuthorizationParameter: EndpointParamProvider,
getEndpointDataParameter: EndpointParamProvider
): ServiceConnectionMetadata {
const tenantId =
getEndpointAuthorizationParameter(endpointId, 'tenantid', true) ||
getEndpointDataParameter(endpointId, 'tenantid', true);
const clientId =
getEndpointAuthorizationParameter(endpointId, 'serviceprincipalid', true) ||
getEndpointAuthorizationParameter(endpointId, 'clientid', true) ||
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 };
}
export 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);
}
export async function requestOidcToken(requestUrl: string, accessToken: string, validateJwt: boolean): 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 (validateJwt && !isJwtLike(token)) {
throw new Error('OIDC token format is invalid (expected JWT).');
}
return token;
}
export async function exchangeOidcForScopedToken(
tenantId: string,
clientId: string,
oidcToken: string,
scope: string
): Promise<string> {
const tokenUrl = `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
const body = new URLSearchParams({
client_id: clientId,
scope,
grant_type: 'client_credentials',
client_assertion_type: CLIENT_ASSERTION_TYPE,
client_assertion: oidcToken
}).toString();
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body
});
const rawBody = await response.text();
let parsed: TokenResponse = {};
if (rawBody.trim()) {
try {
parsed = JSON.parse(rawBody) as TokenResponse;
} catch {
parsed = {};
}
}
if (!response.ok) {
const details = parsed.error_description || parsed.error || rawBody || 'Unknown token exchange error.';
throw new Error(`Token request failed (${response.status} ${response.statusText}): ${details}`);
}
const token = parsed.access_token?.trim();
if (!token) {
throw new Error('Token exchange succeeded but access_token is missing.');
}
return token;
}

View File

@@ -2,7 +2,7 @@
"manifestVersion": 1,
"id": "sk-azure-devops-toolkit",
"name": "SK Azure DevOps Toolkit",
"version": "1.0.1",
"version": "1.0.2",
"publisher": "skoszewski-lab",
"targets": [
{