Skip to content
Open
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
Binary file modified smart_tests/jar/exe_deploy.jar
Binary file not shown.
94 changes: 75 additions & 19 deletions smart_tests/utils/authentication.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import os
from typing import Tuple
from typing import Optional, Tuple
from urllib.parse import quote

import click
import requests

import smart_tests.args4p.typer as typer

from .env_keys import OIDC_TOKEN_KEY, ORGANIZATION_KEY, WORKSPACE_KEY, get_token
from .env_keys import (GITHUB_OIDC_KEY, LEGACY_GITHUB_OIDC_KEY, OIDC_AUDIENCE_KEY,
OIDC_TOKEN_KEY, ORGANIZATION_KEY, WORKSPACE_KEY, get_token)

# Default audience Intake expects in an OIDC id-token (launchableinc.intake.oidc.audience).
DEFAULT_OIDC_AUDIENCE = "https://app.cloudbees.io/smart-tests"
# Header the CLI sends to opt into Intake's deprecated GitHub Actions OIDC path. Absent it, a
# GitHub-issued token is verified through the generic OIDC path. Mirrors RESTAuthConverter.
LEGACY_GITHUB_OIDC_HEADER = "GitHub-OIDC-Legacy"

# authentication_headers() runs on every API request, so guard the legacy deprecation notice to
# print at most once per process instead of once per request.
_legacy_oidc_warning_shown = False


def get_org_workspace():
Expand Down Expand Up @@ -60,24 +72,33 @@ def authentication_headers():
if oidc_token:
return {'Authorization': f'Bearer {oidc_token}'}

if os.getenv('EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH'):
req_url = os.getenv('ACTIONS_ID_TOKEN_REQUEST_URL')
rt_token = os.getenv('ACTIONS_ID_TOKEN_REQUEST_TOKEN')
if not req_url or not rt_token:
# Generic GitHub Actions OIDC: fetch the id-token minted for the Smart Tests audience and present
# it like any other OIDC token. Intake routes by `iss` to the generic verifier and matches the
# normalized `repo:OWNER/REPO` subject against trusted_oidc_subjects. The audience is required
# here because the generic path enforces `aud` for GitHub's issuer.
if os.getenv(GITHUB_OIDC_KEY):
id_token = _fetch_github_id_token(audience=_expected_oidc_audience())
return {'Authorization': f'Bearer {id_token}'}

# Deprecated legacy GitHub Actions OIDC: Intake matches the `repository` claim against
# trusted_github_repositories. The legacy path never checks `aud`, so no audience is requested.
# The header tells Intake to take the legacy branch; without it the token would be verified
# through the generic path.
if os.getenv(LEGACY_GITHUB_OIDC_KEY):
global _legacy_oidc_warning_shown
if not _legacy_oidc_warning_shown:
_legacy_oidc_warning_shown = True
click.secho(
"GitHub Actions OIDC tokens cannot be retrieved."
"Confirm that you have added necessary permissions following "
"https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings", # noqa: E501
fg='red', err=True)
raise typer.Exit(1)
r = requests.get(req_url,
headers={
'Authorization': f'Bearer {rt_token}',
'Accept': 'application/json; api-version=2.0',
'Content-Type': 'application/json',
})
r.raise_for_status()
return {"Authorization": f"Bearer {r.json()['value']}"}
f"{LEGACY_GITHUB_OIDC_KEY} enables the deprecated GitHub Actions OIDC flow. Migrate "
f"by registering your repository as a Trusted OIDC subject and switching to "
f"{GITHUB_OIDC_KEY}=1. See "
"https://docs.cloudbees.com/docs/cloudbees-smart-tests/latest/send-data-to-smart-tests/set-up-smart-tests/migration-to-github-oidc-auth", # noqa: E501
fg='yellow', err=True)
id_token = _fetch_github_id_token()
return {
'Authorization': f'Bearer {id_token}',
LEGACY_GITHUB_OIDC_HEADER: '1',
}

if os.getenv('GITHUB_ACTIONS'):
headers = {
Expand All @@ -97,3 +118,38 @@ def authentication_headers():

return headers
return {}


def _expected_oidc_audience() -> str:
'''Audience the GitHub id-token must carry for Intake's generic OIDC path to accept it.'''
return os.getenv(OIDC_AUDIENCE_KEY) or DEFAULT_OIDC_AUDIENCE


def _fetch_github_id_token(audience: Optional[str] = None) -> str:
'''
Retrieve a GitHub Actions OIDC id-token via the runner's token endpoint.

Requires the `id-token: write` workflow permission, which populates ACTIONS_ID_TOKEN_REQUEST_URL
and ACTIONS_ID_TOKEN_REQUEST_TOKEN. When `audience` is given it is requested so the token's `aud`
claim matches what Intake expects (the generic OIDC path enforces it); the legacy path omits it.
'''
req_url = os.getenv('ACTIONS_ID_TOKEN_REQUEST_URL')
rt_token = os.getenv('ACTIONS_ID_TOKEN_REQUEST_TOKEN')
if not req_url or not rt_token:
click.secho(
"GitHub Actions OIDC tokens cannot be retrieved."
"Confirm that you have added necessary permissions following "
"https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings", # noqa: E501
fg='red', err=True)
raise typer.Exit(1)
if audience:
sep = '&' if '?' in req_url else '?'
req_url = f"{req_url}{sep}audience={quote(audience, safe='')}"
r = requests.get(req_url,
headers={
'Authorization': f'Bearer {rt_token}',
'Accept': 'application/json; api-version=2.0',
'Content-Type': 'application/json',
})
r.raise_for_status()
return r.json()['value']
10 changes: 10 additions & 0 deletions smart_tests/utils/env_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
ORGANIZATION_KEY = "SMART_TESTS_ORGANIZATION"
WORKSPACE_KEY = "SMART_TESTS_WORKSPACE"
BASE_URL_KEY = "SMART_TESTS_BASE_URL"
# Opt in to the generic GitHub Actions OIDC flow: the CLI fetches the GitHub id-token and presents
# it like any other OIDC token. Intake verifies it against trusted_oidc_subjects (self-serve in the
# webapp) instead of the deprecated trusted_github_repositories path. See authentication_headers().
GITHUB_OIDC_KEY = "SMART_TESTS_GITHUB_OIDC_TOKEN_AUTH"
# Deprecated opt in to the legacy GitHub Actions OIDC flow (repository-claim matching). Kept working
# for backward compatibility; when set, the CLI signals Intake to use the legacy path via a header.
LEGACY_GITHUB_OIDC_KEY = "EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH"
# Audience the GitHub id-token must be minted for so the generic OIDC path's aud check passes.
# Overridable for non-production Intake environments.
OIDC_AUDIENCE_KEY = "SMART_TESTS_OIDC_AUDIENCE"
SKIP_TIMEOUT_RETRY = "SMART_TESTS_SKIP_TIMEOUT_RETRY"
COMMIT_TIMEOUT = "SMART_TESTS_COMMIT_TIMEOUT"
SKIP_CERT_VERIFICATION = "SMART_TESTS_SKIP_CERT_VERIFICATION"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.apache.http.Header;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
Expand All @@ -15,10 +18,29 @@
import org.kohsuke.args4j.CmdLineException;

public class GitHubIdTokenAuthenticator implements Authenticator {
// Default audience Intake expects in an OIDC id-token (launchableinc.intake.oidc.audience).
// Mirrors the Python CLI's DEFAULT_OIDC_AUDIENCE.
static final String DEFAULT_OIDC_AUDIENCE = "https://app.cloudbees.io/smart-tests";
// Header the CLI sends to opt into Intake's deprecated GitHub Actions OIDC path. Absent it, a
// GitHub-issued token is verified through the generic OIDC path. Mirrors RESTAuthConverter.
static final String LEGACY_GITHUB_OIDC_HEADER = "GitHub-OIDC-Legacy";

private static final ObjectMapper objectMapper = new ObjectMapper();
private final String idToken;
private final boolean legacy;

public GitHubIdTokenAuthenticator() throws CmdLineException {
/**
* Retrieves a GitHub Actions OIDC id-token via the runner's token endpoint.
*
* @param audience When non-empty, requested so the token's {@code aud} claim matches what
* Intake's generic OIDC path enforces. Pass {@code null}/empty for the legacy path, which
* never checks {@code aud}.
* @param legacy When true, signals Intake to take the deprecated GitHub Actions OIDC path via the
* {@link #LEGACY_GITHUB_OIDC_HEADER} header; without it the token is verified through the
* generic path.
*/
public GitHubIdTokenAuthenticator(String audience, boolean legacy) throws CmdLineException {
this.legacy = legacy;
String reqUrl = System.getenv("ACTIONS_ID_TOKEN_REQUEST_URL");
String rtToken = System.getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
if (Strings.isNullOrEmpty(reqUrl) || Strings.isNullOrEmpty(rtToken)) {
Expand All @@ -28,6 +50,11 @@ public GitHubIdTokenAuthenticator() throws CmdLineException {
+ "https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings");
}

if (!Strings.isNullOrEmpty(audience)) {
String sep = reqUrl.contains("?") ? "&" : "?";
reqUrl = reqUrl + sep + "audience=" + encode(audience);
}

HttpGet request = new HttpGet(reqUrl);
request.setHeader("Authorization", "Bearer " + rtToken);
request.setHeader("Accept", "applicaiton/json; api-version=2.0");
Expand All @@ -49,7 +76,21 @@ public GitHubIdTokenAuthenticator() throws CmdLineException {

@Override
public ImmutableList<Header> getAuthenticationHeaders() {
return ImmutableList.of(new BasicHeader("Authorization", "Bearer " + idToken));
ImmutableList.Builder<Header> headers = ImmutableList.builder();
headers.add(new BasicHeader("Authorization", "Bearer " + idToken));
if (legacy) {
headers.add(new BasicHeader(LEGACY_GITHUB_OIDC_HEADER, "1"));
}
return headers.build();
}

private static String encode(String value) {
try {
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
// UTF-8 is always supported.
throw new AssertionError(e);
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
Expand Down
22 changes: 20 additions & 2 deletions src/main/java/com/launchableinc/ingest/commits/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,26 @@ private void parseConfiguration() throws CmdLineException {
this.ws = w;
}

if (System.getenv("EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH") != null) {
authenticator = new GitHubIdTokenAuthenticator();
if (System.getenv("SMART_TESTS_GITHUB_OIDC_TOKEN_AUTH") != null) {

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.

// Generic GitHub Actions OIDC: fetch the id-token minted for the Smart Tests audience and
// present it like any other OIDC token. Intake routes by `iss` to the generic verifier
// and matches the normalized `repo:OWNER/REPO` subject against trusted_oidc_subjects. The
// audience is required because the generic path enforces `aud` for GitHub's issuer.
String audience = System.getenv("SMART_TESTS_OIDC_AUDIENCE");
if (audience == null || audience.isEmpty()) {

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.

audience = GitHubIdTokenAuthenticator.DEFAULT_OIDC_AUDIENCE;
}
authenticator = new GitHubIdTokenAuthenticator(audience, false);
} else if (System.getenv("EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH") != null) {

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.

// Deprecated legacy GitHub Actions OIDC: Intake matches the `repository` claim against
// trusted_github_repositories. The legacy path never checks `aud`, so no audience is
// requested; the legacy header tells Intake to take the legacy branch.
System.err.println(
"EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH enables the deprecated GitHub Actions OIDC flow."
+ " Migrate by registering your repository as a Trusted OIDC subject and switching"
+ " to SMART_TESTS_GITHUB_OIDC_TOKEN_AUTH=1. See "
+ "https://docs.cloudbees.com/docs/cloudbees-smart-tests/latest/send-data-to-smart-tests/set-up-smart-tests/migration-to-github-oidc-auth");
authenticator = new GitHubIdTokenAuthenticator(null, true);
} else {
authenticator = new GitHubActionsAuthenticator();
}
Expand Down
82 changes: 82 additions & 0 deletions tests/utils/test_authentication.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
from unittest import TestCase, mock

import smart_tests.utils.authentication as authentication
from smart_tests.utils.authentication import authentication_headers, get_org_workspace


Expand Down Expand Up @@ -104,3 +105,84 @@ def test_authentication_headers_SMART_TESTS_TOKEN_and_GitHub_Actions(self):
self.assertEqual(
header["Authorization"],
"Bearer v1:launchableinc/test:token")

@mock.patch("smart_tests.utils.authentication.requests.get")
@mock.patch.dict(
os.environ,
{"SMART_TESTS_GITHUB_OIDC_TOKEN_AUTH": "1",
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://runner.example/token?api-version=2.0",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "rt-token"},
clear=True,
)
def test_authentication_headers_github_oidc_generic(self, mock_get):
mock_get.return_value = mock.Mock(
raise_for_status=mock.Mock(), json=mock.Mock(return_value={"value": "id-token"}))

header = authentication_headers()

# Generic path: plain OIDC bearer, no legacy header.
self.assertEqual(header, {"Authorization": "Bearer id-token"})
# The id-token is requested for the Smart Tests audience so Intake's aud check passes.
requested_url = mock_get.call_args[0][0]
self.assertIn("audience=https%3A%2F%2Fapp.cloudbees.io%2Fsmart-tests", requested_url)

@mock.patch("smart_tests.utils.authentication.requests.get")
@mock.patch.dict(
os.environ,
{"EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH": "1",
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://runner.example/token?api-version=2.0",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "rt-token"},
clear=True,
)
def test_authentication_headers_github_oidc_legacy(self, mock_get):
mock_get.return_value = mock.Mock(
raise_for_status=mock.Mock(), json=mock.Mock(return_value={"value": "id-token"}))

header = authentication_headers()

# Legacy path: bearer plus the opt-in header that routes Intake to the deprecated flow.
self.assertEqual(header["Authorization"], "Bearer id-token")
self.assertEqual(header["GitHub-OIDC-Legacy"], "1")
# Legacy path does not enforce aud, so no audience is requested.
self.assertNotIn("audience=", mock_get.call_args[0][0])

@mock.patch("smart_tests.utils.authentication.click.secho")
@mock.patch("smart_tests.utils.authentication.requests.get")
@mock.patch.dict(
os.environ,
{"EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH": "1",
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://runner.example/token?api-version=2.0",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "rt-token"},
clear=True,
)
def test_authentication_headers_legacy_warning_printed_once(self, mock_get, mock_secho):
mock_get.return_value = mock.Mock(
raise_for_status=mock.Mock(), json=mock.Mock(return_value={"value": "id-token"}))
# The once-per-process guard is module state; reset it so this test is deterministic.
authentication._legacy_oidc_warning_shown = False

# authentication_headers() runs on every API request; the deprecation warning must not
# repeat on each call.
authentication_headers()
authentication_headers()
authentication_headers()

self.assertEqual(mock_secho.call_count, 1)

@mock.patch("smart_tests.utils.authentication.requests.get")
@mock.patch.dict(
os.environ,
{"SMART_TESTS_GITHUB_OIDC_TOKEN_AUTH": "1",
"EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH": "1",
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://runner.example/token?api-version=2.0",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "rt-token"},
clear=True,
)
def test_authentication_headers_github_oidc_generic_wins_over_legacy(self, mock_get):
mock_get.return_value = mock.Mock(
raise_for_status=mock.Mock(), json=mock.Mock(return_value={"value": "id-token"}))

header = authentication_headers()

# When both flags are set, the generic (non-deprecated) path takes precedence.
self.assertEqual(header, {"Authorization": "Bearer id-token"})
Loading