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`
|
- `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`
|
- `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)
|
- `task/AzureFederatedAuth` - task implementation and manifest
|
||||||
- `ARM_TENANT_ID`
|
- `task/CopyBlob` - task implementation and manifest
|
||||||
- `ARM_CLIENT_ID`
|
- `task/_shared` - shared OIDC/auth helpers used by tasks
|
||||||
- `GIT_ACCESS_TOKEN` (secret, optional)
|
- `scripts/build.sh` - builds tasks and packages the extension
|
||||||
|
- `examples/azure-pipelines-smoke.yml` - smoke pipeline example
|
||||||
|
|
||||||
## Requirements
|
## Local development
|
||||||
|
|
||||||
- Linux agents (YAML pipelines)
|
Prerequisites:
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Build
|
- Node.js (LTS)
|
||||||
|
- npm
|
||||||
|
|
||||||
|
Install dependencies (per task):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd task/AzureFederatedAuth && npm install
|
||||||
|
cd ../CopyBlob && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and package extension:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/build.sh
|
./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:
|
## Validation pipeline
|
||||||
- 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.
|
Use `examples/azure-pipelines-smoke.yml` to validate task execution end-to-end in Azure Pipelines.
|
||||||
|
|
||||||
```bash
|
## Publishing notes (maintainers)
|
||||||
AZDO_PAT='<your-pat>' ./scripts/publish.sh <vsix-path> <publisher-id> <org1> <org2> <org3>
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
Publishing requires a Visual Studio Marketplace publisher and sharing the published extension with target Azure DevOps organizations.
|
||||||
|
|
||||||
```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`.
|
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
|
|||||||
4
task/AzureFederatedAuth/package-lock.json
generated
4
task/AzureFederatedAuth/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "azure-federated-auth-task",
|
"name": "azure-federated-auth-task",
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "azure-federated-auth-task",
|
"name": "azure-federated-auth-task",
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"azure-pipelines-task-lib": "^5.2.6"
|
"azure-pipelines-task-lib": "^5.2.6"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "azure-federated-auth-task",
|
"name": "azure-federated-auth-task",
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Slawomir Koszewski",
|
"author": "Slawomir Koszewski",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,159 +1,34 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import * as tl from 'azure-pipelines-task-lib/task';
|
import * as tl from 'azure-pipelines-task-lib/task';
|
||||||
|
import {
|
||||||
type OidcResponse = {
|
buildOidcUrl,
|
||||||
oidcToken?: string;
|
exchangeOidcForScopedToken,
|
||||||
};
|
getServiceConnectionMetadata,
|
||||||
|
requireInput,
|
||||||
type EntraTokenResponse = {
|
requestOidcToken,
|
||||||
access_token?: string;
|
requireVariable
|
||||||
error?: string;
|
} from '../../_shared/src/oidc';
|
||||||
error_description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AZDO_APP_SCOPE = '499b84ac-1321-427f-aa17-267ca6975798/.default';
|
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> {
|
async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const endpointId = tl.getInput('serviceConnectionARM', true);
|
const endpointId = requireInput('serviceConnectionARM', tl.getInput);
|
||||||
const setGitAccessToken = tl.getBoolInput('setGitAccessToken', false);
|
const setGitAccessToken = tl.getBoolInput('setGitAccessToken', false);
|
||||||
const printTokenHashes = tl.getBoolInput('printTokenHashes', false);
|
const printTokenHashes = tl.getBoolInput('printTokenHashes', false);
|
||||||
if (!endpointId) {
|
|
||||||
throw new Error('Task input serviceConnectionARM is required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const oidcBaseUrl = requireVariable('System.OidcRequestUri');
|
const oidcBaseUrl = requireVariable('System.OidcRequestUri', tl.getVariable);
|
||||||
const accessToken = requireVariable('System.AccessToken');
|
const accessToken = requireVariable('System.AccessToken', tl.getVariable);
|
||||||
|
|
||||||
console.log('Requesting OIDC token for ARM authentication...');
|
console.log('Requesting OIDC token for ARM authentication...');
|
||||||
|
|
||||||
const requestUrl = buildOidcUrl(oidcBaseUrl, endpointId);
|
const requestUrl = buildOidcUrl(oidcBaseUrl, endpointId);
|
||||||
const token = await requestOidcToken(requestUrl, accessToken);
|
const token = await requestOidcToken(requestUrl, accessToken, true);
|
||||||
const metadata = getServiceConnectionMetadata(endpointId);
|
const metadata = getServiceConnectionMetadata(
|
||||||
|
endpointId,
|
||||||
|
tl.getEndpointAuthorizationParameter,
|
||||||
|
tl.getEndpointDataParameter
|
||||||
|
);
|
||||||
|
|
||||||
tl.setVariable('ARM_OIDC_TOKEN', token, true);
|
tl.setVariable('ARM_OIDC_TOKEN', token, true);
|
||||||
tl.setVariable('ARM_TENANT_ID', metadata.tenantId);
|
tl.setVariable('ARM_TENANT_ID', metadata.tenantId);
|
||||||
@@ -167,7 +42,7 @@ async function run(): Promise<void> {
|
|||||||
|
|
||||||
if (setGitAccessToken) {
|
if (setGitAccessToken) {
|
||||||
console.log('Exchanging OIDC token for Azure DevOps scoped Git access token...');
|
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);
|
tl.setVariable('GIT_ACCESS_TOKEN', gitToken, true);
|
||||||
if (printTokenHashes) {
|
if (printTokenHashes) {
|
||||||
const gitTokenHash = crypto.createHash('sha256').update(gitToken).digest('hex');
|
const gitTokenHash = crypto.createHash('sha256').update(gitToken).digest('hex');
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
],
|
],
|
||||||
"execution": {
|
"execution": {
|
||||||
"Node20_1": {
|
"Node20_1": {
|
||||||
"target": "dist/index.js"
|
"target": "dist/AzureFederatedAuth/src/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimumAgentVersion": "3.225.0"
|
"minimumAgentVersion": "3.225.0"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": ".."
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"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",
|
"name": "copy-blob-task",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "copy-blob-task",
|
"name": "copy-blob-task",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"azure-pipelines-task-lib": "^5.2.6"
|
"azure-pipelines-task-lib": "^5.2.6"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "copy-blob-task",
|
"name": "copy-blob-task",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Slawomir Koszewski",
|
"author": "Slawomir Koszewski",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,90 +1,14 @@
|
|||||||
import * as tl from 'azure-pipelines-task-lib/task';
|
import * as tl from 'azure-pipelines-task-lib/task';
|
||||||
|
import {
|
||||||
type OidcResponse = {
|
buildOidcUrl,
|
||||||
oidcToken?: string;
|
exchangeOidcForScopedToken,
|
||||||
};
|
getServiceConnectionMetadata,
|
||||||
|
requireInput,
|
||||||
type TokenResponse = {
|
requestOidcToken,
|
||||||
access_token?: string;
|
requireVariable
|
||||||
error?: string;
|
} from '../../_shared/src/oidc';
|
||||||
error_description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
||||||
const STORAGE_SCOPE = 'https://storage.azure.com/.default';
|
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 {
|
function buildBlobUrl(accountName: string, containerName: string, blobName: string): string {
|
||||||
const trimmedBlobName = blobName.replace(/^\/+/, '');
|
const trimmedBlobName = blobName.replace(/^\/+/, '');
|
||||||
const encodedContainer = encodeURIComponent(containerName);
|
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}`;
|
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(
|
async function copyBlob(
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
destinationUrl: string,
|
destinationUrl: string,
|
||||||
@@ -171,28 +53,32 @@ async function copyBlob(
|
|||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const endpointId = requireInput('serviceConnectionARM');
|
const endpointId = requireInput('serviceConnectionARM', tl.getInput);
|
||||||
const srcStorageAccountName = requireInput('srcStorageAccountName');
|
const srcStorageAccountName = requireInput('srcStorageAccountName', tl.getInput);
|
||||||
const dstStorageAccountName = requireInput('dstStorageAccountName');
|
const dstStorageAccountName = requireInput('dstStorageAccountName', tl.getInput);
|
||||||
const srcContainerName = requireInput('srcContainerName');
|
const srcContainerName = requireInput('srcContainerName', tl.getInput);
|
||||||
const dstContainerNameInput = tl.getInput('dstContainerName', false)?.trim() || '';
|
const dstContainerNameInput = tl.getInput('dstContainerName', false)?.trim() || '';
|
||||||
const blobName = requireInput('blobName');
|
const blobName = requireInput('blobName', tl.getInput);
|
||||||
|
|
||||||
const oidcBaseUrl = requireVariable('System.OidcRequestUri');
|
const oidcBaseUrl = requireVariable('System.OidcRequestUri', tl.getVariable);
|
||||||
const systemAccessToken = requireVariable('System.AccessToken');
|
const systemAccessToken = requireVariable('System.AccessToken', tl.getVariable);
|
||||||
|
|
||||||
const metadata = getServiceConnectionMetadata(endpointId);
|
const metadata = getServiceConnectionMetadata(
|
||||||
|
endpointId,
|
||||||
|
tl.getEndpointAuthorizationParameter,
|
||||||
|
tl.getEndpointDataParameter
|
||||||
|
);
|
||||||
const oidcRequestUrl = buildOidcUrl(oidcBaseUrl, endpointId);
|
const oidcRequestUrl = buildOidcUrl(oidcBaseUrl, endpointId);
|
||||||
|
|
||||||
console.log('Requesting OIDC token for ARM authentication...');
|
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 dstContainerName = dstContainerNameInput || srcContainerName;
|
||||||
const srcUrl = buildBlobUrl(srcStorageAccountName, srcContainerName, blobName);
|
const srcUrl = buildBlobUrl(srcStorageAccountName, srcContainerName, blobName);
|
||||||
const dstUrl = buildBlobUrl(dstStorageAccountName, dstContainerName, blobName);
|
const dstUrl = buildBlobUrl(dstStorageAccountName, dstContainerName, blobName);
|
||||||
|
|
||||||
console.log('Requesting storage access token from Microsoft Entra ID...');
|
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}...`);
|
console.log(`Copying blob ${srcStorageAccountName}/${srcContainerName}/${blobName} -> ${dstStorageAccountName}/${dstContainerName}/${blobName}...`);
|
||||||
const copyResult = await copyBlob(srcUrl, dstUrl, accessToken);
|
const copyResult = await copyBlob(srcUrl, dstUrl, accessToken);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
],
|
],
|
||||||
"execution": {
|
"execution": {
|
||||||
"Node20_1": {
|
"Node20_1": {
|
||||||
"target": "dist/index.js"
|
"target": "dist/CopyBlob/src/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimumAgentVersion": "3.225.0"
|
"minimumAgentVersion": "3.225.0"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": ".."
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"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,
|
"manifestVersion": 1,
|
||||||
"id": "sk-azure-devops-toolkit",
|
"id": "sk-azure-devops-toolkit",
|
||||||
"name": "SK Azure DevOps Toolkit",
|
"name": "SK Azure DevOps Toolkit",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"publisher": "skoszewski-lab",
|
"publisher": "skoszewski-lab",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user