From 1ef0999a3e4eda24c166bb47ccc50f2a0ef124df Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Wed, 25 Feb 2026 07:16:56 +0100 Subject: [PATCH] feat: refactored common OIDC code and moved to shared sources directory. --- README.md | 98 ++++++------- task/AzureFederatedAuth/package-lock.json | 4 +- task/AzureFederatedAuth/package.json | 2 +- task/AzureFederatedAuth/src/index.ts | 161 +++------------------- task/AzureFederatedAuth/task.json | 2 +- task/AzureFederatedAuth/tsconfig.json | 2 +- task/CopyBlob/package-lock.json | 4 +- task/CopyBlob/package.json | 2 +- task/CopyBlob/src/index.ts | 158 +++------------------ task/CopyBlob/task.json | 2 +- task/CopyBlob/tsconfig.json | 2 +- task/_shared/src/oidc.ts | 153 ++++++++++++++++++++ vss-extension.json | 2 +- 13 files changed, 243 insertions(+), 349 deletions(-) create mode 100644 task/_shared/src/oidc.ts diff --git a/README.md b/README.md index 79bf2a0..b56dbea 100644 --- a/README.md +++ b/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='' ./scripts/publish.sh -``` +## 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 diff --git a/task/AzureFederatedAuth/package-lock.json b/task/AzureFederatedAuth/package-lock.json index 50e6440..b84ac26 100644 --- a/task/AzureFederatedAuth/package-lock.json +++ b/task/AzureFederatedAuth/package-lock.json @@ -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" diff --git a/task/AzureFederatedAuth/package.json b/task/AzureFederatedAuth/package.json index f12f304..96edbb4 100644 --- a/task/AzureFederatedAuth/package.json +++ b/task/AzureFederatedAuth/package.json @@ -1,6 +1,6 @@ { "name": "azure-federated-auth-task", - "version": "1.0.6", + "version": "1.0.7", "private": true, "author": "Slawomir Koszewski", "license": "MIT", diff --git a/task/AzureFederatedAuth/src/index.ts b/task/AzureFederatedAuth/src/index.ts index f5a4094..3d33290 100644 --- a/task/AzureFederatedAuth/src/index.ts +++ b/task/AzureFederatedAuth/src/index.ts @@ -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 { - 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 { - 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 { 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 { 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'); diff --git a/task/AzureFederatedAuth/task.json b/task/AzureFederatedAuth/task.json index 98a8448..ef4137f 100644 --- a/task/AzureFederatedAuth/task.json +++ b/task/AzureFederatedAuth/task.json @@ -41,7 +41,7 @@ ], "execution": { "Node20_1": { - "target": "dist/index.js" + "target": "dist/AzureFederatedAuth/src/index.js" } }, "minimumAgentVersion": "3.225.0" diff --git a/task/AzureFederatedAuth/tsconfig.json b/task/AzureFederatedAuth/tsconfig.json index b82293d..83ca213 100644 --- a/task/AzureFederatedAuth/tsconfig.json +++ b/task/AzureFederatedAuth/tsconfig.json @@ -8,7 +8,7 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "outDir": "dist", - "rootDir": "src" + "rootDir": ".." }, "include": ["src/**/*.ts"] } diff --git a/task/CopyBlob/package-lock.json b/task/CopyBlob/package-lock.json index 7ee833e..05aaa78 100644 --- a/task/CopyBlob/package-lock.json +++ b/task/CopyBlob/package-lock.json @@ -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" diff --git a/task/CopyBlob/package.json b/task/CopyBlob/package.json index e7ef157..2ce488a 100644 --- a/task/CopyBlob/package.json +++ b/task/CopyBlob/package.json @@ -1,6 +1,6 @@ { "name": "copy-blob-task", - "version": "1.0.0", + "version": "1.0.1", "private": true, "author": "Slawomir Koszewski", "license": "MIT", diff --git a/task/CopyBlob/src/index.ts b/task/CopyBlob/src/index.ts index 76edf86..4baa653 100644 --- a/task/CopyBlob/src/index.ts +++ b/task/CopyBlob/src/index.ts @@ -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 { - 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 { - 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 { 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); diff --git a/task/CopyBlob/task.json b/task/CopyBlob/task.json index 333e71b..91dca18 100644 --- a/task/CopyBlob/task.json +++ b/task/CopyBlob/task.json @@ -65,7 +65,7 @@ ], "execution": { "Node20_1": { - "target": "dist/index.js" + "target": "dist/CopyBlob/src/index.js" } }, "minimumAgentVersion": "3.225.0" diff --git a/task/CopyBlob/tsconfig.json b/task/CopyBlob/tsconfig.json index b82293d..83ca213 100644 --- a/task/CopyBlob/tsconfig.json +++ b/task/CopyBlob/tsconfig.json @@ -8,7 +8,7 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "outDir": "dist", - "rootDir": "src" + "rootDir": ".." }, "include": ["src/**/*.ts"] } diff --git a/task/_shared/src/oidc.ts b/task/_shared/src/oidc.ts new file mode 100644 index 0000000..5353fd6 --- /dev/null +++ b/task/_shared/src/oidc.ts @@ -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 { + 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 { + 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; +} diff --git a/vss-extension.json b/vss-extension.json index 8d54a6d..224f56d 100644 --- a/vss-extension.json +++ b/vss-extension.json @@ -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": [ {