From ac623d194d215f2a53834b86a892d53e2ce567d7 Mon Sep 17 00:00:00 2001 From: agrasth Date: Tue, 14 Jul 2026 09:23:35 +0530 Subject: [PATCH 1/5] fix(JFrogToolsInstaller): authenticate CLI download for OIDC service connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JFrog Tools Installer built the CLI download auth via createAuthHandlers(), which only understands static credentials (access token / username+password). For an OIDC service connection none of those endpoint parameters exist, so it returned no auth handlers and downloaded the CLI anonymously — failing with HTTP 401 on a private cliInstallationRepo. It only worked when the CLI was already staged in the agent tool cache (the download was skipped entirely). Add a CLI-independent OIDC token exchange (exchangeOidcTokenViaRest) that posts the Azure DevOps ID token to JFrog Access /access/api/v1/oidc/token and uses the returned access token as a Bearer credential for the download. The existing CLI-based exchange cannot be used here because the CLI is the very artifact being downloaded (chicken-and-egg). createCliDownloadAuthHandlers() routes OIDC connections through the REST exchange and all other connection types through the existing createAuthHandlers(), so token/basic/anonymous behavior is unchanged. The handler provider is resolved lazily in getCliPath(), so a cached CLI never triggers an exchange. Adds unit tests covering the OIDC and non-OIDC CLI download auth paths. --- jfrog-tasks-utils/utils.js | 109 ++++++++++++++++++-- tasks/JFrogToolsInstaller/toolsInstaller.js | 7 +- tests/tests.ts | 71 +++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) diff --git a/jfrog-tasks-utils/utils.js b/jfrog-tasks-utils/utils.js index 4b787364..f8460696 100644 --- a/jfrog-tasks-utils/utils.js +++ b/jfrog-tasks-utils/utils.js @@ -201,6 +201,8 @@ module.exports = { isToolExists: isToolExists, buildCliArtifactoryDownloadUrl: buildCliArtifactoryDownloadUrl, createAuthHandlers: createAuthHandlers, + createCliDownloadAuthHandlers: createCliDownloadAuthHandlers, + exchangeOidcTokenViaRest: exchangeOidcTokenViaRest, taskDefaultCleanup: taskDefaultCleanup, writeSpecContentToSpecPath: writeSpecContentToSpecPath, stripTrailingSlash: stripTrailingSlash, @@ -285,7 +287,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 +336,33 @@ function createAuthHandlers(serviceConnection) { return [new credentialsHandler.BasicCredentialHandler(artifactoryUser, artifactoryPassword, false)]; } +/** + * 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) { + const oidcProviderName = tl.getEndpointAuthorizationParameter(serviceConnection, 'oidcProviderName', true); + if (!oidcProviderName) { + // Not an OIDC connection - use the existing static-credential handlers. + return createAuthHandlers(serviceConnection); + } + const platformUrl = resolvePlatformUrl(serviceConnection); + 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 +426,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 +444,15 @@ async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) { if (!platformUrl || !platformUrl.trim()) { platformUrl = parsePlatformUrlFromServiceUrl(serviceUrl); } + return platformUrl; +} + +async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) { + const oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true); + if (!oidcProviderName) { + return undefined; + } + const platformUrl = resolvePlatformUrl(service); return exchangeOidcTokenAndSetStepVariables(service, platformUrl, oidcProviderName, cliPath, buildDir); } @@ -619,6 +664,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/tests.ts b/tests/tests.ts index ee1ef5fd..290772b0 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,76 @@ describe('JFrog Artifactory Extension Tests', (): void => { TestUtils.isSkipTest('unit'), ); + runSyncTest( + 'OIDC CLI download builds a Bearer handler from the exchanged token', + async (): Promise => { + // Regression: JFrogToolsInstaller downloaded the CLI anonymously for OIDC + // service connections (createAuthHandlers returned []), causing HTTP 401. + // It must instead perform an OIDC token exchange and authenticate the + // download with the resulting access token. + 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: (service: string, platformUrl: string, providerName: string) => Promise = async ( + service: string, + platformUrl: string, + providerName: string, + ): Promise => { + exchanged = { service, platformUrl, providerName }; + return 'EXCHANGED_ACCESS_TOKEN'; + }; + const handlers: any[] = await (jfrogUtils as any).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 as any).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( 'Fix windows paths', async (): Promise => { From 8597094de4a5194da43c5805f37e4fe2e53c99ed Mon Sep 17 00:00:00 2001 From: agrasth Date: Tue, 14 Jul 2026 11:12:55 +0530 Subject: [PATCH 2/5] test: pin TypeScript to 5.x for ts-node compatibility The tests project never pinned typescript and its lockfile is not committed, so `npm i` re-resolved ts-node's unbounded `typescript: >=2.7` peer to the latest release. TypeScript 6.0.x breaks ts-node 10.9.2's config reader (TypeError: Cannot read properties of undefined (reading 'fileExists')), failing every suite before any test runs. This surfaced now (not on the last green dev run) because TS 6.0 was published after it. Pin typescript to ^5.2.2 to match the root project and keep ts-node working. --- tests/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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" From ea19eafd5ba8e0eac527c6f6433f9a452ba4b7df Mon Sep 17 00:00:00 2001 From: agrasth Date: Thu, 16 Jul 2026 00:47:08 +0530 Subject: [PATCH 3/5] Resolved comments --- jfrog-tasks-utils/utils.d.ts | 9 ++- jfrog-tasks-utils/utils.js | 20 ++++-- tests/tests.ts | 128 ++++++++++++++++++++++++++++++++--- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/jfrog-tasks-utils/utils.d.ts b/jfrog-tasks-utils/utils.d.ts index 90e57ff4..7990e304 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,13 @@ 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 f8460696..d87b5357 100644 --- a/jfrog-tasks-utils/utils.js +++ b/jfrog-tasks-utils/utils.js @@ -203,6 +203,8 @@ module.exports = { createAuthHandlers: createAuthHandlers, createCliDownloadAuthHandlers: createCliDownloadAuthHandlers, exchangeOidcTokenViaRest: exchangeOidcTokenViaRest, + isOidcConnection: isOidcConnection, + resolvePlatformUrl: resolvePlatformUrl, taskDefaultCleanup: taskDefaultCleanup, writeSpecContentToSpecPath: writeSpecContentToSpecPath, stripTrailingSlash: stripTrailingSlash, @@ -336,6 +338,15 @@ 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. * @@ -353,12 +364,11 @@ function createAuthHandlers(serviceConnection) { * @returns {Promise} Authentication handlers for the CLI download. */ async function createCliDownloadAuthHandlers(serviceConnection, exchangeFn = exchangeOidcTokenViaRest) { - const oidcProviderName = tl.getEndpointAuthorizationParameter(serviceConnection, 'oidcProviderName', true); - if (!oidcProviderName) { - // Not an OIDC connection - use the existing static-credential handlers. + 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)]; } @@ -448,10 +458,10 @@ function resolvePlatformUrl(service) { } async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) { - const oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true); - if (!oidcProviderName) { + if (!isOidcConnection(service)) { return undefined; } + const oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true); const platformUrl = resolvePlatformUrl(service); return exchangeOidcTokenAndSetStepVariables(service, platformUrl, oidcProviderName, cliPath, buildDir); } diff --git a/tests/tests.ts b/tests/tests.ts index 290772b0..32346892 100644 --- a/tests/tests.ts +++ b/tests/tests.ts @@ -113,10 +113,6 @@ describe('JFrog Artifactory Extension Tests', (): void => { runSyncTest( 'OIDC CLI download builds a Bearer handler from the exchanged token', async (): Promise => { - // Regression: JFrogToolsInstaller downloaded the CLI anonymously for OIDC - // service connections (createAuthHandlers returned []), causing HTTP 401. - // It must instead perform an OIDC token exchange and authenticate the - // download with the resulting access token. const anyTl: any = taskLib; const origGetParam: unknown = anyTl.getEndpointAuthorizationParameter; const origGetUrl: unknown = anyTl.getEndpointUrl; @@ -131,15 +127,11 @@ describe('JFrog Artifactory Extension Tests', (): void => { return params[key]; }; let exchanged: { service: string; platformUrl: string; providerName: string } | undefined; - const fakeExchange: (service: string, platformUrl: string, providerName: string) => Promise = async ( - service: string, - platformUrl: string, - providerName: string, - ): Promise => { + const fakeExchange = async (service: string, platformUrl: string, providerName: string): Promise => { exchanged = { service, platformUrl, providerName }; return 'EXCHANGED_ACCESS_TOKEN'; }; - const handlers: any[] = await (jfrogUtils as any).createCliDownloadAuthHandlers('svc', fakeExchange); + 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'); @@ -163,7 +155,7 @@ describe('JFrog Artifactory Extension Tests', (): void => { anyTl.getEndpointAuthorizationParameter = (id: string, key: string): string | undefined => key === 'apitoken' ? 'STATIC_TOKEN' : undefined; let exchanged: boolean = false; - const handlers: any[] = await (jfrogUtils as any).createCliDownloadAuthHandlers( + const handlers: any[] = await jfrogUtils.createCliDownloadAuthHandlers( 'svc', async (): Promise => { exchanged = true; @@ -180,6 +172,120 @@ describe('JFrog Artifactory Extension Tests', (): void => { 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 = 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 = 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 = 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 => { From e78a5e92ee0f6b598421dadf02a5afecfbb75f39 Mon Sep 17 00:00:00 2001 From: agrasth Date: Wed, 22 Jul 2026 11:28:09 +0530 Subject: [PATCH 4/5] fix: add typedef annotations to satisfy eslint in OIDC unit tests Co-authored-by: Cursor --- tests/tests.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/tests.ts b/tests/tests.ts index 32346892..006ea158 100644 --- a/tests/tests.ts +++ b/tests/tests.ts @@ -127,7 +127,7 @@ describe('JFrog Artifactory Extension Tests', (): void => { return params[key]; }; let exchanged: { service: string; platformUrl: string; providerName: string } | undefined; - const fakeExchange = async (service: string, platformUrl: string, providerName: string): Promise => { + 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'; }; @@ -187,7 +187,7 @@ describe('JFrog Artifactory Extension Tests', (): void => { }; return params[key]; }; - const failingExchange = async (): Promise => { + const failingExchange: () => Promise = async (): Promise => { throw new Error('OIDC token exchange failed: HTTP 403\nBody: {"error":"forbidden"}'); }; await assert.rejects( @@ -221,7 +221,7 @@ describe('JFrog Artifactory Extension Tests', (): void => { }; return params[key]; }; - const emptyTokenExchange = async (): Promise => { + const emptyTokenExchange: () => Promise = async (): Promise => { throw new Error('OIDC token exchange response did not contain an access token.'); }; await assert.rejects( @@ -257,7 +257,7 @@ describe('JFrog Artifactory Extension Tests', (): void => { (toolLib as any).findLocalTool = (): string => cachedDir; let providerCalled: boolean = false; - const authProvider = async (): Promise => { + const authProvider: () => Promise = async (): Promise => { providerCalled = true; return []; }; From 213d0fb8bd8bfe58ce4e31d4b10f82a38cf64317 Mon Sep 17 00:00:00 2001 From: agrasth Date: Wed, 22 Jul 2026 12:22:48 +0530 Subject: [PATCH 5/5] style: collapse createCliDownloadAuthHandlers declaration to one line Co-authored-by: Cursor --- jfrog-tasks-utils/utils.d.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/jfrog-tasks-utils/utils.d.ts b/jfrog-tasks-utils/utils.d.ts index 7990e304..5bd3e2db 100644 --- a/jfrog-tasks-utils/utils.d.ts +++ b/jfrog-tasks-utils/utils.d.ts @@ -63,10 +63,7 @@ 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 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;