diff --git a/README.md b/README.md index be9bb02e..8695b826 100644 --- a/README.md +++ b/README.md @@ -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) @@ -263,9 +264,169 @@ To enable TLS 1.2 on TFS: +## 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. + +
+ + +#### Configure OpenID Connect Integration + + + + +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://// +OIDC Token Claims: {"sub": "sc:////"} +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. + + +
+ +
+ + +#### Configure Identity Mappings + + + +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": "", + "sub": "sc:////", + "aud": "api://AzureADTokenExchange", + "iss": "https://vstoken.dev.azure.com/", + "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. + +
+ +
+ + +#### Configure the Service Connection + + + +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. + +
+
+ + ## Executing JFrog CLI Commands
diff --git a/buildScripts/publish-private.sh b/buildScripts/publish-private.sh old mode 100644 new mode 100755 index 0c1798a3..89a2519b --- a/buildScripts/publish-private.sh +++ b/buildScripts/publish-private.sh @@ -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 diff --git a/images/oidc-integration.png b/images/oidc-integration.png new file mode 100644 index 00000000..6021184f Binary files /dev/null and b/images/oidc-integration.png differ diff --git a/images/oidc-json-mapping.png b/images/oidc-json-mapping.png new file mode 100644 index 00000000..5ae392e5 Binary files /dev/null and b/images/oidc-json-mapping.png differ diff --git a/images/oidc-service-connection.png b/images/oidc-service-connection.png new file mode 100644 index 00000000..ff7c66ab Binary files /dev/null and b/images/oidc-service-connection.png differ diff --git a/jfrog-tasks-utils/package.json b/jfrog-tasks-utils/package.json index 441564f6..675d3f42 100644 --- a/jfrog-tasks-utils/package.json +++ b/jfrog-tasks-utils/package.json @@ -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": { diff --git a/jfrog-tasks-utils/utils.d.ts b/jfrog-tasks-utils/utils.d.ts index e825dc9f..94aa881e 100644 --- a/jfrog-tasks-utils/utils.d.ts +++ b/jfrog-tasks-utils/utils.d.ts @@ -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 }; } diff --git a/jfrog-tasks-utils/utils.js b/jfrog-tasks-utils/utils.js index 67176783..9c2a0689 100644 --- a/jfrog-tasks-utils/utils.js +++ b/jfrog-tasks-utils/utils.js @@ -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'; 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. @@ -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)); diff --git a/package.json b/package.json index 2c29849c..dcdf7ac2 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ "publish-private": "npm i --no-fund && bash buildScripts/publish-private.sh", "compile": "npx tsc -p ./", "lint": "npx eslint . --ext .ts,.tsx,.js" + }, + "dependencies": { + "semver": "^7.7.1" } } diff --git a/vss-extension.json b/vss-extension.json index 6852df80..24fe0e05 100644 --- a/vss-extension.json +++ b/vss-extension.json @@ -149,6 +149,26 @@ } } ] + }, + { + "type": "ms.vss-endpoint.endpoint-auth-scheme-none", + "displayName": "OpenID Connect Integration", + "properties": { + "isVerifiable": "False" + }, + "inputDescriptors": [ + { + "id": "oidcProviderName", + "name": "OpenID Connect Provider Name", + "description": "The OpenID Connect \"Provider Name\" configured in JFrog Platform. Click here for information about how to configure OpenID Connect.", + "inputMode": "textbox", + "isConfidential": false, + "validation": { + "isRequired": true, + "dataType": "string" + } + } + ] } ] } @@ -165,7 +185,7 @@ "displayName": "JFrog Artifactory V2", "url": { "displayName": "Server URL", - "helpText": "Specify the root URL of your Artifactory installation. For example, https://repo.jfrog.org/artifactory" + "helpText": "Specify the root URL of your Artifactory installation. For example, https://my.jfrog.io/artifactory" }, "icon": "images/artifactory.png", "dataSources": [ @@ -234,6 +254,37 @@ } } ] + }, + { + "type": "ms.vss-endpoint.endpoint-auth-scheme-none", + "displayName": "OpenID Connect Integration", + "properties": { + "isVerifiable": "False" + }, + "inputDescriptors": [ + { + "id": "oidcProviderName", + "name": "OpenID Connect Provider Name", + "description": "The OpenID Connect \"Provider Name\" configured in JFrog Platform. Click here for information about how to configure OpenID Connect.", + "inputMode": "textbox", + "isConfidential": false, + "validation": { + "isRequired": true, + "dataType": "string" + } + }, + { + "id": "jfrogPlatformUrl", + "name": "Platform URL", + "description": "The access token will be obtained from this URL. For example, https://my.jfrog.io/", + "inputMode": "textbox", + "isConfidential": false, + "validation": { + "isRequired": true, + "dataType": "string" + } + } + ] } ] } @@ -250,7 +301,7 @@ "displayName": "JFrog Distribution V2", "url": { "displayName": "Server URL", - "helpText": "Specify the root URL of your Distribution installation. For example, https://repo.jfrog.org/distribution" + "helpText": "Specify the root URL of your Distribution installation. For example, https://my.jfrog.io/distribution" }, "icon": "images/distribution.png", "dataSources": [ @@ -309,6 +360,37 @@ } } ] + }, + { + "type": "ms.vss-endpoint.endpoint-auth-scheme-none", + "displayName": "OpenID Connect Integration", + "properties": { + "isVerifiable": "False" + }, + "inputDescriptors": [ + { + "id": "oidcProviderName", + "name": "OpenID Connect Provider Name", + "description": "The OpenID Connect \"Provider Name\" configured in JFrog Platform. Click here for information about how to configure OpenID Connect.", + "inputMode": "textbox", + "isConfidential": false, + "validation": { + "isRequired": true, + "dataType": "string" + } + }, + { + "id": "jfrogPlatformUrl", + "name": "Platform URL", + "description": "The access token will be obtained from this URL. For example, https://my.jfrog.io/", + "inputMode": "textbox", + "isConfidential": false, + "validation": { + "isRequired": true, + "dataType": "string" + } + } + ] } ] } @@ -325,7 +407,7 @@ "displayName": "JFrog Xray V2", "url": { "displayName": "Server URL", - "helpText": "Specify the root URL of your Xray installation. For example, https://repo.jfrog.org/xray" + "helpText": "Specify the root URL of your Xray installation. For example, https://my.jfrog.io/xray" }, "icon": "images/xray.png", "dataSources": [ @@ -384,6 +466,37 @@ } } ] + }, + { + "type": "ms.vss-endpoint.endpoint-auth-scheme-none", + "displayName": "OpenID Connect Integration", + "properties": { + "isVerifiable": "False" + }, + "inputDescriptors": [ + { + "id": "oidcProviderName", + "name": "OpenID Connect Provider Name", + "description": "The OpenID Connect \"Provider Name\" configured in JFrog Platform. Click here for information about how to configure OpenID Connect.", + "inputMode": "textbox", + "isConfidential": false, + "validation": { + "isRequired": true, + "dataType": "string" + } + }, + { + "id": "jfrogPlatformUrl", + "name": "Platform URL", + "description": "The access token will be obtained from this URL. For example, https://my.jfrog.io/", + "inputMode": "textbox", + "isConfidential": false, + "validation": { + "isRequired": true, + "dataType": "string" + } + } + ] } ] } @@ -908,4 +1021,4 @@ "path": "tasks/JFrogGenericArtifacts" } ] -} \ No newline at end of file +}