Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 5 additions & 1 deletion jfrog-tasks-utils/utils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ declare module '@jfrog/tasks-utils' {
runTaskFunc: (cliPath: string) => void | Promise<void>,
cliVersion?: string,
cliDownloadUrl?: string,
cliAuthHandlers?: ifm.IRequestHandler[],
cliAuthHandlers?: ifm.IRequestHandler[] | (() => Promise<ifm.IRequestHandler[]>),
): void;
export function quote(str: string): string;
export function downloadCli(cliDownloadUrl?: string, cliAuthHandlers?: ifm.IRequestHandler[], cliVersion?: string): Promise<string>;
Expand Down Expand Up @@ -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<string>,): Promise<ifm.IRequestHandler[]>;
export function exchangeOidcTokenViaRest(service: string, platformUrl: string, oidcProviderName: string): Promise<string>;
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;
Expand Down
119 changes: 113 additions & 6 deletions jfrog-tasks-utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ module.exports = {
isToolExists: isToolExists,
buildCliArtifactoryDownloadUrl: buildCliArtifactoryDownloadUrl,
createAuthHandlers: createAuthHandlers,
createCliDownloadAuthHandlers: createCliDownloadAuthHandlers,
Comment thread
agrasth marked this conversation as resolved.
exchangeOidcTokenViaRest: exchangeOidcTokenViaRest,
isOidcConnection: isOidcConnection,
resolvePlatformUrl: resolvePlatformUrl,
taskDefaultCleanup: taskDefaultCleanup,
writeSpecContentToSpecPath: writeSpecContentToSpecPath,
stripTrailingSlash: stripTrailingSlash,
Expand Down Expand Up @@ -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<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 +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<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) {
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)) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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<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) {
Comment thread
agrasth marked this conversation as resolved.

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
Loading
Loading