Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
26422dc
add jwt conformance to DummyBackend
niebl Feb 5, 2026
cde00d6
add further conformance
niebl Feb 6, 2026
b0a66a5
add conformance checking to basic auth
niebl Feb 6, 2026
d4d5dad
fix has_conformance
niebl Feb 6, 2026
1e75abe
add jwt conformant bearer token support to oidc auth
niebl Feb 6, 2026
6566959
Add tests for jwt conformance
niebl Feb 9, 2026
208b729
add tests for basic authentication
niebl Feb 9, 2026
39958bf
fix bearer token formatting
niebl Feb 9, 2026
65eff77
fix basic auth test
niebl Feb 9, 2026
4273d74
refactor requests_mock
niebl Feb 9, 2026
84a82ab
use comparableVersion for cofnormance determination
niebl Feb 9, 2026
ce742e3
Update openeo/rest/auth/auth.py
niebl Feb 10, 2026
d05271f
Update openeo/rest/_testing.py
niebl Feb 10, 2026
5166eda
Update openeo/rest/_testing.py
niebl Feb 10, 2026
9884bf8
refactor to use get
niebl Feb 10, 2026
299a079
indentation
niebl Feb 10, 2026
71a4503
refactor conformance string
niebl Feb 10, 2026
d8dda41
fix: OidcBearerAuth
niebl Feb 10, 2026
7107497
Update tests/rest/test_connection.py
niebl Feb 10, 2026
d925094
use re in has_conformance
niebl Feb 10, 2026
7a92e8f
add oidc tests for jwt bearer token
niebl Feb 10, 2026
9317759
Apply suggestion from @m-mohr
niebl Feb 10, 2026
dc89970
line breaks
niebl Feb 10, 2026
6040a9c
Update openeo/rest/_testing.py
niebl Feb 18, 2026
8b72876
bump stack version in build conformance
niebl Feb 18, 2026
85ff1b0
keep bearer auth simple
niebl Feb 18, 2026
240c18d
formatting
niebl Feb 18, 2026
94fe914
change import location of jwt bearer uri template
niebl Feb 18, 2026
e6230a3
revert test_testing.py to af55fd68312c6e0b1bf7c819d576d7d0ec32e959
niebl Feb 18, 2026
c2c5766
re-add tests for jwt conformance
niebl Feb 18, 2026
82c00f4
parametrize basic auth tests
niebl Feb 18, 2026
1d5c20f
parametrize oidc tests
niebl Feb 18, 2026
8f960e8
use compiled regex to for has_conformance
niebl Feb 18, 2026
33641c4
wip: fix parametrized tests
niebl Mar 2, 2026
f943691
Fix parsing tokens
m-mohr Mar 2, 2026
9a810c1
Add origin of token to BearerAuth class
m-mohr Mar 2, 2026
a6d3cc2
fix: tests depending on api version number in get_me_handler
niebl Mar 2, 2026
4abf67e
fix: remaining oidc test
niebl Mar 2, 2026
7c02e85
fix: conformance check
niebl Mar 2, 2026
8ed91bf
fix: dummy backend conformance checking
niebl Mar 2, 2026
15bfc77
Merge branch 'master' into jwt
niebl Apr 8, 2026
63d607a
refactor: parametrization
niebl Apr 8, 2026
de28943
decouple api_version and jwt_conformance
niebl Apr 8, 2026
555613c
refactor: bearer token check
niebl Apr 9, 2026
cc4a2e5
revert dummy backend conformance tests
niebl Apr 9, 2026
473ede2
refactor: code review
niebl Apr 9, 2026
ed0fa58
remove unused code
niebl Apr 9, 2026
c0490dd
edit changelog
niebl Apr 9, 2026
897da28
refactor: use helper
niebl Apr 9, 2026
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
2 changes: 2 additions & 0 deletions openeo/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
niebl marked this conversation as resolved.
Outdated

class OpenEoClientException(BaseOpenEoException):
"""Base class for OpenEO client exceptions"""
pass
Expand Down
31 changes: 30 additions & 1 deletion openeo/rest/_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -189,6 +190,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)"""
Expand Down Expand Up @@ -424,6 +433,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",
Comment thread
niebl marked this conversation as resolved.
Outdated
) -> 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 ComparableVersion(api_version) >= ComparableVersion("1.3.0"):
conformance.append(f"https://api.openeo.org/{api_version}/authentication/jwt")
Comment thread
niebl marked this conversation as resolved.
return conformance


def build_capabilities(
*,
Expand Down Expand Up @@ -470,17 +493,23 @@ 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 = build_conformance(
api_version=api_version,
stac_version=stac_version
Comment thread
niebl marked this conversation as resolved.
Outdated
)

capabilities = {
"api_version": api_version,
"stac_version": stac_version,
"id": "dummy",
"title": "Dummy openEO back-end",
"description": "Dummy openeEO back-end",
"endpoints": endpoints,
"conformsTo": conformance,
"links": [],
}
return capabilities
13 changes: 9 additions & 4 deletions openeo/rest/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,17 @@ 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):
Comment thread
niebl marked this conversation as resolved.
Outdated
if not jwt_conformance:
access_token = "basic//{t}".format(t=access_token)
super().__init__(bearer=access_token)


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 not jwt_conformance:
access_token = "oidc/{p}/{t}".format(p=provider_id, t=access_token)
super().__init__(bearer=access_token)

10 changes: 10 additions & 0 deletions openeo/rest/capabilities.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Dict, List, Optional, Union
import re

from openeo.internal.jupyter import render_component
from openeo.rest.models import federation_extension
Expand Down Expand Up @@ -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, uri: str) -> bool:
"""Check if backend provides a given conformance string"""
uri = re.escape(uri).replace('\\*', '[^/]+')
for conformance_uri in self.capabilities.get("conformsTo", []):
if re.match(uri, conformance_uri):
return True
return False
Comment thread
niebl marked this conversation as resolved.
Outdated


def supports_endpoint(self, path: str, method="GET") -> bool:
"""Check if backend supports given endpoint"""
Expand Down
11 changes: 9 additions & 2 deletions openeo/rest/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from openeo.metadata import CollectionMetadata
from openeo.rest import (
DEFAULT_DOWNLOAD_CHUNK_SIZE,
CONFORMANCE_JWT_BEARER,
CapabilitiesException,
OpenEoApiError,
OpenEoClientException,
Expand Down Expand Up @@ -277,8 +278,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(CONFORMANCE_JWT_BEARER)

# 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(
Expand Down Expand Up @@ -416,7 +421,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(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

Expand Down
6 changes: 6 additions & 0 deletions tests/rest/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 50 additions & 5 deletions tests/rest/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
]
Comment thread
niebl marked this conversation as resolved.
Outdated


GEOJSON_POINT_01 = {"type": "Point", "coordinates": [3, 52]}
Expand Down Expand Up @@ -407,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/"},
Expand Down Expand Up @@ -462,8 +464,8 @@ def test_connect_with_session():
],
"https://oeo.test/openeo/1.1.0/",
"1.1.0",
),
(
),
(
[
{
"api_version": "0.1.0",
Expand Down Expand Up @@ -860,6 +862,19 @@ 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=build_capabilities(api_version="1.3.0"))

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") == True
assert conn.auth.bearer == "6cc3570k3n"

@pytest.mark.slow
def test_authenticate_oidc_authorization_code_100_single_implicit(requests_mock, caplog):
requests_mock.get(API_URL, json={"api_version": "1.0.0"})
Expand Down Expand Up @@ -1049,6 +1064,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 == []
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of duplicating tests for JWT conformace mode (like this single test_authenticate_oidc_auth_code_pkce_flow), I think we should look instead into parameterizing all existing tests here.

e.g. there are 41 occurrences of assert.*bearer.*oidc/ in this file, so there is a lot of test coverage that should be ported to JWT conformance mode (as it is meant to become the default/recommended approach)

Copy link
Copy Markdown
Author

@niebl niebl Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @soxofaan,
that's a lot of test coverage.
1d5c20f now uses both 1.0.0 and 1.3.0 versions as parameters.

There are still five failing tests under version 1.3.0, namely the tests from test_authenticate_oidc_auto_renew_expired_access_token_invalid_refresh_token to test_authenticate_oidc_auto_renew_expired_access_token_initial_device_code

All of these have in common that they use the get_me_handler to extract access token and oidc_provider from the bearer token. This could be changed to extract the oicd provider from the iss issuer-attribute in the jwt, but afaik that is not yet included in the example bearer tokens and would need to be implemented first.

Should we change those parts of the code accordingly so the get_me_handler can also pick the oidc_provider from he token?


def test_authenticate_oidc_client_credentials(requests_mock):
requests_mock.get(API_URL, json={"api_version": "1.0.0"})
Expand Down
69 changes: 43 additions & 26 deletions tests/rest/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,54 +7,57 @@


@pytest.fixture
def dummy_backend(requests_mock, con120):
def dummy_backend120(requests_mock, con120):
Comment thread
niebl marked this conversation as resolved.
Outdated
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},
}


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}},
"status": "created",
}
}

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"
Expand All @@ -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"]:
Expand All @@ -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