Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-id for openSUSE:Factory checked in at 2026-03-30 18:29:53 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-id (Old) and /work/SRC/openSUSE:Factory/.python-id.new.1999 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-id" Mon Mar 30 18:29:53 2026 rev:3 rq:1343437 version:1.6.1 Changes: -------- --- /work/SRC/openSUSE:Factory/python-id/python-id.changes 2025-01-27 20:52:45.276452583 +0100 +++ /work/SRC/openSUSE:Factory/.python-id.new.1999/python-id.changes 2026-03-30 18:30:06.728109819 +0200 @@ -1,0 +2,10 @@ +Sun Mar 29 13:50:15 UTC 2026 - Dirk Müller <[email protected]> + +- update to 1.6.1: + * Fixed an issue where the correct audience was not being + requested for GitHub identities + * CircleCI: default to --root-issuer when generating OIDC Token + * Drop dependency on `requests` in favor of underlying + `urllib3` + +------------------------------------------------------------------- Old: ---- id-1.5.0.tar.gz New: ---- id-1.6.1.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-id.spec ++++++ --- /var/tmp/diff_new_pack.JNMi85/_old 2026-03-30 18:30:07.408138081 +0200 +++ /var/tmp/diff_new_pack.JNMi85/_new 2026-03-30 18:30:07.416138415 +0200 @@ -1,7 +1,7 @@ # # spec file for package python-id # -# Copyright (c) 2024 SUSE LLC +# Copyright (c) 2026 SUSE LLC and contributors # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -18,24 +18,24 @@ %{?sle15_python_module_pythons} Name: python-id -Version: 1.5.0 +Version: 1.6.1 Release: 0 Summary: A tool for generating OIDC identities License: Apache-2.0 URL: https://github.com/di/id Source: https://github.com/di/id/archive/v%{version}.tar.gz#/id-%{version}.tar.gz -BuildRequires: python-rpm-macros BuildRequires: %{python_module flit-core >= 3.2} BuildRequires: %{python_module pip} +BuildRequires: python-rpm-macros # SECTION test requirements -BuildRequires: %{python_module requests} +BuildRequires: %{python_module urllib3} BuildRequires: %{python_module coverage} BuildRequires: %{python_module pretend} -BuildRequires: %{python_module pytest} BuildRequires: %{python_module pytest-cov} +BuildRequires: %{python_module pytest} # /SECTION BuildRequires: fdupes -Requires: python-requests +Requires: python-urllib3 BuildArch: noarch %python_subpackages ++++++ id-1.5.0.tar.gz -> id-1.6.1.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/.github/dependabot.yml new/id-1.6.1/.github/dependabot.yml --- old/id-1.5.0/.github/dependabot.yml 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/.github/dependabot.yml 2026-02-04 16:11:48.000000000 +0100 @@ -4,12 +4,20 @@ - package-ecosystem: pip directory: / schedule: - interval: daily + interval: monthly + cooldown: + default-days: 7 + groups: + pip: + patterns: + - "*" - package-ecosystem: github-actions directory: / schedule: - interval: daily + interval: monthly + cooldown: + default-days: 7 open-pull-requests-limit: 99 rebase-strategy: "disabled" groups: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/.github/workflows/ci.yml new/id-1.6.1/.github/workflows/ci.yml --- old/id-1.5.0/.github/workflows/ci.yml 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/.github/workflows/ci.yml 2026-02-04 16:11:48.000000000 +0100 @@ -6,34 +6,35 @@ - main pull_request: schedule: - - cron: '0 12 * * *' + - cron: "0 12 * * *" + workflow_dispatch: + +permissions: {} jobs: - test: - permissions: - # Needed to access the workflow's OIDC identity. - id-token: write + unit-test: strategy: matrix: conf: - - { py: "3.8", os: "ubuntu-latest" } - { py: "3.9", os: "ubuntu-latest" } - { py: "3.10", os: "ubuntu-latest" } - { py: "3.11", os: "ubuntu-latest" } - { py: "3.12", os: "ubuntu-latest" } - { py: "3.13", os: "ubuntu-latest" } + - { py: "3.14", os: "ubuntu-latest" } + - { py: "pypy3.11", os: "ubuntu-latest" } # NOTE: We only test Windows and macOS on the latest Python; # these primarily exist to ensure that we don't accidentally # introduce Linux-isms into the development tooling. - - { py: "3.13", os: "windows-latest" } - - { py: "3.13", os: "macos-latest" } + - { py: "3.14", os: "windows-latest" } + - { py: "3.14", os: "macos-latest" } runs-on: ${{ matrix.conf.os }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: ${{ matrix.conf.py }} cache: "pip" @@ -45,11 +46,37 @@ - name: test run: make test TEST_ARGS="-vv --showlocals" + github-actions-test: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + permissions: + id-token: write # for OIDC + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 + with: + python-version: "3.x" + cache: "pip" + cache-dependency-path: pyproject.toml + + - name: deps + run: pip install . + + - name: Test id with GitHub Actions OIDC + shell: python + run: | + import id + assert id.detect_credential("my-audience") + all-tests-pass: if: always() needs: - - test + - unit-test + - github-actions-test runs-on: ubuntu-latest @@ -58,3 +85,4 @@ uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 with: jobs: ${{ toJSON(needs) }} + allowed-skips: github-actions-test diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/.github/workflows/lint.yml new/id-1.6.1/.github/workflows/lint.yml --- old/id-1.5.0/.github/workflows/lint.yml 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/.github/workflows/lint.yml 2026-02-04 16:11:48.000000000 +0100 @@ -6,18 +6,20 @@ - main pull_request: +permissions: {} + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # NOTE: We intentionally lint against our minimum supported Python. - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: - python-version: "3.8" + python-version: "3.9" cache: "pip" cache-dependency-path: pyproject.toml @@ -30,15 +32,15 @@ check-readme: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # NOTE: We intentionally check `--help` rendering against our minimum Python, # since it changes slightly between Python versions. - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: - python-version: "3.8" + python-version: "3.9" cache: "pip" cache-dependency-path: pyproject.toml @@ -51,7 +53,7 @@ licenses: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -68,9 +70,9 @@ if: always() needs: - - lint - - check-readme - - licenses + - lint + - check-readme + - licenses runs-on: ubuntu-latest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/.github/workflows/release.yml new/id-1.6.1/.github/workflows/release.yml --- old/id-1.5.0/.github/workflows/release.yml 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/.github/workflows/release.yml 2026-02-04 16:11:48.000000000 +0100 @@ -5,7 +5,7 @@ types: - published -permissions: # added using https://github.com/step-security/secure-workflows +permissions: # added using https://github.com/step-security/secure-workflows contents: read jobs: @@ -15,11 +15,11 @@ outputs: hashes: ${{ steps.hash.outputs.hashes }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: "3.x" cache: "pip" @@ -41,7 +41,7 @@ echo "hashes=$(sha256sum ./dist/* | base64 -w0)" >> $GITHUB_OUTPUT - name: Upload built packages - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: built-packages path: ./dist/ @@ -51,12 +51,12 @@ needs: [build] name: Generate build provenance permissions: - actions: read # To read the workflow path. + actions: read # To read the workflow path. id-token: write # To sign the provenance. contents: write # To add assets to a release. # Currently this action needs to be referred by tag. More details at: # https://github.com/slsa-framework/slsa-github-generator#verification-of-provenance - uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected] + uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected] with: provenance-name: provenance-id-${{ github.event.release.tag_name }}.intoto.jsonl base64-subjects: "${{ needs.build.outputs.hashes }}" @@ -69,10 +69,10 @@ id-token: write # To upload via OIDC + generate attestations. steps: - name: Download artifacts directories # goes to current working directory - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - name: publish - uses: pypa/gh-action-pypi-publish@15c56dba361d8335944d31a2ecd17d700fc7bcbc # v1.12.2 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: packages-dir: built-packages/ attestations: true @@ -85,13 +85,13 @@ contents: write steps: - name: Download artifacts directories # goes to current working directory - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - name: Upload artifacts to GitHub # Confusingly, this action also supports updating releases, not # just creating them. This is what we want here, since we've manually # created the release that triggered the action. - uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: files: | built-packages/* diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/.github/workflows/scorecards-analysis.yml new/id-1.6.1/.github/workflows/scorecards-analysis.yml --- old/id-1.5.0/.github/workflows/scorecards-analysis.yml 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/.github/workflows/scorecards-analysis.yml 2026-02-04 16:11:48.000000000 +0100 @@ -4,9 +4,9 @@ workflow_dispatch: # Manual branch_protection_rule: schedule: - - cron: '30 4 * * 0' + - cron: "30 4 * * 0" push: - branches: [ main ] + branches: [main] permissions: {} # Remove all job-level permissions. @@ -23,12 +23,12 @@ id-token: write steps: - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif @@ -43,7 +43,7 @@ # Upload the results as artifacts (optional). - name: "Upload artifact" - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v2.3.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: SARIF file path: results.sarif @@ -51,6 +51,6 @@ # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6 + uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: sarif_file: results.sarif diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/CHANGELOG.md new/id-1.6.1/CHANGELOG.md --- old/id-1.5.0/CHANGELOG.md 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/CHANGELOG.md 2026-02-04 16:11:48.000000000 +0100 @@ -6,6 +6,23 @@ ## [Unreleased] + +## [1.6.1] + +### Fixed + +* Fixed an issue where the correct audience was not being requested for GitHub + identities ([#441](https://github.com/di/id/issues/441)) + +## [1.6.0] + +### Changed + +* CircleCI: default to --root-issuer when generating OIDC Token ([#438](https://github.com/di/id/pull/438)) + +* Drop dependency on `requests` in favor of underlying + `urllib3` ([#333](https://github.com/di/id/pull/333)) + ## [1.5.0] ### Changed @@ -60,7 +77,9 @@ * Initial split from https://github.com/sigstore/sigstore-python <!--Release URLs --> -[Unreleased]: https://github.com/di/id/compare/v1.5.0...HEAD +[Unreleased]: https://github.com/di/id/compare/v1.6.1...HEAD +[1.6.1]: https://github.com/di/id/compare/v1.6.0...v1.6.1 +[1.6.0]: https://github.com/di/id/compare/v1.5.0...v1.6.0 [1.5.0]: https://github.com/di/id/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/di/id/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/di/id/compare/v1.2.1...v1.3.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/id/__init__.py new/id-1.6.1/id/__init__.py --- old/id-1.5.0/id/__init__.py 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/id/__init__.py 2026-02-04 16:11:48.000000000 +0100 @@ -19,9 +19,11 @@ from __future__ import annotations import base64 +import binascii +import json from typing import Callable -__version__ = "1.5.0" +__version__ = "1.6.1" class IdentityError(Exception): @@ -50,6 +52,26 @@ pass +def _validate_credential(credential: str, audience: str) -> None: + # Decode credential to verify it roughly looks like a token and contains + # the correct audience + try: + _, payload, _ = credential.split(".") + decoded_payload = base64.urlsafe_b64decode(payload + "==").decode("utf-8") + payload_json = json.loads(decoded_payload) + except (ValueError, binascii.Error, json.decoder.JSONDecodeError) as e: + raise AmbientCredentialError("Malformed token") from e + + if not isinstance(payload_json, dict): + raise AmbientCredentialError("Malformed token payload (JWT is not a JSON object)") + if "aud" not in payload_json: + raise AmbientCredentialError("Malformed token payload (audience claim is missing)") + if payload_json["aud"] != audience: + raise AmbientCredentialError( + f"Token audience claim mismatch (expected {audience}, got {payload_json['aud']})" + ) + + def detect_credential(audience: str) -> str | None: """ Try each ambient credential detector, returning the first one to succeed @@ -76,6 +98,7 @@ for detector in detectors: credential = detector(audience) if credential is not None: + _validate_credential(credential, audience) return credential return None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/id/_internal/oidc/ambient.py new/id-1.6.1/id/_internal/oidc/ambient.py --- old/id-1.5.0/id/_internal/oidc/ambient.py 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/id/_internal/oidc/ambient.py 2026-02-04 16:11:48.000000000 +0100 @@ -24,8 +24,10 @@ import re import shutil import subprocess # nosec B404 +from typing import Any, TextIO +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse -import requests +import urllib3 from ... import AmbientCredentialError, GitHubOidcPermissionCredentialError @@ -45,6 +47,32 @@ _env_var_regex = re.compile(r"[^A-Z0-9_]|^[^A-Z_]") +def _request( + method: str, + url: str, + *, + fields: dict[str, str] | None = None, + **kwargs: Any, +) -> urllib3.BaseHTTPResponse: + """request wrapper that handles adding query parameters to URLs that may already have them""" + _encode_url_methods = {"DELETE", "GET", "HEAD", "OPTIONS"} + if method.upper() in _encode_url_methods and fields: + url_parts = list(urlparse(url)) + query = dict(parse_qsl(url_parts[4])) + query.update(fields) + url_parts[4] = urlencode(query) + + url = urlunparse(url_parts) + fields = None + + return urllib3.request(method, url, fields=fields, **kwargs) + + +# Wrap `open` for testing purposes +def _open(filename: str) -> TextIO: + return open(filename) + + def detect_github(audience: str) -> str | None: """ Detect and return a GitHub Actions ambient OIDC credential. @@ -77,22 +105,23 @@ ) logger.debug("GitHub: requesting OIDC token") - resp = requests.get( - req_url, - params={"audience": audience}, - headers={"Authorization": f"bearer {req_token}"}, - timeout=30, - ) + try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GitHub: OIDC token request failed (code={resp.status_code}, " - f"body={resp.content.decode()!r})" - ) from http_error - except requests.Timeout: + resp = _request( + "GET", + req_url, + fields={"audience": audience}, + headers={"Authorization": f"bearer {req_token}"}, + timeout=30, + ) + except urllib3.exceptions.MaxRetryError: raise AmbientCredentialError("GitHub: OIDC token request timed out") + if resp.status != 200: + raise AmbientCredentialError( + f"GitHub: OIDC token request failed (code={resp.status}, body={resp.data.decode()!r})" + ) + try: body = resp.json() value = body["value"] @@ -122,47 +151,49 @@ logger.debug("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation") logger.debug("GCP: requesting access token") - resp = requests.get( - _GCP_TOKEN_REQUEST_URL, - params={"scopes": "https://www.googleapis.com/auth/cloud-platform"}, - headers={"Metadata-Flavor": "Google"}, - timeout=30, - ) + try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GCP: access token request failed (code={resp.status_code}, " - f"body={resp.content.decode()!r})" - ) from http_error - except requests.Timeout: + resp = _request( + "GET", + _GCP_TOKEN_REQUEST_URL, + fields={"scopes": "https://www.googleapis.com/auth/cloud-platform"}, + headers={"Metadata-Flavor": "Google"}, + timeout=30, + ) + except urllib3.exceptions.MaxRetryError: raise AmbientCredentialError("GCP: access token request timed out") + if resp.status != 200: + raise AmbientCredentialError( + f"GCP: access token request failed (code={resp.status}, " + f"body={resp.data.decode()!r})" + ) + access_token = resp.json().get("access_token") if not access_token: raise AmbientCredentialError("GCP: access token missing from response") - resp = requests.post( - _GCP_GENERATEIDTOKEN_REQUEST_URL.format(service_account_name), - json={"audience": audience, "includeEmail": True}, - headers={ - "Authorization": f"Bearer {access_token}", - }, - timeout=30, - ) - logger.debug("GCP: requesting OIDC token") + try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GCP: OIDC token request failed (code={resp.status_code}, " - f"body={resp.content.decode()!r})" - ) from http_error - except requests.Timeout: + resp = _request( + "POST", + _GCP_GENERATEIDTOKEN_REQUEST_URL.format(service_account_name), + json={"audience": audience, "includeEmail": True}, + headers={ + "Authorization": f"Bearer {access_token}", + }, + timeout=30, + ) + except urllib3.exceptions.MaxRetryError: raise AmbientCredentialError("GCP: OIDC token request timed out") + if resp.status != 200: + raise AmbientCredentialError( + f"GCP: OIDC token request failed (code={resp.status}, body={resp.data.decode()!r})" + ) + oidc_token: str = resp.json().get("token") if not oidc_token: @@ -175,7 +206,7 @@ logger.debug("GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation") try: - with open(_GCP_PRODUCT_NAME_FILE) as f: + with _open(_GCP_PRODUCT_NAME_FILE) as f: name = f.read().strip() except OSError: logger.debug("GCP: environment doesn't have GCP product name file; giving up") @@ -186,25 +217,25 @@ return None logger.debug("GCP: requesting OIDC token") - resp = requests.get( - _GCP_IDENTITY_REQUEST_URL, - params={"audience": audience, "format": "full"}, - headers={"Metadata-Flavor": "Google"}, - timeout=30, - ) try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GCP: OIDC token request failed (code={resp.status_code}, " - f"body={resp.content.decode()!r})" - ) from http_error - except requests.Timeout: + resp = _request( + "GET", + _GCP_IDENTITY_REQUEST_URL, + fields={"audience": audience, "format": "full"}, + headers={"Metadata-Flavor": "Google"}, + timeout=30, + ) + except urllib3.exceptions.MaxRetryError: raise AmbientCredentialError("GCP: OIDC token request timed out") + if resp.status != 200: + raise AmbientCredentialError( + f"GCP: OIDC token request failed (code={resp.status}, body={resp.data.decode()!r})" + ) + logger.debug("GCP: successfully requested OIDC token") - return resp.text + return resp.data.decode() def detect_buildkite(audience: str) -> str | None: @@ -293,7 +324,7 @@ return token -def detect_circleci(audience: str) -> str | None: +def detect_circleci(audience: str, root_issuer: bool = True) -> str | None: """ Detect and return a CircleCI ambient OIDC credential. @@ -312,17 +343,21 @@ if shutil.which("circleci") is None: raise AmbientCredentialError("CircleCI: could not find `circleci` in the environment") - # See NOTE on `detect_buildkite` for why we silence these warnings. payload = json.dumps({"aud": audience}) + cmd = ["circleci", "run", "oidc", "get", "--claims", payload] + if root_issuer: + cmd.append("--root-issuer") + + # See NOTE on `detect_buildkite` for why we silence these warnings. process = subprocess.run( # nosec B603, B607 - ["circleci", "run", "oidc", "get", "--claims", payload], + cmd, capture_output=True, text=True, ) if process.returncode != 0: raise AmbientCredentialError( - f"CircleCI: the `circleci` tool encountered an error: {process.stdout}" + f"CircleCI: the `circleci` tool encountered an error: {process.stderr}" ) return process.stdout.strip() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/id/py.typed new/id-1.6.1/id/py.typed --- old/id-1.5.0/id/py.typed 1970-01-01 01:00:00.000000000 +0100 +++ new/id-1.6.1/id/py.typed 2026-02-04 16:11:48.000000000 +0100 @@ -0,0 +1,4 @@ +`id` is a project providing helpers for making OIDC identities. +This PEP 561 marker file exists to let the type checkers know that this project +has type annotations declared. Additionally, this is useful +for type-checking our own tests since they make corresponding imports. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/pyproject.toml new/id-1.6.1/pyproject.toml --- old/id-1.5.0/pyproject.toml 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/pyproject.toml 2026-02-04 16:11:48.000000000 +0100 @@ -12,19 +12,19 @@ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Security", "Topic :: Security :: Cryptography", ] -dependencies = ["requests"] -requires-python = ">=3.8" +dependencies = ["urllib3 >= 2, < 3"] +requires-python = ">=3.9" [project.urls] Homepage = "https://pypi.org/project/id/" @@ -42,8 +42,7 @@ "mypy", # NOTE(ww): ruff is under active development, so we pin conservatively here # and let Dependabot periodically perform this update. - "ruff < 0.8.2", - "types-requests", + "ruff < 0.14.15", ] dev = ["build", "bump >= 1.3.2", "id[test,lint]"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/test/unit/internal/oidc/gha_token.txt new/id-1.6.1/test/unit/internal/oidc/gha_token.txt --- old/id-1.5.0/test/unit/internal/oidc/gha_token.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/id-1.6.1/test/unit/internal/oidc/gha_token.txt 2026-02-04 16:11:48.000000000 +0100 @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6IjM4ODI2YjE3LTZhMzAtNWY5Yi1iMTY5LThiZWI4MjAyZjcyMyIsInR5cCI6IkpXVCIsIng1dCI6InlrTmFZNHFNX3RhNGsyVGdaT0NFWUxrY1lsQSJ9.eyJhY3RvciI6ImdpdGh1Yi1hY3Rpb25zW2JvdF0iLCJhY3Rvcl9pZCI6IjQxODk4MjgyIiwiYXVkIjoic2lnc3RvcmUiLCJiYXNlX3JlZiI6IiIsImNoZWNrX3J1bl9pZCI6IjYyMTI1NTYxNzA2IiwiZXZlbnRfbmFtZSI6IndvcmtmbG93X2Rpc3BhdGNoIiwiZXhwIjoxNzY5OTQxODU5LCJoZWFkX3JlZiI6IiIsImlhdCI6MTc2OTk0MTU1OSwiaXNzIjoiaHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImpvYl93b3JrZmxvd19yZWYiOiJzaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4iLCJqb2Jfd29ya2Zsb3dfc2hhIjoiOGMxMzUxNzcyMWQ2YWUxMWNhYzM1N2U1OGI5YzgxZTk4OGRjODZlNCIsImp0aSI6Ijg5NzA2ZWUzLTRmYmItNDY2MS1hMzU3LWQxMmE3OGZmYWI1OCIsIm5iZiI6MTc2OTk0MTI1OSwicmVmIjoicmVmcy9oZWFkcy9tYWluIiwicmVmX3Byb3RlY3RlZCI6InRydWUiLCJyZWZfdHlwZSI6ImJyYW5jaCIsInJlcG9zaXRvcnkiOiJzaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vy b3VzLXB1YmxpYy1vaWRjLWJlYWNvbiIsInJlcG9zaXRvcnlfaWQiOiI2MzI1OTY4OTciLCJyZXBvc2l0b3J5X293bmVyIjoic2lnc3RvcmUtY29uZm9ybWFuY2UiLCJyZXBvc2l0b3J5X293bmVyX2lkIjoiMTMxODA0NTYzIiwicmVwb3NpdG9yeV92aXNpYmlsaXR5IjoicHVibGljIiwicnVuX2F0dGVtcHQiOiIxIiwicnVuX2lkIjoiMjE1NjEyNDQ2OTciLCJydW5fbnVtYmVyIjoiMzA2Njk4IiwicnVubmVyX2Vudmlyb25tZW50IjoiZ2l0aHViLWhvc3RlZCIsInNoYSI6IjhjMTM1MTc3MjFkNmFlMTFjYWMzNTdlNThiOWM4MWU5ODhkYzg2ZTQiLCJzdWIiOiJyZXBvOnNpZ3N0b3JlLWNvbmZvcm1hbmNlL2V4dHJlbWVseS1kYW5nZXJvdXMtcHVibGljLW9pZGMtYmVhY29uOnJlZjpyZWZzL2hlYWRzL21haW4iLCJ3b3JrZmxvdyI6IkV4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24iLCJ3b3JrZmxvd19yZWYiOiJzaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4iLCJ3b3JrZmxvd19zaGEiOiI4YzEzNTE3NzIxZDZhZTExY2FjMzU3ZTU4YjljODFlOTg4ZGM4NmU0In0.3ZAoeVzyxsaVNAda9nTLWANuOYUfjkGZXjZDwjBdxuURNHBxsygoaa5qlOcs-n0ApTIAZAFcTL2PcG_ig1FncIEAqKplYbTEWo1MP8-J-hqJ73ehBUIJ5_cUhO0-j WDzVCCQuqFlkXRLGfNZoh20SvQhv0G23v31MPvrn41oGcB-iNTcMv_ECrBjqAKgb1rPHxWq7cq5jscLdrBJwPoW5fLY6AEyijgWWRBkps7mPCRnRSaadLCzJ3Juh0TGGxIw1pl0H5KAOeHG3NqMWJFBW9sJGZDQ-iAXG4CfLYYtdj-E_lAvZohbh0-vWeMcEITggfzqjEppElSM2tp_OWkeSQ \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/id-1.5.0/test/unit/internal/oidc/test_ambient.py new/id-1.6.1/test/unit/internal/oidc/test_ambient.py --- old/id-1.5.0/test/unit/internal/oidc/test_ambient.py 2024-12-04 20:50:43.000000000 +0100 +++ new/id-1.6.1/test/unit/internal/oidc/test_ambient.py 2026-02-04 16:11:48.000000000 +0100 @@ -13,14 +13,22 @@ # limitations under the License. import json +from pathlib import Path import pretend import pytest -from requests import HTTPError, Timeout from id import detect_credential from id._internal.oidc import ambient +HERE = Path(__file__).parent + + +# example from Github Actions (audience of the token is "sigstore") +_GHA_TOKEN = (HERE / "gha_token.txt").read_text().strip() +# request URL example from Github Actions: note the query params already in the URL +_GHA_TOKEN_REQUEST_URL = "https://run-actions-3-azure-eastus.actions.githubusercontent.com/64//idtoken/918f5315-f823-4b74-ae16-8fc423e48661/0b77e920-7dce-5419-aca2-996d3c2116b7?api-version=2.0" + def test_detect_credential_none(monkeypatch): detect_none = pretend.call_recorder(lambda audience: None) @@ -31,10 +39,32 @@ def test_detect_credential(monkeypatch): - detect_github = pretend.call_recorder(lambda audience: "fakejwt") + detect_github = pretend.call_recorder(lambda audience: _GHA_TOKEN) monkeypatch.setattr(ambient, "detect_github", detect_github) - assert detect_credential("some-audience") == "fakejwt" + assert detect_credential("sigstore") == _GHA_TOKEN + + +def test_detect_credential_audience_mismatch(monkeypatch): + detect_github = pretend.call_recorder(lambda audience: _GHA_TOKEN) + monkeypatch.setattr(ambient, "detect_github", detect_github) + + with pytest.raises( + ambient.AmbientCredentialError, + match=r"Token audience claim mismatch \(expected my-audience, got sigstore\)", + ): + detect_credential("my-audience") + + +def test_detect_credential_malformed_token(monkeypatch): + detect_github = pretend.call_recorder(lambda audience: "header.payload.sig") + monkeypatch.setattr(ambient, "detect_github", detect_github) + + with pytest.raises( + ambient.AmbientCredentialError, + match="Malformed token", + ): + detect_credential("my-audience") def test_detect_github_bad_env(monkeypatch): @@ -54,7 +84,7 @@ def test_detect_github_bad_request_token(monkeypatch): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.delenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", raising=False) - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", _GHA_TOKEN_REQUEST_URL) logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) monkeypatch.setattr(ambient, "logger", logger) @@ -90,25 +120,25 @@ def test_detect_github_request_fails(monkeypatch): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", _GHA_TOKEN_REQUEST_URL) resp = pretend.stub( - raise_for_status=pretend.raiser(HTTPError), - status_code=999, - content=b"something", + status=999, + data=b"something", ) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError) - monkeypatch.setattr(ambient, "requests", requests) + u3 = pretend.stub(request=pretend.call_recorder(lambda meth, url, **kw: resp)) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, match=r"GitHub: OIDC token request failed \(code=999, body='something'\)", ): ambient.detect_github("some-audience") - assert requests.get.calls == [ + assert u3.request.calls == [ pretend.call( - "fakeurl", - params={"audience": "some-audience"}, + "GET", + f"{_GHA_TOKEN_REQUEST_URL}&audience=some-audience", + fields=None, headers={"Authorization": "bearer faketoken"}, timeout=30, ) @@ -118,49 +148,40 @@ def test_detect_github_request_timeout(monkeypatch): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", _GHA_TOKEN_REQUEST_URL) - resp = pretend.stub(raise_for_status=pretend.raiser(Timeout)) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: resp), - HTTPError=HTTPError, - Timeout=Timeout, + u3 = pretend.stub( + request=pretend.raiser(ValueError), exceptions=pretend.stub(MaxRetryError=ValueError) ) - monkeypatch.setattr(ambient, "requests", requests) + + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, match=r"GitHub: OIDC token request timed out", ): ambient.detect_github("some-audience") - assert requests.get.calls == [ - pretend.call( - "fakeurl", - params={"audience": "some-audience"}, - headers={"Authorization": "bearer faketoken"}, - timeout=30, - ) - ] def test_detect_github_invalid_json_payload(monkeypatch): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", _GHA_TOKEN_REQUEST_URL) - resp = pretend.stub(raise_for_status=lambda: None, json=pretend.raiser(json.JSONDecodeError)) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) + resp = pretend.stub(status=200, json=pretend.raiser(json.JSONDecodeError)) + request = pretend.call_recorder(lambda meth, url, **kw: resp) + monkeypatch.setattr(ambient.urllib3, "request", request) with pytest.raises( ambient.AmbientCredentialError, match="GitHub: malformed or incomplete JSON", ): ambient.detect_github("some-audience") - assert requests.get.calls == [ + assert request.calls == [ pretend.call( - "fakeurl", - params={"audience": "some-audience"}, + "GET", + f"{_GHA_TOKEN_REQUEST_URL}&audience=some-audience", + fields=None, headers={"Authorization": "bearer faketoken"}, timeout=30, ) @@ -171,21 +192,22 @@ def test_detect_github_bad_payload(monkeypatch, payload): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", _GHA_TOKEN_REQUEST_URL) - resp = pretend.stub(raise_for_status=lambda: None, json=pretend.call_recorder(lambda: payload)) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) + resp = pretend.stub(status=200, json=pretend.call_recorder(lambda: payload)) + u3 = pretend.stub(request=pretend.call_recorder(lambda meth, url, **kw: resp)) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, match="GitHub: malformed or incomplete JSON", ): ambient.detect_github("some-audience") - assert requests.get.calls == [ + assert u3.request.calls == [ pretend.call( - "fakeurl", - params={"audience": "some-audience"}, + "GET", + f"{_GHA_TOKEN_REQUEST_URL}&audience=some-audience", + fields=None, headers={"Authorization": "bearer faketoken"}, timeout=30, ) @@ -196,20 +218,21 @@ def test_detect_github(monkeypatch): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", _GHA_TOKEN_REQUEST_URL) resp = pretend.stub( - raise_for_status=lambda: None, + status=200, json=pretend.call_recorder(lambda: {"value": "fakejwt"}), ) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) + u3 = pretend.stub(request=pretend.call_recorder(lambda meth, url, **kw: resp)) + monkeypatch.setattr(ambient, "urllib3", u3) assert ambient.detect_github("some-audience") == "fakejwt" - assert requests.get.calls == [ + assert u3.request.calls == [ pretend.call( - "fakeurl", - params={"audience": "some-audience"}, + "GET", + f"{_GHA_TOKEN_REQUEST_URL}&audience=some-audience", + fields=None, headers={"Authorization": "bearer faketoken"}, timeout=30, ) @@ -223,13 +246,9 @@ logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) monkeypatch.setattr(ambient, "logger", logger) - resp = pretend.stub( - raise_for_status=pretend.raiser(HTTPError), - status_code=999, - content=b"something", - ) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError) - monkeypatch.setattr(ambient, "requests", requests) + resp = pretend.stub(status=999, data=b"something") + u3 = pretend.stub(request=pretend.call_recorder(lambda meth, url, **kw: resp)) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, @@ -250,13 +269,11 @@ logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) monkeypatch.setattr(ambient, "logger", logger) - resp = pretend.stub(raise_for_status=pretend.raiser(Timeout)) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: resp), - HTTPError=HTTPError, - Timeout=Timeout, + u3 = pretend.stub( + request=pretend.raiser(ValueError), exceptions=pretend.stub(MaxRetryError=ValueError) ) - monkeypatch.setattr(ambient, "requests", requests) + + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, @@ -277,9 +294,9 @@ logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) monkeypatch.setattr(ambient, "logger", logger) - resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {}) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) + resp = pretend.stub(status=200, json=lambda: {}) + u3 = pretend.stub(request=pretend.call_recorder(lambda meth, url, **kw: resp)) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, @@ -301,20 +318,22 @@ monkeypatch.setattr(ambient, "logger", logger) access_token = pretend.stub() - get_resp = pretend.stub( - raise_for_status=lambda: None, json=lambda: {"access_token": access_token} - ) + get_resp = pretend.stub(status=200, json=lambda: {"access_token": access_token}) post_resp = pretend.stub( - raise_for_status=pretend.raiser(HTTPError), - status_code=999, - content=b"something", - ) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: get_resp), - post=pretend.call_recorder(lambda url, **kw: post_resp), - HTTPError=HTTPError, + status=999, + data=b"something", ) - monkeypatch.setattr(ambient, "requests", requests) + + def _request(meth, *a, **kw): + if meth == "GET": + return get_resp + elif meth == "POST": + return post_resp + else: + assert False + + u3 = pretend.stub(request=_request) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, @@ -337,17 +356,18 @@ monkeypatch.setattr(ambient, "logger", logger) access_token = pretend.stub() - get_resp = pretend.stub( - raise_for_status=lambda: None, json=lambda: {"access_token": access_token} - ) - post_resp = pretend.stub(raise_for_status=pretend.raiser(Timeout)) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: get_resp), - post=pretend.call_recorder(lambda url, **kw: post_resp), - HTTPError=HTTPError, - Timeout=Timeout, - ) - monkeypatch.setattr(ambient, "requests", requests) + get_resp = pretend.stub(status=200, json=lambda: {"access_token": access_token}) + + def _request(meth, *a, **kw): + if meth == "GET": + return get_resp + elif meth == "POST": + raise ValueError + else: + assert False + + u3 = pretend.stub(request=_request, exceptions=pretend.stub(MaxRetryError=ValueError)) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, @@ -370,16 +390,19 @@ monkeypatch.setattr(ambient, "logger", logger) access_token = pretend.stub() - get_resp = pretend.stub( - raise_for_status=lambda: None, json=lambda: {"access_token": access_token} - ) - post_resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {}) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: get_resp), - post=pretend.call_recorder(lambda url, **kw: post_resp), - HTTPError=HTTPError, - ) - monkeypatch.setattr(ambient, "requests", requests) + get_resp = pretend.stub(status=200, json=lambda: {"access_token": access_token}) + post_resp = pretend.stub(status=200, json=lambda: {}) + + def _request(meth, *a, **kw): + if meth == "GET": + return get_resp + elif meth == "POST": + return post_resp + else: + assert False + + u3 = pretend.stub(request=_request) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, @@ -403,16 +426,19 @@ access_token = pretend.stub() oidc_token = pretend.stub() - get_resp = pretend.stub( - raise_for_status=lambda: None, json=lambda: {"access_token": access_token} - ) - post_resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {"token": oidc_token}) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: get_resp), - post=pretend.call_recorder(lambda url, **kw: post_resp), - HTTPError=HTTPError, - ) - monkeypatch.setattr(ambient, "requests", requests) + get_resp = pretend.stub(status=200, json=lambda: {"access_token": access_token}) + post_resp = pretend.stub(status=200, json=lambda: {"token": oidc_token}) + + def _request(meth, *a, **kw): + if meth == "GET": + return get_resp + elif meth == "POST": + return post_resp + else: + assert False + + u3 = pretend.stub(request=_request) + monkeypatch.setattr(ambient, "urllib3", u3) assert ambient.detect_gcp("some-audience") == oidc_token @@ -427,7 +453,7 @@ def test_gcp_bad_env(monkeypatch): oserror = pretend.raiser(OSError) - monkeypatch.setitem(ambient.__builtins__, "open", oserror) # type: ignore + monkeypatch.setattr(ambient, "_open", oserror) # type: ignore logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) monkeypatch.setattr(ambient, "logger", logger) @@ -445,7 +471,7 @@ __enter__=lambda *a: pretend.stub(read=lambda: "Unsupported Product"), __exit__=lambda *a: None, ) - monkeypatch.setitem(ambient.__builtins__, "open", lambda fn: stub_file) # type: ignore + monkeypatch.setattr(ambient, "_open", lambda fn: stub_file) # type: ignore logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) monkeypatch.setattr(ambient, "logger", logger) @@ -466,25 +492,25 @@ __enter__=lambda *a: pretend.stub(read=lambda: "Google"), __exit__=lambda *a: None, ) - monkeypatch.setitem(ambient.__builtins__, "open", lambda fn: stub_file) # type: ignore + monkeypatch.setattr(ambient, "_open", lambda fn: stub_file) # type: ignore resp = pretend.stub( - raise_for_status=pretend.raiser(HTTPError), - status_code=999, - content=b"something", + status=999, + data=b"something", ) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError) - monkeypatch.setattr(ambient, "requests", requests) + u3 = pretend.stub(request=pretend.call_recorder(lambda meth, url, **kw: resp)) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, match=r"GCP: OIDC token request failed \(code=999, body='something'\)", ): ambient.detect_gcp("some-audience") - assert requests.get.calls == [ + assert u3.request.calls == [ pretend.call( - ambient._GCP_IDENTITY_REQUEST_URL, - params={"audience": "some-audience", "format": "full"}, + "GET", + f"{ambient._GCP_IDENTITY_REQUEST_URL}?audience=some-audience&format=full", + fields=None, headers={"Metadata-Flavor": "Google"}, timeout=30, ) @@ -496,29 +522,18 @@ __enter__=lambda *a: pretend.stub(read=lambda: "Google"), __exit__=lambda *a: None, ) - monkeypatch.setitem(ambient.__builtins__, "open", lambda fn: stub_file) # type: ignore + monkeypatch.setattr(ambient, "_open", lambda fn: stub_file) # type: ignore - resp = pretend.stub(raise_for_status=pretend.raiser(Timeout)) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: resp), - HTTPError=HTTPError, - Timeout=Timeout, + u3 = pretend.stub( + request=pretend.raiser(ValueError), exceptions=pretend.stub(MaxRetryError=ValueError) ) - monkeypatch.setattr(ambient, "requests", requests) + monkeypatch.setattr(ambient, "urllib3", u3) with pytest.raises( ambient.AmbientCredentialError, match=r"GCP: OIDC token request timed out", ): ambient.detect_gcp("some-audience") - assert requests.get.calls == [ - pretend.call( - ambient._GCP_IDENTITY_REQUEST_URL, - params={"audience": "some-audience", "format": "full"}, - headers={"Metadata-Flavor": "Google"}, - timeout=30, - ) - ] @pytest.mark.parametrize("product_name", ("Google", "Google Compute Engine")) @@ -527,23 +542,24 @@ __enter__=lambda *a: pretend.stub(read=lambda: product_name), __exit__=lambda *a: None, ) - monkeypatch.setitem(ambient.__builtins__, "open", lambda fn: stub_file) # type: ignore + monkeypatch.setattr(ambient, "_open", lambda fn: stub_file) # type: ignore logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) monkeypatch.setattr(ambient, "logger", logger) resp = pretend.stub( - raise_for_status=lambda: None, - text="fakejwt", + status=200, + data=b"fakejwt", ) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) + u3 = pretend.stub(request=pretend.call_recorder(lambda meth, url, **kw: resp)) + monkeypatch.setattr(ambient, "urllib3", u3) assert ambient.detect_gcp("some-audience") == "fakejwt" - assert requests.get.calls == [ + assert u3.request.calls == [ pretend.call( - ambient._GCP_IDENTITY_REQUEST_URL, - params={"audience": "some-audience", "format": "full"}, + "GET", + f"{ambient._GCP_IDENTITY_REQUEST_URL}?audience=some-audience&format=full", + fields=None, headers={"Metadata-Flavor": "Google"}, timeout=30, ) @@ -721,7 +737,8 @@ assert shutil.which.calls == [pretend.call("circleci")] -def test_circleci_circlecli_error(monkeypatch): [email protected]("root_issuer", [True, False]) +def test_circleci_circlecli_error(monkeypatch, root_issuer): monkeypatch.setenv("CIRCLECI", "true") # Mock out the `which` call to show that we have a `circleci` in our `PATH`. @@ -731,29 +748,34 @@ # Mock out `run` call to emulate getting a non-zero return code from the `circleci`. resp = pretend.stub( returncode=-1, - stdout="mock error message", + stderr="mock error message", ) subprocess = pretend.stub(run=pretend.call_recorder(lambda run_args, **kw: resp), PIPE=None) monkeypatch.setattr(ambient, "subprocess", subprocess) + payload = json.dumps({"aud": "some-audience"}) + expected_cmd = ["circleci", "run", "oidc", "get", "--claims", payload] + if root_issuer: + expected_cmd.append("--root-issuer") with pytest.raises( ambient.AmbientCredentialError, match=r"CircleCI: the `circleci` tool encountered an error: mock error message", ): - ambient.detect_circleci("some-audience") + ambient.detect_circleci("some-audience", root_issuer) assert shutil.which.calls == [pretend.call("circleci")] assert subprocess.run.calls == [ pretend.call( - ["circleci", "run", "oidc", "get", "--claims", payload], + expected_cmd, capture_output=True, text=True, ) ] -def test_circleci(monkeypatch): [email protected]("root_issuer", [True, False]) +def test_circleci(monkeypatch, root_issuer): monkeypatch.setenv("CIRCLECI", "true") # Mock out the `which` call to show that we have a `circleci` in our `PATH`. @@ -767,13 +789,17 @@ ) subprocess = pretend.stub(run=pretend.call_recorder(lambda run_args, **kw: resp), PIPE=None) monkeypatch.setattr(ambient, "subprocess", subprocess) + payload = json.dumps({"aud": "some-audience"}) + expected_cmd = ["circleci", "run", "oidc", "get", "--claims", payload] + if root_issuer: + expected_cmd.append("--root-issuer") - assert ambient.detect_circleci("some-audience") == "fakejwt" + assert ambient.detect_circleci("some-audience", root_issuer) == "fakejwt" assert shutil.which.calls == [pretend.call("circleci")] assert subprocess.run.calls == [ pretend.call( - ["circleci", "run", "oidc", "get", "--claims", payload], + expected_cmd, capture_output=True, text=True, )
