From cb741d48efbea3feffa2bfedab63858535eca7ae Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Fri, 25 Mar 2022 19:58:17 +0100 Subject: [PATCH] Issue #187/#196 `openeo.connect` support auto-authentication from config --- openeo/config.py | 11 ++- openeo/rest/connection.py | 4 +- tests/rest/test_connection.py | 137 +++++++++++++++++++++++++++++----- 3 files changed, 129 insertions(+), 23 deletions(-) diff --git a/openeo/config.py b/openeo/config.py index 798f45d64..7fe44cad7 100644 --- a/openeo/config.py +++ b/openeo/config.py @@ -10,7 +10,7 @@ from configparser import ConfigParser from copy import deepcopy from pathlib import Path -from typing import Union, Any, Sequence, Iterator +from typing import Union, Any, Sequence, Iterator, Optional _log = logging.getLogger(__name__) @@ -164,9 +164,12 @@ def load(cls) -> ClientConfig: _global_config = None -def get_config() -> ClientConfig: - """Get global :py:class:`ClientConfig` (lazily loaded).""" +def get_config(key: Optional[str] = None, default=None) -> Union[ClientConfig, str]: + """Get a value from (or the whole) global :py:class:`ClientConfig` (lazily loaded).""" global _global_config if _global_config is None: _global_config = ConfigLoader.load() - return _global_config + if key: + return _global_config.get(key, default=default) + else: + return _global_config diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index ca1ac364d..a22e62cc6 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -1156,10 +1156,12 @@ def connect( """ if url is None: # TODO: print info about which connection is used (and based on which config) - url = get_config().get("connection.default_backend") + url = get_config("connection.default_backend") if not url: raise OpenEoClientException("No back-end URL given or known to connect to.") connection = Connection(url, session=session, default_timeout=default_timeout) + + auth_type = auth_type or get_config("connection.auto_authenticate") auth_type = auth_type.lower() if isinstance(auth_type, str) else auth_type if auth_type in {None, 'null', 'none'}: pass diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index b15db25db..0029dc32b 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -1,7 +1,9 @@ +import json import logging import os import random import re +import textwrap import typing import unittest.mock as mock import zlib @@ -432,15 +434,20 @@ def test_api_error_non_json(requests_mock): assert exc.url is None +def _credentials_basic_handler(username, password, access_token="w3lc0m3"): + expected_auth = requests.auth._basic_auth_str(username=username, password=password) + + def handler(request, context): + assert request.headers["Authorization"] == expected_auth + return json.dumps({"access_token": access_token}) + + return handler + + def test_create_connection_lazy_auth_config(requests_mock, api_version): user, pwd = "john262", "J0hndo3" requests_mock.get(API_URL, json={"api_version": api_version, "endpoints": BASIC_ENDPOINTS}) - - def text_callback(request, context): - assert request.headers["Authorization"] == requests.auth._basic_auth_str(username=user, password=pwd) - return '{"access_token":"w3lc0m3"}' - - requests_mock.get(API_URL + 'credentials/basic', text=text_callback) + requests_mock.get(API_URL + 'credentials/basic', text=_credentials_basic_handler(user, pwd)) with mock.patch('openeo.rest.connection.AuthConfig') as AuthConfig: # Don't create default AuthConfig when not necessary @@ -500,12 +507,7 @@ def test_authenticate_basic_no_support(requests_mock, api_version): def test_authenticate_basic(requests_mock, api_version): user, pwd = "john262", "J0hndo3" requests_mock.get(API_URL, json={"api_version": api_version, "endpoints": BASIC_ENDPOINTS}) - - def text_callback(request, context): - assert request.headers["Authorization"] == requests.auth._basic_auth_str(username=user, password=pwd) - return '{"access_token":"w3lc0m3"}' - - requests_mock.get(API_URL + 'credentials/basic', text=text_callback) + requests_mock.get(API_URL + 'credentials/basic', text=_credentials_basic_handler(user, pwd)) conn = Connection(API_URL) assert isinstance(conn.auth, NullAuth) @@ -520,12 +522,7 @@ def text_callback(request, context): def test_authenticate_basic_from_config(requests_mock, api_version, auth_config): user, pwd = "john281", "J0hndo3" requests_mock.get(API_URL, json={"api_version": api_version, "endpoints": BASIC_ENDPOINTS}) - - def text_callback(request, context): - assert request.headers["Authorization"] == requests.auth._basic_auth_str(username=user, password=pwd) - return '{"access_token":"w3lc0m3"}' - - requests_mock.get(API_URL + 'credentials/basic', text=text_callback) + requests_mock.get(API_URL + 'credentials/basic', text=_credentials_basic_handler(user, pwd)) auth_config.set_basic_auth(backend=API_URL, username=user, password=pwd) conn = Connection(API_URL) @@ -1844,3 +1841,107 @@ def test_connect_default_backend_from_config(requests_mock, custom_client_config requests_mock.get(f"https://{url}", json={"api_version": "1.0.0"}) con = connect() assert con.root_url == f"https://{url}" + + +def test_connect_auto_auth_from_config_basic(requests_mock, custom_client_config, auth_config): + url = f"https://openeo{random.randint(0, 1000)}.test" + custom_client_config.write_text(textwrap.dedent(f""" + [Connection] + default_backend = {url} + auto_authenticate = basic + """)) + user, pwd = "john", "j0hn" + auth_config.set_basic_auth(backend=url, username=user, password=pwd) + + requests_mock.get(url, json={"api_version": "1.0.0", "endpoints": BASIC_ENDPOINTS}) + requests_mock.get(f"{url}/credentials/basic", text=_credentials_basic_handler(user, pwd, access_token="Hell0!")) + + con = connect() + assert con.root_url == url + assert isinstance(con.auth, BearerAuth) + assert con.auth.bearer == "basic//Hell0!" + + +def test_connect_auto_auth_from_config_oidc_refresh_token( + requests_mock, custom_client_config, auth_config, refresh_token_store +): + """Auto-authorize with client config, auth config and refresh tokens""" + api_url = f"https://openeo{random.randint(0, 1000)}.test" + client_id = "myclient" + refresh_token = "r3fr35h!" + issuer = "https://oidc.test" + + custom_client_config.write_text(textwrap.dedent(f""" + [Connection] + default_backend = {api_url} + auto_authenticate = oidc + """)) + + requests_mock.get(api_url, json={"api_version": "1.0.0"}) + requests_mock.get(f"{api_url}/credentials/oidc", json={ + "providers": [{"id": "oi", "issuer": issuer, "title": "example", "scopes": ["openid"]}] + }) + oidc_mock = OidcMock( + requests_mock=requests_mock, + expected_grant_type="refresh_token", + expected_client_id=client_id, + oidc_discovery_url=f"{issuer}/.well-known/openid-configuration", + expected_fields={"refresh_token": refresh_token} + ) + refresh_token_store.set_refresh_token(issuer=issuer, client_id=client_id, refresh_token=refresh_token) + auth_config.set_oidc_client_config(backend=api_url, provider_id="oi", client_id=client_id) + + # With all this set up, now the real work: + con = connect() + assert con.root_url == api_url + assert isinstance(con.auth, BearerAuth) + assert con.auth.bearer == "oidc/oi/" + oidc_mock.state["access_token"] + + +def test_connect_auto_auth_from_config_oidc_device_code( + requests_mock, custom_client_config, auth_config +): + """Auto-authorize without auth config or refresh tokens""" + api_url = f"https://openeo{random.randint(0, 1000)}.test" + default_client_id = "dadefaultklient" + grant_types = ["urn:ietf:params:oauth:grant-type:device_code+pkce", "refresh_token"] + issuer = "https://auth.test" + + custom_client_config.write_text(textwrap.dedent(f""" + [Connection] + default_backend = {api_url} + auto_authenticate = oidc + """)) + + requests_mock.get(api_url, json={"api_version": "1.0.0"}) + requests_mock.get(f"{api_url}/credentials/oidc", json={ + "providers": [ + { + "id": "auth", "issuer": issuer, "title": "Auth", "scopes": ["openid"], + "default_clients": [{"id": default_client_id, "grant_types": grant_types}] + }, + ] + }) + + expected_fields = { + "scope": "openid", + "code_verifier": True, + "code_challenge": True + } + oidc_mock = OidcMock( + requests_mock=requests_mock, + expected_grant_type="urn:ietf:params:oauth:grant-type:device_code", + expected_client_id=default_client_id, + expected_fields=expected_fields, + scopes_supported=["openid"], + oidc_discovery_url=f"{issuer}/.well-known/openid-configuration", + ) + assert auth_config.load() == {} + + # With all this set up, now the real work: + oidc_mock.state["device_code_callback_timeline"] = ["great success"] + with assert_device_code_poll_sleep(): + con = connect() + assert con.root_url == api_url + assert isinstance(con.auth, BearerAuth) + assert con.auth.bearer == "oidc/auth/" + oidc_mock.state["access_token"]