diff --git a/.env.example b/.env.example index 8150316..59586be 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,14 @@ ANYVLM_STORAGE_URI=postgresql://anyvlm:anyvlm-pw@localhost:5435/anyvlm # use a blank string to use an AnyVar class instance within AnyVLM for variant service ANYVLM_ANYVAR_URI="" + +############################################# +## REQUIRED VLM NETWORK CLIENT CREDENTIALS ## +############################################# +VLM_CLIENT_ID="example-client-id" +VLM_CLIENT_SECRET="example-client-secret" + + #################################### ## REQUIRED VLM RESPONSE SETTINGS ## #################################### diff --git a/docs/source/configuration/vlm.rst b/docs/source/configuration/vlm.rst index ffa8a65..52714a2 100644 --- a/docs/source/configuration/vlm.rst +++ b/docs/source/configuration/vlm.rst @@ -24,3 +24,9 @@ The VLM API presently requires declaration of several values for service identif * - ``BEACON_NODE_ID`` - Unique ID for the service instance - ``"org.anyvlm.gregor"`` + * - ``VLM_CLIENT_ID`` + - The `client_id` provided to you by the VLM Network administrators + - ``"example-client-id"`` + * - ``VLM_CLIENT_SECRET`` + - The `client_secret` provided to you by the VLM Network administrators + - ``"example-client-secret"`` diff --git a/docs/source/getting_started/docker_compose.rst b/docs/source/getting_started/docker_compose.rst index a08faf7..bc4d8c8 100644 --- a/docs/source/getting_started/docker_compose.rst +++ b/docs/source/getting_started/docker_compose.rst @@ -7,6 +7,12 @@ Prerequisites ============= * `Docker Compose `_ +* VLM Network credentials + +..warning:: + You **must** contact the VLM Network admins and request permission to join the network prior to setup. You will be unable to + connect to the network until you are given your unique client credentials. The network admins will give you instructions on + how to obtain these once you are accepted onto the network. Steps ===== @@ -26,6 +32,9 @@ Create all required volumes: Download ``uta_20241220.pgd.gz`` from https://dl.biocommons.org/uta/ using a web browser and move it to the root of the repository. +Create a `.env` file following the included `.emv.example` template, and set the required environment variables variables as outlined +in the :doc:`VLM API Configuration page `. + Then, launch the application: .. code-block:: bash diff --git a/pyproject.toml b/pyproject.toml index 4f2071c..f38e422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,7 +146,7 @@ ignore-variadic-names = true [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "E402"] -"tests/*" = ["ANN001", "ANN2", "ANN102", "S101", "B011", "INP001", "D", "C400", "PLR2004", "PLC0415"] +"tests/*" = ["ANN001", "ANN2", "ANN102", "S101", "B011", "INP001", "D", "C400", "PLR2004", "PLC0415", "S106", "S107", "SLF001"] [tool.ruff.format] docstring-code-format = true diff --git a/src/anyvlm/auth/__init__.py b/src/anyvlm/auth/__init__.py new file mode 100644 index 0000000..9428c81 --- /dev/null +++ b/src/anyvlm/auth/__init__.py @@ -0,0 +1 @@ +"""Handles auth for requests on the VLM network""" diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py new file mode 100644 index 0000000..8cf95ae --- /dev/null +++ b/src/anyvlm/auth/token_provider.py @@ -0,0 +1,76 @@ +"""Handles retrieving/caching valid bearer tokens""" + +import os +from datetime import UTC, datetime, timedelta +from http import HTTPStatus + +import requests +from requests.models import Response + +from anyvlm.utils.exceptions import TokenRetrievalError + + +class TokenProvider: + """Handles retrieving/caching valid bearer tokens""" + + BASE_VLM_AUTH_URL: str = "https://vlm-auth.us.auth0.com" + AUDIENCE_URL: str = BASE_VLM_AUTH_URL + "/api/v2/" + TOKEN_REQUEST_URL: str = BASE_VLM_AUTH_URL + "/oauth/token" + + # used to add margin to the token expiry time, to account for the time + # it takes for the token provider to send its response back to AnyVLM + TOKEN_EXPIRY_MARGIN_SECONDS = 30 + + _token: str = "" + _token_expiry: datetime + + def __init__(self) -> None: + """Initializes a TokenProvider to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM Network""" + client_id: str | None = os.getenv("VLM_CLIENT_ID") + client_secret: str | None = os.getenv("VLM_CLIENT_SECRET") + + if not client_id or not client_secret: + error_message: str = "Unable to retrieve token - missing 'VLM_CLIENT_ID' and/or 'VLM_CLIENT_SECRET' environment variables" + raise TokenRetrievalError(error_message) + + self.client_id: str = client_id + self.client_secret: str = client_secret + + self.get_token() + + def get_token(self) -> str: + """Retrieves a valid JWT token. Will obtain a new token if + one is not currently set or if the current one is expired. + + :return: a JWT token + :raises: a `TokenRetrievalError`, if unable to obtain a token + """ + now: datetime = datetime.now(tz=UTC) + if (not self._token) or (now >= self._token_expiry): + self._refresh_token() + return self._token + + def _refresh_token(self) -> None: + """Retrieves and sets a new JWT token and updates the token expiry time""" + now: datetime = datetime.now(tz=UTC) + response: Response = requests.post( + url=self.TOKEN_REQUEST_URL, + json={ + "client_id": self.client_id, + "client_secret": self.client_secret, + "audience": self.AUDIENCE_URL, + "grant_type": "client_credentials", + }, + timeout=10, + ) + + if response.status_code != HTTPStatus.OK: + error_message: str = f"Unable to fetch bearer token from Auth0 - {response.status_code} response received" + raise TokenRetrievalError(error_message) + + data = response.json() + + self._token = data["access_token"] + self._token_expiry = now + timedelta( + seconds=(data["expires_in"] - self.TOKEN_EXPIRY_MARGIN_SECONDS) + ) diff --git a/src/anyvlm/main.py b/src/anyvlm/main.py index c6cfbf5..6047486 100644 --- a/src/anyvlm/main.py +++ b/src/anyvlm/main.py @@ -152,7 +152,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: version=__version__, docs_url=API_PREFIX, openapi_url=f"{API_PREFIX}/openapi.json", - license={ + license_info={ "name": "Apache 2.0", "url": "https://github.com/genomicmedlab/anyvlm/blob/main/LICENSE", }, diff --git a/src/anyvlm/outbound/__init__.py b/src/anyvlm/outbound/__init__.py new file mode 100644 index 0000000..5852a18 --- /dev/null +++ b/src/anyvlm/outbound/__init__.py @@ -0,0 +1 @@ +"""Handles sending outbound requests to other VLM Network nodes""" diff --git a/src/anyvlm/outbound/vlm_client.py b/src/anyvlm/outbound/vlm_client.py new file mode 100644 index 0000000..f68dde4 --- /dev/null +++ b/src/anyvlm/outbound/vlm_client.py @@ -0,0 +1,35 @@ +"""Handles sending authenticated outbound requests to the VLM Network""" + +from http import HTTPMethod +from typing import Any + +import requests +from requests import Response + +from anyvlm.auth.token_provider import TokenProvider + + +class VlmClient: + """Handles sending authenticated outbound requests to the VLM Network""" + + def __init__(self, token_provider: TokenProvider) -> None: + """Initializes a VLM client to send outbound requests to the VLM Network + + :param token_provider: A TokenProvider instance; used to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM network + """ + self.token_provider: TokenProvider = token_provider + + def request( + self, + method_type: HTTPMethod, + url: str, + request_body: dict[Any, Any] | None = None, + ) -> Response: + """Send an authenticated outbound request to the VLM Network""" + return requests.request( + method=method_type, + url=url, + headers={"Authorization": f"Bearer {self.token_provider.get_token()}"}, + json=request_body, + timeout=10, + ) diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index 9d0152a..a65365f 100644 --- a/src/anyvlm/utils/exceptions.py +++ b/src/anyvlm/utils/exceptions.py @@ -19,3 +19,7 @@ class VcfIngestionError(Exception): class VariantLookupError(Exception): """Raised when a variant cannot be retrieved from AnyVar""" + + +class TokenRetrievalError(Exception): + """Raised when a token cannot be retrieved""" diff --git a/tests/conftest.py b/tests/conftest.py index 4c29665..f873101 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,8 @@ environ.setdefault("HANDOVER_TYPE_ID", "CUSTOM:test-id") environ.setdefault("HANDOVER_TYPE_LABEL", "Test Label") environ.setdefault("BEACON_HANDOVER_URL", "https://test.example.com") +environ.setdefault("VLM_CLIENT_ID", "example-client-id") +environ.setdefault("VLM_CLIENT_SECRET", "example-client-secret") @pytest.fixture(scope="session") diff --git a/tests/unit/test_token_provider.py b/tests/unit/test_token_provider.py new file mode 100644 index 0000000..3154f8b --- /dev/null +++ b/tests/unit/test_token_provider.py @@ -0,0 +1,76 @@ +import time +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch + +from anyvlm.auth.token_provider import TokenProvider + + +def _token_response( + token: str = "test-token", expires_in: int = 3600, delay: bool = False +) -> MagicMock: + if delay: + time.sleep(3) + + response: MagicMock = MagicMock() + response.status_code = 200 + response.json.return_value = { + "access_token": token, + "expires_in": expires_in, + } + return response + + +def test_fetches_token_on_initialization() -> None: + with patch("anyvlm.auth.token_provider.requests.post") as mock_post: + mock_post.return_value = _token_response() + + provider: TokenProvider = TokenProvider() + + assert provider.get_token() == "test-token" + mock_post.assert_called_once() + + +def test_fetches_new_token_when_current_token_is_expired() -> None: + with patch("anyvlm.auth.token_provider.requests.post") as mock_post: + mock_post.side_effect = [ + _token_response(token="initial-token"), + _token_response(token="refreshed-token"), + ] + provider: TokenProvider = TokenProvider() + provider._token_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] + + assert provider.get_token() == "refreshed-token" + assert mock_post.call_count == 2 + + +def test_does_not_fetch_new_token_when_current_token_is_not_expired() -> None: + with patch("anyvlm.auth.token_provider.requests.post") as mock_post: + mock_post.side_effect = [ + _token_response(token="initial-token"), + _token_response( + token="refreshed-token" + ), # this token should NOT be returned, since the call should only be made once + ] + provider: TokenProvider = TokenProvider() + + assert provider.get_token() == "initial-token" + assert ( + provider.get_token() == "initial-token" + ) # a second call should still return the same initial token + mock_post.assert_called_once() + + +def test_token_provider_does_not_extend_token_validity_after_delayed_response() -> None: + with patch("anyvlm.auth.token_provider.requests.post") as mock_post: + mock_post.side_effect = [ + # Initial token will arrive already expired due to response delay + _token_response(token="initial-token", expires_in=1, delay=True), + _token_response(token="refreshed-token"), + ] + + # TokenProvider is unknowingly initialized w/ expired token + provider: TokenProvider = TokenProvider() + + # Call to 'get_token' should trigger token refresh + assert provider.get_token() == "refreshed-token" + assert mock_post.call_count == 2 diff --git a/tests/unit/test_vlm_client.py b/tests/unit/test_vlm_client.py new file mode 100644 index 0000000..a0e34a3 --- /dev/null +++ b/tests/unit/test_vlm_client.py @@ -0,0 +1,81 @@ +from datetime import UTC, datetime, timedelta +from http import HTTPMethod +from unittest.mock import MagicMock, patch + +import pytest + +from anyvlm.auth.token_provider import TokenProvider +from anyvlm.outbound.vlm_client import VlmClient +from anyvlm.utils.exceptions import TokenRetrievalError + + +def _token_response(token: str) -> MagicMock: + response: MagicMock = MagicMock() + response.status_code = 200 + response.json.return_value = { + "access_token": token, + "expires_in": 3600, + } + return response + + +def test_request_includes_cached_token_in_authorization_header() -> None: + with patch("anyvlm.auth.token_provider.requests.post") as mock_post: + mock_post.return_value = _token_response(token="cached-token") + token_provider: TokenProvider = TokenProvider() + + with patch("anyvlm.outbound.vlm_client.requests.request") as mock_request: + VlmClient(token_provider).request( + method_type=HTTPMethod.GET, + url="https://example.com/variants", + request_body={"variant": "test"}, + ) + + mock_request.assert_called_once_with( + method=HTTPMethod.GET, + url="https://example.com/variants", + headers={"Authorization": "Bearer cached-token"}, + json={"variant": "test"}, + timeout=10, + ) + mock_post.assert_called_once() + + +def test_request_includes_refreshed_token_in_authorization_header() -> None: + with patch("anyvlm.auth.token_provider.requests.post") as mock_post: + mock_post.side_effect = [ + _token_response(token="cached-token"), + _token_response(token="refreshed-token"), + ] + token_provider: TokenProvider = TokenProvider() + token_provider._token_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] + + with patch("anyvlm.outbound.vlm_client.requests.request") as mock_request: + VlmClient(token_provider).request( + method_type=HTTPMethod.POST, url="https://example.com/variants" + ) + + mock_request.assert_called_once_with( + method=HTTPMethod.POST, + url="https://example.com/variants", + headers={"Authorization": "Bearer refreshed-token"}, + json=None, + timeout=10, + ) + assert mock_post.call_count == 2 + + +def test_request_does_not_send_request_when_token_retrieval_fails() -> None: + token_provider: MagicMock = MagicMock(spec=TokenProvider) + token_provider.get_token.side_effect = TokenRetrievalError( + "Unable to fetch bearer token from Auth0" + ) + client: VlmClient = VlmClient(token_provider) + + with ( + patch("anyvlm.outbound.vlm_client.requests.request") as mock_request, + pytest.raises(TokenRetrievalError), + ): + client.request(method_type=HTTPMethod.GET, url="https://example.com/variants") + + mock_request.assert_not_called()