diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d431ee80..0769a20f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `has_conformance()` method in capabilities to check for JWT bearer token conformance +- Add Bearer token formatting check for JWT conformance in `authenticate_basic()` and `_authenticate_oidc()` methods of `Connection` class +- Add `build_conformance()` helper to build conformance dicts for testing +- Parametrize test cases for additional JWT bearer token use and conformance checking in `test_connection` + ### Changed +- `build_capabilities()` helper: add `conformance` argument to allow building capabilities with custom conformance classes for testing + ### Removed ### Fixed diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index b8343cb16..53ec53171 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -17,6 +17,7 @@ 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 +from openeo.utils.version import ComparableVersion OPENEO_BACKEND = "https://openeo.test/" @@ -424,6 +425,21 @@ 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 = "1.1.0", + jwt_auth: bool = False, # may be more dynamic to pass auth-strings in the future +) -> 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 jwt_auth: + conformance.append(f"https://api.openeo.org/{api_version}/authentication/jwt") + return conformance + def build_capabilities( *, @@ -436,6 +452,7 @@ def build_capabilities( processes: bool = True, sync_processing: bool = True, validation: bool = False, + token_type: str = "legacy", batch_jobs: bool = True, udp: bool = False, ) -> dict: @@ -473,10 +490,12 @@ 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, jwt_auth=(token_type == "jwt")) + capabilities = { "api_version": api_version, "stac_version": stac_version, @@ -484,6 +503,7 @@ def build_capabilities( "title": "Dummy openEO back-end", "description": "Dummy openeEO back-end", "endpoints": endpoints, + "conformsTo": conformance, "links": [], } return capabilities diff --git a/openeo/rest/capabilities.py b/openeo/rest/capabilities.py index 768093f6f..42504a4be 100644 --- a/openeo/rest/capabilities.py +++ b/openeo/rest/capabilities.py @@ -1,3 +1,4 @@ +import re from typing import Dict, List, Optional, Union from openeo.internal.jupyter import render_component @@ -7,6 +8,8 @@ __all__ = ["OpenEoCapabilities"] +CONFORMANCE_JWT_BEARER = re.compile(r"https://api\.openeo\.org/[^/]+/authentication/jwt") + class OpenEoCapabilities: """Container of the openEO capabilities document of an openEO backend.""" @@ -37,6 +40,13 @@ def api_version_check(self) -> ComparableVersion: 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""" + for conformance_uri in self.capabilities.get("conformsTo", []): + if re.fullmatch(uri, conformance_uri): + return True + return False + def supports_endpoint(self, path: str, method="GET") -> bool: """Check if backend supports given endpoint""" return any( diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index d4d4d5995..496fd5632 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -68,7 +68,7 @@ OidcRefreshTokenAuthenticator, OidcResourceOwnerPasswordAuthenticator, ) -from openeo.rest.capabilities import OpenEoCapabilities +from openeo.rest.capabilities import CONFORMANCE_JWT_BEARER, OpenEoCapabilities from openeo.rest.datacube import DataCube, InputDate from openeo.rest.graph_building import CollectionProperty from openeo.rest.job import BatchJob @@ -277,8 +277,15 @@ 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"]) + if jwt_conformance: + self.auth = BearerAuth(bearer=resp["access_token"]) + else: + self.auth = BasicBearerAuth(access_token=resp["access_token"]) return self def _get_oidc_provider( @@ -416,7 +423,12 @@ 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) + if jwt_conformance: + self.auth = BearerAuth(bearer=token) + else: + self.auth = OidcBearerAuth(provider_id=provider_id, access_token=token) self._oidc_auth_renewer = oidc_auth_renewer return self @@ -736,7 +748,7 @@ def try_access_token_refresh(self, *, reason: Optional[str] = None) -> bool: Returns whether a new access token was obtained. """ reason = f" Reason: {reason}" if reason else "" - if isinstance(self.auth, OidcBearerAuth) and self._oidc_auth_renewer: + if self._oidc_auth_renewer: try: self._authenticate_oidc( authenticator=self._oidc_auth_renewer, diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index 6da731f71..81266f5d2 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -39,6 +39,7 @@ from openeo.rest.auth.auth import BearerAuth, NullAuth from openeo.rest.auth.oidc import OidcException from openeo.rest.auth.testing import ABSENT, OidcMock, SimpleBasicAuthMocker +from openeo.rest.capabilities import CONFORMANCE_JWT_BEARER from openeo.rest.connection import ( DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_SYNCHRONOUS_EXECUTE, @@ -52,7 +53,7 @@ from openeo.rest.vectorcube import VectorCube from openeo.testing.stac import StacDummyBuilder from openeo.util import ContextTimer, deep_get, dict_no_none -from openeo.utils.version import ApiVersionException +from openeo.utils.version import ApiVersionException, ComparableVersion from .auth.test_cli import auth_config, refresh_token_store @@ -103,6 +104,20 @@ refresh_token_store = refresh_token_store +# override api-version for authentication tests +@pytest.fixture(params=["1.0.0", "1.3.0"]) +def api_version(request): + return request.param + + +# Token type fixture for testing legacy vs JWT authentication +# JWT is only tested with api_version 1.3.0+ +@pytest.fixture(params=["legacy", "jwt"]) +def token_type(request, api_version): + if request.param == "jwt" and api_version != "1.3.0": + pytest.skip("JWT is only supported with api_version >= 1.3.0") + return request.param + @pytest.mark.parametrize( ["base", "paths", "expected_path"], [ @@ -181,6 +196,13 @@ def test_rest_api_expected_status_with_error(requests_mock): conn.get("/bar", check_error=False, expected_status=[302, 303]) +def generate_bearer_token(token_type, access_token, prefix="oidc/fauth/"): + if token_type == "jwt": + return access_token + else: + return f"{prefix}{access_token}" + + @pytest.mark.parametrize( ["status_code", "text", "expected"], [ @@ -407,8 +429,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/"}, @@ -462,8 +484,8 @@ def test_connect_with_session(): ], "https://oeo.test/openeo/1.1.0/", "1.1.0", - ), - ( + ), + ( [ { "api_version": "0.1.0", @@ -768,8 +790,18 @@ def test_create_connection_lazy_refresh_token_store(requests_mock): ) -def test_list_auth_providers(requests_mock, api_version): - requests_mock.get(API_URL, json=build_capabilities(api_version=api_version)) +def assert_provider(providers, provider_id, expected_type, expected_issuer, expected_title): + # helper for test_list_auth_providers + provider = next(filter(lambda x: x["id"] == provider_id, providers), None) + assert isinstance(provider, dict) + assert provider["type"] == expected_type + assert provider["issuer"] == expected_issuer + assert provider["title"] == expected_title + return provider + + +def test_list_auth_providers(requests_mock, api_version, token_type): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) requests_mock.get( API_URL + "credentials/oidc", json={ @@ -784,29 +816,15 @@ def test_list_auth_providers(requests_mock, api_version): providers = conn.list_auth_providers() assert len(providers) == 3 - p1 = next(filter(lambda x: x["id"] == "p1", providers), None) - assert isinstance(p1, dict) - assert p1["type"] == "oidc" - assert p1["issuer"] == "https://openeo.example" - assert p1["title"] == "openEO" - - p2 = next(filter(lambda x: x["id"] == "p2", providers), None) - assert isinstance(p2, dict) - assert p2["type"] == "oidc" - assert p2["issuer"] == "https://other.example" - assert p2["title"] == "Other" + assert_provider(providers, "p1", "oidc", "https://openeo.example", "openEO") + assert_provider(providers, "p2", "oidc", "https://other.example", "Other") + assert_provider(providers, "/credentials/basic", "basic", API_URL + "credentials/basic", "Internal") - basic = next(filter(lambda x: x["id"] == "/credentials/basic", providers), None) - assert isinstance(basic, dict) - assert basic["type"] == "basic" - assert basic["issuer"] == API_URL + "credentials/basic" - assert basic["title"] == "Internal" - -def test_list_auth_providers_empty(requests_mock, api_version): +def test_list_auth_providers_empty(requests_mock, api_version, token_type): requests_mock.get( API_URL, - json=build_capabilities(api_version=api_version, basic_auth=False, oidc_auth=False), + json=build_capabilities(api_version=api_version, token_type=token_type, basic_auth=False, oidc_auth=False), ) conn = Connection(API_URL) @@ -814,8 +832,10 @@ def test_list_auth_providers_empty(requests_mock, api_version): assert len(providers) == 0 -def test_list_auth_providers_invalid(requests_mock, api_version, caplog): - requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, basic_auth=False)) +def test_list_auth_providers_invalid(requests_mock, api_version, token_type, caplog): + requests_mock.get( + API_URL, json=build_capabilities(api_version=api_version, token_type=token_type, basic_auth=False) + ) error_message = "Maintenance ongoing" requests_mock.get( API_URL + "credentials/oidc", @@ -839,30 +859,39 @@ def test_authenticate_basic_no_support(requests_mock, api_version): assert isinstance(conn.auth, NullAuth) -def test_authenticate_basic(requests_mock, api_version, basic_auth): - requests_mock.get(API_URL, json={"api_version": api_version, "endpoints": BASIC_ENDPOINTS}) +def test_authenticate_basic(requests_mock, api_version, token_type, basic_auth): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) 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 conn.auth.bearer == "basic//6cc3570k3n" + if token_type == "jwt": + assert capabilities.has_conformance(CONFORMANCE_JWT_BEARER) == True + assert conn.auth.bearer == "6cc3570k3n" + else: + 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}) +def test_authenticate_basic_from_config(requests_mock, api_version, token_type, auth_config, basic_auth): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) auth_config.set_basic_auth(backend=API_URL, username=basic_auth.username, password=basic_auth.password) conn = Connection(API_URL) assert isinstance(conn.auth, NullAuth) conn.authenticate_basic() assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == "basic//6cc3570k3n" + if token_type == "jwt": + assert conn.auth.bearer == "6cc3570k3n" + else: + assert conn.auth.bearer == "basic//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"}) +def test_authenticate_oidc_authorization_code_100_single_implicit(requests_mock, api_version, token_type, caplog): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" requests_mock.get(API_URL + 'credentials/oidc', json={ "providers": [{"id": "fauth", "issuer": "https://fauth.test", "title": "Foo Auth", "scopes": ["openid", "im"]}] @@ -882,12 +911,12 @@ def test_authenticate_oidc_authorization_code_100_single_implicit(requests_mock, assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_authorization_code(client_id=client_id, webbrowser_open=oidc_mock.webbrowser_open) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/fauth/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, 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"}) +def test_authenticate_oidc_authorization_code_100_single_wrong_id(requests_mock, api_version, token_type): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" requests_mock.get(API_URL + 'credentials/oidc', json={ "providers": [{"id": "fauth", "issuer": "https://fauth.test", "title": "Foo Auth", "scopes": ["openid", "w"]}] @@ -903,8 +932,8 @@ def test_authenticate_oidc_authorization_code_100_single_wrong_id(requests_mock) @pytest.mark.slow -def test_authenticate_oidc_authorization_code_100_multiple_no_given_id(requests_mock, caplog): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_authorization_code_100_multiple_no_given_id(requests_mock, api_version, token_type, caplog): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" requests_mock.get(API_URL + 'credentials/oidc', json={ "providers": [ @@ -927,7 +956,7 @@ def test_authenticate_oidc_authorization_code_100_multiple_no_given_id(requests_ assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_authorization_code(client_id=client_id, webbrowser_open=oidc_mock.webbrowser_open) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/fauth/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/fauth/") assert "No OIDC provider given. Using first provider 'fauth' as advertised by backend." in caplog.text @@ -950,8 +979,8 @@ def test_authenticate_oidc_authorization_code_100_multiple_wrong_id(requests_moc @pytest.mark.slow -def test_authenticate_oidc_authorization_code_100_multiple_success(requests_mock): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_authorization_code_100_multiple_success(requests_mock, api_version, token_type): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" requests_mock.get(API_URL + 'credentials/oidc', json={ "providers": [ @@ -975,7 +1004,7 @@ def test_authenticate_oidc_authorization_code_100_multiple_success(requests_mock client_id=client_id, provider_id="bauth", webbrowser_open=oidc_mock.webbrowser_open ) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/bauth/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/bauth/") @pytest.mark.slow @@ -988,8 +1017,10 @@ def test_authenticate_oidc_authorization_code_100_multiple_success(requests_mock (True, ["openid", "email", "offline_access"], "offline_access openid"), ] ) -def test_authenticate_oidc_auth_code_pkce_flow(requests_mock, store_refresh_token, scopes_supported, expected_scope): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_auth_code_pkce_flow( + requests_mock, api_version, token_type, store_refresh_token, scopes_supported, expected_scope +): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" issuer = "https://oidc.test" requests_mock.get(API_URL + 'credentials/oidc', json={ @@ -1012,7 +1043,7 @@ def test_authenticate_oidc_auth_code_pkce_flow(requests_mock, store_refresh_toke client_id=client_id, webbrowser_open=oidc_mock.webbrowser_open, store_refresh_token=store_refresh_token ) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") if store_refresh_token: refresh_token = oidc_mock.state["refresh_token"] assert refresh_token_store.mock_calls == [ @@ -1023,8 +1054,8 @@ def test_authenticate_oidc_auth_code_pkce_flow(requests_mock, store_refresh_toke @pytest.mark.slow -def test_authenticate_oidc_auth_code_pkce_flow_client_from_config(requests_mock, auth_config): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_auth_code_pkce_flow_client_from_config(requests_mock, api_version, token_type, auth_config): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" issuer = "https://oidc.test" requests_mock.get(API_URL + 'credentials/oidc', json={ @@ -1046,12 +1077,12 @@ def test_authenticate_oidc_auth_code_pkce_flow_client_from_config(requests_mock, assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_authorization_code(webbrowser_open=oidc_mock.webbrowser_open) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") 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"}) +def test_authenticate_oidc_client_credentials(requests_mock, api_version, token_type): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" client_secret = "$3cr3t" issuer = "https://oidc.test" @@ -1074,17 +1105,17 @@ def test_authenticate_oidc_client_credentials(requests_mock): client_id=client_id, client_secret=client_secret ) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert refresh_token_store.mock_calls == [] # Again but store refresh token conn.authenticate_oidc_client_credentials(client_id=client_id, client_secret=client_secret) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == "oidc/oi/" + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert refresh_token_store.mock_calls == [] -def test_authenticate_oidc_client_credentials_client_from_config(requests_mock, auth_config): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_client_credentials_client_from_config(requests_mock, api_version, token_type, auth_config): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" client_secret = "$3cr3t" issuer = "https://oidc.test" @@ -1108,7 +1139,7 @@ def test_authenticate_oidc_client_credentials_client_from_config(requests_mock, assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_client_credentials() assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert refresh_token_store.mock_calls == [] @@ -1121,9 +1152,9 @@ def test_authenticate_oidc_client_credentials_client_from_config(requests_mock, ], ) def test_authenticate_oidc_client_credentials_client_from_env( - requests_mock, monkeypatch, env_provider_id, expected_provider_id + requests_mock, monkeypatch, env_provider_id, expected_provider_id, api_version, token_type ): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" client_secret = "$3cr3t" monkeypatch.setenv("OPENEO_AUTH_CLIENT_ID", client_id) @@ -1153,7 +1184,9 @@ def test_authenticate_oidc_client_credentials_client_from_env( assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_client_credentials() assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == f"oidc/{expected_provider_id}/" + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token( + token_type, oidc_mock.state["access_token"], f"oidc/{expected_provider_id}/" + ) assert refresh_token_store.mock_calls == [] @@ -1182,8 +1215,10 @@ def test_authenticate_oidc_client_credentials_client_precedence( env_client_id, arg_client_id, expected_client_id, + api_version, + token_type, ): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_secret = "$3cr3t" if env_client_id: monkeypatch.setenv("OPENEO_AUTH_CLIENT_ID", env_client_id) @@ -1215,7 +1250,9 @@ def test_authenticate_oidc_client_credentials_client_precedence( client_id=arg_client_id, client_secret=client_secret if arg_client_id else None, provider_id=arg_provider_id ) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == f"oidc/{expected_provider_id}/" + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token( + token_type, oidc_mock.state["access_token"], f"oidc/{expected_provider_id}/" + ) assert refresh_token_store.mock_calls == [] @@ -1232,9 +1269,17 @@ def test_authenticate_oidc_client_credentials_client_precedence( ], ) def test_authenticate_oidc_client_credentials_client_multiple_provider_resolution( - requests_mock, monkeypatch, auth_config, provider_id_arg, provider_id_env, provider_id_conf, expected_provider_id + requests_mock, + monkeypatch, + auth_config, + provider_id_arg, + provider_id_env, + provider_id_conf, + expected_provider_id, + api_version, + token_type, ): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" client_secret = "$3cr3t" monkeypatch.setenv("OPENEO_AUTH_CLIENT_ID", client_id) @@ -1278,12 +1323,14 @@ def test_authenticate_oidc_client_credentials_client_multiple_provider_resolutio assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_client_credentials(provider_id=provider_id_arg) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == f"oidc/{expected_provider_id}/" + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token( + token_type, oidc_mock.state["access_token"], f"oidc/{expected_provider_id}/" + ) assert refresh_token_store.mock_calls == [] -def test_authenticate_oidc_resource_owner_password_credentials(requests_mock): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_resource_owner_password_credentials(requests_mock, api_version, token_type): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" client_secret = "$3cr3t" username, password = "john", "j0hn" @@ -1309,7 +1356,7 @@ def test_authenticate_oidc_resource_owner_password_credentials(requests_mock): client_id=client_id, username=username, password=password, client_secret=client_secret ) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert refresh_token_store.mock_calls == [] # Again but store refresh token conn.authenticate_oidc_resource_owner_password_credentials( @@ -1317,14 +1364,17 @@ def test_authenticate_oidc_resource_owner_password_credentials(requests_mock): store_refresh_token=True ) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert refresh_token_store.mock_calls == [ mock.call.set_refresh_token(client_id=client_id, issuer=issuer, refresh_token=oidc_mock.state["refresh_token"]) ] -def test_authenticate_oidc_resource_owner_password_credentials_client_from_config(requests_mock, auth_config): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_resource_owner_password_credentials_client_from_config( + requests_mock, auth_config, api_version, token_type +): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) + client_id = "myclient" client_secret = "$3cr3t" username, password = "john", "j0hn" @@ -1351,7 +1401,7 @@ def test_authenticate_oidc_resource_owner_password_credentials_client_from_confi assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_resource_owner_password_credentials(username=username, password=password) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert refresh_token_store.mock_calls == [] @@ -1365,9 +1415,15 @@ def test_authenticate_oidc_resource_owner_password_credentials_client_from_confi ] ) def test_authenticate_oidc_device_flow_with_secret( - requests_mock, store_refresh_token, scopes_supported, expected_scopes, oidc_device_code_flow_checker + requests_mock, + store_refresh_token, + scopes_supported, + expected_scopes, + oidc_device_code_flow_checker, + api_version, + token_type, ): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" client_secret = "$3cr3t" issuer = "https://oidc.test" @@ -1397,7 +1453,7 @@ def test_authenticate_oidc_device_flow_with_secret( client_id=client_id, client_secret=client_secret, store_refresh_token=store_refresh_token ) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") if store_refresh_token: refresh_token = oidc_mock.state["refresh_token"] assert refresh_token_store.mock_calls == [ @@ -1408,9 +1464,9 @@ def test_authenticate_oidc_device_flow_with_secret( def test_authenticate_oidc_device_flow_with_secret_from_config( - requests_mock, auth_config, caplog, oidc_device_code_flow_checker + requests_mock, auth_config, caplog, oidc_device_code_flow_checker, api_version, token_type ): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" client_secret = "$3cr3t" issuer = "https://oidc.test" @@ -1440,15 +1496,15 @@ def test_authenticate_oidc_device_flow_with_secret_from_config( with oidc_device_code_flow_checker(): conn.authenticate_oidc_device() assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert refresh_token_store.mock_calls == [] assert "No OIDC provider given, but only one available: 'oi'. Using that one." in caplog.text assert "Using client_id 'myclient' from config (provider 'oi')" in caplog.text @pytest.mark.slow -def test_authenticate_oidc_device_flow_no_support(requests_mock, auth_config): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_device_flow_no_support(requests_mock, auth_config, api_version, token_type): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" client_secret = "$3cr3t" issuer = "https://oidc.test" @@ -1482,10 +1538,17 @@ def test_authenticate_oidc_device_flow_no_support(requests_mock, auth_config): (False, False), ]) def test_authenticate_oidc_device_flow_pkce_multiple_providers_no_given( - requests_mock, auth_config, caplog, use_pkce, expect_pkce, oidc_device_code_flow_checker + requests_mock, + auth_config, + caplog, + use_pkce, + expect_pkce, + oidc_device_code_flow_checker, + api_version, + token_type, ): """OIDC device flow + PKCE with multiple OIDC providers and none specified to use.""" - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" requests_mock.get(API_URL + 'credentials/oidc', json={ "providers": [ @@ -1517,7 +1580,7 @@ def test_authenticate_oidc_device_flow_pkce_multiple_providers_no_given( with oidc_device_code_flow_checker(url=f"{oidc_issuer}/dc"): conn.authenticate_oidc_device(client_id=client_id, use_pkce=use_pkce) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/fauth/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"]) assert refresh_token_store.mock_calls == [] assert "No OIDC provider given. Using first provider 'fauth' as advertised by backend." in caplog.text @@ -1528,10 +1591,17 @@ def test_authenticate_oidc_device_flow_pkce_multiple_providers_no_given( (False, False), ]) def test_authenticate_oidc_device_flow_pkce_multiple_provider_one_config_no_given( - requests_mock, auth_config, caplog, use_pkce, expect_pkce, oidc_device_code_flow_checker + requests_mock, + auth_config, + caplog, + use_pkce, + expect_pkce, + oidc_device_code_flow_checker, + api_version, + token_type, ): """OIDC device flow + PKCE with multiple OIDC providers, one in config and none specified to use.""" - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" requests_mock.get(API_URL + 'credentials/oidc', json={ "providers": [ @@ -1564,19 +1634,19 @@ def test_authenticate_oidc_device_flow_pkce_multiple_provider_one_config_no_give with oidc_device_code_flow_checker(url=f"{oidc_issuer}/dc"): conn.authenticate_oidc_device(use_pkce=use_pkce) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/fauth/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"]) assert refresh_token_store.mock_calls == [] assert "No OIDC provider given, but only one in config (for backend 'https://oeo.test/'): 'fauth'. Using that one." in caplog.text assert "Using client_id 'myclient' from config (provider 'fauth')" in caplog.text def test_authenticate_oidc_device_flow_pkce_multiple_provider_one_config_no_given_default_client( - requests_mock, auth_config, oidc_device_code_flow_checker + requests_mock, auth_config, oidc_device_code_flow_checker, api_version, token_type ): """ OIDC device flow + default_clients + PKCE with multiple OIDC providers, one in config and none specified to use. """ - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) default_client_id = "dadefaultklient" requests_mock.get(API_URL + 'credentials/oidc', json={ "providers": [ @@ -1612,7 +1682,8 @@ def test_authenticate_oidc_device_flow_pkce_multiple_provider_one_config_no_give with oidc_device_code_flow_checker(url=f"{oidc_issuer}/dc"): conn.authenticate_oidc_device() assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/bauth/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/bauth/") + assert refresh_token_store.mock_calls == [] @@ -1637,11 +1708,13 @@ def test_authenticate_oidc_device_flow_pkce_multiple_provider_resolution( provider_id_conf, expected_provider, monkeypatch, + api_version, + token_type, ): """ OIDC device flow + default_clients + PKCE with multiple OIDC providers: provider resolution/precedence """ - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "klientid" requests_mock.get( API_URL + "credentials/oidc", @@ -1686,7 +1759,9 @@ def test_authenticate_oidc_device_flow_pkce_multiple_provider_resolution( with oidc_device_code_flow_checker(url=f"{oidc_issuer}/dc"): conn.authenticate_oidc_device(client_id=client_id, provider_id=provider_id_arg) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == f"oidc/{expected_provider}/" + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token( + token_type, oidc_mock.state["access_token"], f"oidc/{expected_provider}/" + ) assert refresh_token_store.mock_calls == [] @@ -1701,12 +1776,12 @@ def test_authenticate_oidc_device_flow_pkce_multiple_provider_resolution( ], ) def test_authenticate_oidc_device_flow_pkce_default_client_handling( - requests_mock, grant_types, use_pkce, expect_pkce, oidc_device_code_flow_checker + requests_mock, grant_types, use_pkce, expect_pkce, oidc_device_code_flow_checker, api_version, token_type ): """ OIDC device authn grant + secret/PKCE/neither: default client grant_types handling """ - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) default_client_id = "dadefaultklient" oidc_issuer = "https://auth.test" requests_mock.get( @@ -1749,13 +1824,15 @@ def test_authenticate_oidc_device_flow_pkce_default_client_handling( with oidc_device_code_flow_checker(url=f"{oidc_issuer}/dc"): conn.authenticate_oidc_device(use_pkce=use_pkce) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/auth/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/auth/") assert refresh_token_store.mock_calls == [] -def test_authenticate_oidc_device_flow_pkce_store_refresh_token(requests_mock, oidc_device_code_flow_checker): +def test_authenticate_oidc_device_flow_pkce_store_refresh_token( + requests_mock, oidc_device_code_flow_checker, api_version, token_type +): """OIDC device authn grant + PKCE + refresh token storage""" - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) default_client_id = "dadefaultklient" requests_mock.get(API_URL + 'credentials/oidc', json={ "providers": [ @@ -1790,7 +1867,7 @@ def test_authenticate_oidc_device_flow_pkce_store_refresh_token(requests_mock, o with oidc_device_code_flow_checker(url=f"{oidc_issuer}/dc"): conn.authenticate_oidc_device(store_refresh_token=True) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/auth/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/auth/") assert refresh_token_store.mock_calls == [ mock.call.set_refresh_token( client_id=default_client_id, issuer="https://auth.test", @@ -1799,8 +1876,8 @@ def test_authenticate_oidc_device_flow_pkce_store_refresh_token(requests_mock, o ] -def test_authenticate_oidc_refresh_token(requests_mock): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_refresh_token(requests_mock, api_version, token_type): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" refresh_token = "r3fr35h!" issuer = "https://oidc.test" @@ -1821,11 +1898,11 @@ def test_authenticate_oidc_refresh_token(requests_mock): assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_refresh_token(refresh_token=refresh_token, client_id=client_id) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") -def test_authenticate_oidc_refresh_token_expired(requests_mock): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_refresh_token_expired(requests_mock, api_version, token_type): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" issuer = "https://oidc.test" requests_mock.get(API_URL + 'credentials/oidc', json={ @@ -1861,10 +1938,18 @@ def test_authenticate_oidc_refresh_token_expired(requests_mock): ], ) def test_authenticate_oidc_refresh_token_multiple_provider_resolution( - requests_mock, auth_config, provider_id_arg, provider_id_env, provider_id_conf, expected_provider, monkeypatch + requests_mock, + auth_config, + provider_id_arg, + provider_id_env, + provider_id_conf, + expected_provider, + monkeypatch, + api_version, + token_type, ): """Multiple OIDC Providers: provider resolution/precedence""" - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" refresh_token = "r3fr35h!" requests_mock.get( @@ -1907,12 +1992,16 @@ def test_authenticate_oidc_refresh_token_multiple_provider_resolution( assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc_refresh_token(refresh_token=refresh_token, client_id=client_id, provider_id=provider_id_arg) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == f"oidc/{expected_provider}/" + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token( + token_type, oidc_mock.state["access_token"], f"oidc/{expected_provider}/" + ) @pytest.mark.parametrize("store_refresh_token", [True, False]) -def test_authenticate_oidc_auto_with_existing_refresh_token(requests_mock, refresh_token_store, store_refresh_token): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) +def test_authenticate_oidc_auto_with_existing_refresh_token( + requests_mock, refresh_token_store, store_refresh_token, api_version, token_type +): + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" orig_refresh_token = "r3fr35h!" issuer = "https://oidc.test" @@ -1933,7 +2022,7 @@ def test_authenticate_oidc_auto_with_existing_refresh_token(requests_mock, refre assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc(client_id=client_id, store_refresh_token=store_refresh_token) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") new_refresh_token = refresh_token_store.get_refresh_token(issuer=issuer, client_id=client_id) assert new_refresh_token == orig_refresh_token @@ -1949,9 +2038,15 @@ def test_authenticate_oidc_auto_with_existing_refresh_token(requests_mock, refre ], ) def test_authenticate_oidc_auto_no_existing_refresh_token( - requests_mock, refresh_token_store, use_pkce, expect_pkce, oidc_device_code_flow_checker + requests_mock, + refresh_token_store, + use_pkce, + expect_pkce, + oidc_device_code_flow_checker, + api_version, + token_type, ): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" issuer = "https://oidc.test" requests_mock.get(API_URL + 'credentials/oidc', json={ @@ -1978,7 +2073,7 @@ def test_authenticate_oidc_auto_no_existing_refresh_token( with oidc_device_code_flow_checker(): conn.authenticate_oidc(client_id=client_id, use_pkce=use_pkce) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert [r["grant_type"] for r in oidc_mock.grant_request_history] == [ "urn:ietf:params:oauth:grant-type:device_code" ] @@ -1993,9 +2088,15 @@ def test_authenticate_oidc_auto_no_existing_refresh_token( ], ) def test_authenticate_oidc_auto_expired_refresh_token( - requests_mock, refresh_token_store, use_pkce, expect_pkce, oidc_device_code_flow_checker + requests_mock, + refresh_token_store, + use_pkce, + expect_pkce, + oidc_device_code_flow_checker, + api_version, + token_type, ): - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) client_id = "myclient" issuer = "https://oidc.test" requests_mock.get(API_URL + 'credentials/oidc', json={ @@ -2023,7 +2124,7 @@ def test_authenticate_oidc_auto_expired_refresh_token( with oidc_device_code_flow_checker(): conn.authenticate_oidc(client_id=client_id, use_pkce=use_pkce) assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token(token_type, oidc_mock.state["access_token"], "oidc/oi/") assert [r["grant_type"] for r in oidc_mock.grant_request_history] == [ "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", @@ -2040,7 +2141,7 @@ def test_authenticate_oidc_auto_expired_refresh_token( ], ) def test_authenticate_oidc_method_client_credentials_from_env( - requests_mock, monkeypatch, env_provider_id, expected_provider_id + requests_mock, monkeypatch, env_provider_id, expected_provider_id, api_version, token_type ): client_id = "myclient" client_secret = "$3cr3t!" @@ -2049,7 +2150,7 @@ def test_authenticate_oidc_method_client_credentials_from_env( monkeypatch.setenv("OPENEO_AUTH_CLIENT_SECRET", client_secret) if env_provider_id: monkeypatch.setenv("OPENEO_AUTH_PROVIDER_ID", env_provider_id) - requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get(API_URL, json=build_capabilities(api_version=api_version, token_type=token_type)) requests_mock.get( API_URL + "credentials/oidc", json={ @@ -2072,25 +2173,47 @@ def test_authenticate_oidc_method_client_credentials_from_env( assert isinstance(conn.auth, NullAuth) conn.authenticate_oidc() assert isinstance(conn.auth, BearerAuth) - assert conn.auth.bearer == f"oidc/{expected_provider_id}/" + oidc_mock.state["access_token"] + assert conn.auth.bearer == generate_bearer_token( + token_type, oidc_mock.state["access_token"], f"oidc/{expected_provider_id}/" + ) -def _setup_get_me_handler(requests_mock, oidc_mock: OidcMock, token_invalid_status_code: int = 403): +def _setup_get_me_handler( + requests_mock, + oidc_mock: OidcMock, + token_invalid_status_code: int = 403, + version="1.0.0", + token_type: str = "legacy", +): def get_me(request: requests.Request, context): """handler for `GET /me` (with access_token checking)""" auth_header = request.headers["Authorization"] - oidc_provider, access_token = re.match(r"Bearer oidc/(?P
\w+)/(?P.*)", auth_header).group("p", "a") - try: - user_id = oidc_mock.validate_access_token(access_token)["user_id"] - except LookupError: - context.status_code = token_invalid_status_code - return {"code": "TokenInvalid", "message": "Authorization token has expired or is invalid."} - - return { - "user_id": user_id, - # Normally not part of `GET /me` response, but just for test/inspection purposes - "_used_oidc_provider": oidc_provider, - "_used_access_token": access_token, + if token_type == "jwt": + # TODO: parse jwt, also encode appropriate values in test JWT + result = re.match(r"Bearer (?P.*)", auth_header) + access_token = result.group("a") if result else None + + try: + user_id = oidc_mock.validate_access_token(access_token)["user_id"] + except LookupError: + context.status_code = token_invalid_status_code + return {"code": "TokenInvalid", "message": "Authorization token has expired or is invalid."} + + return {"user_id": user_id, "_used_oidc_provider": "oi", "_used_access_token": access_token} + else: + result = re.match(r"Bearer oidc/(?P