Skip to content

Commit

Permalink
Restore "Adding OIDC token & parsed OIDC user as Action outputs (#151)…
Browse files Browse the repository at this point in the history
…" (#153)
  • Loading branch information
yahavi authored May 21, 2024
1 parent c2349e9 commit ca5ea1d
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 6 deletions.
13 changes: 10 additions & 3 deletions .github/workflows/oidc-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ name: OpenID Connect Test
on:
push:
branches:
- '**'
tags-ignore:
- '**'
- master
# Triggers the workflow on labeled PRs only.
pull_request_target:
types: [ labeled ]
Expand Down Expand Up @@ -63,6 +61,7 @@ jobs:
}'
- name: Setup JFrog CLI
id: setup-jfrog-cli
uses: ./
env:
JF_URL: ${{ secrets.JFROG_PLATFORM_URL }}
Expand All @@ -73,6 +72,14 @@ jobs:
run: |
jf rt s "some-repo/"
- name: Test User Output
shell: bash
run: test -n "${{ steps.setup-jfrog-cli.outputs.oidc-user }}"

- name: Test Token Output
shell: bash
run: test -n "${{ steps.setup-jfrog-cli.outputs.oidc-token }}"

# Removing the OIDC integration will remove the Identity Mapping as well
- name: Delete OIDC integration
shell: bash
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ Example step utilizing OpenID Connect:
oidc-provider-name: setup-jfrog-cli
```

**Notice:** When using OIDC authentication, this action outputs both the OIDC token and the OIDC token username. These can be utilized within the current workflow to log into the JFrog platform through other actions or clients (e.g., for use with `docker login`). The added outputs are `oidc-token` and `oidc-user`, respectively.

</details>

## Setting the build name and build number when publishing build-info to Artifactory
Expand Down
6 changes: 5 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ inputs:
oidc-audience:
description: "By default, this is the URL of the GitHub repository owner, such as the organization that owns the repository."
required: false

outputs:
oidc-token:
description: "JFrog OIDC token generated by the Setup JFrog CLI when setting oidc-provider-name."
oidc-user:
description: "JFrog OIDC username from the OIDC token generated by the Setup JFrog CLI when setting oidc-provider-name."
runs:
using: "node20"
main: "lib/main.js"
Expand Down
57 changes: 56 additions & 1 deletion lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,69 @@ class Utils {
const responseJson = JSON.parse(responseString);
jfrogCredentials.accessToken = responseJson.access_token;
if (jfrogCredentials.accessToken) {
core.setSecret(jfrogCredentials.accessToken);
this.outputOidcTokenAndUsername(jfrogCredentials.accessToken);
}
if (responseJson.errors) {
throw new Error(`${JSON.stringify(responseJson.errors)}`);
}
return jfrogCredentials;
});
}
/**
* Output the OIDC access token as a secret and the user from the OIDC access token subject as a secret.
* Both are set as secrets to prevent them from being printed in the logs or exported to other workflows.
* @param oidcToken access token received from the JFrog platform during OIDC token exchange
*/
static outputOidcTokenAndUsername(oidcToken) {
// Making sure the token is treated as a secret
core.setSecret(oidcToken);
// Output the oidc access token as a secret
core.setOutput('oidc-token', oidcToken);
// Output the user from the oidc access token subject as a secret
let payload = this.decodeOidcToken(oidcToken);
let tokenUser = this.extractTokenUser(payload.sub);
// Mark the user as a secret
core.setSecret(tokenUser);
// Output the user from the oidc access token subject extracted from the last section of the subject
core.setOutput('oidc-user', tokenUser);
}
/**
* Extract the username from the OIDC access token subject.
* @param subject OIDC token subject
* @returns the username
*/
static extractTokenUser(subject) {
// Main OIDC user parsing logic
if (subject.startsWith('jfrt@') || subject.includes('/users/')) {
let lastSlashIndex = subject.lastIndexOf('/');
let userSubstring = subject.substring(lastSlashIndex + 1);
// Return the user extracted from the token
return userSubstring;
}
// No parsing was needed, returning original sub from the token as the user
return subject;
}
/**
* Decode the OIDC access token and return the payload.
* @param oidcToken access token received from the JFrog platform during OIDC token exchange
* @returns the payload of the OIDC access token
*/
static decodeOidcToken(oidcToken) {
// Split jfrogCredentials.accessToken into 3 parts divided by .
let tokenParts = oidcToken.split('.');
if (tokenParts.length != 3) {
// this error should not happen since access only generates valid JWT tokens
throw new Error(`OIDC invalid access token format`);
}
// Decode the second part of the token
let base64Payload = tokenParts[1];
let utf8Payload = Buffer.from(base64Payload, 'base64').toString('utf8');
let payload = JSON.parse(utf8Payload);
if (!payload || !payload.sub) {
throw new Error(`OIDC invalid access token format`);
}
return payload;
}
static getAndAddCliToPath(jfrogCredentials) {
return __awaiter(this, void 0, void 0, function* () {
let version = core.getInput(Utils.CLI_VERSION_ARG);
Expand Down
71 changes: 70 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,73 @@ export class Utils {
const responseJson: TokenExchangeResponseData = JSON.parse(responseString);
jfrogCredentials.accessToken = responseJson.access_token;
if (jfrogCredentials.accessToken) {
core.setSecret(jfrogCredentials.accessToken);
this.outputOidcTokenAndUsername(jfrogCredentials.accessToken);
}
if (responseJson.errors) {
throw new Error(`${JSON.stringify(responseJson.errors)}`);
}
return jfrogCredentials;
}

/**
* Output the OIDC access token as a secret and the user from the OIDC access token subject as a secret.
* Both are set as secrets to prevent them from being printed in the logs or exported to other workflows.
* @param oidcToken access token received from the JFrog platform during OIDC token exchange
*/
private static outputOidcTokenAndUsername(oidcToken: string): void {
// Making sure the token is treated as a secret
core.setSecret(oidcToken);
// Output the oidc access token as a secret
core.setOutput('oidc-token', oidcToken);

// Output the user from the oidc access token subject as a secret
let payload: JWTTokenData = this.decodeOidcToken(oidcToken);
let tokenUser: string = this.extractTokenUser(payload.sub);
// Mark the user as a secret
core.setSecret(tokenUser);
// Output the user from the oidc access token subject extracted from the last section of the subject
core.setOutput('oidc-user', tokenUser);
}

/**
* Extract the username from the OIDC access token subject.
* @param subject OIDC token subject
* @returns the username
*/
public static extractTokenUser(subject: string): string {
// Main OIDC user parsing logic
if (subject.startsWith('jfrt@') || subject.includes('/users/')) {
let lastSlashIndex: number = subject.lastIndexOf('/');
let userSubstring: string = subject.substring(lastSlashIndex + 1);
// Return the user extracted from the token
return userSubstring;
}
// No parsing was needed, returning original sub from the token as the user
return subject;
}

/**
* Decode the OIDC access token and return the payload.
* @param oidcToken access token received from the JFrog platform during OIDC token exchange
* @returns the payload of the OIDC access token
*/
public static decodeOidcToken(oidcToken: string): JWTTokenData {
// Split jfrogCredentials.accessToken into 3 parts divided by .
let tokenParts: string[] = oidcToken.split('.');
if (tokenParts.length != 3) {
// this error should not happen since access only generates valid JWT tokens
throw new Error(`OIDC invalid access token format`);
}
// Decode the second part of the token
let base64Payload: string = tokenParts[1];
let utf8Payload: string = Buffer.from(base64Payload, 'base64').toString('utf8');
let payload: JWTTokenData = JSON.parse(utf8Payload);
if (!payload || !payload.sub) {
throw new Error(`OIDC invalid access token format`);
}
return payload;
}

public static async getAndAddCliToPath(jfrogCredentials: JfrogCredentials) {
let version: string = core.getInput(Utils.CLI_VERSION_ARG);
let cliRemote: string = core.getInput(Utils.CLI_REMOTE_ARG);
Expand Down Expand Up @@ -411,3 +470,13 @@ export interface TokenExchangeResponseData {
access_token: string;
errors: string;
}

export interface JWTTokenData {
sub: string;
scp: string;
aud: string;
iss: string;
exp: bigint;
iat: bigint;
jti: string;
}
50 changes: 50 additions & 0 deletions test/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,53 @@ test('User agent', () => {
expect(split[0]).toBe('setup-jfrog-cli-github-action');
expect(split[1]).toMatch(/\d*.\d*.\d*/);
});

describe('extractTokenUser', () => {
it('should extract user from subject starting with jfrt@', () => {
const subject = 'jfrt@/users/johndoe';
const result = Utils.extractTokenUser(subject);
expect(result).toBe('johndoe');
});

it('should extract user from subject containing /users/', () => {
const subject = '/users/johndoe';
const result = Utils.extractTokenUser(subject);
expect(result).toBe('johndoe');
});

it('should return original subject when it does not start with jfrt@ or contain /users/', () => {
const subject = 'johndoe';
const result = Utils.extractTokenUser(subject);
expect(result).toBe(subject);
});

it('should handle empty subject', () => {
const subject = '';
const result = Utils.extractTokenUser(subject);
expect(result).toBe(subject);
});
});

describe('decodeOidcToken', () => {
it('should decode valid OIDC token', () => {
const oidcToken =
Buffer.from(JSON.stringify({ sub: 'test' })).toString('base64') +
'.eyJzdWIiOiJ0ZXN0In0.' +
Buffer.from(JSON.stringify({ sub: 'test' })).toString('base64');
const result = Utils.decodeOidcToken(oidcToken);
expect(result).toEqual({ sub: 'test' });
});

it('should throw error for OIDC token with invalid format', () => {
const oidcToken = 'invalid.token.format';
expect(() => Utils.decodeOidcToken(oidcToken)).toThrow(SyntaxError);
});

it('should throw error for OIDC token without subject', () => {
const oidcToken =
Buffer.from(JSON.stringify({ notSub: 'test' })).toString('base64') +
'.eyJub3RTdWIiOiJ0ZXN0In0.' +
Buffer.from(JSON.stringify({ notSub: 'test' })).toString('base64');
expect(() => Utils.decodeOidcToken(oidcToken)).toThrowError('OIDC invalid access token format');
});
});

0 comments on commit ca5ea1d

Please sign in to comment.