diff --git a/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_assertion.py b/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_assertion.py new file mode 100644 index 00000000..49b237aa --- /dev/null +++ b/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_assertion.py @@ -0,0 +1,48 @@ +from typing import Dict, Any, List + +import requests + +from dnastack.common.tracing import Span +from dnastack.http.authenticators.oauth2_adapter.abstract import OAuth2Adapter, AuthException + + +class ClientCredentialsClientAssertionAdapter(OAuth2Adapter): + __grant_type = 'client_credentials' + + @staticmethod + def get_expected_auth_info_fields() -> List[str]: + return [ + 'client_id', + 'client_assertion_file', # normally at /var/run/secrets/kubernetes.io/serviceaccount/token + 'grant_type', + 'resource_url', + 'token_endpoint', + ] + + def exchange_tokens(self, trace_context: Span) -> Dict[str, Any]: + auth_info = self._auth_info + resource_urls = self._prepare_resource_urls_for_request(auth_info.resource_url) + with open(auth_info.client_assertion_file, 'r') as f: + client_assertion = f.read() + auth_params = dict( + client_id=auth_info.client_id, + grant_type=self.__grant_type, + resource=resource_urls, + client_assertion_type='urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion=client_assertion, + ) + + if auth_info.scope: + auth_params['scope'] = auth_info.scope + + with trace_context.new_span(metadata={'oauth': 'client-credentials', 'init_url': auth_info.token_endpoint}) \ + as sub_span: + span_headers = sub_span.create_http_headers() + response = requests.post(auth_info.token_endpoint, data=auth_params, headers=span_headers) + + if not response.ok: + raise AuthException(f'Client assertion authentication for {auth_info.client_id} failed with ' + f'HTTP {response.status_code}:\n\n{response.text}\n', + resource_urls) + + return response.json() diff --git a/dnastack/http/authenticators/oauth2_adapter/client_credential.py b/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_secret.py similarity index 89% rename from dnastack/http/authenticators/oauth2_adapter/client_credential.py rename to dnastack/http/authenticators/oauth2_adapter/client_credentials_client_secret.py index fbd12b95..e586cc76 100644 --- a/dnastack/http/authenticators/oauth2_adapter/client_credential.py +++ b/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_secret.py @@ -5,7 +5,7 @@ from dnastack.http.client_factory import HttpClientFactory -class ClientCredentialAdapter(OAuth2Adapter): +class ClientCredentialsClientSecretAdapter(OAuth2Adapter): __grant_type = 'client_credentials' @staticmethod @@ -55,8 +55,8 @@ def exchange_tokens(self, trace_context: Span) -> Dict[str, Any]: if not response.ok: sub_logger.debug(f'exchange_token: Token exchange fails.') - raise AuthException(f'Failed to perform client-credential authentication for ' - f'{auth_info.client_id} as the server responds with HTTP {response.status_code}:' + raise AuthException(f'Client secret authentication for {auth_info.client_id} failed with ' + f'HTTP {response.status_code}:' f'\n\n{response.text}\n', resource_urls) diff --git a/dnastack/http/authenticators/oauth2_adapter/factory.py b/dnastack/http/authenticators/oauth2_adapter/factory.py index f780e318..bbd4cfa7 100644 --- a/dnastack/http/authenticators/oauth2_adapter/factory.py +++ b/dnastack/http/authenticators/oauth2_adapter/factory.py @@ -3,7 +3,9 @@ from imagination.decorator import service from dnastack.http.authenticators.oauth2_adapter.abstract import OAuth2Adapter -from dnastack.http.authenticators.oauth2_adapter.client_credential import ClientCredentialAdapter +from dnastack.http.authenticators.oauth2_adapter.client_credentials_client_assertion import \ + ClientCredentialsClientAssertionAdapter +from dnastack.http.authenticators.oauth2_adapter.client_credentials_client_secret import ClientCredentialsClientSecretAdapter from dnastack.http.authenticators.oauth2_adapter.device_code_flow import DeviceCodeFlowAdapter from dnastack.http.authenticators.oauth2_adapter.models import OAuth2Authentication @@ -12,8 +14,9 @@ class OAuth2AdapterFactory: # NOTE: It was ordered this way to accommodate the general intended authentication flow. __supported_auth_adapter_classes = [ + ClientCredentialsClientAssertionAdapter, DeviceCodeFlowAdapter, - ClientCredentialAdapter, + ClientCredentialsClientSecretAdapter, ] def get_from(self, auth_info: OAuth2Authentication) -> Optional[OAuth2Adapter]: diff --git a/dnastack/http/authenticators/oauth2_adapter/models.py b/dnastack/http/authenticators/oauth2_adapter/models.py index f81ec9f4..8bd97fe1 100644 --- a/dnastack/http/authenticators/oauth2_adapter/models.py +++ b/dnastack/http/authenticators/oauth2_adapter/models.py @@ -10,6 +10,7 @@ class OAuth2Authentication(BaseModel, HashableModel): authorization_endpoint: Optional[str] client_id: Optional[str] client_secret: Optional[str] + client_assertion_file: Optional[str] device_code_endpoint: Optional[str] grant_type: str personal_access_endpoint: Optional[str] diff --git a/dnastack/http/session.py b/dnastack/http/session.py index e5b89fa7..ba8eb498 100644 --- a/dnastack/http/session.py +++ b/dnastack/http/session.py @@ -121,7 +121,7 @@ def __init__(self, self.__events.set_passthrough(authenticator.events) if not self.__enable_auth: - self.__logger.info('Authentication has been disable for this session.') + self.__logger.info('Authentication has been disabled for this session.') @property def events(self) -> EventSource: