From 1353681a01c43ac8195824f9421083c6eb7a8db6 Mon Sep 17 00:00:00 2001 From: Ales Raszka Date: Mon, 9 Nov 2026 10:21:08 +0100 Subject: [PATCH] fix(auth): restore netrc authentication for unregistered hosts Fall back to netrc Basic authentication when SessionAuth has no registered callback for a host. Preserve registered handlers and explicit request authentication. Cover both home-directory and NETRC paths with regression tests. Co-Authored-By: Codex Signed-off-by: Ales Raszka --- docs/how-tos/authentication.md | 15 +++++ src/fromager/request_session.py | 10 ++- tests/test_request_session.py | 111 ++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/docs/how-tos/authentication.md b/docs/how-tos/authentication.md index e196cea7e..a02dafef3 100644 --- a/docs/how-tos/authentication.md +++ b/docs/how-tos/authentication.md @@ -25,6 +25,15 @@ the first match: 2. `CI_JOB_TOKEN` environment variable 3. `GITLAB_PRIVATE_TOKEN` environment variable +**Other hosts**, including private wheel indexes, use HTTP Basic authentication +from a matching netrc entry. Registered authentication callbacks take precedence; +the netrc fallback does not replace an explicit `Authorization` header or +request-specific authentication. + +```{versionchanged} 0.104.0 +Restore netrc Basic authentication for hosts without a registered authentication callback. +``` + ## netrc The [requests](https://requests.readthedocs.io) library, `pip`, and @@ -34,6 +43,12 @@ specified by setting the `NETRC` environment variable. Note that `NETRC` variable since [8.16.0](https://curl.se/ch/8.16.0.html) (2025-09-10). Older versions only read `$HOME/.netrc`. +For example, to authenticate to a private wheel index: + +```text +machine wheels.example login reader password secret +``` + For example, to authenticate to a GitLab package registry with a [personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token): diff --git a/src/fromager/request_session.py b/src/fromager/request_session.py index e1abd1665..9df8b4110 100644 --- a/src/fromager/request_session.py +++ b/src/fromager/request_session.py @@ -40,7 +40,8 @@ class SessionAuth(requests.auth.AuthBase): and does not provide per-host authentication on mounted adapters. This class fills that gap by mapping ``(scheme, hostname)`` keys to auth resolver callbacks. On the first request to a given host the - callback is invoked and the result is cached. + callback is invoked and the result is cached. Hosts without a registered + callback use HTTP Basic authentication from netrc. """ def __init__(self) -> None: @@ -77,6 +78,13 @@ def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: auth_header = self.get(r.url or "") if auth_header: r.headers.update(auth_header) + elif "Authorization" not in r.headers: + parsed = urlparse(r.url or "") + if (parsed.scheme, parsed.hostname or "") not in self._callbacks: + # Setting session.auth disables Requests' automatic netrc lookup. + netrc_auth = get_netrc_auth(r.url or "") + if netrc_auth is not None: + requests.auth.HTTPBasicAuth(*netrc_auth)(r) return r diff --git a/tests/test_request_session.py b/tests/test_request_session.py index 692edc9b5..64afad26f 100644 --- a/tests/test_request_session.py +++ b/tests/test_request_session.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import pathlib from collections.abc import Generator, MutableMapping from unittest.mock import MagicMock, patch @@ -129,3 +130,113 @@ def test_create_session( assert s.auth is auth assert ("https", "api.github.com") in auth._callbacks assert ("https", "gitlab.com") in auth._callbacks + + +@pytest.mark.parametrize("netrc_location", ["home", "environment"]) +def test_session_uses_netrc_for_unregistered_host( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + netrc_location: str, +) -> None: + """Prepare wheel-index requests with Basic auth from either netrc location.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("NETRC", raising=False) + path = tmp_path / (".netrc" if netrc_location == "home" else "credentials") + path.touch(mode=0o600) + path.write_text("machine wheels.test login reader password secret\n") + if netrc_location == "environment": + monkeypatch.setenv("NETRC", str(path)) + session, _ = create_session() + + request = session.prepare_request( + requests.Request("GET", "https://wheels.test/simple/banks/") + ) + other_host = session.prepare_request( + requests.Request("GET", "https://other.test/simple/") + ) + + assert request.headers["Authorization"] == "Basic cmVhZGVyOnNlY3JldA==" + assert "Authorization" not in other_host.headers + + +def test_session_without_netrc_credentials(mock_netrc: MagicMock) -> None: + """Leave an unregistered host unauthenticated when netrc has no match.""" + session, _ = create_session() + + request = session.prepare_request( + requests.Request("GET", "https://wheels.test/simple/") + ) + + assert "Authorization" not in request.headers + + +@pytest.mark.parametrize( + "url,username,expected_headers", + [ + ("https://api.github.com/repos", "reader", {"Authorization": "token secret"}), + ("https://gitlab.com/api/v4", "reader", {"PRIVATE-TOKEN": "secret"}), + ("https://gitlab.com/api/v4", "gitlab-ci-token", {"JOB-TOKEN": "secret"}), + ], +) +def test_session_preserves_registered_auth( + mock_netrc: MagicMock, + url: str, + username: str, + expected_headers: dict[str, str], +) -> None: + """Keep GitHub and GitLab token headers instead of applying Basic auth.""" + mock_netrc.return_value = (username, "secret") + session, _ = create_session() + + request = session.prepare_request(requests.Request("GET", url)) + + for name, value in expected_headers.items(): + assert request.headers[name] == value + if "Authorization" not in expected_headers: + assert "Authorization" not in request.headers + + +def test_session_preserves_empty_registered_auth(mock_netrc: MagicMock) -> None: + """Do not apply fallback credentials to a host with a registered callback.""" + mock_netrc.return_value = ("reader", "secret") + session, auth = create_session() + auth.add("https://wheels.test", lambda scheme, hostname: {}) + + request = session.prepare_request( + requests.Request("GET", "https://wheels.test/simple/") + ) + + assert "Authorization" not in request.headers + mock_netrc.assert_not_called() + + +def test_session_preserves_authorization_header(mock_netrc: MagicMock) -> None: + """Do not overwrite an explicit bearer token with netrc credentials.""" + mock_netrc.return_value = ("reader", "secret") + session, _ = create_session() + + request = session.prepare_request( + requests.Request( + "GET", + "https://wheels.test/simple/", + headers={"Authorization": "Bearer token"}, + ) + ) + + assert request.headers["Authorization"] == "Bearer token" + mock_netrc.assert_not_called() + + +def test_session_preserves_request_auth(mock_netrc: MagicMock) -> None: + """Request-specific authentication overrides the session handler.""" + mock_netrc.return_value = ("reader", "secret") + session, _ = create_session() + + request = session.prepare_request( + requests.Request( + "GET", "https://wheels.test/simple/", auth=("explicit", "token") + ) + ) + + assert request.headers["Authorization"] == "Basic ZXhwbGljaXQ6dG9rZW4=" + mock_netrc.assert_not_called()