Skip to content

fix(JFrogToolsInstaller): authenticate CLI download for OIDC service connections - #638

Merged
agrasth merged 6 commits into
devfrom
RTECO-1402-oidc-tools-installer-cli-download
Jul 29, 2026
Merged

fix(JFrogToolsInstaller): authenticate CLI download for OIDC service connections#638
agrasth merged 6 commits into
devfrom
RTECO-1402-oidc-tools-installer-cli-download

Conversation

@agrasth

@agrasth agrasth commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

The JFrog Tools Installer task fails to download the JFrog CLI with HTTP 401 when the Artifactory service connection uses OIDC, if the CLI is not already staged in the agent tool cache:

Downloading: ***artifactory/jfrog-cli-virtual/2.85.0/jfrog-cli-linux-amd64/jf
##[error]Error occurred while executing task: Failed while attempting to download JFrog CLI ...
Error: Unexpected HTTP response: 401

It works with access-token connections, and it works on agents where the CLI is already cached (the download is skipped entirely), which is why the failure only surfaces for OIDC + a fresh agent.

Root cause

JFrogToolsInstaller built the CLI-download authentication via createAuthHandlers(), which only understands static credentials:

  • apitoken → Bearer handler
  • username + password → Basic handler
  • otherwise → [] (anonymous)

An OIDC service connection carries none of those endpoint parameters — its credential only exists after an OIDC token exchange. So createAuthHandlers() returned [], the CLI was downloaded anonymously, and a private cliInstallationRepo responded 401.

The extension's existing OIDC exchange runs jf eot, i.e. it needs the CLI to already exist — which is a chicken-and-egg problem at install time, since the CLI is the very artifact being downloaded.

Fix

  • exchangeOidcTokenViaRest() — a CLI-independent OIDC exchange. Reuses the existing fetchAzureOidcToken() for the Azure DevOps ID token, then POSTs it to JFrog Access /access/api/v1/oidc/token with the same request jf eot sends for an Azure provider (grant_type / subject_token_type / subject_token / provider_name / provider_type=Azure / audience), and returns the exchanged access token.
  • createCliDownloadAuthHandlers() — routes OIDC connections through the REST exchange (Bearer of the exchanged token) and every other connection type through the existing createAuthHandlers() (unchanged).
  • Lazy resolution — the Tools Installer passes a handler provider; getCliPath() only invokes it when a download is actually required, so a cached CLI never triggers an exchange.
  • Extracted a shared resolvePlatformUrl() helper (dedup with the existing OIDC flow).

Backward compatibility

The only shared code path changed is getCliPath() (used by all 18 tasks); it accepts an array or a provider function, and arrays pass through untouched. Verified behavior across scenarios:

  • Existing task (no URL) → releases URL + [] handlers — unchanged
  • Existing task, CLI cached → no download — unchanged
  • Legacy array handlers + download URL → passed through unchanged
  • Installer, token connection → Bearer(apitoken), no exchange
  • Installer, OIDC connection → Bearer(exchanged token) authenticates the download (the fix)
  • Installer, OIDC + cached CLI → exchange never runs, no download
  • Exchange failure → task fails cleanly with the error surfaced

Token, basic, anonymous, cached, custom-path, releases-fallback and the extractors flow are all unchanged. tsc and eslint are clean on the changed files.

Tests

Adds unit tests covering the OIDC download path (Bearer built from the exchanged token) and the non-OIDC path (static credentials, no exchange).

…connections

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.
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.
@naveenku-jfrog naveenku-jfrog added the safe to test Approve running integration tests on a pull request label Jul 14, 2026
@github-actions github-actions Bot removed the safe to test Approve running integration tests on a pull request label Jul 14, 2026
@agrasth agrasth added the safe to test Approve running integration tests on a pull request label Jul 14, 2026
@github-actions github-actions Bot removed the safe to test Approve running integration tests on a pull request label Jul 14, 2026

@bhanurp bhanurp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ticket Alignment

  • Implements the ticket's core ask: JFrogToolsInstaller now authenticates the CLI download itself for OIDC-based Artifactory service connections, via a new CLI-independent REST OIDC exchange (exchangeOidcTokenViaRest) wired in through createCliDownloadAuthHandlers, matching the requested "use artifactoryConnection to do the OIDC exchange to download from cliInstallationRepo" behavior.
  • Nothing required by the ticket appears to be missing: the fix is backward compatible (static-credential and anonymous paths untouched, download still skipped entirely when the CLI is already cached, so no exchange runs), which covers all three of the ticket's reported/working scenarios (Windows cached, OIDC + fresh Linux agent, access-token connections).
  • Out of scope but reasonable/low-risk: extraction of resolvePlatformUrl() as a shared helper (dedup with the pre-existing CLI-based OIDC flow) and a typescript devDependency bump for the test project.

Findings Summary

Major

  1. jfrog-tasks-utils/utils.d.ts was not updated for this change. createCliDownloadAuthHandlers and exchangeOidcTokenViaRest are newly exported from utils.js but have no type declarations, and executeCliTask's cliAuthHandlers parameter is still typed as ifm.IRequestHandler[] even though toolsInstaller.js now passes a () => Promise<IRequestHandler[]> provider. This isn't hypothetical: the PR's own tests/tests.ts had to bypass the type system with (jfrogUtils as any).createCliDownloadAuthHandlers(...) (see lines 142 and 166) because the declared module type doesn't have the method. Any other TypeScript consumer of @jfrog/tasks-utils loses type safety/autocomplete for this new API and would hit the same compile error the tests worked around.

Minor
2. No test exercises the "lazy resolution" guarantee that is central to this fix's safety story (per the PR description: "a cached CLI never triggers an exchange"). The two new tests only unit-test createCliDownloadAuthHandlers in isolation; there's no test that drives getCliPath/executeCliTask with a pre-cached tool and asserts the auth-handler provider function is never invoked.
3. There's no negative-path test for exchangeOidcTokenViaRest (non-200 response or a response missing access_token) to confirm the CLI download task fails with the expected wrapped error message instead of an unhandled rejection.

Nit
4. createCliDownloadAuthHandlers and fetchOidcTokenIfConfigured now each independently re-implement the "is this an OIDC connection?" check (getEndpointAuthorizationParameter(service, 'oidcProviderName', true)). Worth factoring into a small shared isOidcConnection(service) helper alongside the already-extracted resolvePlatformUrl() so the two OIDC entry points can't drift apart later.

Overall the core fix is sound and well-targeted at the reported 401; the main gap is the stale type declarations forcing an unsafe cast in the test suite.

Comment thread jfrog-tasks-utils/utils.js
Comment thread jfrog-tasks-utils/utils.js Outdated
Comment thread jfrog-tasks-utils/utils.js
Comment thread tests/tests.ts
Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread jfrog-tasks-utils/utils.d.ts Outdated

const statusCode = response.message.statusCode;
const responseBody = await response.readBody();
if (statusCode !== 200) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can it be 2**? can you check what are the possible status codes when successful?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JFrog OpenAPI for /access/api/v1/oidc/token only documents 200 on success (400/401 on failure), That's the reason I kept it as 200 only.
https://docs.jfrog.com/administration/reference/oidctokenexchange

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: where is it downloading cli from?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the Artifactory service connection + the task's cliInstallationRepo input — unchanged by this PR.

let downloadUrl = utils.buildCliArtifactoryDownloadUrl(artifactoryUrl, cliInstallationRepo, cliVersion);

which resolves to:

{artifactoryUrl}/{cliInstallationRepo}/{cliVersion}/jfrog-cli-{os}-{arch}/jf

e.g. https://my.jfrog.io/artifactory/jfrog-cli-remote/2.111.0/jfrog-cli-linux-amd64/jf

What this PR changes is only the auth on that download: for OIDC connections we now exchange a token first and send it as Bearer, instead of downloading anonymously (which was the 401). The URL source is the same as before.

Comment on lines +295 to +296
return Promise.resolve(typeof cliAuthHandlers === 'function' ? cliAuthHandlers() : cliAuthHandlers)
.then((resolvedHandlers) => downloadCli(cliDownloadUrl, resolvedHandlers, cliVersion))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this type of checks in javascript are little risky, I would look for some other mechanism where I can maintain the consistency of the data type instead of passing a function or an array.
Maybe write a factory method or a strategy pattern returning a resolver function which does the needful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current dual type keeps backward compat for the other 17 tasks that still pass an array.
A factory would be a broader refactor, if you still think if that's a valid case do let me know, I can again look into it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's pass and object (struct) then, and pass array in another variable, just like other 17 are doing and maybe pass function as another field in the struct from the new method. Check is one of them is null then go ahead with other variable, will this work?

Comment thread jfrog-tasks-utils/utils.js
Comment thread tests/package.json
"sync-request": "^6.1.0",
"ts-node": "^10.9.1"
"ts-node": "^10.9.1",
"typescript": "^5.2.2"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this dependency is required

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was pinned because unpinned ts-node peer resolved to TypeScript 6.x and broke every suite (fileExists error). Pin to ^5.2.2 matches root and keeps tests runnable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I guess that is because in your local you might be on another node version

Co-authored-by: Cursor <cursoragent@cursor.com>
@agrasth
agrasth requested review from bhanurp and fluxxBot July 22, 2026 09:09
@agrasth agrasth added the safe to test Approve running integration tests on a pull request label Jul 29, 2026
@github-actions github-actions Bot removed the safe to test Approve running integration tests on a pull request label Jul 29, 2026
…lution

Add unit tests for the two pieces of new logic that previously had no direct
coverage:

- exchangeOidcTokenViaRest: exercises the real function with a stubbed HTTP
  client — asserts the request shape (endpoint, grant_type, subject_token_type,
  subject_token, provider_name, provider_type=Azure, audience), that it returns
  the exchanged access token and publishes the oidc_token output variable, and
  that it throws clear errors on a non-200 response and on a missing access_token.

- getCliPath auth-handler resolution: a function provider is resolved to an
  array before reaching toolLib.downloadTool (guards the
  "this.handlers.forEach is not a function" regression), and a plain array
  handler still passes through unchanged (backward compatibility).
@agrasth agrasth added the safe to test Approve running integration tests on a pull request label Jul 29, 2026
@github-actions github-actions Bot removed the safe to test Approve running integration tests on a pull request label Jul 29, 2026
@agrasth agrasth added the safe to test Approve running integration tests on a pull request label Jul 29, 2026
@github-actions github-actions Bot removed the safe to test Approve running integration tests on a pull request label Jul 29, 2026
@agrasth
agrasth merged commit 696faad into dev Jul 29, 2026
42 of 48 checks passed
naveenku-jfrog added a commit that referenced this pull request Aug 3, 2026
Brings the post-2026-04-30 non-e2e product work onto v2 so the release
pipeline can build and publish the new version:

- #638: authenticate JFrog CLI download for OIDC service connections
        (JFrogToolsInstaller)
- Default JFrog CLI 2.111.0 / pluginVersion 2.14.2
- Node 22 in the test matrix (RTECO-813)

Also aligns e2e-plugin-tests.yml and trigger-ado-pipeline.sh with dev so
the release step `git merge origin/dev` is conflict-free. No change to
extension task runtime behavior beyond the items above.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants