Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Spatiotemporal filtering is now reflected more accurately in `DataCube.metadata` (it no longer always carries forward the full original collection extent). The client-side result is a best-effort approximation of backend behavior. When there is a limitation in that simulation (not only because of a shortcoming in implementation, but also because of fundamental unknowns, e.g. parameterized UDP construction), that uncertainty is represented explicitly (e.g. through a `_NonConcreteExtent` instance) instead of misleading fallbacks. ([#827](https://github.com/Open-EO/openeo-python-client/issues/827))
- Improve compatibility with oschmod 0.5.0 on Windows ([#967](https://github.com/Open-EO/openeo-python-client/issues/967)).
- Gracefully ignore a corrupt refresh token file in `authenticate_oidc()` and fall back to other authentication flows ([#268](https://github.com/Open-EO/openeo-python-client/issues/268))


## [0.54.0] - 2026-10-28
Expand Down
13 changes: 11 additions & 2 deletions openeo/rest/auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,20 @@ class RefreshTokenStore(PrivateJsonFile):
def default_path(cls) -> Path:
return get_user_data_dir(auto_create=True) / cls.DEFAULT_FILENAME

def _load_or_empty(self, consequence: str) -> dict:
"""Load the file content, or warn and return an empty dict if the file is corrupt."""
try:
return self.load()
except RuntimeError as e:
log.warning(f"Refresh token file {self.path} is corrupt and will be {consequence}: {e}")
return {}

def get_refresh_token(self, issuer: str, client_id: str) -> Union[str, None]:
return self.get(_normalize_url(issuer), client_id, "refresh_token", default=None)
data = self._load_or_empty(consequence="ignored")
return deep_get(data, _normalize_url(issuer), client_id, "refresh_token", default=None)

def set_refresh_token(self, issuer: str, client_id: str, refresh_token: str):
data = self.load()
data = self._load_or_empty(consequence="overwritten with the new refresh token")
log.info("Storing refresh token for issuer {i!r} (client {c!r})".format(i=issuer, c=client_id))
deep_set(
data,
Expand Down
20 changes: 20 additions & 0 deletions tests/rest/auth/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,23 @@ def test_get_set_refresh_token(self, tmp_path):
r = RefreshTokenStore(path=tmp_path)
r.set_refresh_token("foo", "bar", "ih6zdaT0k3n")
assert r.get_refresh_token("foo", "bar") == "ih6zdaT0k3n"

def test_get_refresh_token_corrupt_file(self, tmp_path, caplog):
path = tmp_path / RefreshTokenStore.DEFAULT_FILENAME
path.write_text("{\nInvalid JSON here!")
set_file_mode(path, mode=PRIVATE_PERMISSIONS)

r = RefreshTokenStore(path=tmp_path)
assert r.get_refresh_token("foo", "bar") is None
assert re.search(r"refresh-tokens\.json is corrupt and will be ignored", caplog.text)

def test_set_refresh_token_corrupt_file(self, tmp_path, caplog):
path = tmp_path / RefreshTokenStore.DEFAULT_FILENAME
path.write_text("{\nInvalid JSON here!")
set_file_mode(path, mode=PRIVATE_PERMISSIONS)

r = RefreshTokenStore(path=tmp_path)
r.set_refresh_token("foo", "bar", "ih6zdaT0k3n")
assert re.search(r"refresh-tokens\.json is corrupt and will be overwritten", caplog.text)
assert json.loads(path.read_text())["foo"]["bar"]["refresh_token"] == "ih6zdaT0k3n"
assert r.get_refresh_token("foo", "bar") == "ih6zdaT0k3n"
36 changes: 36 additions & 0 deletions tests/rest/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)
from openeo.rest._testing import build_capabilities
from openeo.rest.auth.auth import BearerAuth, NullAuth
from openeo.rest.auth.config import PRIVATE_PERMISSIONS, set_file_mode
from openeo.rest.auth.oidc import OidcException
from openeo.rest.auth.testing import ABSENT, OidcMock, SimpleBasicAuthMocker
from openeo.rest.connection import (
Expand Down Expand Up @@ -1989,6 +1990,41 @@ def test_authenticate_oidc_auto_no_existing_refresh_token(
]


def test_authenticate_oidc_auto_corrupt_refresh_token_file(
requests_mock, refresh_token_store, oidc_device_code_flow_checker, caplog
):
"""Corrupt refresh token file: fall back to device flow and overwrite the file with the new token."""
refresh_token_store.path.write_text("{\nInvalid JSON here!")
set_file_mode(refresh_token_store.path, mode=PRIVATE_PERMISSIONS)

requests_mock.get(API_URL, json={"api_version": "1.0.0"})
client_id = "myclient"
issuer = "https://oidc.test"
requests_mock.get(
API_URL + "credentials/oidc",
json={"providers": [{"id": "oi", "issuer": issuer, "title": "example", "scopes": ["openid"]}]},
)
oidc_mock = OidcMock(
requests_mock=requests_mock,
expected_client_id=client_id,
expected_grant_type=None,
oidc_issuer=issuer,
expected_fields={"scope": "offline_access openid"},
)

conn = Connection(API_URL, refresh_token_store=refresh_token_store)
oidc_mock.state["device_code_callback_timeline"] = ["great success"]
with oidc_device_code_flow_checker():
conn.authenticate_oidc(client_id=client_id)
assert conn.auth.bearer == "oidc/oi/" + oidc_mock.state["access_token"]
assert [r["grant_type"] for r in oidc_mock.grant_request_history] == [
"urn:ietf:params:oauth:grant-type:device_code"
]
assert "is corrupt and will be ignored" in caplog.text
assert "is corrupt and will be overwritten" in caplog.text
assert refresh_token_store.get_refresh_token(issuer=issuer, client_id=client_id) == oidc_mock.state["refresh_token"]


@pytest.mark.parametrize(
["use_pkce", "expect_pkce"],
[
Expand Down