diff --git a/smart_tests/jar/exe_deploy.jar b/smart_tests/jar/exe_deploy.jar index b0c3e41cd..134913d4c 100755 Binary files a/smart_tests/jar/exe_deploy.jar and b/smart_tests/jar/exe_deploy.jar differ diff --git a/smart_tests/utils/authentication.py b/smart_tests/utils/authentication.py index 831c8bd77..244925600 100644 --- a/smart_tests/utils/authentication.py +++ b/smart_tests/utils/authentication.py @@ -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(): @@ -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 = { @@ -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'] diff --git a/smart_tests/utils/env_keys.py b/smart_tests/utils/env_keys.py index 1db916d39..bb22a6137 100644 --- a/smart_tests/utils/env_keys.py +++ b/smart_tests/utils/env_keys.py @@ -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" diff --git a/src/main/java/com/launchableinc/ingest/commits/GitHubIdTokenAuthenticator.java b/src/main/java/com/launchableinc/ingest/commits/GitHubIdTokenAuthenticator.java index d16b2013a..05c5786f7 100644 --- a/src/main/java/com/launchableinc/ingest/commits/GitHubIdTokenAuthenticator.java +++ b/src/main/java/com/launchableinc/ingest/commits/GitHubIdTokenAuthenticator.java @@ -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; @@ -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)) { @@ -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"); @@ -49,7 +76,21 @@ public GitHubIdTokenAuthenticator() throws CmdLineException { @Override public ImmutableList
getAuthenticationHeaders() { - return ImmutableList.of(new BasicHeader("Authorization", "Bearer " + idToken)); + ImmutableList.Builder
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) diff --git a/src/main/java/com/launchableinc/ingest/commits/Main.java b/src/main/java/com/launchableinc/ingest/commits/Main.java index ef6b88f07..40ce35335 100644 --- a/src/main/java/com/launchableinc/ingest/commits/Main.java +++ b/src/main/java/com/launchableinc/ingest/commits/Main.java @@ -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) { + // 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()) { + audience = GitHubIdTokenAuthenticator.DEFAULT_OIDC_AUDIENCE; + } + authenticator = new GitHubIdTokenAuthenticator(audience, false); + } else if (System.getenv("EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH") != null) { + // 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(); } diff --git a/tests/utils/test_authentication.py b/tests/utils/test_authentication.py index f7923580a..06643cc3f 100644 --- a/tests/utils/test_authentication.py +++ b/tests/utils/test_authentication.py @@ -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 @@ -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"})