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
161 changes: 161 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ The *[JFrog Extension](https://marketplace.visualstudio.com/items?itemName=JFrog
- [Installing the Extension](#Installing-the-Extension)
- [Installing the Build Agent](#Installing-the-Build-Agent)
- [Configuring the Service Connections](#Configuring-the-Service-Connections)
- [Using OpenID Connect (OIDC) Authentication](#using-openid-connect-oidc-authentication)
- [Executing JFrog CLI Commands](#Executing-JFrog-CLI-Commands)
- [Build tools Tasks](#build-tools-tasks)
- [JFrog Maven](#JFrog-Maven-Task)
Expand Down Expand Up @@ -263,9 +264,169 @@ To enable TLS 1.2 on TFS:

</details>

## Using OpenID Connect (OIDC) Authentication

Using OpenID Connect (OIDC) to authenticate your pipelines eliminates the need for long lived static credentials providing a whole range of [security and practical benefits](https://jfrog.com/help/r/jfrog-platform-administration-documentation/openid-connect-integration-benefits).
You can read more about the [JFrog OpenID Connection Integration](https://jfrog.com/help/r/jfrog-platform-administration-documentation/openid-connect-integration) in the documentation.

Setting up OpenID Connect has 3 separate parts:
- Setting up an OpenID Connect Integration inside of the JFrog Platform.
- Configuring Identity Mappings with Claim rules, matching to Projects & Service Connections.
- Configuring Service Connections as OpenID Connect in the Projects in your Azure Devops Instance.

> [!IMPORTANT]
> To use OIDC authentication, make sure you're using **JFrog CLI version 2.75.0 or later**
> and **JFrog Azure DevOps Extension version 2.11.0 or later**.

Follow the guides below to configure each part.

<details>
<summary>

#### Configure OpenID Connect Integration

</summary>


First, configure an OpenID Connect integration to your Azure DevOps server in your JFrog instance.
Log in to your JFrog instance as an administrator,
then as [described in the documentation:](https://jfrog.com/help/r/jfrog-platform-administration-documentation/openid-connect-configurations-overview)

1. Go to the **Administrator panel**.
2. Select **General Management**.
3. Choose **Manage Integrations**.
4. Select New Integration - **OpenID Connect**

Next, fill out the integration form with your Azure DevOps instance parameters.

| Property name | Description |
| ------------- |---------------------------------------------------------------------------------------|
| Provider Name | A name for your provider, this name is used in the Azure DevOps tasks in the pipelines. |
| Provider Type | `Azure` |
| Description | A description of what this provider is for. |
| Provider URL | `https://vstoken.dev.azure.com/{ORG_GUID}` (see how to get the {ORG_GUID} below). |
| Audience | example: `api://AzureADTokenExchange` |
| Token Issuer | If the issuer is different from the provider, for Azure DevOps this can be left blank. |

For example, the final integration configuration will look like this:

![oidc-integration.png](images/oidc-integration.png)

In order to obtain your Azure DevOps Organization GUID (`{ORG_GUID}`) you can simply run a pipeline in your Azure DevOps organization using any of the JFrog Task setup using a Service Connection configured with the `OpenID Connect Integration` authentication method, see the [Configure the Service Connection](#configure-the-service-connection) section. Even if the task fails due to you not yet having configured the Integration in JFrog, it will output the relevant information as part of the pipeline.

In the Pipeline Output, look for the `OIDC Token Issuer`,value, which you need to enter as your `Provider URL`.
The rest of the information can also be helpful for you to configure the Identity Mappings as described in the section below.

```
OIDC Token Subject: sc://<DevopsOrgName>/<ProjectName>/<ServiceConnectionName>
OIDC Token Claims: {"sub": "sc://<DevopsOrgName>/<ProjectName>/<ServiceConnectionName>"}
OIDC Token Issuer: https://vstoken.dev.azure.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
OIDC Token Audience: api://AzureADTokenExchange
```

> **Security Tip**: It's safe to log OpenID Connect claims like `sub`, `aud`, or `iss` in debug output for troubleshooting purposes.
> However, never print the full ID token or access token, even in debug logs.


</details>

<details>
<summary>

#### Configure Identity Mappings

</summary>

When the `OpenID Connect Integration` has been configured, you must now configure `Identity Mappings` for your projects and service connections to allow them to utilize the integration.
You can find the full documentation for configuring [Identity Mappings in the Documentation](https://jfrog.com/help/r/jfrog-platform-administration-documentation/identity-mappings).
For this part we will focus on how to setup the JSON Claim which is used to map the JWT request of the pipeline to the access rights in your mapping.

When working with OpenID Connect, we must look at the `ID Token` that our provider (Azure DevOps) outputs.
Based on the information in the token, we can map properties into rules in our `Identity Mappings` JSON Claim.
The `ID Token` from the Azure DevOps token provider looks like this:

```json
{
"jti": "<guid>",
"sub": "sc://<DevopsOrgName>/<ProjectName>/<ServiceConnectionName>",
"aud": "api://AzureADTokenExchange",
"iss": "https://vstoken.dev.azure.com/<ORG_GUID>",
"nbf": 1708639268,
"exp": 1708640467,
"iat": 1708639868
}
```

Relative to most other `ID Token` providers, our options are fairly sparse, the only sensible option is using the subject (`"sub"`) field.
The claim mapping does support wildcards with the `*` operator.

A sample JSON Claim mapping which maps a specified ServiceConnection in a specified Project in your Organization would look like this:

![oidc-json-mapping.png](images/oidc-json-mapping.png)

To allow all projects in your Organization with a ServiceConnection with a specified name, you could replace MyProject with `*`.
Just make sure to never replace your Organization name with a `*` operator as that would allow any Azure DevOps Organization to gain access to your instance.

</details>

<details>
<summary>

#### Configure the Service Connection

</summary>

You must configure a `ServiceConnection` setting the `Authentication method` to `OpenID Connect Integration`.

This requires you to fill in the following inputs:

| Property name | Description |
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |
| Server URL | The URL of your JFrog instance with the `/artifactory` path fx. (`https://my.jfrog.io/artifactory`) |
| OpenID Connect Provider Name | The `Provider Name` you configured in the `Configure OpenID Connect Integration` step |
| Platform URL | The URL of your JFrog instance fx. (`https://my.jfrog.io/`) |
| Service connection name | The name of the Service Connection, must match the values put into the `JSON Claims mapping` |
| Description (optional) | A short of the purpose of this ServiceConnection |


A sample configuration would look like this:

![oidc-service-connection.png](images/oidc-service-connection.png)

Now this Service Connection can be used for any of JFrog tasks as normal, authenticating with a temporary access token each time the pipeline runs.

> 💡 **Tip**
> The extension automatically exports the authenticated user and access token
> as step outputs named `oidc_user` and `oidc_token`. These outputs can be used in later steps (e.g., for Docker login, Helm registry, or custom scripts).
> Example usage in a later step:

```yaml
steps:
- task: JfrogCliV2@1
name: jfStep
inputs:
jfrogPlatformConnection: 'azure-oidc'
command: 'jf rt ping'

- task: PowerShell@2
inputs:
targetType: 'inline'
script: |
echo "OIDC Username (from output): $(jfStep.oidc_user)"
echo "OIDC Token (from env): $env:oidc_token"
displayName: 'Use OIDC Output Variables'
```


See [JFrog CLI - OIDC Token Exchange (`jf eot`)](https://jfrog.com/help/r/jfrog-cli/jfrog-cli-eot) for more information on how the CLI handles OpenID Connect tokens behind the scenes.

</details>


<br>



## Executing JFrog CLI Commands

<details>
Expand Down
5 changes: 3 additions & 2 deletions buildScripts/publish-private.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ cp vss-extension.json vss-extension-private.json
npx tfx extension unshare -t "$ADO_ARTIFACTORY_API_KEY" --extension-id jfrog-azure-devops-extension --publisher "$PUBLISHER" --unshare-with "$ADO_ARTIFACTORY_DEVELOPER" 2>/dev/null
npx tfx extension unpublish -t "$ADO_ARTIFACTORY_API_KEY" --extension-id jfrog-azure-devops-extension --publisher "$PUBLISHER"
npx tfx extension create --manifest-globs vss-extension-private.json --publisher "$PUBLISHER"
# Check that vsix size is less then 30MB

# Max size is 50MB, but we want to be under 40.
vsixSize="$(du -m -- *.vsix | awk '{print $1}' | head -1)"
if [ "${vsixSize}" -gt 30 ]; then
if [ "${vsixSize}" -gt 40 ]; then
echo "Extension vsix size is greater than 30MB! (${vsixSize}MB) - Hint: Most of the dependencies on package-json are in format of - <^x.y.z>, so maybe one of them got updated, and the node_modules directory became bigger"
exit 1
fi
Expand Down
Binary file added images/oidc-integration.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/oidc-json-mapping.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/oidc-service-connection.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion jfrog-tasks-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"typings": "utils.d.ts",
"dependencies": {
"azure-pipelines-task-lib": "4.5.0",
"azure-pipelines-tool-lib": "2.0.6",
"azure-pipelines-tasks-java-common": "^2.219.1",
"azure-pipelines-tool-lib": "2.0.6",
"typed-rest-client": "^1.8.11"
},
"scripts": {
Expand Down
9 changes: 8 additions & 1 deletion jfrog-tasks-utils/utils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ declare module '@jfrog/tasks-utils' {
export function removeExtractorsDownloadVariables(cliPath: string, workDir: string);
export function configureArtifactoryCliServer(artifactoryService: string, serverId: string, cliPath: string, buildDir: string);
export function setJdkHomeForJavaTasks();

export function fetchAzureOidcToken(serviceConnectionID: string): string;
export function exchangeOidcTokenAndSetStepVariables(
service: service,
serviceUrl: string,
oidcProviderName: string,
cliPath: string,
buildDir: string,
): string;
export { taskSelectedCliVersionEnv };
}
149 changes: 148 additions & 1 deletion jfrog-tasks-utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@ const execSync = require('child_process').execSync;
const toolLib = require('azure-pipelines-tool-lib/tool');
const credentialsHandler = require('typed-rest-client/Handlers');
const findJavaHome = require('azure-pipelines-tasks-java-common/java-common').findJavaHome;
const syncRequest = require('sync-request');
import * as semver from 'semver';

const fileName = getCliExecutableName();
const jfrogCliToolName = 'jf';
const cliPackage = 'jfrog-cli-' + getArchitecture();
const jfrogFolderPath = encodePath(join(tl.getVariable('Agent.ToolsDirectory') || '', '_jf'));
const defaultJfrogCliVersion = '2.73.3';
const defaultJfrogCliVersion = '2.75.0';

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.

there is an issue with latest version of jfrog-cli in azure devops plugin better to revert to old version until the issue is resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is mandatory for the OIDC token exchange.
Maybe we can merge this and not release yet.

const minCustomCliVersion = '2.10.0';
const minSupportedStdinSecretCliVersion = '2.36.0';
const minSupportedServerIdEnvCliVersion = '2.37.0';
const minSupportedOidcCliVersion = '2.75.0';
const pluginVersion = '2.10.4';
const buildAgent = 'jfrog-azure-devops-extension';
const customFolderPath = encodePath(join(jfrogFolderPath, 'current'));
const customCliPath = encodePath(join(customFolderPath, fileName)); // Optional - Customized jfrog-cli path.
const jfrogCliReleasesUrl = 'https://releases.jfrog.io/artifactory/jfrog-cli/v2-jf';
const oidcUserOutputName = 'oidc_user';
const oidcTokenOutputName = 'oidc_token';

// Set by Tools Installer Task. This JFrog CLI version will be used in all tasks unless manual installation is used,
// or a specific version was requested in a task. If not set, use the default CLI version.
Expand Down Expand Up @@ -253,14 +258,156 @@ function configureXrayCliServer(xrayService, serverId, cliPath, buildDir) {
return configureSpecificCliServer(xrayService, '--xray-url', serverId, cliPath, buildDir);
}

/**
* logging oidc token values for debugging
* @param oidcToken
*/
function debugLogIDToken(oidcToken) {
/**
* @typedef {Object} OidcClaims
* @property {string} sub - The subject of the token.
* @property {string} iss - The issuer of the token.
* @property {string} aud - The audience of the token.
*/

/** @type {OidcClaims} */
const oidcClaims = JSON.parse(Buffer.from(oidcToken.split('.')[1], 'base64').toString());
console.debug('OIDC Token Subject: ', oidcClaims.sub);
console.debug(`OIDC Token Claims: {"sub": "${oidcClaims.sub}"}`);
console.debug('OIDC Token Issuer (Provider URL): ', oidcClaims.iss);
console.debug('OIDC Token Audience: ', oidcClaims.aud);
}

function fetchAzureOidcToken(serviceConnectionID) {
const uri = tl.getVariable('System.CollectionUri');
const teamPrjID = tl.getVariable('System.TeamProjectId');
const hub = tl.getVariable('System.HostType');
const planID = tl.getVariable('System.PlanId');
const jobID = tl.getVariable('System.JobId');
const apiVersion = '7.1-preview.1';

const token = tl.getVariable('System.AccessToken');
if (!token) {
throw new Error('System.AccessToken is not available. Make sure "Allow scripts to access OAuth token" is enabled.');
}

const url = `${uri}${teamPrjID}/_apis/distributedtask/hubs/${hub}/plans/${planID}/jobs/${jobID}/oidctoken?api-version=${apiVersion}&serviceConnectionId=${serviceConnectionID}`;

const res = syncRequest('POST', url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});

if (res.statusCode !== 200) {
throw new Error(`OIDC token request failed: HTTP ${res.statusCode}\nBody: ${res.getBody('utf8')}`);
}
/** @type {{ oidcToken?: string }} */
const body = JSON.parse(res.getBody('utf8'));
if (!body.oidcToken) {
throw new Error('OIDC token not found in response body.');
}
debugLogIDToken(body.oidcToken);
return body.oidcToken;
}

function exchangeOidcTokenAndSetStepVariables(service, serviceUrl, oidcProviderName, cliPath, buildDir) {
// First validate supported CLI version
let cliVersion = getCliVersion(cliPath);
if (semver.lt(cliVersion, '2.75.0')) {
throw new Error('CLI version too low');
}
if (cliVersion < minSupportedOidcCliVersion) {
throw new Error(
`The CLI version ${cliVersion} is not supported for OIDC token exchange. Minimum required version is ${minSupportedOidcCliVersion}.`,
);
}
let oidcAudience = tl.getEndpointAuthorizationParameter(service, 'oidcAudience', true) || 'api://AzureADTokenExchange';
const repoName = tl.getVariable('Build.Repository.Name');
const idToken = fetchAzureOidcToken(service);

// Build the CLI command
let cliCommand = cliJoin(
cliPath,
`eot ${quote(oidcProviderName)} ${quote(idToken)} --url=${quote(serviceUrl)} --oidc-provider-type=Azure --oidc-audience=${quote(oidcAudience)} --repository=${quote(repoName)}`,
);

// Execute the CLI command and capture the output
let exeRes = executeCliCommand(cliCommand, buildDir, { withOutput: true }).toString();

// Extract AccessToken
const { username, accessToken } = extractAccessTokenAndUsername(exeRes);

// Set output variables
tl.setVariable(oidcUserOutputName, username, true);
tl.setVariable(oidcTokenOutputName, accessToken, true);

return accessToken;
}

/**
* Extracts AccessToken and Username from the CLI output.
* Supports both JSON and non-JSON (regex) outputs.
* Currently, the output is a non-valid JSON, which should be changed in the future.
* @param {string} output - The CLI output.
* @returns {{ accessToken: string, username: string }} - Extracted values.
* @throws {Error} - If neither JSON nor regex extraction succeeds.
*/
function extractAccessTokenAndUsername(output) {
// Attempt to parse as JSON
try {
/**
* @typedef {Object} ParsedOutput
* @property {string} AccessToken
* @property {string} Username
*/

/** @type {ParsedOutput} */
const parsedOutput = JSON.parse(output);
if (parsedOutput.AccessToken && parsedOutput.Username) {
return {
accessToken: parsedOutput.AccessToken,
username: parsedOutput.Username,
};
}
} catch (e) {
console.debug('Failed to parse output as JSON, trying with regex..');
}

// Fallback to regex extraction
const accessTokenMatch = output.match(/AccessToken:\s*(\S+)/);
const usernameMatch = output.match(/Username:\s*(\S+)/);

if (accessTokenMatch && usernameMatch) {
return {
accessToken: accessTokenMatch[1],
username: usernameMatch[1],
};
}

// If both methods fail, throw an error
throw new Error('Failed to extract AccessToken or Username from the output.');
}

function configureSpecificCliServer(service, urlFlag, serverId, cliPath, buildDir) {
let serviceUrl = tl.getEndpointUrl(service, false);
let serviceUser = tl.getEndpointAuthorizationParameter(service, 'username', true);
let servicePassword = tl.getEndpointAuthorizationParameter(service, 'password', true);
let serviceAccessToken = tl.getEndpointAuthorizationParameter(service, 'apitoken', true);
let oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true);
let cliCommand = cliJoin(cliPath, jfrogCliConfigAddCommand, quote(serverId), urlFlag + '=' + quote(serviceUrl), '--interactive=false');
let stdinSecret;
let secretInStdinSupported = isStdinSecretSupported();

// In the case of OIDC, we exchange tokens via the CLI
// and populate the access token to the CLI config.
// This is done by the exchange command and not the config to export
// username and access token params for further use by the users.
if (oidcProviderName) {
serviceAccessToken = exchangeOidcTokenAndSetStepVariables(service, serviceUrl, oidcProviderName, cliPath, buildDir);
}

if (serviceAccessToken) {
// Add access-token if required.
cliCommand = cliJoin(cliCommand, secretInStdinSupported ? '--access-token-stdin' : '--access-token=' + quote(serviceAccessToken));
Expand Down
Loading