Added 3 more tasks and refactored code to use a standalone shared npm package (installed locally from a tarball).
This commit is contained in:
34
shared/src/blob.ts
Normal file
34
shared/src/blob.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
buildOidcUrl,
|
||||
exchangeOidcForScopedToken,
|
||||
getServiceConnectionMetadata,
|
||||
requestOidcToken
|
||||
} from './oidc';
|
||||
import { requireVariable } from './devops-helpers';
|
||||
|
||||
export const STORAGE_SCOPE = 'https://storage.azure.com/.default';
|
||||
|
||||
export async function requestStorageAccessToken(
|
||||
endpointId: string
|
||||
): Promise<string> {
|
||||
const oidcBaseUrl = requireVariable('System.OidcRequestUri');
|
||||
const systemAccessToken = requireVariable('System.AccessToken');
|
||||
|
||||
const metadata = getServiceConnectionMetadata(endpointId);
|
||||
|
||||
const oidcRequestUrl = buildOidcUrl(oidcBaseUrl, endpointId);
|
||||
const oidcToken = await requestOidcToken(oidcRequestUrl, systemAccessToken, false);
|
||||
|
||||
return exchangeOidcForScopedToken(metadata.tenantId, metadata.clientId, oidcToken, STORAGE_SCOPE);
|
||||
}
|
||||
|
||||
export function buildBlobUrl(accountName: string, containerName: string, blobName: string): string {
|
||||
const trimmedBlobName = blobName.replace(/^\/+/, '');
|
||||
const encodedContainer = encodeURIComponent(containerName);
|
||||
const encodedBlobName = trimmedBlobName
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
return `https://${accountName}.blob.core.windows.net/${encodedContainer}/${encodedBlobName}`;
|
||||
}
|
||||
28
shared/src/devops-helpers.ts
Normal file
28
shared/src/devops-helpers.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
type TaskLibBridge = {
|
||||
getInput: (name: string, required?: boolean) => string | undefined;
|
||||
getVariable: (name: string) => string | undefined;
|
||||
};
|
||||
|
||||
function getTaskLibBridge(): TaskLibBridge {
|
||||
return require('azure-pipelines-task-lib/task') as TaskLibBridge;
|
||||
}
|
||||
|
||||
export function requireInput(name: string): string {
|
||||
const taskLib = getTaskLibBridge();
|
||||
const value = taskLib.getInput(name, true);
|
||||
if (!value) {
|
||||
throw new Error(`Task input ${name} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function requireVariable(name: string): string {
|
||||
const taskLib = getTaskLibBridge();
|
||||
const value = taskLib.getVariable(name);
|
||||
if (!value) {
|
||||
throw new Error(`Missing required pipeline variable: ${name}.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
3
shared/src/index.ts
Normal file
3
shared/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './devops-helpers';
|
||||
export * from './oidc';
|
||||
export * from './blob';
|
||||
142
shared/src/oidc.ts
Normal file
142
shared/src/oidc.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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 TaskLibEndpointBridge = {
|
||||
getEndpointAuthorizationParameter: (
|
||||
endpointId: string,
|
||||
key: string,
|
||||
optional: boolean
|
||||
) => string | undefined;
|
||||
getEndpointDataParameter: (endpointId: string, key: string, optional: boolean) => string | undefined;
|
||||
};
|
||||
|
||||
type OidcResponse = {
|
||||
oidcToken?: string;
|
||||
};
|
||||
|
||||
function getTaskLibEndpointBridge(): TaskLibEndpointBridge {
|
||||
return require('azure-pipelines-task-lib/task') as TaskLibEndpointBridge;
|
||||
}
|
||||
|
||||
export function getServiceConnectionMetadata(endpointId: string): ServiceConnectionMetadata {
|
||||
const taskLib = getTaskLibEndpointBridge();
|
||||
|
||||
const tenantId =
|
||||
taskLib.getEndpointAuthorizationParameter(endpointId, 'tenantid', true) ||
|
||||
taskLib.getEndpointDataParameter(endpointId, 'tenantid', true);
|
||||
|
||||
const clientId =
|
||||
taskLib.getEndpointAuthorizationParameter(endpointId, 'serviceprincipalid', true) ||
|
||||
taskLib.getEndpointAuthorizationParameter(endpointId, 'clientid', true) ||
|
||||
taskLib.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;
|
||||
}
|
||||
Reference in New Issue
Block a user