diff --git a/CHANGELOG.md b/CHANGELOG.md index 023600f1c..b54deea9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Support OIDC client credentials grant from a generic `connection.authenticate_oidc()` call + through environment variables + [#419](https://github.com/Open-EO/openeo-python-client/issues/419) + ### Changed ### Removed diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index f8db8e750..ea52781dd 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -4,6 +4,7 @@ import datetime import json import logging +import os import shlex import sys import warnings @@ -346,9 +347,11 @@ def _get_oidc_provider(self, provider_id: Union[str, None] = None) -> Tuple[str, return provider_id, provider def _get_oidc_provider_and_client_info( - self, provider_id: str, - client_id: Union[str, None], client_secret: Union[str, None], - default_client_grant_check: Union[None, GrantsChecker] = None + self, + provider_id: str, + client_id: Union[str, None], + client_secret: Union[str, None], + default_client_grant_check: Union[None, GrantsChecker] = None, ) -> Tuple[str, OidcClientInfo]: """ Resolve provider_id and client info (as given or from config) @@ -444,20 +447,35 @@ def authenticate_oidc_authorization_code( return self._authenticate_oidc(authenticator, provider_id=provider_id, store_refresh_token=store_refresh_token) def authenticate_oidc_client_credentials( - self, - client_id: str = None, - client_secret: str = None, - provider_id: str = None, - store_refresh_token=False, + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + provider_id: Optional[str] = None, ) -> 'Connection': """ OpenID Connect Client Credentials flow. + + Client id, secret and provider id can be specified directly through the available arguments. + It is also possible to leave these arguments empty and specify them through + environment variables ``OPENEO_AUTH_CLIENT_ID``, + ``OPENEO_AUTH_CLIENT_SECRET`` and ``OPENEO_AUTH_PROVIDER_ID`` respectively. + + .. versionchanged:: 0.18.0 Allow specifying client id, secret and provider id through environment variables. """ + # TODO: option to get client id/secret from a config file too? + if client_id is None and "OPENEO_AUTH_CLIENT_ID" in os.environ and "OPENEO_AUTH_CLIENT_SECRET" in os.environ: + client_id = os.environ.get("OPENEO_AUTH_CLIENT_ID") + client_secret = os.environ.get("OPENEO_AUTH_CLIENT_SECRET") + _log.debug(f"Getting client id ({client_id}) and secret from environment") + + # TODO: also support specifying provider through issuer URL? + provider_id = provider_id or os.environ.get("OPENEO_AUTH_PROVIDER_ID") + provider_id, client_info = self._get_oidc_provider_and_client_info( provider_id=provider_id, client_id=client_id, client_secret=client_secret ) authenticator = OidcClientCredentialsAuthenticator(client_info=client_info) - return self._authenticate_oidc(authenticator, provider_id=provider_id, store_refresh_token=store_refresh_token) + return self._authenticate_oidc(authenticator, provider_id=provider_id, store_refresh_token=False) def authenticate_oidc_resource_owner_password_credentials( self, @@ -551,10 +569,31 @@ def authenticate_oidc( max_poll_time: float = OidcDeviceAuthenticator.DEFAULT_MAX_POLL_TIME, ): """ - Do OpenID Connect authentication, first trying refresh tokens and falling back on device code flow. + Generic method to do OpenID Connect authentication. + + In the context of interactive usage, this method first tries to use refresh tokens + and falls back on device code flow. + + For non-interactive, machine-to-machine contexts, it is also possible to trigger + the usage of the "client_credentials" flow through environment variables. + Assuming you have set up a OIDC client (with a secret): + set ``OPENEO_AUTH_METHOD`` to ``client_credentials``, + set ``OPENEO_AUTH_CLIENT_ID`` to the client id, + and set ``OPENEO_AUTH_CLIENT_SECRET`` to the client secret. .. versionadded:: 0.6.0 - """ + .. versionchanged:: 0.18.0 Add support for client credentials flow. + """ + # TODO: unify `os.environ.get` with `get_config_option`? + auth_method = os.environ.get("OPENEO_AUTH_METHOD") + if auth_method == "client_credentials": + _log.debug("authenticate_oidc: going for 'client_credentials' authentication") + return self.authenticate_oidc_client_credentials( + client_id=client_id, client_secret=client_secret, provider_id=provider_id + ) + elif auth_method: + raise ValueError(f"Unhandled auth method {auth_method}") + _g = DefaultOidcClientGrant # alias for compactness provider_id, client_info = self._get_oidc_provider_and_client_info( provider_id=provider_id, client_id=client_id, client_secret=client_secret, diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index 13fc5b969..9a7d37f22 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -554,9 +554,9 @@ def test_create_connection_lazy_refresh_token_store(requests_mock): }) oidc_mock = OidcMock( requests_mock=requests_mock, - expected_grant_type="client_credentials", + expected_grant_type="password", expected_client_id=client_id, - expected_fields={"client_secret": client_secret, "scope": "openid"}, + expected_fields={"username": user, "password": pwd, "scope": "openid", "client_secret": client_secret}, oidc_issuer=issuer, ) @@ -564,8 +564,8 @@ def test_create_connection_lazy_refresh_token_store(requests_mock): conn = Connection(API_URL) assert RefreshTokenStore.call_count == 0 # Create RefreshTokenStore lazily when necessary - conn.authenticate_oidc_client_credentials( - client_id=client_id, client_secret=client_secret, store_refresh_token=True + conn.authenticate_oidc_resource_owner_password_credentials( + username=user, password=pwd, client_id=client_id, client_secret=client_secret, store_refresh_token=True ) assert RefreshTokenStore.call_count == 1 RefreshTokenStore.return_value.set_refresh_token.assert_called_with( @@ -825,14 +825,10 @@ def test_authenticate_oidc_client_credentials(requests_mock): assert conn.auth.bearer == 'oidc/oi/' + oidc_mock.state["access_token"] assert refresh_token_store.mock_calls == [] # Again but store refresh token - conn.authenticate_oidc_client_credentials( - client_id=client_id, client_secret=client_secret, store_refresh_token=True - ) + 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 refresh_token_store.mock_calls == [ - mock.call.set_refresh_token(client_id=client_id, issuer=issuer, refresh_token=oidc_mock.state["refresh_token"]) - ] + assert conn.auth.bearer == "oidc/oi/" + oidc_mock.state["access_token"] + assert refresh_token_store.mock_calls == [] def test_authenticate_oidc_client_credentials_client_from_config(requests_mock, auth_config): @@ -864,6 +860,113 @@ def test_authenticate_oidc_client_credentials_client_from_config(requests_mock, assert refresh_token_store.mock_calls == [] +@pytest.mark.parametrize( + ["env_provider_id", "expected_provider_id"], + [ + (None, "oi"), + ("oi", "oi"), + ("dc", "dc"), + ], +) +def test_authenticate_oidc_client_credentials_client_from_env( + requests_mock, monkeypatch, env_provider_id, expected_provider_id +): + requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + client_id = "myclient" + client_secret = "$3cr3t" + monkeypatch.setenv("OPENEO_AUTH_CLIENT_ID", client_id) + 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 + "credentials/oidc", + json={ + "providers": [ + {"id": "oi", "issuer": "https://oi.test", "title": "example", "scopes": ["openid"]}, + {"id": "dc", "issuer": "https://dc.test", "title": "example", "scopes": ["openid"]}, + ] + }, + ) + oidc_mock = OidcMock( + requests_mock=requests_mock, + oidc_issuer=f"https://{expected_provider_id}.test", + expected_grant_type="client_credentials", + expected_client_id=client_id, + expected_fields={"client_secret": client_secret, "scope": "openid"}, + ) + + # 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_client_credentials() + assert isinstance(conn.auth, BearerAuth) + assert conn.auth.bearer == f"oidc/{expected_provider_id}/" + oidc_mock.state["access_token"] + assert refresh_token_store.mock_calls == [] + + +@pytest.mark.parametrize( + [ + "env_provider_id", + "arg_provider_id", + "expected_provider_id", + "env_client_id", + "arg_client_id", + "expected_client_id", + ], + [ + (None, None, "oi", None, "aclient", "aclient"), + (None, "dc", "dc", None, "aclient", "aclient"), + ("dc", None, "dc", "eclient", None, "eclient"), + ("oi", "dc", "dc", "eclient", "aclient", "aclient"), + ], +) +def test_authenticate_oidc_client_credentials_client_precedence( + requests_mock, + monkeypatch, + env_provider_id, + arg_provider_id, + expected_provider_id, + env_client_id, + arg_client_id, + expected_client_id, +): + requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + client_secret = "$3cr3t" + if env_client_id: + monkeypatch.setenv("OPENEO_AUTH_CLIENT_ID", env_client_id) + 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 + "credentials/oidc", + json={ + "providers": [ + {"id": "oi", "issuer": "https://oi.test", "title": "example", "scopes": ["openid"]}, + {"id": "dc", "issuer": "https://dc.test", "title": "example", "scopes": ["openid"]}, + ] + }, + ) + oidc_mock = OidcMock( + requests_mock=requests_mock, + oidc_issuer=f"https://{expected_provider_id}.test", + expected_grant_type="client_credentials", + expected_client_id=expected_client_id, + expected_fields={"client_secret": client_secret, "scope": "openid"}, + ) + + # 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_client_credentials( + 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 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"}) client_id = "myclient" @@ -1477,6 +1580,50 @@ def test_authenticate_oidc_auto_expired_refresh_token( assert oidc_mock.grant_request_history[0]["response"] == {"error": "invalid refresh token"} +@pytest.mark.parametrize( + ["env_provider_id", "expected_provider_id"], + [ + (None, "oi"), + ("oi", "oi"), + ("dc", "dc"), + ], +) +def test_authenticate_oidc_method_client_credentials_from_env( + requests_mock, monkeypatch, env_provider_id, expected_provider_id +): + client_id = "myclient" + client_secret = "$3cr3t!" + monkeypatch.setenv("OPENEO_AUTH_METHOD", "client_credentials") + monkeypatch.setenv("OPENEO_AUTH_CLIENT_ID", client_id) + 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 + "credentials/oidc", + json={ + "providers": [ + {"id": "oi", "issuer": "https://oi.test", "title": "example", "scopes": ["openid"]}, + {"id": "dc", "issuer": "https://dc.test", "title": "example", "scopes": ["openid"]}, + ] + }, + ) + oidc_mock = OidcMock( + requests_mock=requests_mock, + expected_grant_type="client_credentials", + expected_client_id=client_id, + oidc_issuer=f"https://{expected_provider_id}.test", + expected_fields={"scope": "openid", "client_secret": client_secret}, + ) + + # With all this set up, kick off the openid connect flow + conn = Connection(API_URL) + 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"] + + def _setup_get_me_handler(requests_mock, oidc_mock: OidcMock): def get_me(request: requests.Request, context): """handler for `GET /me` (with access_token checking)"""