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