@@ -12,7 +12,7 @@ const fileName = getCliExecutableName();
1212const jfrogCliToolName = 'jf' ;
1313const cliPackage = 'jfrog-cli-' + getArchitecture ( ) ;
1414const fallbackCliVersion = '2.99.0' ;
15- let defaultJfrogCliVersion = '2.103 .0' ;
15+ let defaultJfrogCliVersion = '2.111 .0' ;
1616
1717/**
1818 * Executes an HTTP request with retry logic for 5xx errors.
@@ -144,7 +144,7 @@ const minCustomCliVersion = '2.10.0';
144144const minSupportedStdinSecretCliVersion = '2.36.0' ;
145145const minSupportedServerIdEnvCliVersion = '2.37.0' ;
146146const minSupportedOidcCliVersion = '2.75.0' ;
147- const pluginVersion = '2.14.1 ' ;
147+ const pluginVersion = '2.14.2 ' ;
148148const buildAgent = 'jfrog-azure-devops-extension' ;
149149
150150/**
@@ -201,6 +201,10 @@ module.exports = {
201201 isToolExists : isToolExists ,
202202 buildCliArtifactoryDownloadUrl : buildCliArtifactoryDownloadUrl ,
203203 createAuthHandlers : createAuthHandlers ,
204+ createCliDownloadAuthHandlers : createCliDownloadAuthHandlers ,
205+ exchangeOidcTokenViaRest : exchangeOidcTokenViaRest ,
206+ isOidcConnection : isOidcConnection ,
207+ resolvePlatformUrl : resolvePlatformUrl ,
204208 taskDefaultCleanup : taskDefaultCleanup ,
205209 writeSpecContentToSpecPath : writeSpecContentToSpecPath ,
206210 stripTrailingSlash : stripTrailingSlash ,
@@ -285,7 +289,11 @@ function getCliPath(cliDownloadUrl, cliAuthHandlers, cliVersion) {
285289 } else {
286290 const errMsg = generateDownloadCliErrorMessage ( cliDownloadUrl , cliVersion ) ;
287291 createCliDirs ( ) ;
288- return downloadCli ( cliDownloadUrl , cliAuthHandlers , cliVersion )
292+ // cliAuthHandlers may be an array or a provider function returning a Promise<array>.
293+ // Resolve it lazily here so that work such as an OIDC token exchange only happens
294+ // when a download is actually required — never when the CLI is already cached.
295+ return Promise . resolve ( typeof cliAuthHandlers === 'function' ? cliAuthHandlers ( ) : cliAuthHandlers )
296+ . then ( ( resolvedHandlers ) => downloadCli ( cliDownloadUrl , resolvedHandlers , cliVersion ) )
289297 . then ( ( cliPath ) => resolve ( cliPath ) )
290298 . catch ( ( error ) => reject ( errMsg + '\n' + error ) ) ;
291299 }
@@ -330,6 +338,41 @@ function createAuthHandlers(serviceConnection) {
330338 return [ new credentialsHandler . BasicCredentialHandler ( artifactoryUser , artifactoryPassword , false ) ] ;
331339}
332340
341+ /**
342+ * Returns whether the given service connection uses OIDC authentication.
343+ * @param {string } serviceConnection - The service connection ID.
344+ * @returns {boolean }
345+ */
346+ function isOidcConnection ( serviceConnection ) {
347+ return ! ! tl . getEndpointAuthorizationParameter ( serviceConnection , 'oidcProviderName' , true ) ;
348+ }
349+
350+ /**
351+ * Builds the authentication handlers used to download the JFrog CLI.
352+ *
353+ * For OIDC-based service connections the credential does not exist as a static
354+ * token — it must be obtained through an OIDC token exchange. The CLI-based
355+ * exchange (exchangeOidcTokenAndSetStepVariables) cannot be used here because the
356+ * CLI is the very artifact being downloaded, so this performs a CLI-independent
357+ * REST exchange (exchangeOidcTokenViaRest) and authenticates the download with the
358+ * resulting access token. For all other connection types it falls back to the
359+ * synchronous createAuthHandlers (access token / basic / anonymous).
360+ *
361+ * @param {string } serviceConnection - The Artifactory service connection ID.
362+ * @param {(service: string, platformUrl: string, oidcProviderName: string) => Promise<string> } [exchangeFn]
363+ * - OIDC exchange implementation; injectable for testing. Defaults to exchangeOidcTokenViaRest.
364+ * @returns {Promise<Array> } Authentication handlers for the CLI download.
365+ */
366+ async function createCliDownloadAuthHandlers ( serviceConnection , exchangeFn = exchangeOidcTokenViaRest ) {
367+ if ( ! isOidcConnection ( serviceConnection ) ) {
368+ return createAuthHandlers ( serviceConnection ) ;
369+ }
370+ const platformUrl = resolvePlatformUrl ( serviceConnection ) ;
371+ const oidcProviderName = tl . getEndpointAuthorizationParameter ( serviceConnection , 'oidcProviderName' , true ) ;
372+ const accessToken = await exchangeFn ( serviceConnection , platformUrl , oidcProviderName ) ;
373+ return [ new credentialsHandler . BearerCredentialHandler ( accessToken , false ) ] ;
374+ }
375+
333376function generateDownloadCliErrorMessage ( downloadUrl , cliVersion ) {
334377 let errMsg = 'Failed while attempting to download JFrog CLI from ' + downloadUrl ;
335378 if ( downloadUrl === buildReleasesDownloadUrl ( cliVersion ) ) {
@@ -393,11 +436,14 @@ function maskSecrets(str) {
393436 . replace ( / - - a c c e s s - t o k e n = ' .* ?' / g, '--access-token=***' ) ;
394437}
395438
396- async function fetchOidcTokenIfConfigured ( service , cliPath , buildDir ) {
397- const oidcProviderName = tl . getEndpointAuthorizationParameter ( service , 'oidcProviderName' , true ) ;
398- if ( ! oidcProviderName ) {
399- return undefined ;
400- }
439+ /**
440+ * Resolves the JFrog platform URL for a service connection. Prefers the explicit
441+ * 'jfrogPlatformUrl' authorization parameter and falls back to parsing it from the
442+ * service URL.
443+ * @param {string } service - The service connection ID.
444+ * @returns {string } The resolved platform URL.
445+ */
446+ function resolvePlatformUrl ( service ) {
401447 const serviceUrl = tl . getEndpointUrl ( service , false ) ;
402448 let platformUrl = '' ;
403449 try {
@@ -408,6 +454,15 @@ async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) {
408454 if ( ! platformUrl || ! platformUrl . trim ( ) ) {
409455 platformUrl = parsePlatformUrlFromServiceUrl ( serviceUrl ) ;
410456 }
457+ return platformUrl ;
458+ }
459+
460+ async function fetchOidcTokenIfConfigured ( service , cliPath , buildDir ) {
461+ if ( ! isOidcConnection ( service ) ) {
462+ return undefined ;
463+ }
464+ const oidcProviderName = tl . getEndpointAuthorizationParameter ( service , 'oidcProviderName' , true ) ;
465+ const platformUrl = resolvePlatformUrl ( service ) ;
411466 return exchangeOidcTokenAndSetStepVariables ( service , platformUrl , oidcProviderName , cliPath , buildDir ) ;
412467}
413468
@@ -619,6 +674,58 @@ async function fetchAzureOidcToken(serviceConnectionID) {
619674 return body . oidcToken ;
620675}
621676
677+ /**
678+ * Performs an OIDC token exchange WITHOUT the JFrog CLI, via a direct REST call to
679+ * JFrog Access. Required by the JFrog Tools Installer, which must authenticate the
680+ * CLI *download* itself — at that point the CLI does not yet exist, so the
681+ * CLI-based exchange cannot be used. The request mirrors what `jf eot` sends for an
682+ * Azure provider (grant_type / subject_token_type / subject_token / provider_name /
683+ * provider_type / audience). The Azure DevOps identity mapping is matched on the ID
684+ * token's subject claim, which is carried in subject_token.
685+ *
686+ * @param {string } service - The service connection ID.
687+ * @param {string } platformUrl - The JFrog platform base URL.
688+ * @param {string } oidcProviderName - The configured OIDC provider name.
689+ * @returns {Promise<string> } The exchanged JFrog access token.
690+ */
691+ async function exchangeOidcTokenViaRest ( service , platformUrl , oidcProviderName ) {
692+ const oidcAudience = tl . getEndpointAuthorizationParameter ( service , 'oidcAudience' , true ) || 'api://AzureADTokenExchange' ;
693+ const idToken = await fetchAzureOidcToken ( service ) ;
694+
695+ const exchangeUrl = addTrailingSlashIfNeeded ( platformUrl ) + 'access/api/v1/oidc/token' ;
696+ const requestBody = {
697+ grant_type : 'urn:ietf:params:oauth:grant-type:token-exchange' ,
698+ subject_token_type : 'urn:ietf:params:oauth:token-type:id_token' ,
699+ subject_token : idToken ,
700+ provider_name : oidcProviderName ,
701+ provider_type : 'Azure' ,
702+ audience : oidcAudience ,
703+ } ;
704+
705+ const requestOptions = { ...getProxyConfiguration ( ) , socketTimeout : 30000 } ;
706+ const httpClient = new httpm . HttpClient ( buildAgent , [ ] , requestOptions ) ;
707+ tl . debug ( 'Exchanging OIDC token via REST at: ' + exchangeUrl ) ;
708+ const response = await httpClient . post ( exchangeUrl , JSON . stringify ( requestBody ) , {
709+ 'Content-Type' : 'application/json' ,
710+ } ) ;
711+
712+ const statusCode = response . message . statusCode ;
713+ const responseBody = await response . readBody ( ) ;
714+ if ( statusCode !== 200 ) {
715+ throw new Error ( `OIDC token exchange failed: HTTP ${ statusCode } \nBody: ${ responseBody } ` ) ;
716+ }
717+ /** @type {{ access_token?: string, username?: string } } */
718+ const body = JSON . parse ( responseBody ) ;
719+ if ( ! body . access_token ) {
720+ throw new Error ( 'OIDC token exchange response did not contain an access token.' ) ;
721+ }
722+
723+ // Publish outputs for parity with the CLI-based OIDC flow (downstream consumption / debug).
724+ tl . setVariable ( oidcUserOutputName , body . username || '' , true ) ;
725+ tl . setVariable ( oidcTokenOutputName , body . access_token , true ) ;
726+ return body . access_token ;
727+ }
728+
622729async function exchangeOidcTokenAndSetStepVariables ( service , serviceUrl , oidcProviderName , cliPath , buildDir ) {
623730 // First validate supported CLI version
624731 let cliVersion = getCliVersion ( cliPath ) ;
0 commit comments