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/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 "
".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 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 ##############