diff --git a/jfrog-tasks-utils/utils.d.ts b/jfrog-tasks-utils/utils.d.ts index 90e57ff4..5bd3e2db 100644 --- a/jfrog-tasks-utils/utils.d.ts +++ b/jfrog-tasks-utils/utils.d.ts @@ -6,7 +6,7 @@ declare module '@jfrog/tasks-utils' { runTaskFunc: (cliPath: string) => void | Promise, cliVersion?: string, cliDownloadUrl?: string, - cliAuthHandlers?: ifm.IRequestHandler[], + cliAuthHandlers?: ifm.IRequestHandler[] | (() => Promise), ): void; export function quote(str: string): string; export function downloadCli(cliDownloadUrl?: string, cliAuthHandlers?: ifm.IRequestHandler[], cliVersion?: string): Promise; @@ -63,6 +63,10 @@ declare module '@jfrog/tasks-utils' { export function addTrailingSlashIfNeeded(str: string): string; export function buildCliArtifactoryDownloadUrl(rtUrl: string, repoName: string, cliVersion?: string): string; export function createAuthHandlers(serviceConnection: string): ifm.IRequestHandler[]; + export function createCliDownloadAuthHandlers(serviceConnection: string, exchangeFn?: (service: string, platformUrl: string, oidcProviderName: string) => Promise,): Promise; + export function exchangeOidcTokenViaRest(service: string, platformUrl: string, oidcProviderName: string): Promise; + export function isOidcConnection(serviceConnection: string): boolean; + export function resolvePlatformUrl(service: string): string; export function stripTrailingSlash(str: string): string; export function writeSpecContentToSpecPath(specSource: string, specPath: string): void; export function addCommonGenericParams(cliCommand: string, specPath: string): string; diff --git a/jfrog-tasks-utils/utils.js b/jfrog-tasks-utils/utils.js index 4b787364..d87b5357 100644 --- a/jfrog-tasks-utils/utils.js +++ b/jfrog-tasks-utils/utils.js @@ -201,6 +201,10 @@ module.exports = { isToolExists: isToolExists, buildCliArtifactoryDownloadUrl: buildCliArtifactoryDownloadUrl, createAuthHandlers: createAuthHandlers, + createCliDownloadAuthHandlers: createCliDownloadAuthHandlers, + exchangeOidcTokenViaRest: exchangeOidcTokenViaRest, + isOidcConnection: isOidcConnection, + resolvePlatformUrl: resolvePlatformUrl, taskDefaultCleanup: taskDefaultCleanup, writeSpecContentToSpecPath: writeSpecContentToSpecPath, stripTrailingSlash: stripTrailingSlash, @@ -285,7 +289,11 @@ function getCliPath(cliDownloadUrl, cliAuthHandlers, cliVersion) { } else { const errMsg = generateDownloadCliErrorMessage(cliDownloadUrl, cliVersion); createCliDirs(); - return downloadCli(cliDownloadUrl, cliAuthHandlers, cliVersion) + // cliAuthHandlers may be an array or a provider function returning a Promise. + // Resolve it lazily here so that work such as an OIDC token exchange only happens + // when a download is actually required — never when the CLI is already cached. + return Promise.resolve(typeof cliAuthHandlers === 'function' ? cliAuthHandlers() : cliAuthHandlers) + .then((resolvedHandlers) => downloadCli(cliDownloadUrl, resolvedHandlers, cliVersion)) .then((cliPath) => resolve(cliPath)) .catch((error) => reject(errMsg + '\n' + error)); } @@ -330,6 +338,41 @@ function createAuthHandlers(serviceConnection) { return [new credentialsHandler.BasicCredentialHandler(artifactoryUser, artifactoryPassword, false)]; } +/** + * Returns whether the given service connection uses OIDC authentication. + * @param {string} serviceConnection - The service connection ID. + * @returns {boolean} + */ +function isOidcConnection(serviceConnection) { + return !!tl.getEndpointAuthorizationParameter(serviceConnection, 'oidcProviderName', true); +} + +/** + * Builds the authentication handlers used to download the JFrog CLI. + * + * For OIDC-based service connections the credential does not exist as a static + * token — it must be obtained through an OIDC token exchange. The CLI-based + * exchange (exchangeOidcTokenAndSetStepVariables) cannot be used here because the + * CLI is the very artifact being downloaded, so this performs a CLI-independent + * REST exchange (exchangeOidcTokenViaRest) and authenticates the download with the + * resulting access token. For all other connection types it falls back to the + * synchronous createAuthHandlers (access token / basic / anonymous). + * + * @param {string} serviceConnection - The Artifactory service connection ID. + * @param {(service: string, platformUrl: string, oidcProviderName: string) => Promise} [exchangeFn] + * - OIDC exchange implementation; injectable for testing. Defaults to exchangeOidcTokenViaRest. + * @returns {Promise} Authentication handlers for the CLI download. + */ +async function createCliDownloadAuthHandlers(serviceConnection, exchangeFn = exchangeOidcTokenViaRest) { + if (!isOidcConnection(serviceConnection)) { + return createAuthHandlers(serviceConnection); + } + const platformUrl = resolvePlatformUrl(serviceConnection); + const oidcProviderName = tl.getEndpointAuthorizationParameter(serviceConnection, 'oidcProviderName', true); + const accessToken = await exchangeFn(serviceConnection, platformUrl, oidcProviderName); + return [new credentialsHandler.BearerCredentialHandler(accessToken, false)]; +} + function generateDownloadCliErrorMessage(downloadUrl, cliVersion) { let errMsg = 'Failed while attempting to download JFrog CLI from ' + downloadUrl; if (downloadUrl === buildReleasesDownloadUrl(cliVersion)) { @@ -393,11 +436,14 @@ function maskSecrets(str) { .replace(/--access-token='.*?'/g, '--access-token=***'); } -async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) { - const oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true); - if (!oidcProviderName) { - return undefined; - } +/** + * Resolves the JFrog platform URL for a service connection. Prefers the explicit + * 'jfrogPlatformUrl' authorization parameter and falls back to parsing it from the + * service URL. + * @param {string} service - The service connection ID. + * @returns {string} The resolved platform URL. + */ +function resolvePlatformUrl(service) { const serviceUrl = tl.getEndpointUrl(service, false); let platformUrl = ''; try { @@ -408,6 +454,15 @@ async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) { if (!platformUrl || !platformUrl.trim()) { platformUrl = parsePlatformUrlFromServiceUrl(serviceUrl); } + return platformUrl; +} + +async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) { + if (!isOidcConnection(service)) { + return undefined; + } + const oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true); + const platformUrl = resolvePlatformUrl(service); return exchangeOidcTokenAndSetStepVariables(service, platformUrl, oidcProviderName, cliPath, buildDir); } @@ -619,6 +674,58 @@ async function fetchAzureOidcToken(serviceConnectionID) { return body.oidcToken; } +/** + * Performs an OIDC token exchange WITHOUT the JFrog CLI, via a direct REST call to + * JFrog Access. Required by the JFrog Tools Installer, which must authenticate the + * CLI *download* itself — at that point the CLI does not yet exist, so the + * CLI-based exchange cannot be used. The request mirrors what `jf eot` sends for an + * Azure provider (grant_type / subject_token_type / subject_token / provider_name / + * provider_type / audience). The Azure DevOps identity mapping is matched on the ID + * token's subject claim, which is carried in subject_token. + * + * @param {string} service - The service connection ID. + * @param {string} platformUrl - The JFrog platform base URL. + * @param {string} oidcProviderName - The configured OIDC provider name. + * @returns {Promise} The exchanged JFrog access token. + */ +async function exchangeOidcTokenViaRest(service, platformUrl, oidcProviderName) { + const oidcAudience = tl.getEndpointAuthorizationParameter(service, 'oidcAudience', true) || 'api://AzureADTokenExchange'; + const idToken = await fetchAzureOidcToken(service); + + const exchangeUrl = addTrailingSlashIfNeeded(platformUrl) + 'access/api/v1/oidc/token'; + const requestBody = { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + subject_token: idToken, + provider_name: oidcProviderName, + provider_type: 'Azure', + audience: oidcAudience, + }; + + const requestOptions = { ...getProxyConfiguration(), socketTimeout: 30000 }; + const httpClient = new httpm.HttpClient(buildAgent, [], requestOptions); + tl.debug('Exchanging OIDC token via REST at: ' + exchangeUrl); + const response = await httpClient.post(exchangeUrl, JSON.stringify(requestBody), { + 'Content-Type': 'application/json', + }); + + const statusCode = response.message.statusCode; + const responseBody = await response.readBody(); + if (statusCode !== 200) { + throw new Error(`OIDC token exchange failed: HTTP ${statusCode}\nBody: ${responseBody}`); + } + /** @type {{ access_token?: string, username?: string }} */ + const body = JSON.parse(responseBody); + if (!body.access_token) { + throw new Error('OIDC token exchange response did not contain an access token.'); + } + + // Publish outputs for parity with the CLI-based OIDC flow (downstream consumption / debug). + tl.setVariable(oidcUserOutputName, body.username || '', true); + tl.setVariable(oidcTokenOutputName, body.access_token, true); + return body.access_token; +} + async function exchangeOidcTokenAndSetStepVariables(service, serviceUrl, oidcProviderName, cliPath, buildDir) { // First validate supported CLI version let cliVersion = getCliVersion(cliPath); diff --git a/tasks/JFrogToolsInstaller/toolsInstaller.js b/tasks/JFrogToolsInstaller/toolsInstaller.js index 069a9104..1ed9cd13 100644 --- a/tasks/JFrogToolsInstaller/toolsInstaller.js +++ b/tasks/JFrogToolsInstaller/toolsInstaller.js @@ -21,8 +21,11 @@ function InstallCliAndExecuteCliTask(RunTaskCbk) { // Set the requested CLI version env to download it now, and to use in succeeding tasks. tl.setVariable(utils.pipelineRequestedCliVersionEnv, cliVersion); let downloadUrl = utils.buildCliArtifactoryDownloadUrl(artifactoryUrl, cliInstallationRepo, cliVersion); - let authHandlers = utils.createAuthHandlers(artifactoryService); - utils.executeCliTask(RunTaskCbk, cliVersion, downloadUrl, authHandlers); + // Pass a provider (resolved lazily by executeCliTask only if a download is needed). + // For OIDC service connections this performs a CLI-independent OIDC token exchange so + // the CLI download itself is authenticated; other connection types use static credentials. + let authHandlersProvider = () => utils.createCliDownloadAuthHandlers(artifactoryService); + utils.executeCliTask(RunTaskCbk, cliVersion, downloadUrl, authHandlersProvider); } async function RunTaskCbk(cliPath) { diff --git a/tests/package.json b/tests/package.json index 0aeda8fe..4766502d 100644 --- a/tests/package.json +++ b/tests/package.json @@ -22,7 +22,8 @@ "null-writable": "^1.0.5", "rimraf": "^6.1.2", "sync-request": "^6.1.0", - "ts-node": "^10.9.1" + "ts-node": "^10.9.1", + "typescript": "^5.2.2" }, "scripts": { "test": "npm i && mocha -r ts-node/register tests.ts -t 1000000" diff --git a/tests/tests.ts b/tests/tests.ts index ee1ef5fd..006ea158 100644 --- a/tests/tests.ts +++ b/tests/tests.ts @@ -8,6 +8,7 @@ import * as syncRequest from 'sync-request'; import * as TestUtils from './testUtils'; import { platformDockerDomain } from './testUtils'; import * as toolLib from 'azure-pipelines-tool-lib/tool'; +import * as taskLib from 'azure-pipelines-task-lib/task'; import * as assert from 'assert'; import * as os from 'os'; import conanUtils from '../tasks/JFrogConan/conanUtils'; @@ -109,6 +110,182 @@ describe('JFrog Artifactory Extension Tests', (): void => { TestUtils.isSkipTest('unit'), ); + runSyncTest( + 'OIDC CLI download builds a Bearer handler from the exchanged token', + async (): Promise => { + const anyTl: any = taskLib; + const origGetParam: unknown = anyTl.getEndpointAuthorizationParameter; + const origGetUrl: unknown = anyTl.getEndpointUrl; + try { + anyTl.getEndpointUrl = (): string => 'https://example.jfrog.io/artifactory'; + anyTl.getEndpointAuthorizationParameter = (id: string, key: string): string | undefined => { + const params: { [k: string]: string } = { + oidcProviderName: 'my-azure-oidc', + oidcAudience: 'api://AzureADTokenExchange', + jfrogPlatformUrl: 'https://example.jfrog.io', + }; + return params[key]; + }; + let exchanged: { service: string; platformUrl: string; providerName: string } | undefined; + const fakeExchange: (s: string, p: string, o: string) => Promise = async (service: string, platformUrl: string, providerName: string): Promise => { + exchanged = { service, platformUrl, providerName }; + return 'EXCHANGED_ACCESS_TOKEN'; + }; + const handlers: any[] = await jfrogUtils.createCliDownloadAuthHandlers('svc', fakeExchange); + assert.strictEqual(handlers.length, 1, 'expected exactly one auth handler'); + assert.strictEqual(handlers[0].constructor.name, 'BearerCredentialHandler', 'expected a Bearer handler'); + assert.strictEqual(handlers[0].token, 'EXCHANGED_ACCESS_TOKEN', 'Bearer handler must carry the exchanged token'); + assert.ok(exchanged, 'OIDC exchange must be invoked'); + assert.strictEqual(exchanged!.providerName, 'my-azure-oidc'); + assert.strictEqual(exchanged!.platformUrl, 'https://example.jfrog.io'); + } finally { + anyTl.getEndpointAuthorizationParameter = origGetParam; + anyTl.getEndpointUrl = origGetUrl; + } + }, + TestUtils.isSkipTest('unit'), + ); + + runSyncTest( + 'Non-OIDC CLI download uses static credentials without an exchange', + async (): Promise => { + const anyTl: any = taskLib; + const origGetParam: unknown = anyTl.getEndpointAuthorizationParameter; + try { + anyTl.getEndpointAuthorizationParameter = (id: string, key: string): string | undefined => + key === 'apitoken' ? 'STATIC_TOKEN' : undefined; + let exchanged: boolean = false; + const handlers: any[] = await jfrogUtils.createCliDownloadAuthHandlers( + 'svc', + async (): Promise => { + exchanged = true; + return 'unused'; + }, + ); + assert.strictEqual(exchanged, false, 'must not perform an OIDC exchange for a token connection'); + assert.strictEqual(handlers[0].constructor.name, 'BearerCredentialHandler'); + assert.strictEqual(handlers[0].token, 'STATIC_TOKEN'); + } finally { + anyTl.getEndpointAuthorizationParameter = origGetParam; + } + }, + TestUtils.isSkipTest('unit'), + ); + + runSyncTest( + 'OIDC exchange failure surfaces a clear error, not an unhandled rejection', + async (): Promise => { + const anyTl: any = taskLib; + const origGetParam: unknown = anyTl.getEndpointAuthorizationParameter; + const origGetUrl: unknown = anyTl.getEndpointUrl; + try { + anyTl.getEndpointUrl = (): string => 'https://example.jfrog.io/artifactory'; + anyTl.getEndpointAuthorizationParameter = (id: string, key: string): string | undefined => { + const params: { [k: string]: string } = { + oidcProviderName: 'my-azure-oidc', + jfrogPlatformUrl: 'https://example.jfrog.io', + }; + return params[key]; + }; + const failingExchange: () => Promise = async (): Promise => { + throw new Error('OIDC token exchange failed: HTTP 403\nBody: {"error":"forbidden"}'); + }; + await assert.rejects( + () => jfrogUtils.createCliDownloadAuthHandlers('svc', failingExchange), + (err: Error) => { + assert.ok(err.message.includes('OIDC token exchange failed'), 'error must mention OIDC exchange failure'); + assert.ok(err.message.includes('403'), 'error must include the HTTP status'); + return true; + }, + ); + } finally { + anyTl.getEndpointAuthorizationParameter = origGetParam; + anyTl.getEndpointUrl = origGetUrl; + } + }, + TestUtils.isSkipTest('unit'), + ); + + runSyncTest( + 'OIDC exchange returning no access_token surfaces a clear error', + async (): Promise => { + const anyTl: any = taskLib; + const origGetParam: unknown = anyTl.getEndpointAuthorizationParameter; + const origGetUrl: unknown = anyTl.getEndpointUrl; + try { + anyTl.getEndpointUrl = (): string => 'https://example.jfrog.io/artifactory'; + anyTl.getEndpointAuthorizationParameter = (id: string, key: string): string | undefined => { + const params: { [k: string]: string } = { + oidcProviderName: 'my-azure-oidc', + jfrogPlatformUrl: 'https://example.jfrog.io', + }; + return params[key]; + }; + const emptyTokenExchange: () => Promise = async (): Promise => { + throw new Error('OIDC token exchange response did not contain an access token.'); + }; + await assert.rejects( + () => jfrogUtils.createCliDownloadAuthHandlers('svc', emptyTokenExchange), + (err: Error) => { + assert.ok(err.message.includes('did not contain an access token'), 'error must describe the missing token'); + return true; + }, + ); + } finally { + anyTl.getEndpointAuthorizationParameter = origGetParam; + anyTl.getEndpointUrl = origGetUrl; + } + }, + TestUtils.isSkipTest('unit'), + ); + + runSyncTest( + 'Cached CLI never triggers the OIDC auth-handler provider', + async (): Promise => { + const anyTl: any = taskLib; + const origGetParam: unknown = anyTl.getEndpointAuthorizationParameter; + const origGetUrl: unknown = anyTl.getEndpointUrl; + const origFindLocalTool: unknown = (toolLib as any).findLocalTool; + try { + anyTl.getEndpointUrl = (): string => 'https://example.jfrog.io/artifactory'; + anyTl.getEndpointAuthorizationParameter = (id: string, key: string): string | undefined => { + const params: { [k: string]: string } = { oidcProviderName: 'my-azure-oidc' }; + return params[key]; + }; + // Simulate a cache hit: findLocalTool returns a directory + const cachedDir: string = join(__dirname, 'testData', 'jf', '2.111.0', os.arch()); + (toolLib as any).findLocalTool = (): string => cachedDir; + + let providerCalled: boolean = false; + const authProvider: () => Promise = async (): Promise => { + providerCalled = true; + return []; + }; + + const cliPath: string = await new Promise((resolve, reject) => { + jfrogUtils.executeCliTask( + (path: string) => { + resolve(path); + }, + '2.111.0', + 'https://example.jfrog.io/artifactory/repo/2.111.0/jfrog-cli-linux-amd64/jf', + authProvider, + ); + // executeCliTask catches errors and sets task result; give it time + setTimeout(() => reject(new Error('executeCliTask did not invoke callback within 5s')), 5000); + }); + + assert.strictEqual(providerCalled, false, 'auth provider must NOT be called when CLI is cached'); + assert.ok(cliPath.includes('jf'), 'resolved path must contain the CLI binary name'); + } finally { + anyTl.getEndpointAuthorizationParameter = origGetParam; + anyTl.getEndpointUrl = origGetUrl; + (toolLib as any).findLocalTool = origFindLocalTool; + } + }, + TestUtils.isSkipTest('unit'), + ); + runSyncTest( 'Fix windows paths', async (): Promise => {