Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 103 additions & 6 deletions jfrog-tasks-utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ module.exports = {
isToolExists: isToolExists,
buildCliArtifactoryDownloadUrl: buildCliArtifactoryDownloadUrl,
createAuthHandlers: createAuthHandlers,
createCliDownloadAuthHandlers: createCliDownloadAuthHandlers,

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.

Major: This newly exported createCliDownloadAuthHandlers (and exchangeOidcTokenViaRest below it) has no corresponding declaration in jfrog-tasks-utils/utils.d.ts. executeCliTask's cliAuthHandlers type there is also still ifm.IRequestHandler[] only, even though it must now also accept a () => Promise<IRequestHandler[]> provider. This is why tests/tests.ts had to call this function via (jfrogUtils as any) instead of the typed jfrogUtils import — any other TypeScript consumer of this package loses type safety for the new API. Suggested fix: update utils.d.ts to declare both new exports and widen executeCliTask's cliAuthHandlers param to ifm.IRequestHandler[] | (() => Promise<ifm.IRequestHandler[]>).

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.

Added createCliDownloadAuthHandlers, exchangeOidcTokenViaRest, isOidcConnection, resolvePlatformUrl to .d.ts; widened cliAuthHandlers to IRequestHandler[] | (() => Promise<IRequestHandler[]>)

exchangeOidcTokenViaRest: exchangeOidcTokenViaRest,
taskDefaultCleanup: taskDefaultCleanup,
writeSpecContentToSpecPath: writeSpecContentToSpecPath,
stripTrailingSlash: stripTrailingSlash,
Expand Down Expand Up @@ -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<array>.
// 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))
Comment on lines +295 to +296

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?

.then((cliPath) => resolve(cliPath))
.catch((error) => reject(errMsg + '\n' + error));
}
Expand Down Expand Up @@ -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<string>} [exchangeFn]
* - OIDC exchange implementation; injectable for testing. Defaults to exchangeOidcTokenViaRest.
* @returns {Promise<Array>} Authentication handlers for the CLI download.
*/
async function createCliDownloadAuthHandlers(serviceConnection, exchangeFn = exchangeOidcTokenViaRest) {
const oidcProviderName = tl.getEndpointAuthorizationParameter(serviceConnection, 'oidcProviderName', true);

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.

Nit: The OIDC-connection check (getEndpointAuthorizationParameter(serviceConnection, 'oidcProviderName', true)) is now duplicated here and in fetchOidcTokenIfConfigured. Consider extracting a small isOidcConnection(service) helper next to resolvePlatformUrl() so both OIDC entry points stay in sync if the detection logic ever changes.

@agrasth agrasth Jul 15, 2026

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.

Extracted isOidcConnection() helper, used in both createCliDownloadAuthHandlers and fetchOidcTokenIfConfigured

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)) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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<string>} The exchanged JFrog access token.
*/
async function exchangeOidcTokenViaRest(service, platformUrl, oidcProviderName) {
Comment thread
fluxxBot marked this conversation as resolved.
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) {

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.

Minor: No test covers this failure branch (non-200 response, or a 200 response missing access_token) for exchangeOidcTokenViaRest. Given this is the new code path that authenticates the CLI download itself, a test asserting the task fails with a clear error (rather than an unhandled rejection) when the OIDC exchange fails would be valuable regression coverage.

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.

Added two tests: exchange HTTP failure (403) and missing access_token.

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

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);
Expand Down
7 changes: 5 additions & 2 deletions tasks/JFrogToolsInstaller/toolsInstaller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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.

}

async function RunTaskCbk(cliPath) {
Expand Down
3 changes: 2 additions & 1 deletion tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"

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

},
"scripts": {
"test": "npm i && mocha -r ts-node/register tests.ts -t 1000000"
Expand Down
71 changes: 71 additions & 0 deletions tests/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -109,6 +110,76 @@ describe('JFrog Artifactory Extension Tests', (): void => {
TestUtils.isSkipTest('unit'),
);

runSyncTest(

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.

Minor: These two tests validate createCliDownloadAuthHandlers in isolation but don't cover the "lazy resolution" claim from the PR description — that a cached CLI never triggers an OIDC exchange. Consider adding a test that drives getCliPath/executeCliTask with toolLib.findLocalTool (or the custom CLI path check) returning a hit, and asserts the auth-handler provider function passed in is never called.

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.

Added "Cached CLI never triggers the OIDC auth-handler provider" test

'OIDC CLI download builds a Bearer handler from the exchanged token',
async (): Promise<void> => {
// 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<string> = async (
service: string,
platformUrl: string,
providerName: string,
): Promise<string> => {
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<void> => {
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<string> => {
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<void> => {
Expand Down
Loading