-
Notifications
You must be signed in to change notification settings - Fork 78
fix(JFrogToolsInstaller): authenticate CLI download for OIDC service connections #638
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 2 commits
ac623d1
8597094
ea19eaf
e78a5e9
213d0fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||
| } | ||
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: The OIDC-connection check (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extracted |
||
| 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<string>} The exchanged JFrog access token. | ||
| */ | ||
| async function exchangeOidcTokenViaRest(service, platformUrl, oidcProviderName) { | ||
|
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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added two tests: exchange HTTP failure (403) and missing
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: where is it downloading cli from?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From the Artifactory service connection + the task's let downloadUrl = utils.buildCliArtifactoryDownloadUrl(artifactoryUrl, cliInstallationRepo, cliVersion);which resolves to:
e.g. 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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this dependency is required
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: These two tests validate
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> => { | ||
|
|
||
There was a problem hiding this comment.
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(andexchangeOidcTokenViaRestbelow it) has no corresponding declaration injfrog-tasks-utils/utils.d.ts.executeCliTask'scliAuthHandlerstype there is also stillifm.IRequestHandler[]only, even though it must now also accept a() => Promise<IRequestHandler[]>provider. This is whytests/tests.tshad to call this function via(jfrogUtils as any)instead of the typedjfrogUtilsimport — any other TypeScript consumer of this package loses type safety for the new API. Suggested fix: updateutils.d.tsto declare both new exports and widenexecuteCliTask'scliAuthHandlersparam toifm.IRequestHandler[] | (() => Promise<ifm.IRequestHandler[]>).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
createCliDownloadAuthHandlers,exchangeOidcTokenViaRest,isOidcConnection,resolvePlatformUrlto.d.ts; widenedcliAuthHandlerstoIRequestHandler[] | (() => Promise<IRequestHandler[]>)