From 21209b59d5beaccc93d79dde6e6b050d05612ef0 Mon Sep 17 00:00:00 2001 From: Simon Jungbluth Date: Mon, 31 Aug 2026 17:08:53 +0200 Subject: [PATCH 1/3] integrating auth mechanism in client --- docs/api.md | 6 +- pyproject.toml | 3 + src/shellsmith/auth.py | 200 ++++++++++++++++++++++++++++++ src/shellsmith/clients.py | 11 +- tests/auth/test_token_provider.py | 59 +++++++++ 5 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 src/shellsmith/auth.py create mode 100644 tests/auth/test_token_provider.py diff --git a/docs/api.md b/docs/api.md index 8c30369..8cfcd46 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,7 +16,7 @@ shells = response["result"] # Extract the actual shells list # Fetch a specific Shell by ID shell = shellsmith.get_shell("https://example.com/shells/my-shell") -# Fetch a specific Submodel by ID +# Fetch a specific Submodel by ID submodel = shellsmith.get_submodel("https://example.com/submodels/my-submodel") # Read and update a Submodel Element's value @@ -33,10 +33,10 @@ from shellsmith.clients import Client, AsyncClient with Client() as client: response = client.get_shells() shells = response["result"] - + # Asynchronous client async with AsyncClient() as client: - response = await client.get_shells() + response = await client.get_shells() shells = response["result"] health = await client.get_health_status() ``` diff --git a/pyproject.toml b/pyproject.toml index 05f9f84..1e0d4f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,3 +107,6 @@ ignore = [ [tool.ruff.lint.pydocstyle] convention = "google" + +[tool.ruff.lint.pylint] +max-args = 6 \ No newline at end of file diff --git a/src/shellsmith/auth.py b/src/shellsmith/auth.py new file mode 100644 index 0000000..09ee0cb --- /dev/null +++ b/src/shellsmith/auth.py @@ -0,0 +1,200 @@ +"""Sync and async auth.""" + +import time +import typing +from enum import Enum + +import httpx +from httpx import Request, Response +from typing_extensions import override + +from shellsmith.config import config + + +class GrantType(str, Enum): + """Supported grant types.""" + + CLIENT_CREDENTIALS = "client_credentials" + PASSWORD = "password" + + def __str__(self) -> str: + """Return the enum member's value as its string representation.""" + return self.value + + +class TokenProvider: + """Base class for token providers. + + To implement a custom token provider scheme, subclass `TokenProvider` + and add data elements in self._data. + """ + + def __init__( + self, + token_url: str, + grant_type: GrantType, + timeout: float = config.timeout, + ) -> None: + """Initialize a token provider. + + Args: + token_url: URL of the token endpoint. + grant_type: Grant type used to obtain the token. + timeout: Request timeout in seconds. + """ + self._token_url = token_url + self._grant_type = grant_type + self._timeout = timeout + + self._token = None + self._expires_at = 0 + + self._data = {"grant_type": self._grant_type} + + def _token_valid(self) -> bool: + """Return whether the current token exists and has not expired.""" + return self._token and time.time() < self._expires_at + + def _save_token(self, payload: dict[str, typing.Any]) -> None: + """Save the access token and calculate its expiration time. + + Args: + payload: Token response containing ``access_token`` and optionally + ``expires_in`` in seconds. + """ + self._token = payload["access_token"] + self._expires_at = time.time() + payload.get("expires_in", 3600) + + def get_token_sync(self) -> str: + """Return a valid access token synchronously, refreshing it when necessary.""" + if self._token_valid(): + return self._token + + with httpx.Client() as client: + response = client.post( + self._token_url, timeout=self._timeout, data=self._data + ) + response.raise_for_status() + self._save_token(response.json()) + + return self._token + + async def get_token_async(self) -> str: + """Return a valid access token asynchronously, refreshing it when necessary.""" + if self._token_valid(): + return self._token + + async with httpx.AsyncClient() as client: + r = await client.post( + self._token_url, timeout=self._timeout, data=self._data + ) + r.raise_for_status() + payload = r.json() + + self._save_token(payload) + return self._token + + +class PasswordTokenProvider(TokenProvider): + """Token provider using password.""" + + def __init__( + self, + token_url: str, + client_id: str, + client_secret: str, + username: str, + password: str, + timeout: float = config.timeout, + ) -> None: + """Initialize a password token provider. + + Args: + token_url: URL of the token endpoint. + client_id: Client identifier. + client_secret: Client secret. + username: Username. + password: Password. + timeout: Request timeout in seconds. + """ + super().__init__( + token_url=token_url, timeout=timeout, grant_type=GrantType.PASSWORD + ) + + self._client_id = client_id + self._client_secret = client_secret + self._username = username + self._password = password + + self._data.update( + { + "username": self._username, + "password": self._password, + "client_id": self._client_id, + "client_secret": self._client_secret, + } + ) + + +class ClientCredentialsTokenProvider(TokenProvider): + """Token provider using client credentials.""" + + def __init__( + self, + token_url: str, + client_id: str, + client_secret: str, + timeout: float = config.timeout, + ) -> None: + """Initialize a client credentials token provider. + + Args: + token_url: URL of the token endpoint. + client_id: Client identifier. + client_secret: Client secret. + timeout: Request timeout in seconds. + """ + super().__init__( + token_url=token_url, + timeout=timeout, + grant_type=GrantType.CLIENT_CREDENTIALS, + ) + self._client_id = client_id + self._client_secret = client_secret + + self._data.update( + { + "client_id": self._client_id, + "client_secret": self._client_secret, + } + ) + + +class Auth(httpx.Auth): + """Authentication handler that adds an access token to httpx.request.""" + + def __init__(self, token_provider: "TokenProvider") -> None: + """Initialize the authentication handler. + + Args: + token_provider: Provider used to obtain an access token. + """ + self.token_provider = token_provider + + @override + def sync_auth_flow( + self, request: Request + ) -> typing.Generator[Request, Response, None]: + """Synchronously fetch a token and add it to the request header.""" + token = self.token_provider.get_token_sync() + request.headers["Authorization"] = f"Bearer {token}" + yield request + + @override + async def async_auth_flow( + self, request: Request + ) -> typing.Generator[Request, Response, None]: + """Asynchronously fetch a token and add it to the request header.""" + token = await self.token_provider.get_token_async() + request.headers["Authorization"] = f"Bearer {token}" + yield request diff --git a/src/shellsmith/clients.py b/src/shellsmith/clients.py index 6395c94..8cb0464 100644 --- a/src/shellsmith/clients.py +++ b/src/shellsmith/clients.py @@ -9,6 +9,7 @@ import httpx from httpx import Response +from shellsmith.auth import Auth from shellsmith.config import config from shellsmith.types import JSON from shellsmith.utils import base64_encoded @@ -21,20 +22,23 @@ def __init__( self, host: str = config.host, timeout: float = config.timeout, + auth: Auth | None = None, ) -> None: """Initialize async client. Args: host: Base URL of the AAS server. Defaults to configured host. timeout: Request timeout in seconds. + auth: Authentication configuration for requests. """ self.host = host self.timeout = timeout self._client: httpx.AsyncClient | None = None + self._auth = auth async def __aenter__(self) -> "AsyncClient": """Enter async context manager.""" - self._client = httpx.AsyncClient(timeout=self.timeout) + self._client = httpx.AsyncClient(timeout=self.timeout, auth=self._auth) return self async def __aexit__( @@ -705,20 +709,23 @@ def __init__( self, host: str = config.host, timeout: float = config.timeout, + auth: Auth | None = None, ) -> None: """Initialize sync client. Args: host: Base URL of the AAS server. Defaults to configured host. timeout: Request timeout in seconds. + auth: Authentication configuration for requests. """ self.host = host self.timeout = timeout self._client: httpx.Client | None = None + self._auth = auth def __enter__(self) -> "Client": """Enter context manager.""" - self._client = httpx.Client(timeout=self.timeout) + self._client = httpx.Client(timeout=self.timeout, auth=self._auth) return self def __exit__( diff --git a/tests/auth/test_token_provider.py b/tests/auth/test_token_provider.py new file mode 100644 index 0000000..3f29698 --- /dev/null +++ b/tests/auth/test_token_provider.py @@ -0,0 +1,59 @@ +from unittest.mock import Mock, patch + +from shellsmith.auth import ( + ClientCredentialsTokenProvider, + GrantType, + PasswordTokenProvider, + TokenProvider, +) + + +def verify_provider(token_provider: TokenProvider): + now = 0 + expires: int = 5 + + response = Mock() + response.json.return_value = { + "access_token": "test-token", + "expires_in": expires, + } + + with patch("httpx.Client.post", return_value=response): + token = token_provider.get_token_sync() + + assert token == "test-token" + token_provider._expires_at = now + expires + + with patch("shellsmith.auth.time.time", return_value=now): + assert token_provider._token_valid() + + with patch("shellsmith.auth.time.time", return_value=now + expires + 1): + assert not token_provider._token_valid() + + +def test_client_credentials_provider(): + + client_credentials_provider = ClientCredentialsTokenProvider( + token_url="token_url", client_id="client_id", client_secret="client_secret" + ) + + assert client_credentials_provider._grant_type == GrantType.CLIENT_CREDENTIALS + assert client_credentials_provider._grant_type.value == "client_credentials" + + verify_provider(client_credentials_provider) + + +def test_password_provider(): + + password_provider = PasswordTokenProvider( + token_url="token_url", + client_id="client_id", + client_secret="client_secret", + username="username", + password="password", + ) + + assert password_provider._grant_type == GrantType.PASSWORD + assert password_provider._grant_type.value == "password" + + verify_provider(password_provider) From d5a280a96986c53be0d06bb3e0a7420e3eb75ba8 Mon Sep 17 00:00:00 2001 From: Kuno Zoltner <98545231+kzoltner@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:31:10 +0200 Subject: [PATCH 2/3] Update Codecov token secret in workflow --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fa29773..d6b92ac 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -56,5 +56,5 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ secrets.SFKL_CODECOV_SECRET }} verbose: true From 99eda70fdf73c430ef6b86dc9cdb6f40bfe3f2c0 Mon Sep 17 00:00:00 2001 From: Simon Jungbluth Date: Tue, 1 Sep 2026 15:43:52 +0200 Subject: [PATCH 3/3] Remove unused internal veriables and improve descriptions --- src/shellsmith/auth.py | 124 +++++++++++++++--------------- tests/auth/test_token_provider.py | 26 +++---- 2 files changed, 76 insertions(+), 74 deletions(-) diff --git a/src/shellsmith/auth.py b/src/shellsmith/auth.py index 09ee0cb..be2cbc9 100644 --- a/src/shellsmith/auth.py +++ b/src/shellsmith/auth.py @@ -2,7 +2,7 @@ import time import typing -from enum import Enum +from dataclasses import dataclass import httpx from httpx import Request, Response @@ -11,61 +11,76 @@ from shellsmith.config import config -class GrantType(str, Enum): - """Supported grant types.""" +@dataclass +class UserAuthentication: + """Authentication data for user-based authentication. - CLIENT_CREDENTIALS = "client_credentials" - PASSWORD = "password" + Args: + username: Username used for authentication. + password: Password used for authentication. + """ + + username: str + password: str + + +@dataclass +class ClientAuthentication: + """Authentication data for client-based authentication. + + Args: + client_id: The unique identifier of the client used for authentication. + client_secret: The secret associated with the client used for authentication. + """ - def __str__(self) -> str: - """Return the enum member's value as its string representation.""" - return self.value + client_id: str + client_secret: str class TokenProvider: """Base class for token providers. To implement a custom token provider scheme, subclass `TokenProvider` - and add data elements in self._data. + and provide data. """ def __init__( self, token_url: str, - grant_type: GrantType, + data: dict[str, str], timeout: float = config.timeout, ) -> None: """Initialize a token provider. Args: token_url: URL of the token endpoint. - grant_type: Grant type used to obtain the token. + data: Data required to request an access token, + including the grant type and authentication credentials. timeout: Request timeout in seconds. """ self._token_url = token_url - self._grant_type = grant_type self._timeout = timeout self._token = None self._expires_at = 0 - self._data = {"grant_type": self._grant_type} + self._data = data def _token_valid(self) -> bool: """Return whether the current token exists and has not expired.""" return self._token and time.time() < self._expires_at - def _save_token(self, payload: dict[str, typing.Any]) -> None: + def _save_token(self, data: dict[str, typing.Any]) -> None: """Save the access token and calculate its expiration time. Args: - payload: Token response containing ``access_token`` and optionally + data: Token response containing ``access_token`` and optionally ``expires_in`` in seconds. """ - self._token = payload["access_token"] - self._expires_at = time.time() + payload.get("expires_in", 3600) + self._token = data["access_token"] + self._expires_at = time.time() + data.get("expires_in", 3600) - def get_token_sync(self) -> str: + def sync_get_token(self) -> str: """Return a valid access token synchronously, refreshing it when necessary.""" if self._token_valid(): return self._token @@ -79,7 +94,7 @@ def get_token_sync(self) -> str: return self._token - async def get_token_async(self) -> str: + async def async_get_token(self) -> str: """Return a valid access token asynchronously, refreshing it when necessary.""" if self._token_valid(): return self._token @@ -96,77 +111,64 @@ async def get_token_async(self) -> str: class PasswordTokenProvider(TokenProvider): - """Token provider using password.""" + """Token provider using password grant type (see https://www.rfc-editor.org/info/rfc6749/#section-4.3).""" def __init__( self, token_url: str, - client_id: str, - client_secret: str, - username: str, - password: str, + user_authentication: UserAuthentication, + client_authentication: ClientAuthentication | None = None, timeout: float = config.timeout, ) -> None: """Initialize a password token provider. Args: token_url: URL of the token endpoint. - client_id: Client identifier. - client_secret: Client secret. - username: Username. - password: Password. + user_authentication: Username and password. + client_authentication: Optional client identifier and client secret. timeout: Request timeout in seconds. """ - super().__init__( - token_url=token_url, timeout=timeout, grant_type=GrantType.PASSWORD - ) + _data = { + "username": user_authentication.username, + "password": user_authentication.password, + "grant_type": "password", + } + + if client_authentication: + _data.update( + { + "client_id": client_authentication.client_id, + "client_secret": client_authentication.client_secret, + } + ) - self._client_id = client_id - self._client_secret = client_secret - self._username = username - self._password = password - - self._data.update( - { - "username": self._username, - "password": self._password, - "client_id": self._client_id, - "client_secret": self._client_secret, - } - ) + super().__init__(token_url=token_url, data=_data, timeout=timeout) class ClientCredentialsTokenProvider(TokenProvider): - """Token provider using client credentials.""" + """Token provider using client credentials grant type (see https://www.rfc-editor.org/info/rfc6749/#section-2.3).""" def __init__( self, token_url: str, - client_id: str, - client_secret: str, + client_authentication: ClientAuthentication, timeout: float = config.timeout, ) -> None: """Initialize a client credentials token provider. Args: token_url: URL of the token endpoint. - client_id: Client identifier. - client_secret: Client secret. + client_authentication: Client identifier and client secret. timeout: Request timeout in seconds. """ super().__init__( token_url=token_url, + data={ + "client_id": client_authentication.client_id, + "client_secret": client_authentication.client_secret, + "grant_type": "client_credentials", + }, timeout=timeout, - grant_type=GrantType.CLIENT_CREDENTIALS, - ) - self._client_id = client_id - self._client_secret = client_secret - - self._data.update( - { - "client_id": self._client_id, - "client_secret": self._client_secret, - } ) @@ -186,7 +188,7 @@ def sync_auth_flow( self, request: Request ) -> typing.Generator[Request, Response, None]: """Synchronously fetch a token and add it to the request header.""" - token = self.token_provider.get_token_sync() + token = self.token_provider.sync_get_token() request.headers["Authorization"] = f"Bearer {token}" yield request @@ -195,6 +197,6 @@ async def async_auth_flow( self, request: Request ) -> typing.Generator[Request, Response, None]: """Asynchronously fetch a token and add it to the request header.""" - token = await self.token_provider.get_token_async() + token = await self.token_provider.async_get_token() request.headers["Authorization"] = f"Bearer {token}" yield request diff --git a/tests/auth/test_token_provider.py b/tests/auth/test_token_provider.py index 3f29698..9cea187 100644 --- a/tests/auth/test_token_provider.py +++ b/tests/auth/test_token_provider.py @@ -1,10 +1,11 @@ from unittest.mock import Mock, patch from shellsmith.auth import ( + ClientAuthentication, ClientCredentialsTokenProvider, - GrantType, PasswordTokenProvider, TokenProvider, + UserAuthentication, ) @@ -19,7 +20,7 @@ def verify_provider(token_provider: TokenProvider): } with patch("httpx.Client.post", return_value=response): - token = token_provider.get_token_sync() + token = token_provider.sync_get_token() assert token == "test-token" token_provider._expires_at = now + expires @@ -34,12 +35,12 @@ def verify_provider(token_provider: TokenProvider): def test_client_credentials_provider(): client_credentials_provider = ClientCredentialsTokenProvider( - token_url="token_url", client_id="client_id", client_secret="client_secret" + token_url="token_url", + client_authentication=ClientAuthentication( + client_id="client_id", client_secret="client_secret" + ), ) - assert client_credentials_provider._grant_type == GrantType.CLIENT_CREDENTIALS - assert client_credentials_provider._grant_type.value == "client_credentials" - verify_provider(client_credentials_provider) @@ -47,13 +48,12 @@ def test_password_provider(): password_provider = PasswordTokenProvider( token_url="token_url", - client_id="client_id", - client_secret="client_secret", - username="username", - password="password", + user_authentication=UserAuthentication( + username="username", password="password" + ), + client_authentication=ClientAuthentication( + client_id="client_id", client_secret="client_secret" + ), ) - assert password_provider._grant_type == GrantType.PASSWORD - assert password_provider._grant_type.value == "password" - verify_provider(password_provider)