Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #192: add support for default OIDC clients #199

Merged
merged 1 commit into from
Apr 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add dependency on `xarray` package ([#159](https://github.com/Open-EO/openeo-python-client/issues/159), [#190](https://github.com/Open-EO/openeo-python-client/pull/190), EP-3578)
- Add support for default OIDC clients advertised by backend ([#192](https://github.com/Open-EO/openeo-python-client/issues/192), [Open-EO/openeo-api#366](https://github.com/Open-EO/openeo-api/pull/366))


### Changed

Expand Down
8 changes: 4 additions & 4 deletions openeo/rest/auth/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,18 @@ def main_add_oidc(args):

# Get client_id and client_secret (if necessary)
if use_default_client:
if not provider.default_client:
show_warning("No default client specified for provider {p!r}".format(p=provider_id))
if not provider.default_clients:
show_warning("No default clients declared for provider {p!r}".format(p=provider_id))
client_id, client_secret = None, None
else:
if not client_id:
if provider.default_client:
if provider.default_clients:
client_prompt = "Enter client_id or leave empty to use default client, and press enter: "
else:
client_prompt = "Enter client_id and press enter: "
client_id = builtins.input(client_prompt).strip() or None
print("Using client ID {u!r}".format(u=client_id))
if not client_id and not provider.default_client:
if not client_id and not provider.default_clients:
show_warning("Given client ID was empty.")

if client_id and ask_client_secret:
Expand Down
28 changes: 21 additions & 7 deletions openeo/rest/auth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import base64
import enum
import functools
import hashlib
import http.server
Expand Down Expand Up @@ -216,15 +217,23 @@ def _decode(data: str) -> dict:
return _decode(header), _decode(payload)


class DefaultOidcClientGrant(enum.Enum):
"""
Enum with possible values for "grant_types" field of default OIDC clients provided by backend.
"""
IMPLICIT = "implicit"
AUTH_CODE_PKCE = "authorization_code+pkce"
DEVICE_CODE_PKCE = "urn:ietf:params:oauth:grant-type:device_code+pkce"
REFRESH_TOKEN = "refresh_token"


class OidcProviderInfo:
"""OpenID Connect Provider information, as provided by an openEO back-end (endpoint `/credentials/oidc`)"""

# TODO: The "default_client" feature is still experimental in openEO API. See Open-EO/openeo-api#366

def __init__(
self, issuer: str = None, discovery_url: str = None, scopes: List[str] = None,
provider_id: str = None, title: str = None,
default_client: Union[dict, None] = None,
default_clients: Union[List[dict], None] = None,
):
# TODO: id and title are required in the openEO API spec.
self.id = provider_id
Expand All @@ -239,15 +248,15 @@ def __init__(
# Minimal set of scopes to request
self._supported_scopes = self.config.get("scopes_supported", ["openid"])
self._scopes = {"openid"}.union(scopes or []).intersection(self._supported_scopes)
self.default_client = default_client
self.default_clients = default_clients

@classmethod
def from_dict(cls, data: dict) -> "OidcProviderInfo":
return cls(
provider_id=data["id"], title=data["title"],
issuer=data["issuer"],
scopes=data.get("scopes"),
default_client=data.get("default_client"),
default_clients=data.get("default_clients"),
)

def get_scopes_string(self, request_refresh_token: bool = False):
Expand All @@ -263,8 +272,13 @@ def get_scopes_string(self, request_refresh_token: bool = False):
scopes = scopes | {"offline_access"}
return " ".join(sorted(scopes))

def get_default_client_id(self) -> Union[str, None]:
return self.default_client and self.default_client.get("id")
def get_default_client_id(self, grant_types: List[DefaultOidcClientGrant]) -> Union[str, None]:
"""Get first default client supporting the given grant types"""
for client in self.default_clients or []:
client_id = client.get("id")
supported_grants = client.get("grant_types")
if client_id and supported_grants and all(g.value in supported_grants for g in grant_types):
return client_id


class OidcClientInfo:
Expand Down
26 changes: 16 additions & 10 deletions openeo/rest/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from openeo.rest.auth.config import RefreshTokenStore, AuthConfig
from openeo.rest.auth.oidc import OidcClientCredentialsAuthenticator, OidcAuthCodePkceAuthenticator, \
OidcClientInfo, OidcAuthenticator, OidcRefreshTokenAuthenticator, OidcResourceOwnerPasswordAuthenticator, \
OidcDeviceAuthenticator, OidcProviderInfo, OidcException
OidcDeviceAuthenticator, OidcProviderInfo, OidcException, DefaultOidcClientGrant
from openeo.rest.datacube import DataCube
from openeo.rest.imagecollectionclient import ImageCollectionClient
from openeo.rest.job import RESTJob
Expand Down Expand Up @@ -332,7 +332,8 @@ def _get_oidc_provider(self, provider_id: Union[str, None] = None) -> Tuple[str,

def _get_oidc_provider_and_client_info(
self, provider_id: str,
client_id: Union[str, None], client_secret: Union[str, None]
client_id: Union[str, None], client_secret: Union[str, None],
default_client_grant_types: Union[None, List[DefaultOidcClientGrant]] = None
) -> Tuple[str, OidcClientInfo]:
"""
Resolve provider_id and client info (as given or from config)
Expand All @@ -345,21 +346,22 @@ def _get_oidc_provider_and_client_info(
provider_id, provider = self._get_oidc_provider(provider_id)

if client_id is None:
_log.debug("No client_id: checking config for prefered client_id")
client_id, client_secret = self._get_auth_config().get_oidc_client_configs(
backend=self._orig_url, provider_id=provider_id
)
if client_id:
_log.info("Using client_id {c!r} from config (provider {p!r})".format(c=client_id, p=provider_id))
if client_id is None:
# TODO: This "default_client" feature is still experimental in openEO API. See Open-EO/openeo-api#366
if client_id is None and default_client_grant_types:
# Try "default_client" from backend's provider info.
client_id = provider.get_default_client_id()
_log.debug("No client_id given: checking default client in backend's provider info")
client_id = provider.get_default_client_id(grant_types=default_client_grant_types)
if client_id:
_log.info("Using default client_id {c!r} from OIDC provider {p!r} info.".format(
c=client_id, p=provider_id
))
if client_id is None:
raise OpenEoClientException("No client ID found.")
raise OpenEoClientException("No client_id found.")

client_info = OidcClientInfo(client_id=client_id, client_secret=client_secret, provider=provider)

Expand Down Expand Up @@ -406,7 +408,8 @@ def authenticate_oidc_authorization_code(
OpenID Connect Authorization Code Flow (with PKCE).
"""
provider_id, client_info = self._get_oidc_provider_and_client_info(
provider_id=provider_id, client_id=client_id, client_secret=client_secret
provider_id=provider_id, client_id=client_id, client_secret=client_secret,
default_client_grant_types=[DefaultOidcClientGrant.AUTH_CODE_PKCE],
)
authenticator = OidcAuthCodePkceAuthenticator(
client_info=client_info,
Expand Down Expand Up @@ -457,7 +460,8 @@ def authenticate_oidc_refresh_token(
OpenId Connect Refresh Token
"""
provider_id, client_info = self._get_oidc_provider_and_client_info(
provider_id=provider_id, client_id=client_id, client_secret=client_secret
provider_id=provider_id, client_id=client_id, client_secret=client_secret,
default_client_grant_types=[DefaultOidcClientGrant.REFRESH_TOKEN],
)

if refresh_token is None:
Expand Down Expand Up @@ -487,7 +491,8 @@ def authenticate_oidc_device(
.. versionchanged:: 0.5.1 Add :py:obj:`use_pkce` argument
"""
provider_id, client_info = self._get_oidc_provider_and_client_info(
provider_id=provider_id, client_id=client_id, client_secret=client_secret
provider_id=provider_id, client_id=client_id, client_secret=client_secret,
default_client_grant_types=[DefaultOidcClientGrant.DEVICE_CODE_PKCE],
)
authenticator = OidcDeviceAuthenticator(client_info=client_info, use_pkce=use_pkce, **kwargs)
return self._authenticate_oidc(authenticator, provider_id=provider_id, store_refresh_token=store_refresh_token)
Expand All @@ -504,7 +509,8 @@ def authenticate_oidc(
.. versionadded:: 0.6.0
"""
provider_id, client_info = self._get_oidc_provider_and_client_info(
provider_id=provider_id, client_id=client_id, client_secret=client_secret
provider_id=provider_id, client_id=client_id, client_secret=client_secret,
default_client_grant_types=[DefaultOidcClientGrant.DEVICE_CODE_PKCE, DefaultOidcClientGrant.REFRESH_TOKEN]
)

# Try refresh token first.
Expand Down
46 changes: 40 additions & 6 deletions tests/rest/auth/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from pathlib import Path
from unittest import mock

Expand Down Expand Up @@ -101,27 +102,51 @@ def test_add_oidc_no_secret(auth_config, requests_mock):
assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (client_id, None)


def test_add_oidc_use_default_client(auth_config, requests_mock):
def test_add_oidc_use_default_client(auth_config, requests_mock, caplog):
requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"})
requests_mock.get("https://oeo.test/credentials/oidc", json={
"providers": [{
"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"],
"default_client": {"id": "d3f6ul7cl13n7"}
"default_clients": [{
"id": "d3f6ul7cl13n7",
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code+pkce", "refresh_token"],
}]
}]
})
requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"})
cli.main(["add-oidc", "https://oeo.test", "--use-default-client"])

assert "authit" in auth_config.get_oidc_provider_configs("https://oeo.test")
assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (None, None)
warnings = [r[2] for r in caplog.record_tuples if r[1] == logging.WARN]
assert warnings == []


def test_add_oidc_use_default_client_no_default(auth_config, requests_mock, caplog):
requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"})
requests_mock.get("https://oeo.test/credentials/oidc", json={
"providers": [{
"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"],
}]
})
requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"})
cli.main(["add-oidc", "https://oeo.test", "--use-default-client"])

assert "authit" in auth_config.get_oidc_provider_configs("https://oeo.test")
assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (None, None)
warnings = [r[2] for r in caplog.record_tuples if r[1] == logging.WARN]
assert warnings == ["No default clients declared for provider 'authit'"]


def test_add_oidc_default_client_interactive(auth_config, requests_mock, capsys):
requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"})
requests_mock.get("https://oeo.test/credentials/oidc", json={
"providers": [{
"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"],
"default_client": {"id": "d3f6ul7cl13n7"}
"default_clients": [{
"id": "d3f6ul7cl13n7",
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code+pkce", "refresh_token"]
}]
}]
})
requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"})
Expand All @@ -136,12 +161,15 @@ def test_add_oidc_default_client_interactive(auth_config, requests_mock, capsys)
assert "Using client ID None" in stdout


def test_add_oidc_use_default_client_overwrite(auth_config, requests_mock):
def test_add_oidc_use_default_client_overwrite(auth_config, requests_mock, caplog):
requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"})
requests_mock.get("https://oeo.test/credentials/oidc", json={
"providers": [{
"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"],
"default_client": {"id": "d3f6ul7cl13n7"}
"default_clients": [{
"id": "d3f6ul7cl13n7",
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code+pkce", "refresh_token"]
}]
}]
})
requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"})
Expand All @@ -156,6 +184,9 @@ def test_add_oidc_use_default_client_overwrite(auth_config, requests_mock):
assert "authit" in auth_config.get_oidc_provider_configs("https://oeo.test")
assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (None, None)

warnings = [r[2] for r in caplog.record_tuples if r[1] == logging.WARN]
assert warnings == []


def test_add_oidc_04(auth_config, requests_mock):
requests_mock.get("https://oeo.test/", json={"api_version": "0.4.0"})
Expand Down Expand Up @@ -265,7 +296,10 @@ def test_oidc_auth_device_flow_default_client(auth_config, refresh_token_store,
requests_mock.get("https://oeo.test/credentials/oidc", json={"providers": [
{
"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"],
"default_client": {"id": default_client_id}
"default_clients": [{
"id": default_client_id,
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code+pkce", "refresh_token"],
}]
},
{"id": "youauth", "issuer": "https://youauth.test", "title": "YouAuth", "scopes": ["openid"]}
]})
Expand Down
24 changes: 20 additions & 4 deletions tests/rest/auth/test_oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
import openeo.rest.auth.oidc
from openeo.rest.auth.oidc import QueuingRequestHandler, drain_queue, HttpServerThread, OidcAuthCodePkceAuthenticator, \
OidcClientCredentialsAuthenticator, OidcResourceOwnerPasswordAuthenticator, OidcClientInfo, OidcProviderInfo, \
OidcDeviceAuthenticator, random_string, OidcRefreshTokenAuthenticator, PkceCode, OidcException
OidcDeviceAuthenticator, random_string, OidcRefreshTokenAuthenticator, PkceCode, OidcException, \
DefaultOidcClientGrant
from openeo.util import dict_no_none


Expand Down Expand Up @@ -125,13 +126,28 @@ def test_provider_info_scopes(requests_mock):
def test_provider_info_default_client_none(requests_mock):
requests_mock.get("https://authit.test/.well-known/openid-configuration", json={})
info = OidcProviderInfo(issuer="https://authit.test")
assert info.get_default_client_id() is None
assert info.get_default_client_id(grant_types=[]) is None
assert info.get_default_client_id(grant_types=[DefaultOidcClientGrant.DEVICE_CODE_PKCE]) is None


def test_provider_info_default_client_available(requests_mock):
requests_mock.get("https://authit.test/.well-known/openid-configuration", json={})
info = OidcProviderInfo(issuer="https://authit.test", default_client={"id": "jak4l0v3-45lsdfe3d"})
assert info.get_default_client_id() == "jak4l0v3-45lsdfe3d"
default_client = {
"id": "jak4l0v3-45lsdfe3d",
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code+pkce", "refresh_token"]
}
info = OidcProviderInfo(issuer="https://authit.test", default_clients=[default_client])

assert info.get_default_client_id(grant_types=[]) == "jak4l0v3-45lsdfe3d"
assert info.get_default_client_id(grant_types=[DefaultOidcClientGrant.DEVICE_CODE_PKCE]) == "jak4l0v3-45lsdfe3d"
assert info.get_default_client_id(grant_types=[DefaultOidcClientGrant.REFRESH_TOKEN]) == "jak4l0v3-45lsdfe3d"
assert info.get_default_client_id(grant_types=[
DefaultOidcClientGrant.DEVICE_CODE_PKCE, DefaultOidcClientGrant.REFRESH_TOKEN
]) == "jak4l0v3-45lsdfe3d"
assert info.get_default_client_id(grant_types=[DefaultOidcClientGrant.IMPLICIT]) is None
assert info.get_default_client_id(grant_types=[
DefaultOidcClientGrant.IMPLICIT, DefaultOidcClientGrant.REFRESH_TOKEN
]) is None


@pytest.mark.parametrize(
Expand Down
5 changes: 4 additions & 1 deletion tests/rest/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,10 @@ def test_authenticate_oidc_device_flow_multiple_provider_one_config_no_given_def
{"id": "fauth", "issuer": "https://fauth.test", "title": "Foo", "scopes": ["openid"]},
{
"id": "bauth", "issuer": "https://bauth.test", "title": "Bar", "scopes": ["openid"],
"default_client": {"id": default_client_id}
"default_clients": [{
"id": default_client_id,
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code+pkce", "refresh_token"]
}]
},
]
})
Expand Down