From 35a7e88c073e87d5f6d454c051b42eaebf849521 Mon Sep 17 00:00:00 2001 From: scranford1 Date: Tue, 15 Sep 2026 11:16:34 -0400 Subject: [PATCH 1/2] Bugfix/pydap dependency (#566) * Replace pydap.cas.urs module which is no longer available --- podpac/core/authentication.py | 58 ++++++++-- podpac/core/test/test_authentication.py | 134 +++++++++++++++++++++++- 2 files changed, 185 insertions(+), 7 deletions(-) diff --git a/podpac/core/authentication.py b/podpac/core/authentication.py index c5a2c887..bd07026c 100644 --- a/podpac/core/authentication.py +++ b/podpac/core/authentication.py @@ -8,15 +8,11 @@ import requests import traitlets as tl from lazy_import import lazy_module +from urllib.parse import urlparse from podpac.core.settings import settings from podpac.core.utils import cached_property -# Optional dependencies -# see pydap_source.py for import note -# pydap_setup_session = lazy_function("pydap.cas.urs.setup_session") -from pydap.cas.urs import setup_session as pydap_setup_session - _log = logging.getLogger(__name__) _USERNAME_AT = "username@{}" _PASSWORD_AT = "password@{}" @@ -161,6 +157,48 @@ def _create_session(self): return s +class _SessionWithHeaderRedirection(requests.Session): + """Session with header redirection for Earthdata Login (URS) authentication + (see https://urs.earthdata.nasa.gov/documentation/for_users/data_access/python). + """ + + def __init__(self, auth_host: str) -> None: + super().__init__() + self.auth_host = auth_host + + def rebuild_auth(self, prepared_request: requests.PreparedRequest, response: requests.Response) -> None: + """Overrides :meth:`requests.Session.rebuild_auth` to keep the `Authorization` header + attached across redirects to or from NASA's Earthdata Login (URS) host. + + Parameters + ---------- + prepared_request : requests.PreparedRequest + The request about to be sent following the redirect. + response : requests.Response + The response that triggered the redirect. + + Notes + ----- + `requests` strips the `Authorization` header by default whenever a redirect changes + hostname, to avoid leaking credentials to unrelated hosts. NASA Earthdata Login's + OAuth flow relies on redirecting between the data host and `self.AUTH_HOST`, so that + default behavior would break authentication unless overridden here. + """ + headers = prepared_request.headers + url = prepared_request.url + + if "Authorization" in headers: + original_parsed = urlparse(response.request.url) + redirect_parsed = urlparse(url) + + if ( + (original_parsed.hostname != redirect_parsed.hostname) + and redirect_parsed.hostname != self.auth_host + and original_parsed.hostname != self.auth_host + ): + del headers["Authorization"] + + class NASAURSSessionMixin(RequestsSessionMixin): check_url = tl.Unicode() hostname = tl.Unicode(default_value="urs.earthdata.nasa.gov") @@ -177,15 +215,23 @@ def _create_session(self): ----- The session is authenticated against the user-provided self.check_url """ + s = _SessionWithHeaderRedirection(self.hostname) try: - s = pydap_setup_session(self.username, self.password, check_url=self.check_url) + s.auth = (self.username, self.password) except ValueError as e: if self.auth_required: raise e else: _log.warning("No auth provided for session") + if self.check_url: + response = s.get(self.check_url) + if "html" in response.headers.get("Content-Type", "").lower() and " requests.PreparedRequest: + """Build a request from the URL and headers. + + Parameters + ---------- + url : str + The request URL. + headers : dict | None + Request headers, by default None + + Returns + ------- + requests.PreparedRequest + The request prepared for testing. + """ + return requests.Request(method="GET", url=url, headers=headers or {}).prepare() + + def _response(self, url: str) -> requests.Response: + """Build a response for the URL. + + Parameters + ---------- + url : str + The request URL. + + Returns + ------- + requests.Response + The request response. + """ + response = requests.Response() + response.request = self._prepared_request(url) + return response + + def test_noop_without_authorization_header(self) -> None: + """No Authorization header means there is nothing for rebuild_auth to strip.""" + session = _SessionWithHeaderRedirection(auth_host=self.AUTH_HOST) + prepared = self._prepared_request("https://data.example.com/file.nc") + session.rebuild_auth(prepared, self._response("https://data.example.com/other")) + assert "Authorization" not in prepared.headers + + def test_keeps_header_when_redirecting_to_urs(self) -> None: + """Redirecting to the URS auth host keeps the Authorization header.""" + session = _SessionWithHeaderRedirection(auth_host=self.AUTH_HOST) + prepared = self._prepared_request("https://urs.earthdata.nasa.gov/oauth/authorize", headers=self.AUTH_HEADER) + session.rebuild_auth(prepared, self._response("https://data.example.com/file.nc")) + assert prepared.headers["Authorization"] == self.AUTH + + def test_keeps_header_when_redirecting_from_urs(self) -> None: + """Redirecting away from the URS auth host keeps the Authorization header.""" + session = _SessionWithHeaderRedirection(auth_host=self.AUTH_HOST) + prepared = self._prepared_request("https://data.example.com/file.nc", headers=self.AUTH_HEADER) + session.rebuild_auth(prepared, self._response("https://urs.earthdata.nasa.gov/oauth/authorize")) + assert prepared.headers["Authorization"] == self.AUTH + + def test_keeps_header_for_same_host_redirect(self) -> None: + """A same-host redirect keeps the Authorization header regardless of URS.""" + session = _SessionWithHeaderRedirection(auth_host=self.AUTH_HOST) + prepared = self._prepared_request("https://data.example.com/file2.nc", headers=self.AUTH_HEADER) + session.rebuild_auth(prepared, self._response("https://data.example.com/file.nc")) + assert prepared.headers["Authorization"] == self.AUTH + + def test_strips_header_for_unrelated_host_redirect(self) -> None: + """A cross-host redirect unrelated to URS strips the Authorization header.""" + session = _SessionWithHeaderRedirection(auth_host=self.AUTH_HOST) + prepared = self._prepared_request("https://other-host.example.com/file.nc", headers=self.AUTH_HEADER) + session.rebuild_auth(prepared, self._response("https://data.example.com/file.nc")) + assert "Authorization" not in prepared.headers + + +class TestNASAURSSessionMixin(object): + def test_session(self) -> None: + """Test NASAURSSessionMixin session with authetication.""" + node = NASAURSSessionMixin() + with ( + patch.object(NASAURSSessionMixin, "username", new_callable=PropertyMock, return_value="testuser"), + patch.object(NASAURSSessionMixin, "password", new_callable=PropertyMock, return_value="testpass"), + ): + assert isinstance(node.session, _SessionWithHeaderRedirection) + assert node.session.auth == ("testuser", "testpass") + + def test_auth_required_traitlet(self) -> None: + """Test auth_required for the the session mixin.""" + node_auth_required = NASAURSSessionMixin() + node_no_auth_required = NASAURSSessionMixin(auth_required=False) + with patch.object(NASAURSSessionMixin, "username", new_callable=PropertyMock, side_effect=ValueError): + with pytest.raises(ValueError): + node_auth_required.session + assert isinstance(node_no_auth_required.session, _SessionWithHeaderRedirection) + + def test_raises_when_check_url_requires_registration(self) -> None: + """Test check_url returning an HTML form raises instead of silently continuing.""" + response = requests.Response() + response.headers["Content-Type"] = "text/html" + response._content = "
".encode() + node = NASAURSSessionMixin(check_url="https://data.example.com/file.nc") + with ( + patch.object(NASAURSSessionMixin, "username", new_callable=PropertyMock, return_value="testuser"), + patch.object(NASAURSSessionMixin, "password", new_callable=PropertyMock, return_value="testpass"), + patch.object(_SessionWithHeaderRedirection, "get", return_value=response), + ): + with pytest.raises(ValueError): + node.session + + def test_no_raise_when_check_url_returns_data(self) -> None: + """Test check_url returning non-HTML data does not raise.""" + response = requests.Response() + response.headers["Content-Type"] = "application/octet-stream" + response._content = b"" + node = NASAURSSessionMixin(check_url="https://data.example.com/file.nc") + with ( + patch.object(NASAURSSessionMixin, "username", new_callable=PropertyMock, return_value="testuser"), + patch.object(NASAURSSessionMixin, "password", new_callable=PropertyMock, return_value="testpass"), + patch.object(_SessionWithHeaderRedirection, "get", return_value=response), + ): + session = node.session + + assert isinstance(session, _SessionWithHeaderRedirection) + + class TestS3Mixin(object): class S3Node(S3Mixin, Node): pass From aacd0b04ecc5b5411b91d431ee19a6540cdf2b2b Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 15 Sep 2026 15:22:55 +0000 Subject: [PATCH 2/2] Release 4.0.5 --- CHANGELOG.md | 5 +++++ podpac/version.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3fc530..12a23da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## 4.0.5 + +### Maintenance +* Removed reference to `pydap.cas` module which was removed in 3.5.11. + ## 4.0.4 ### Features diff --git a/podpac/version.py b/podpac/version.py index 3ec2e894..323e4b97 100644 --- a/podpac/version.py +++ b/podpac/version.py @@ -18,7 +18,7 @@ ############## MAJOR = 4 MINOR = 0 -HOTFIX = 4 +HOTFIX = 5 ##############