diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e578b..d2e8af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +* Drop dependency on `requests` in favor of underlying + `urllib3` ([#333](https://github.com/di/id/pull/333)) + ## [1.5.0] ### Changed diff --git a/id/_internal/oidc/ambient.py b/id/_internal/oidc/ambient.py index c66874d..c4015da 100644 --- a/id/_internal/oidc/ambient.py +++ b/id/_internal/oidc/ambient.py @@ -26,7 +26,7 @@ import subprocess # nosec B404 from typing import TextIO -import requests +import urllib3 from ... import AmbientCredentialError, GitHubOidcPermissionCredentialError @@ -83,22 +83,23 @@ def detect_github(audience: str) -> str | None: ) 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 = urllib3.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"] @@ -128,47 +129,49 @@ def detect_gcp(audience: str) -> str | None: 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 = urllib3.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 = urllib3.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: @@ -192,25 +195,25 @@ def detect_gcp(audience: str) -> str | None: 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 = urllib3.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: diff --git a/pyproject.toml b/pyproject.toml index 1287c68..a5822b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Topic :: Security", "Topic :: Security :: Cryptography", ] -dependencies = ["requests"] +dependencies = ["urllib3 >= 2, < 3"] requires-python = ">=3.8" [project.urls] @@ -43,7 +43,6 @@ lint = [ # NOTE(ww): ruff is under active development, so we pin conservatively here # and let Dependabot periodically perform this update. "ruff < 0.9.6", - "types-requests", ] dev = ["build", "bump >= 1.3.2", "id[test,lint]"] diff --git a/test/unit/internal/oidc/test_ambient.py b/test/unit/internal/oidc/test_ambient.py index 1a2f1c7..2b50987 100644 --- a/test/unit/internal/oidc/test_ambient.py +++ b/test/unit/internal/oidc/test_ambient.py @@ -16,7 +16,6 @@ import pretend import pytest -from requests import HTTPError, Timeout from id import detect_credential from id._internal.oidc import ambient @@ -93,22 +92,22 @@ def test_detect_github_request_fails(monkeypatch): monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") 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( + "GET", "fakeurl", - params={"audience": "some-audience"}, + fields={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) @@ -120,27 +119,17 @@ def test_detect_github_request_timeout(monkeypatch): monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") - 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): @@ -148,19 +137,20 @@ def test_detect_github_invalid_json_payload(monkeypatch): monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") - 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( + "GET", "fakeurl", - params={"audience": "some-audience"}, + fields={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) @@ -173,19 +163,20 @@ def test_detect_github_bad_payload(monkeypatch, payload): monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") - 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( + "GET", "fakeurl", - params={"audience": "some-audience"}, + fields={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) @@ -199,17 +190,18 @@ def test_detect_github(monkeypatch): monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") 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( + "GET", "fakeurl", - params={"audience": "some-audience"}, + fields={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) @@ -223,13 +215,9 @@ def test_gcp_impersonation_access_token_request_fail(monkeypatch): 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 +238,11 @@ def test_gcp_impersonation_access_token_request_timeout(monkeypatch): 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 +263,9 @@ def test_gcp_impersonation_access_token_missing(monkeypatch): 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 +287,22 @@ def test_gcp_impersonation_identity_token_request_fail(monkeypatch): 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 +325,18 @@ def test_gcp_impersonation_identity_token_request_timeout(monkeypatch): 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 +359,19 @@ def test_gcp_impersonation_identity_token_missing(monkeypatch): 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 +395,19 @@ def test_gcp_impersonation_succeeds(monkeypatch): 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 @@ -469,22 +464,22 @@ def test_detect_gcp_request_fails(monkeypatch): 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( + "GET", ambient._GCP_IDENTITY_REQUEST_URL, - params={"audience": "some-audience", "format": "full"}, + fields={"audience": "some-audience", "format": "full"}, headers={"Metadata-Flavor": "Google"}, timeout=30, ) @@ -498,27 +493,16 @@ def test_detect_gcp_request_timeout(monkeypatch): ) 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")) @@ -533,17 +517,18 @@ def test_detect_gcp(monkeypatch, product_name): 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( + "GET", ambient._GCP_IDENTITY_REQUEST_URL, - params={"audience": "some-audience", "format": "full"}, + fields={"audience": "some-audience", "format": "full"}, headers={"Metadata-Flavor": "Google"}, timeout=30, )