Skip to content

Commit

Permalink
Merge branch 'issue419-auth_oidc-client-creds'
Browse files Browse the repository at this point in the history
  • Loading branch information
soxofaan committed May 25, 2023
2 parents 3ce362b + 77c5348 commit 9cb5376
Show file tree
Hide file tree
Showing 3 changed files with 212 additions and 22 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 50 additions & 11 deletions openeo/rest/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import datetime
import json
import logging
import os
import shlex
import sys
import warnings
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
169 changes: 158 additions & 11 deletions tests/rest/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,18 +554,18 @@ 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,
)

with mock.patch('openeo.rest.connection.RefreshTokenStore') as RefreshTokenStore:
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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)"""
Expand Down

0 comments on commit 9cb5376

Please sign in to comment.