Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
58 changes: 52 additions & 6 deletions podpac/core/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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@{}"
Expand Down Expand Up @@ -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")
Expand All @@ -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 "<form" in response.text.lower():
raise ValueError(
"Checked %s, and a form was returned. Manual registration is required.", self.check_url
)

return s


Expand Down
134 changes: 133 additions & 1 deletion podpac/core/test/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
import traitlets as tl
import s3fs
from numpy.testing import assert_equal
from unittest.mock import patch, PropertyMock
import logging

from podpac import settings, Node
from podpac.core.authentication import RequestsSessionMixin, S3Mixin, set_credentials
from podpac.core.authentication import (
RequestsSessionMixin,
NASAURSSessionMixin,
S3Mixin,
set_credentials,
_SessionWithHeaderRedirection,
)

_USERNAME_TEST_COM = "username@test.com"
_PASSWORD_TEST_COM = "password@test.com"
Expand Down Expand Up @@ -139,6 +146,131 @@ def test_auth_required(self):
assert isinstance(node.session, requests.Session)


class TestSessionWithHeaderRedirection(object):
AUTH = "TEST"
AUTH_HEADER = {"Authorization": AUTH}
AUTH_HOST = "urs.earthdata.nasa.gov"

def _prepared_request(self, url: str, headers: dict | None = None) -> 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 = "<html><form></form></html>".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
Expand Down
2 changes: 1 addition & 1 deletion podpac/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
##############
MAJOR = 4
MINOR = 0
HOTFIX = 4
HOTFIX = 5
##############


Expand Down
Loading