diff --git a/CHANGELOG.md b/CHANGELOG.md index 743f5bf29..10871faec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/openeo/rest/auth/config.py b/openeo/rest/auth/config.py index 7a419214e..7355e0024 100644 --- a/openeo/rest/auth/config.py +++ b/openeo/rest/auth/config.py @@ -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, diff --git a/tests/rest/auth/test_config.py b/tests/rest/auth/test_config.py index cc683dfe5..3b07508c5 100644 --- a/tests/rest/auth/test_config.py +++ b/tests/rest/auth/test_config.py @@ -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" diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index 499a48916..57c11bd9c 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -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 ( @@ -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"], [