From 26422dc5f658be3e6074a2ee391f22a374db717a Mon Sep 17 00:00:00 2001 From: niebl Date: Thu, 5 Feb 2026 17:48:04 +0100 Subject: [PATCH 01/23] add jwt conformance to DummyBackend --- openeo/rest/_testing.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index 998874551..fe8fbb89b 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -474,6 +474,8 @@ def build_capabilities( ] ) + conformance = ["https://api.openeo.org/1.3.0/authentication/jwt"] + capabilities = { "api_version": api_version, "stac_version": stac_version, @@ -481,6 +483,7 @@ def build_capabilities( "title": "Dummy openEO back-end", "description": "Dummy openeEO back-end", "endpoints": endpoints, + "conformsTo": conformance, "links": [], } return capabilities From cde00d63134a98714ec46b4c3c78e9fab3bbebe0 Mon Sep 17 00:00:00 2001 From: niebl Date: Fri, 6 Feb 2026 11:49:16 +0100 Subject: [PATCH 02/23] add further conformance --- openeo/rest/_testing.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index fe8fbb89b..83a1ae9eb 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -470,11 +470,17 @@ def build_capabilities( endpoints.extend( [ {"path": "/process_graphs", "methods": ["GET"]}, - {"path": "/process_graphs/{process_graph_id", "methods": ["GET", "PUT", "DELETE"]}, + {"path": "/process_graphs/{process_graph_id}", "methods": ["GET", "PUT", "DELETE"]}, ] ) - conformance = ["https://api.openeo.org/1.3.0/authentication/jwt"] + conformance = [ + "https://api.openeo.org/{api_version}", + "https://api.stacspec.org/v{stac_version}/core", + "https://api.stacspec.org/v{stac_version}/collections" + ] + if api_version == "1.3.0": #might need a way to compare version numbers via greater than + conformance.append("https://api.openeo.org/1.3.0/authentication/jwt") capabilities = { "api_version": api_version, From b0a66a510694f01254810b8d89ef7fcf7927d9b6 Mon Sep 17 00:00:00 2001 From: niebl Date: Fri, 6 Feb 2026 12:50:07 +0100 Subject: [PATCH 03/23] add conformance checking to basic auth --- openeo/rest/_testing.py | 35 ++++++++++++++++++++++++++++------- openeo/rest/auth/auth.py | 16 ++++++++++++---- openeo/rest/capabilities.py | 10 ++++++++++ openeo/rest/connection.py | 6 +++++- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index 83a1ae9eb..bc920ab5e 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -189,6 +189,14 @@ def setup_file_format(self, name: str, type: str = "output", gis_data_types: Ite } self._requests_mock.get(self.connection.build_url("/file_formats"), json=self.file_formats) return self + + def _get_conformance(self, request, context): + return { + "conformsTo": build_conformance( + api_version="1.3.0", + stac_version="1.0.0" + ) + } def _handle_post_result(self, request, context): """handler of `POST /result` (synchronous execute)""" @@ -424,6 +432,20 @@ def get_status(job_id: str, current_status: str) -> str: self.job_status_updater = get_status +def build_conformance( + *, + api_version: str = "1.0.0", + stac_version: str = "0.9.0", +) -> list[str]: + conformance = [ + "https://api.openeo.org/{api_version}", + "https://api.stacspec.org/v{stac_version}/core", + "https://api.stacspec.org/v{stac_version}/collections" + ] + if api_version == "1.3.0": #TODO: use ComparableVersion + conformance.append("https://api.openeo.org/1.3.0/authentication/jwt") + return conformance + def build_capabilities( *, @@ -441,6 +463,8 @@ def build_capabilities( """Build a dummy capabilities document for testing purposes.""" endpoints = [] + if basic_auth: + endpoints.append({"path": "/conformance", "methods": ["GET"]}) if basic_auth: endpoints.append({"path": "/credentials/basic", "methods": ["GET"]}) if oidc_auth: @@ -474,13 +498,10 @@ def build_capabilities( ] ) - conformance = [ - "https://api.openeo.org/{api_version}", - "https://api.stacspec.org/v{stac_version}/core", - "https://api.stacspec.org/v{stac_version}/collections" - ] - if api_version == "1.3.0": #might need a way to compare version numbers via greater than - conformance.append("https://api.openeo.org/1.3.0/authentication/jwt") + conformance = build_conformance( + api_version=api_version, + stac_version=stac_version + ) capabilities = { "api_version": api_version, diff --git a/openeo/rest/auth/auth.py b/openeo/rest/auth/auth.py index 378fbdbc2..9d47edb7b 100644 --- a/openeo/rest/auth/auth.py +++ b/openeo/rest/auth/auth.py @@ -41,12 +41,20 @@ def __call__(self, req: Request) -> Request: class BasicBearerAuth(BearerAuth): """Bearer token for Basic Auth (openEO API 1.0.0 style)""" - def __init__(self, access_token: str): - super().__init__(bearer="basic//{t}".format(t=access_token)) + def __init__(self, access_token: str, jwt_conformance: bool = False): + if jwt_conformance: + bearer="{t}" + else: + bearer = "basic//{t}".format(t=access_token) + super().__init__(bearer=bearer) class OidcBearerAuth(BearerAuth): """Bearer token for OIDC Auth (openEO API 1.0.0 style)""" - def __init__(self, provider_id: str, access_token: str): - super().__init__(bearer="oidc/{p}/{t}".format(p=provider_id, t=access_token)) + def __init__(self, provider_id: str, access_token: str, jwt_conformance: bool = False): + if jwt_conformance: + bearer="{t}" + else: + bearer="oidc/{p}/{t}".format(p=provider_id, t=access_token) + super().__init__(bearer=bearer) diff --git a/openeo/rest/capabilities.py b/openeo/rest/capabilities.py index 768093f6f..cb672d79e 100644 --- a/openeo/rest/capabilities.py +++ b/openeo/rest/capabilities.py @@ -1,4 +1,5 @@ from typing import Dict, List, Optional, Union +from fnmatch import fnmatch from openeo.internal.jupyter import render_component from openeo.rest.models import federation_extension @@ -36,6 +37,15 @@ def api_version_check(self) -> ComparableVersion: if not api_version: raise ApiVersionException("No API version found") return ComparableVersion(api_version) + + def has_conformance(self, conformance: str) -> bool: + """Check if backend provides a given conformance string""" + if "conformsTo" in self.capabilities: + for url in conformsTo: + if fnmatch(url, conformance): + return True + return False + def supports_endpoint(self, path: str, method="GET") -> bool: """Check if backend supports given endpoint""" diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index d4d4d5995..b09a52196 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -277,8 +277,12 @@ def authenticate_basic(self, username: Optional[str] = None, password: Optional[ # /credentials/basic is the only endpoint that expects a Basic HTTP auth auth=HTTPBasicAuth(username, password) ).json() + + # check for JWT bearer token conformance + jwt_conformance = self.capabilities().has_conformance("https://api.openeo.org/*/authentication/jwt") + # Switch to bearer based authentication in further requests. - self.auth = BasicBearerAuth(access_token=resp["access_token"]) + self.auth = BasicBearerAuth(access_token=resp["access_token"], jwt_conformance = jwt_conformance) return self def _get_oidc_provider( From d4d5dad8334327fb60d60382ed2ccfd7156ad234 Mon Sep 17 00:00:00 2001 From: niebl Date: Fri, 6 Feb 2026 15:54:30 +0100 Subject: [PATCH 04/23] fix has_conformance --- openeo/rest/capabilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openeo/rest/capabilities.py b/openeo/rest/capabilities.py index cb672d79e..bf59649de 100644 --- a/openeo/rest/capabilities.py +++ b/openeo/rest/capabilities.py @@ -41,7 +41,7 @@ def api_version_check(self) -> ComparableVersion: def has_conformance(self, conformance: str) -> bool: """Check if backend provides a given conformance string""" if "conformsTo" in self.capabilities: - for url in conformsTo: + for url in self.capabilities["conformsTo"]: if fnmatch(url, conformance): return True return False From 1e75abe05baec31e9eb8d3b9fd7c61930375dfff Mon Sep 17 00:00:00 2001 From: niebl Date: Fri, 6 Feb 2026 16:53:33 +0100 Subject: [PATCH 05/23] add jwt conformant bearer token support to oidc auth --- openeo/rest/connection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index b09a52196..199ca9735 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -420,7 +420,9 @@ def _authenticate_oidc( ) token = tokens.access_token - self.auth = OidcBearerAuth(provider_id=provider_id, access_token=token) + # check for JWT bearer token conformance + jwt_conformance = self.capabilities().has_conformance("https://api.openeo.org/*/authentication/jwt") + self.auth = OidcBearerAuth(provider_id=provider_id, access_token=token, jwt_conformance=jwt_conformance) self._oidc_auth_renewer = oidc_auth_renewer return self From 6566959ccc681ccacc35affe8d4076e9637813f2 Mon Sep 17 00:00:00 2001 From: niebl Date: Mon, 9 Feb 2026 11:33:23 +0100 Subject: [PATCH 06/23] Add tests for jwt conformance --- tests/rest/conftest.py | 6 ++++ tests/rest/test_testing.py | 69 ++++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/tests/rest/conftest.py b/tests/rest/conftest.py index 2255cca85..411c82e1e 100644 --- a/tests/rest/conftest.py +++ b/tests/rest/conftest.py @@ -99,6 +99,12 @@ def con120(requests_mock, api_capabilities): con = Connection(API_URL) return con +@pytest.fixture +def con130(requests_mock, api_capabilities): + requests_mock.get(API_URL, json=build_capabilities(api_version="1.3.0", **api_capabilities)) + con = Connection(API_URL) + return con + @pytest.fixture def dummy_backend(requests_mock, con120) -> DummyBackend: diff --git a/tests/rest/test_testing.py b/tests/rest/test_testing.py index 589dda3dc..7b11ecd22 100644 --- a/tests/rest/test_testing.py +++ b/tests/rest/test_testing.py @@ -7,9 +7,12 @@ @pytest.fixture -def dummy_backend(requests_mock, con120): +def dummy_backend120(requests_mock, con120): return DummyBackend(requests_mock=requests_mock, connection=con120) +@pytest.fixture +def dummy_backend130(requests_mock, con130): + return DummyBackend(requests_mock=requests_mock, connection=con130) DUMMY_PG_ADD35 = { "add35": {"process_id": "add", "arguments": {"x": 3, "y": 5}, "result": True}, @@ -17,10 +20,10 @@ def dummy_backend(requests_mock, con120): class TestDummyBackend: - def test_create_job(self, dummy_backend, con120): - assert dummy_backend.batch_jobs == {} + def test_create_job(self, dummy_backend120, con120): + assert dummy_backend120.batch_jobs == {} _ = con120.create_job(DUMMY_PG_ADD35) - assert dummy_backend.batch_jobs == { + assert dummy_backend120.batch_jobs == { "job-000": { "job_id": "job-000", "pg": {"add35": {"process_id": "add", "arguments": {"x": 3, "y": 5}, "result": True}}, @@ -28,33 +31,33 @@ def test_create_job(self, dummy_backend, con120): } } - def test_start_job(self, dummy_backend, con120): + def test_start_job(self, dummy_backend120, con120): job = con120.create_job(DUMMY_PG_ADD35) - assert dummy_backend.batch_jobs == { + assert dummy_backend120.batch_jobs == { "job-000": {"job_id": "job-000", "pg": DUMMY_PG_ADD35, "status": "created"}, } job.start() - assert dummy_backend.batch_jobs == { + assert dummy_backend120.batch_jobs == { "job-000": {"job_id": "job-000", "pg": DUMMY_PG_ADD35, "status": "finished"}, } - def test_job_status_updater_error(self, dummy_backend, con120): - dummy_backend.job_status_updater = lambda job_id, current_status: "error" + def test_job_status_updater_error(self, dummy_backend120, con120): + dummy_backend120.job_status_updater = lambda job_id, current_status: "error" job = con120.create_job(DUMMY_PG_ADD35) - assert dummy_backend.batch_jobs["job-000"]["status"] == "created" + assert dummy_backend120.batch_jobs["job-000"]["status"] == "created" job.start() - assert dummy_backend.batch_jobs["job-000"]["status"] == "error" + assert dummy_backend120.batch_jobs["job-000"]["status"] == "error" @pytest.mark.parametrize("final", ["finished", "error"]) - def test_setup_simple_job_status_flow(self, dummy_backend, con120, final): - dummy_backend.setup_simple_job_status_flow(queued=2, running=3, final=final) + def test_setup_simple_job_status_flow(self, dummy_backend120, con120, final): + dummy_backend120.setup_simple_job_status_flow(queued=2, running=3, final=final) job = con120.create_job(DUMMY_PG_ADD35) - assert dummy_backend.batch_jobs["job-000"]["status"] == "created" + assert dummy_backend120.batch_jobs["job-000"]["status"] == "created" # Note that first status update (to "queued" here) is triggered from `start()`, not `status()` like below job.start() - assert dummy_backend.batch_jobs["job-000"]["status"] == "queued" + assert dummy_backend120.batch_jobs["job-000"]["status"] == "queued" # Now go through rest of status flow, through `status()` calls assert job.status() == "queued" @@ -66,25 +69,25 @@ def test_setup_simple_job_status_flow(self, dummy_backend, con120, final): assert job.status() == final assert job.status() == final - def test_setup_simple_job_status_flow_final_per_job(self, dummy_backend, con120): + def test_setup_simple_job_status_flow_final_per_job(self, dummy_backend120, con120): """Test per-job specific final status""" - dummy_backend.setup_simple_job_status_flow( + dummy_backend120.setup_simple_job_status_flow( queued=2, running=3, final="finished", final_per_job={"job-001": "error"} ) job0 = con120.create_job(DUMMY_PG_ADD35) job1 = con120.create_job(DUMMY_PG_ADD35) job2 = con120.create_job(DUMMY_PG_ADD35) - assert dummy_backend.batch_jobs["job-000"]["status"] == "created" - assert dummy_backend.batch_jobs["job-001"]["status"] == "created" - assert dummy_backend.batch_jobs["job-002"]["status"] == "created" + assert dummy_backend120.batch_jobs["job-000"]["status"] == "created" + assert dummy_backend120.batch_jobs["job-001"]["status"] == "created" + assert dummy_backend120.batch_jobs["job-002"]["status"] == "created" # Note that first status update (to "queued" here) is triggered from `start()`, not `status()` like below job0.start() job1.start() job2.start() - assert dummy_backend.batch_jobs["job-000"]["status"] == "queued" - assert dummy_backend.batch_jobs["job-001"]["status"] == "queued" - assert dummy_backend.batch_jobs["job-002"]["status"] == "queued" + assert dummy_backend120.batch_jobs["job-000"]["status"] == "queued" + assert dummy_backend120.batch_jobs["job-001"]["status"] == "queued" + assert dummy_backend120.batch_jobs["job-002"]["status"] == "queued" # Now go through rest of status flow, through `status()` calls for expected_status in ["queued", "running", "running", "running"]: @@ -98,9 +101,23 @@ def test_setup_simple_job_status_flow_final_per_job(self, dummy_backend, con120) assert job1.status() == "error" assert job2.status() == "finished" - def test_setup_job_start_failure(self, dummy_backend): - job = dummy_backend.connection.create_job(process_graph={}) - dummy_backend.setup_job_start_failure() + def test_setup_job_start_failure(self, dummy_backend120): + job = dummy_backend120.connection.create_job(process_graph={}) + dummy_backend120.setup_job_start_failure() with pytest.raises(OpenEoApiError, match=re.escape("[500] Internal: No job starting for you, buddy")): job.start() assert job.status() == "error" + + def test_version(self, dummy_backend120, dummy_backend130): + capabilities120 = dummy_backend120.connection.capabilities() + capabilities130 = dummy_backend130.connection.capabilities() + + assert capabilities120.api_version() == "1.2.0" + assert capabilities130.api_version() == "1.3.0" + + def test_jwt_conformance(self, dummy_backend120, dummy_backend130): + capabilities120 = dummy_backend120.connection.capabilities() + capabilities130 = dummy_backend130.connection.capabilities() + + assert capabilities120.has_conformance("https://api.openeo.org/*/authentication/jwt") == False + assert capabilities130.has_conformance("https://api.openeo.org/*/authentication/jwt") == True \ No newline at end of file From 208b72996648bb9e08b98400f5572dd46df3ad2f Mon Sep 17 00:00:00 2001 From: niebl Date: Mon, 9 Feb 2026 14:01:03 +0100 Subject: [PATCH 07/23] add tests for basic authentication --- tests/rest/test_connection.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index 6da731f71..b3b01a89d 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -59,7 +59,9 @@ API_URL = "https://oeo.test/" # TODO: eliminate this and replace with `build_capabilities` usage -BASIC_ENDPOINTS = [{"path": "/credentials/basic", "methods": ["GET"]}] +BASIC_ENDPOINTS = [ + {"path": "/credentials/basic", "methods": ["GET"]} + ] GEOJSON_POINT_01 = {"type": "Point", "coordinates": [3, 52]} @@ -848,7 +850,6 @@ def test_authenticate_basic(requests_mock, api_version, basic_auth): assert isinstance(conn.auth, BearerAuth) assert conn.auth.bearer == "basic//6cc3570k3n" - def test_authenticate_basic_from_config(requests_mock, api_version, auth_config, basic_auth): requests_mock.get(API_URL, json={"api_version": api_version, "endpoints": BASIC_ENDPOINTS}) auth_config.set_basic_auth(backend=API_URL, username=basic_auth.username, password=basic_auth.password) @@ -859,6 +860,17 @@ def test_authenticate_basic_from_config(requests_mock, api_version, auth_config, assert isinstance(conn.auth, BearerAuth) assert conn.auth.bearer == "basic//6cc3570k3n" +def test_authenticate_basic_jwt_bearer(requests_mock, basic_auth): + requests_mock.get(API_URL, json={"api_version": "1.3.0", "endpoints": BASIC_ENDPOINTS}) + + conn = Connection(API_URL) + assert isinstance(conn.auth, NullAuth) + conn.authenticate_basic(username=basic_auth.username, password=basic_auth.password) + capabilities = conn.capabilities() + assert isinstance(conn.auth, BearerAuth) + assert capabilities.api_version() == "1.3.0" + assert capabilities.has_conformance("https://api.openeo.org/*/authentication/jwt") == "1.3.0" + assert conn.auth.bearer == "6cc3570k3n" @pytest.mark.slow def test_authenticate_oidc_authorization_code_100_single_implicit(requests_mock, caplog): From 39958bf1ccb338324b3a3c53e89f15b2683f6de7 Mon Sep 17 00:00:00 2001 From: niebl Date: Mon, 9 Feb 2026 14:36:08 +0100 Subject: [PATCH 08/23] fix bearer token formatting --- openeo/rest/auth/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openeo/rest/auth/auth.py b/openeo/rest/auth/auth.py index 9d47edb7b..d0f0cb766 100644 --- a/openeo/rest/auth/auth.py +++ b/openeo/rest/auth/auth.py @@ -42,8 +42,9 @@ class BasicBearerAuth(BearerAuth): """Bearer token for Basic Auth (openEO API 1.0.0 style)""" def __init__(self, access_token: str, jwt_conformance: bool = False): + bearer = False if jwt_conformance: - bearer="{t}" + bearer= "{t}".format(t=access_token) else: bearer = "basic//{t}".format(t=access_token) super().__init__(bearer=bearer) From 65eff7732546262426948fc6e9a2415a99b4631c Mon Sep 17 00:00:00 2001 From: niebl Date: Mon, 9 Feb 2026 15:32:52 +0100 Subject: [PATCH 09/23] fix basic auth test --- tests/rest/test_connection.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index b3b01a89d..b3f430321 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -861,15 +861,21 @@ def test_authenticate_basic_from_config(requests_mock, api_version, auth_config, assert conn.auth.bearer == "basic//6cc3570k3n" def test_authenticate_basic_jwt_bearer(requests_mock, basic_auth): - requests_mock.get(API_URL, json={"api_version": "1.3.0", "endpoints": BASIC_ENDPOINTS}) + requests_mock.get(API_URL, json={ + "api_version": "1.3.0", + "endpoints": BASIC_ENDPOINTS, + "conformsTo": ["https://api.openeo.org/1.3.0/authentication/jwt"] + } + ) conn = Connection(API_URL) + assert isinstance(conn.auth, NullAuth) conn.authenticate_basic(username=basic_auth.username, password=basic_auth.password) capabilities = conn.capabilities() assert isinstance(conn.auth, BearerAuth) assert capabilities.api_version() == "1.3.0" - assert capabilities.has_conformance("https://api.openeo.org/*/authentication/jwt") == "1.3.0" + assert capabilities.has_conformance("https://api.openeo.org/*/authentication/jwt") == True assert conn.auth.bearer == "6cc3570k3n" @pytest.mark.slow From 4273d74ffc404d208d07db9b80af1b33c907dd95 Mon Sep 17 00:00:00 2001 From: niebl Date: Mon, 9 Feb 2026 15:43:52 +0100 Subject: [PATCH 10/23] refactor requests_mock --- tests/rest/test_connection.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index b3f430321..3acc8ecfb 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -861,12 +861,7 @@ def test_authenticate_basic_from_config(requests_mock, api_version, auth_config, assert conn.auth.bearer == "basic//6cc3570k3n" def test_authenticate_basic_jwt_bearer(requests_mock, basic_auth): - requests_mock.get(API_URL, json={ - "api_version": "1.3.0", - "endpoints": BASIC_ENDPOINTS, - "conformsTo": ["https://api.openeo.org/1.3.0/authentication/jwt"] - } - ) + requests_mock.get(API_URL, json=build_capabilities(api_version="1.3.0")) conn = Connection(API_URL) @@ -903,7 +898,6 @@ def test_authenticate_oidc_authorization_code_100_single_implicit(requests_mock, assert conn.auth.bearer == 'oidc/fauth/' + oidc_mock.state["access_token"] assert "No OIDC provider given, but only one available: 'fauth'. Using that one." in caplog.text - def test_authenticate_oidc_authorization_code_100_single_wrong_id(requests_mock): requests_mock.get(API_URL, json={"api_version": "1.0.0"}) client_id = "myclient" From 84a82abad483edd6be34808ce610fa53ed099d71 Mon Sep 17 00:00:00 2001 From: niebl Date: Mon, 9 Feb 2026 15:47:30 +0100 Subject: [PATCH 11/23] use comparableVersion for cofnormance determination --- openeo/rest/_testing.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index bc920ab5e..70ffba406 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -14,6 +14,7 @@ Union, ) +from openeo.utils.version import ComparableVersion from openeo import Connection, DataCube from openeo.rest.vectorcube import VectorCube from openeo.utils.http import HTTP_201_CREATED, HTTP_202_ACCEPTED, HTTP_204_NO_CONTENT @@ -442,8 +443,8 @@ def build_conformance( "https://api.stacspec.org/v{stac_version}/core", "https://api.stacspec.org/v{stac_version}/collections" ] - if api_version == "1.3.0": #TODO: use ComparableVersion - conformance.append("https://api.openeo.org/1.3.0/authentication/jwt") + if ComparableVersion(api_version) >= ComparableVersion("1.3.0"): + conformance.append(f"https://api.openeo.org/{api_version}/authentication/jwt") return conformance From ce742e3ef63a4a1b9b89c69013506467b6b11f34 Mon Sep 17 00:00:00 2001 From: Caro Niebl Date: Tue, 10 Feb 2026 09:20:35 +0100 Subject: [PATCH 12/23] Update openeo/rest/auth/auth.py Co-authored-by: Matthias Mohr --- openeo/rest/auth/auth.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openeo/rest/auth/auth.py b/openeo/rest/auth/auth.py index d0f0cb766..4c37a69dc 100644 --- a/openeo/rest/auth/auth.py +++ b/openeo/rest/auth/auth.py @@ -42,12 +42,9 @@ class BasicBearerAuth(BearerAuth): """Bearer token for Basic Auth (openEO API 1.0.0 style)""" def __init__(self, access_token: str, jwt_conformance: bool = False): - bearer = False - if jwt_conformance: - bearer= "{t}".format(t=access_token) - else: - bearer = "basic//{t}".format(t=access_token) - super().__init__(bearer=bearer) + if not jwt_conformance: + access_token = "basic//{t}".format(t=access_token) + super().__init__(bearer=access_token) class OidcBearerAuth(BearerAuth): From d05271faa2c53ad24d088744b6fa78781c8042f4 Mon Sep 17 00:00:00 2001 From: Caro Niebl Date: Tue, 10 Feb 2026 10:31:22 +0100 Subject: [PATCH 13/23] Update openeo/rest/_testing.py Co-authored-by: Matthias Mohr --- openeo/rest/_testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index 70ffba406..fd9c7bf48 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -442,7 +442,7 @@ def build_conformance( "https://api.openeo.org/{api_version}", "https://api.stacspec.org/v{stac_version}/core", "https://api.stacspec.org/v{stac_version}/collections" - ] + ] if ComparableVersion(api_version) >= ComparableVersion("1.3.0"): conformance.append(f"https://api.openeo.org/{api_version}/authentication/jwt") return conformance From 5166edaf1804d20a994776331bbe4644e0c52d63 Mon Sep 17 00:00:00 2001 From: Caro Niebl Date: Tue, 10 Feb 2026 10:37:22 +0100 Subject: [PATCH 14/23] Update openeo/rest/_testing.py Co-authored-by: Matthias Mohr --- openeo/rest/_testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index fd9c7bf48..a40c810bd 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -502,7 +502,7 @@ def build_capabilities( conformance = build_conformance( api_version=api_version, stac_version=stac_version - ) + ) capabilities = { "api_version": api_version, From 9884bf8c4d4f9b9191c2e2a193af0fcbaacfba7e Mon Sep 17 00:00:00 2001 From: niebl Date: Tue, 10 Feb 2026 10:36:46 +0100 Subject: [PATCH 15/23] refactor to use get --- openeo/rest/capabilities.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openeo/rest/capabilities.py b/openeo/rest/capabilities.py index bf59649de..4dbc8cdf2 100644 --- a/openeo/rest/capabilities.py +++ b/openeo/rest/capabilities.py @@ -40,10 +40,9 @@ def api_version_check(self) -> ComparableVersion: def has_conformance(self, conformance: str) -> bool: """Check if backend provides a given conformance string""" - if "conformsTo" in self.capabilities: - for url in self.capabilities["conformsTo"]: - if fnmatch(url, conformance): - return True + for url in self.capabilities.get("conformsTo", []): + if fnmatch(url, conformance): + return True return False From 299a079a7cb9f76cc17be0fdf15ac6a148bc4a84 Mon Sep 17 00:00:00 2001 From: niebl Date: Tue, 10 Feb 2026 11:24:34 +0100 Subject: [PATCH 16/23] indentation --- tests/rest/test_connection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index 3acc8ecfb..e9269f51c 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -409,8 +409,8 @@ def test_connect_with_session(): ], "https://oeo.test/openeo/1.1.0/", "1.1.0", - ), - ( + ), + ( [ {"api_version": "0.4.1", "url": "https://oeo.test/openeo/0.4.1/"}, {"api_version": "1.0.0", "url": "https://oeo.test/openeo/1.0.0/"}, @@ -464,8 +464,8 @@ def test_connect_with_session(): ], "https://oeo.test/openeo/1.1.0/", "1.1.0", - ), - ( + ), + ( [ { "api_version": "0.1.0", From 71a4503c7f418475fa507d2630cf98b43697a7ba Mon Sep 17 00:00:00 2001 From: niebl Date: Tue, 10 Feb 2026 11:24:56 +0100 Subject: [PATCH 17/23] refactor conformance string --- openeo/rest/__init__.py | 2 ++ openeo/rest/connection.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/openeo/rest/__init__.py b/openeo/rest/__init__.py index 37b3a8170..dac500c00 100644 --- a/openeo/rest/__init__.py +++ b/openeo/rest/__init__.py @@ -10,6 +10,8 @@ DEFAULT_JOB_STATUS_POLL_CONNECTION_RETRY_INTERVAL = 30 DEFAULT_JOB_STATUS_POLL_SOFT_ERROR_MAX = 10 +CONFORMANCE_JWT_BEARER = "https://api.openeo.org/*/authentication/jwt" + class OpenEoClientException(BaseOpenEoException): """Base class for OpenEO client exceptions""" pass diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index 199ca9735..768841c9b 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -47,6 +47,7 @@ from openeo.metadata import CollectionMetadata from openeo.rest import ( DEFAULT_DOWNLOAD_CHUNK_SIZE, + CONFORMANCE_JWT_BEARER, CapabilitiesException, OpenEoApiError, OpenEoClientException, @@ -279,7 +280,7 @@ def authenticate_basic(self, username: Optional[str] = None, password: Optional[ ).json() # check for JWT bearer token conformance - jwt_conformance = self.capabilities().has_conformance("https://api.openeo.org/*/authentication/jwt") + jwt_conformance = self.capabilities().has_conformance(CONFORMANCE_JWT_BEARER) # Switch to bearer based authentication in further requests. self.auth = BasicBearerAuth(access_token=resp["access_token"], jwt_conformance = jwt_conformance) @@ -421,7 +422,7 @@ def _authenticate_oidc( token = tokens.access_token # check for JWT bearer token conformance - jwt_conformance = self.capabilities().has_conformance("https://api.openeo.org/*/authentication/jwt") + jwt_conformance = self.capabilities().has_conformance(CONFORMANCE_JWT_BEARER) self.auth = OidcBearerAuth(provider_id=provider_id, access_token=token, jwt_conformance=jwt_conformance) self._oidc_auth_renewer = oidc_auth_renewer return self From d8dda4114ab193ed2c243091d36f7fc19ed82df9 Mon Sep 17 00:00:00 2001 From: niebl Date: Tue, 10 Feb 2026 12:37:22 +0100 Subject: [PATCH 18/23] fix: OidcBearerAuth --- openeo/rest/auth/auth.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openeo/rest/auth/auth.py b/openeo/rest/auth/auth.py index 4c37a69dc..7a4684d2e 100644 --- a/openeo/rest/auth/auth.py +++ b/openeo/rest/auth/auth.py @@ -51,8 +51,7 @@ class OidcBearerAuth(BearerAuth): """Bearer token for OIDC Auth (openEO API 1.0.0 style)""" def __init__(self, provider_id: str, access_token: str, jwt_conformance: bool = False): - if jwt_conformance: - bearer="{t}" - else: - bearer="oidc/{p}/{t}".format(p=provider_id, t=access_token) - super().__init__(bearer=bearer) + if not jwt_conformance: + access_token = "oidc/{p}/{t}".format(p=provider_id, t=access_token) + super().__init__(bearer=access_token) + From 7107497e61beaa431186638394846e6b67a0ee41 Mon Sep 17 00:00:00 2001 From: Caro Niebl Date: Tue, 10 Feb 2026 12:40:36 +0100 Subject: [PATCH 19/23] Update tests/rest/test_connection.py Co-authored-by: Matthias Mohr --- tests/rest/test_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index e9269f51c..d62144e75 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -61,7 +61,7 @@ # TODO: eliminate this and replace with `build_capabilities` usage BASIC_ENDPOINTS = [ {"path": "/credentials/basic", "methods": ["GET"]} - ] +] GEOJSON_POINT_01 = {"type": "Point", "coordinates": [3, 52]} From d92509411c9011236c428cdcd8e86c83fbb07d2c Mon Sep 17 00:00:00 2001 From: niebl Date: Tue, 10 Feb 2026 13:03:40 +0100 Subject: [PATCH 20/23] use re in has_conformance --- openeo/rest/capabilities.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openeo/rest/capabilities.py b/openeo/rest/capabilities.py index 4dbc8cdf2..96062bd56 100644 --- a/openeo/rest/capabilities.py +++ b/openeo/rest/capabilities.py @@ -1,5 +1,5 @@ from typing import Dict, List, Optional, Union -from fnmatch import fnmatch +import re from openeo.internal.jupyter import render_component from openeo.rest.models import federation_extension @@ -38,10 +38,11 @@ def api_version_check(self) -> ComparableVersion: raise ApiVersionException("No API version found") return ComparableVersion(api_version) - def has_conformance(self, conformance: str) -> bool: + def has_conformance(self, uri: str) -> bool: """Check if backend provides a given conformance string""" - for url in self.capabilities.get("conformsTo", []): - if fnmatch(url, conformance): + uri = re.escape(uri).replace('\\*', '[^/]+') + for conformance_uri in self.capabilities.get("conformsTo", []): + if re.match(uri, conformance_uri): return True return False From 7a92e8fd83fc608afcea29a9297ecaa6c138b6bb Mon Sep 17 00:00:00 2001 From: niebl Date: Tue, 10 Feb 2026 13:37:34 +0100 Subject: [PATCH 21/23] add oidc tests for jwt bearer token --- tests/rest/test_connection.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index d62144e75..3e74a9eb0 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -1061,6 +1061,36 @@ def test_authenticate_oidc_auth_code_pkce_flow_client_from_config(requests_mock, assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] assert refresh_token_store.mock_calls == [] +@pytest.mark.slow +def test_authenticate_oidc_auth_code_pkce_flow_jwt_bearer(requests_mock, auth_config): + requests_mock.get(API_URL, json=build_capabilities(api_version="1.3.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_grant_type="authorization_code", + expected_client_id=client_id, + expected_fields={"scope": "openid"}, + oidc_issuer=issuer, + scopes_supported=["openid"], + ) + auth_config.set_oidc_client_config(backend=API_URL, provider_id="oi", client_id=client_id) + + # With all this set up, kick off the openid connect flow + refresh_token_store = mock.Mock() + conn = Connection(API_URL, refresh_token_store=refresh_token_store) + assert isinstance(conn.auth, NullAuth) + conn.authenticate_oidc_authorization_code(webbrowser_open=oidc_mock.webbrowser_open) + capabilities = conn.capabilities() + assert isinstance(conn.auth, BearerAuth) + assert capabilities.api_version() == "1.3.0" + assert capabilities.has_conformance("https://api.openeo.org/*/authentication/jwt") == True + assert conn.auth.bearer == oidc_mock.state["access_token"] + # TODO: check issuer ("iss") value in parsed jwt. this will require the example jwt to be formatted accordingly + assert refresh_token_store.mock_calls == [] def test_authenticate_oidc_client_credentials(requests_mock): requests_mock.get(API_URL, json={"api_version": "1.0.0"}) From 9317759329a8d430ad8c378916cc242f912293a9 Mon Sep 17 00:00:00 2001 From: Caro Niebl Date: Tue, 10 Feb 2026 14:56:29 +0100 Subject: [PATCH 22/23] Apply suggestion from @m-mohr Co-authored-by: Matthias Mohr --- openeo/rest/_testing.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index a40c810bd..f67179c81 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -464,8 +464,6 @@ def build_capabilities( """Build a dummy capabilities document for testing purposes.""" endpoints = [] - if basic_auth: - endpoints.append({"path": "/conformance", "methods": ["GET"]}) if basic_auth: endpoints.append({"path": "/credentials/basic", "methods": ["GET"]}) if oidc_auth: From dc899704ac1d38a86255c311adb3f0bf0dc92fbe Mon Sep 17 00:00:00 2001 From: niebl Date: Tue, 10 Feb 2026 15:04:41 +0100 Subject: [PATCH 23/23] line breaks --- tests/rest/test_connection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index 3e74a9eb0..d000dafa4 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -850,6 +850,7 @@ def test_authenticate_basic(requests_mock, api_version, basic_auth): assert isinstance(conn.auth, BearerAuth) assert conn.auth.bearer == "basic//6cc3570k3n" + def test_authenticate_basic_from_config(requests_mock, api_version, auth_config, basic_auth): requests_mock.get(API_URL, json={"api_version": api_version, "endpoints": BASIC_ENDPOINTS}) auth_config.set_basic_auth(backend=API_URL, username=basic_auth.username, password=basic_auth.password) @@ -860,6 +861,7 @@ def test_authenticate_basic_from_config(requests_mock, api_version, auth_config, assert isinstance(conn.auth, BearerAuth) assert conn.auth.bearer == "basic//6cc3570k3n" + def test_authenticate_basic_jwt_bearer(requests_mock, basic_auth): requests_mock.get(API_URL, json=build_capabilities(api_version="1.3.0")) @@ -898,6 +900,7 @@ def test_authenticate_oidc_authorization_code_100_single_implicit(requests_mock, assert conn.auth.bearer == 'oidc/fauth/' + oidc_mock.state["access_token"] assert "No OIDC provider given, but only one available: 'fauth'. Using that one." in caplog.text + def test_authenticate_oidc_authorization_code_100_single_wrong_id(requests_mock): requests_mock.get(API_URL, json={"api_version": "1.0.0"}) client_id = "myclient"