feat: refactored common OIDC code and moved to shared sources directory.
This commit is contained in:
98
README.md
98
README.md
@@ -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`
|
||||
- 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.
|
||||
|
||||
`AzureFederatedAuth@1` requests an OIDC token for a selected AzureRM service connection and exports:
|
||||
## Repository layout
|
||||
|
||||
- `ARM_OIDC_TOKEN` (secret)
|
||||
- `ARM_TENANT_ID`
|
||||
- `ARM_CLIENT_ID`
|
||||
- `GIT_ACCESS_TOKEN` (secret, optional)
|
||||
- `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
|
||||
|
||||
## Requirements
|
||||
## Local development
|
||||
|
||||
- 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)
|
||||
Prerequisites:
|
||||
|
||||
## Build
|
||||
- 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
|
||||
|
||||
|
||||
4
task/AzureFederatedAuth/package-lock.json
generated
4
task/AzureFederatedAuth/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "azure-federated-auth-task",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"private": true,
|
||||
"author": "Slawomir Koszewski",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
],
|
||||
"execution": {
|
||||
"Node20_1": {
|
||||
"target": "dist/index.js"
|
||||
"target": "dist/AzureFederatedAuth/src/index.js"
|
||||
}
|
||||
},
|
||||
"minimumAgentVersion": "3.225.0"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
4
task/CopyBlob/package-lock.json
generated
4
task/CopyBlob/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "copy-blob-task",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"author": "Slawomir Koszewski",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
],
|
||||
"execution": {
|
||||
"Node20_1": {
|
||||
"target": "dist/index.js"
|
||||
"target": "dist/CopyBlob/src/index.js"
|
||||
}
|
||||
},
|
||||
"minimumAgentVersion": "3.225.0"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
153
task/_shared/src/oidc.ts
Normal file
153
task/_shared/src/oidc.ts
Normal 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;
|
||||
}
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user