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,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;
}