Skip to content

Commit e52aeb4

Browse files
authored
Add OIDC Service Connection Authentication method (#533)
1 parent 2349a51 commit e52aeb4

File tree

10 files changed

+441
-9
lines changed

10 files changed

+441
-9
lines changed

README.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ The *[JFrog Extension](https://marketplace.visualstudio.com/items?itemName=JFrog
3636
- [Installing the Extension](#Installing-the-Extension)
3737
- [Installing the Build Agent](#Installing-the-Build-Agent)
3838
- [Configuring the Service Connections](#Configuring-the-Service-Connections)
39+
- [Using OpenID Connect (OIDC) Authentication](#using-openid-connect-oidc-authentication)
3940
- [Executing JFrog CLI Commands](#Executing-JFrog-CLI-Commands)
4041
- [Build tools Tasks](#build-tools-tasks)
4142
- [JFrog Maven](#JFrog-Maven-Task)
@@ -263,9 +264,169 @@ To enable TLS 1.2 on TFS:
263264

264265
</details>
265266

267+
## Using OpenID Connect (OIDC) Authentication
268+
269+
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).
270+
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.
271+
272+
Setting up OpenID Connect has 3 separate parts:
273+
- Setting up an OpenID Connect Integration inside of the JFrog Platform.
274+
- Configuring Identity Mappings with Claim rules, matching to Projects & Service Connections.
275+
- Configuring Service Connections as OpenID Connect in the Projects in your Azure Devops Instance.
276+
277+
> [!IMPORTANT]
278+
> To use OIDC authentication, make sure you're using **JFrog CLI version 2.75.0 or later**
279+
> and **JFrog Azure DevOps Extension version 2.11.0 or later**.
280+
281+
Follow the guides below to configure each part.
282+
283+
<details>
284+
<summary>
285+
286+
#### Configure OpenID Connect Integration
287+
288+
</summary>
289+
290+
291+
First, configure an OpenID Connect integration to your Azure DevOps server in your JFrog instance.
292+
Log in to your JFrog instance as an administrator,
293+
then as [described in the documentation:](https://jfrog.com/help/r/jfrog-platform-administration-documentation/openid-connect-configurations-overview)
294+
295+
1. Go to the **Administrator panel**.
296+
2. Select **General Management**.
297+
3. Choose **Manage Integrations**.
298+
4. Select New Integration - **OpenID Connect**
299+
300+
Next, fill out the integration form with your Azure DevOps instance parameters.
301+
302+
| Property name | Description |
303+
| ------------- |---------------------------------------------------------------------------------------|
304+
| Provider Name | A name for your provider, this name is used in the Azure DevOps tasks in the pipelines. |
305+
| Provider Type | `Azure` |
306+
| Description | A description of what this provider is for. |
307+
| Provider URL | `https://vstoken.dev.azure.com/{ORG_GUID}` (see how to get the {ORG_GUID} below). |
308+
| Audience | example: `api://AzureADTokenExchange` |
309+
| Token Issuer | If the issuer is different from the provider, for Azure DevOps this can be left blank. |
310+
311+
For example, the final integration configuration will look like this:
312+
313+
![oidc-integration.png](images/oidc-integration.png)
314+
315+
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.
316+
317+
In the Pipeline Output, look for the `OIDC Token Issuer`,value, which you need to enter as your `Provider URL`.
318+
The rest of the information can also be helpful for you to configure the Identity Mappings as described in the section below.
319+
320+
```
321+
OIDC Token Subject: sc://<DevopsOrgName>/<ProjectName>/<ServiceConnectionName>
322+
OIDC Token Claims: {"sub": "sc://<DevopsOrgName>/<ProjectName>/<ServiceConnectionName>"}
323+
OIDC Token Issuer: https://vstoken.dev.azure.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
324+
OIDC Token Audience: api://AzureADTokenExchange
325+
```
326+
327+
> **Security Tip**: It's safe to log OpenID Connect claims like `sub`, `aud`, or `iss` in debug output for troubleshooting purposes.
328+
> However, never print the full ID token or access token, even in debug logs.
329+
330+
331+
</details>
332+
333+
<details>
334+
<summary>
335+
336+
#### Configure Identity Mappings
337+
338+
</summary>
339+
340+
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.
341+
You can find the full documentation for configuring [Identity Mappings in the Documentation](https://jfrog.com/help/r/jfrog-platform-administration-documentation/identity-mappings).
342+
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.
343+
344+
When working with OpenID Connect, we must look at the `ID Token` that our provider (Azure DevOps) outputs.
345+
Based on the information in the token, we can map properties into rules in our `Identity Mappings` JSON Claim.
346+
The `ID Token` from the Azure DevOps token provider looks like this:
347+
348+
```json
349+
{
350+
"jti": "<guid>",
351+
"sub": "sc://<DevopsOrgName>/<ProjectName>/<ServiceConnectionName>",
352+
"aud": "api://AzureADTokenExchange",
353+
"iss": "https://vstoken.dev.azure.com/<ORG_GUID>",
354+
"nbf": 1708639268,
355+
"exp": 1708640467,
356+
"iat": 1708639868
357+
}
358+
```
359+
360+
Relative to most other `ID Token` providers, our options are fairly sparse, the only sensible option is using the subject (`"sub"`) field.
361+
The claim mapping does support wildcards with the `*` operator.
362+
363+
A sample JSON Claim mapping which maps a specified ServiceConnection in a specified Project in your Organization would look like this:
364+
365+
![oidc-json-mapping.png](images/oidc-json-mapping.png)
366+
367+
To allow all projects in your Organization with a ServiceConnection with a specified name, you could replace MyProject with `*`.
368+
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.
369+
370+
</details>
371+
372+
<details>
373+
<summary>
374+
375+
#### Configure the Service Connection
376+
377+
</summary>
378+
379+
You must configure a `ServiceConnection` setting the `Authentication method` to `OpenID Connect Integration`.
380+
381+
This requires you to fill in the following inputs:
382+
383+
| Property name | Description |
384+
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |
385+
| Server URL | The URL of your JFrog instance with the `/artifactory` path fx. (`https://my.jfrog.io/artifactory`) |
386+
| OpenID Connect Provider Name | The `Provider Name` you configured in the `Configure OpenID Connect Integration` step |
387+
| Platform URL | The URL of your JFrog instance fx. (`https://my.jfrog.io/`) |
388+
| Service connection name | The name of the Service Connection, must match the values put into the `JSON Claims mapping` |
389+
| Description (optional) | A short of the purpose of this ServiceConnection |
390+
391+
392+
A sample configuration would look like this:
393+
394+
![oidc-service-connection.png](images/oidc-service-connection.png)
395+
396+
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.
397+
398+
> 💡 **Tip**
399+
> The extension automatically exports the authenticated user and access token
400+
> 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).
401+
> Example usage in a later step:
402+
403+
```yaml
404+
steps:
405+
- task: JfrogCliV2@1
406+
name: jfStep
407+
inputs:
408+
jfrogPlatformConnection: 'azure-oidc'
409+
command: 'jf rt ping'
410+
411+
- task: PowerShell@2
412+
inputs:
413+
targetType: 'inline'
414+
script: |
415+
echo "OIDC Username (from output): $(jfStep.oidc_user)"
416+
echo "OIDC Token (from env): $env:oidc_token"
417+
displayName: 'Use OIDC Output Variables'
418+
```
419+
420+
421+
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.
422+
423+
</details>
424+
266425

267426
<br>
268427

428+
429+
269430
## Executing JFrog CLI Commands
270431

271432
<details>

buildScripts/publish-private.sh

100644100755
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ cp vss-extension.json vss-extension-private.json
2828
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
2929
npx tfx extension unpublish -t "$ADO_ARTIFACTORY_API_KEY" --extension-id jfrog-azure-devops-extension --publisher "$PUBLISHER"
3030
npx tfx extension create --manifest-globs vss-extension-private.json --publisher "$PUBLISHER"
31-
# Check that vsix size is less then 30MB
31+
32+
# Max size is 50MB, but we want to be under 40.
3233
vsixSize="$(du -m -- *.vsix | awk '{print $1}' | head -1)"
33-
if [ "${vsixSize}" -gt 30 ]; then
34+
if [ "${vsixSize}" -gt 40 ]; then
3435
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"
3536
exit 1
3637
fi

images/oidc-integration.png

47.9 KB
Loading

images/oidc-json-mapping.png

12.5 KB
Loading

images/oidc-service-connection.png

104 KB
Loading

jfrog-tasks-utils/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
"typings": "utils.d.ts",
1010
"dependencies": {
1111
"azure-pipelines-task-lib": "4.5.0",
12-
"azure-pipelines-tool-lib": "2.0.6",
1312
"azure-pipelines-tasks-java-common": "^2.219.1",
13+
"azure-pipelines-tool-lib": "2.0.6",
1414
"typed-rest-client": "^1.8.11"
1515
},
1616
"scripts": {

jfrog-tasks-utils/utils.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ declare module '@jfrog/tasks-utils' {
4141
export function removeExtractorsDownloadVariables(cliPath: string, workDir: string);
4242
export function configureArtifactoryCliServer(artifactoryService: string, serverId: string, cliPath: string, buildDir: string);
4343
export function setJdkHomeForJavaTasks();
44-
44+
export function fetchAzureOidcToken(serviceConnectionID: string): string;
45+
export function exchangeOidcTokenAndSetStepVariables(
46+
service: service,
47+
serviceUrl: string,
48+
oidcProviderName: string,
49+
cliPath: string,
50+
buildDir: string,
51+
): string;
4552
export { taskSelectedCliVersionEnv };
4653
}

jfrog-tasks-utils/utils.js

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,25 @@ const execSync = require('child_process').execSync;
55
const toolLib = require('azure-pipelines-tool-lib/tool');
66
const credentialsHandler = require('typed-rest-client/Handlers');
77
const findJavaHome = require('azure-pipelines-tasks-java-common/java-common').findJavaHome;
8+
const syncRequest = require('sync-request');
9+
import * as semver from 'semver';
810

911
const fileName = getCliExecutableName();
1012
const jfrogCliToolName = 'jf';
1113
const cliPackage = 'jfrog-cli-' + getArchitecture();
1214
const jfrogFolderPath = encodePath(join(tl.getVariable('Agent.ToolsDirectory') || '', '_jf'));
13-
const defaultJfrogCliVersion = '2.73.3';
15+
const defaultJfrogCliVersion = '2.75.0';
1416
const minCustomCliVersion = '2.10.0';
1517
const minSupportedStdinSecretCliVersion = '2.36.0';
1618
const minSupportedServerIdEnvCliVersion = '2.37.0';
19+
const minSupportedOidcCliVersion = '2.75.0';
1720
const pluginVersion = '2.10.4';
1821
const buildAgent = 'jfrog-azure-devops-extension';
1922
const customFolderPath = encodePath(join(jfrogFolderPath, 'current'));
2023
const customCliPath = encodePath(join(customFolderPath, fileName)); // Optional - Customized jfrog-cli path.
2124
const jfrogCliReleasesUrl = 'https://releases.jfrog.io/artifactory/jfrog-cli/v2-jf';
25+
const oidcUserOutputName = 'oidc_user';
26+
const oidcTokenOutputName = 'oidc_token';
2227

2328
// Set by Tools Installer Task. This JFrog CLI version will be used in all tasks unless manual installation is used,
2429
// 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) {
253258
return configureSpecificCliServer(xrayService, '--xray-url', serverId, cliPath, buildDir);
254259
}
255260

261+
/**
262+
* logging oidc token values for debugging
263+
* @param oidcToken
264+
*/
265+
function debugLogIDToken(oidcToken) {
266+
/**
267+
* @typedef {Object} OidcClaims
268+
* @property {string} sub - The subject of the token.
269+
* @property {string} iss - The issuer of the token.
270+
* @property {string} aud - The audience of the token.
271+
*/
272+
273+
/** @type {OidcClaims} */
274+
const oidcClaims = JSON.parse(Buffer.from(oidcToken.split('.')[1], 'base64').toString());
275+
console.debug('OIDC Token Subject: ', oidcClaims.sub);
276+
console.debug(`OIDC Token Claims: {"sub": "${oidcClaims.sub}"}`);
277+
console.debug('OIDC Token Issuer (Provider URL): ', oidcClaims.iss);
278+
console.debug('OIDC Token Audience: ', oidcClaims.aud);
279+
}
280+
281+
function fetchAzureOidcToken(serviceConnectionID) {
282+
const uri = tl.getVariable('System.CollectionUri');
283+
const teamPrjID = tl.getVariable('System.TeamProjectId');
284+
const hub = tl.getVariable('System.HostType');
285+
const planID = tl.getVariable('System.PlanId');
286+
const jobID = tl.getVariable('System.JobId');
287+
const apiVersion = '7.1-preview.1';
288+
289+
const token = tl.getVariable('System.AccessToken');
290+
if (!token) {
291+
throw new Error('System.AccessToken is not available. Make sure "Allow scripts to access OAuth token" is enabled.');
292+
}
293+
294+
const url = `${uri}${teamPrjID}/_apis/distributedtask/hubs/${hub}/plans/${planID}/jobs/${jobID}/oidctoken?api-version=${apiVersion}&serviceConnectionId=${serviceConnectionID}`;
295+
296+
const res = syncRequest('POST', url, {
297+
headers: {
298+
'Content-Type': 'application/json',
299+
Authorization: `Bearer ${token}`,
300+
},
301+
});
302+
303+
if (res.statusCode !== 200) {
304+
throw new Error(`OIDC token request failed: HTTP ${res.statusCode}\nBody: ${res.getBody('utf8')}`);
305+
}
306+
/** @type {{ oidcToken?: string }} */
307+
const body = JSON.parse(res.getBody('utf8'));
308+
if (!body.oidcToken) {
309+
throw new Error('OIDC token not found in response body.');
310+
}
311+
debugLogIDToken(body.oidcToken);
312+
return body.oidcToken;
313+
}
314+
315+
function exchangeOidcTokenAndSetStepVariables(service, serviceUrl, oidcProviderName, cliPath, buildDir) {
316+
// First validate supported CLI version
317+
let cliVersion = getCliVersion(cliPath);
318+
if (semver.lt(cliVersion, '2.75.0')) {
319+
throw new Error('CLI version too low');
320+
}
321+
if (cliVersion < minSupportedOidcCliVersion) {
322+
throw new Error(
323+
`The CLI version ${cliVersion} is not supported for OIDC token exchange. Minimum required version is ${minSupportedOidcCliVersion}.`,
324+
);
325+
}
326+
let oidcAudience = tl.getEndpointAuthorizationParameter(service, 'oidcAudience', true) || 'api://AzureADTokenExchange';
327+
const repoName = tl.getVariable('Build.Repository.Name');
328+
const idToken = fetchAzureOidcToken(service);
329+
330+
// Build the CLI command
331+
let cliCommand = cliJoin(
332+
cliPath,
333+
`eot ${quote(oidcProviderName)} ${quote(idToken)} --url=${quote(serviceUrl)} --oidc-provider-type=Azure --oidc-audience=${quote(oidcAudience)} --repository=${quote(repoName)}`,
334+
);
335+
336+
// Execute the CLI command and capture the output
337+
let exeRes = executeCliCommand(cliCommand, buildDir, { withOutput: true }).toString();
338+
339+
// Extract AccessToken
340+
const { username, accessToken } = extractAccessTokenAndUsername(exeRes);
341+
342+
// Set output variables
343+
tl.setVariable(oidcUserOutputName, username, true);
344+
tl.setVariable(oidcTokenOutputName, accessToken, true);
345+
346+
return accessToken;
347+
}
348+
349+
/**
350+
* Extracts AccessToken and Username from the CLI output.
351+
* Supports both JSON and non-JSON (regex) outputs.
352+
* Currently, the output is a non-valid JSON, which should be changed in the future.
353+
* @param {string} output - The CLI output.
354+
* @returns {{ accessToken: string, username: string }} - Extracted values.
355+
* @throws {Error} - If neither JSON nor regex extraction succeeds.
356+
*/
357+
function extractAccessTokenAndUsername(output) {
358+
// Attempt to parse as JSON
359+
try {
360+
/**
361+
* @typedef {Object} ParsedOutput
362+
* @property {string} AccessToken
363+
* @property {string} Username
364+
*/
365+
366+
/** @type {ParsedOutput} */
367+
const parsedOutput = JSON.parse(output);
368+
if (parsedOutput.AccessToken && parsedOutput.Username) {
369+
return {
370+
accessToken: parsedOutput.AccessToken,
371+
username: parsedOutput.Username,
372+
};
373+
}
374+
} catch (e) {
375+
console.debug('Failed to parse output as JSON, trying with regex..');
376+
}
377+
378+
// Fallback to regex extraction
379+
const accessTokenMatch = output.match(/AccessToken:\s*(\S+)/);
380+
const usernameMatch = output.match(/Username:\s*(\S+)/);
381+
382+
if (accessTokenMatch && usernameMatch) {
383+
return {
384+
accessToken: accessTokenMatch[1],
385+
username: usernameMatch[1],
386+
};
387+
}
388+
389+
// If both methods fail, throw an error
390+
throw new Error('Failed to extract AccessToken or Username from the output.');
391+
}
392+
256393
function configureSpecificCliServer(service, urlFlag, serverId, cliPath, buildDir) {
257394
let serviceUrl = tl.getEndpointUrl(service, false);
258395
let serviceUser = tl.getEndpointAuthorizationParameter(service, 'username', true);
259396
let servicePassword = tl.getEndpointAuthorizationParameter(service, 'password', true);
260397
let serviceAccessToken = tl.getEndpointAuthorizationParameter(service, 'apitoken', true);
398+
let oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true);
261399
let cliCommand = cliJoin(cliPath, jfrogCliConfigAddCommand, quote(serverId), urlFlag + '=' + quote(serviceUrl), '--interactive=false');
262400
let stdinSecret;
263401
let secretInStdinSupported = isStdinSecretSupported();
402+
403+
// In the case of OIDC, we exchange tokens via the CLI
404+
// and populate the access token to the CLI config.
405+
// This is done by the exchange command and not the config to export
406+
// username and access token params for further use by the users.
407+
if (oidcProviderName) {
408+
serviceAccessToken = exchangeOidcTokenAndSetStepVariables(service, serviceUrl, oidcProviderName, cliPath, buildDir);
409+
}
410+
264411
if (serviceAccessToken) {
265412
// Add access-token if required.
266413
cliCommand = cliJoin(cliCommand, secretInStdinSupported ? '--access-token-stdin' : '--access-token=' + quote(serviceAccessToken));

0 commit comments

Comments
 (0)