From 77fa2b062761bf633fe75c566a5d372e683b8f7d Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Mon, 26 Oct 2026 14:32:19 -0400 Subject: [PATCH 01/13] implement token provider + base outbound request client --- .env.example | 8 +++++ src/anyvlm/auth/__init__.py | 1 + src/anyvlm/auth/token_provider.py | 60 +++++++++++++++++++++++++++++++ src/anyvlm/outbound/__init__.py | 1 + src/anyvlm/outbound/client.py | 27 ++++++++++++++ src/anyvlm/utils/exceptions.py | 4 +++ 6 files changed, 101 insertions(+) create mode 100644 src/anyvlm/auth/__init__.py create mode 100644 src/anyvlm/auth/token_provider.py create mode 100644 src/anyvlm/outbound/__init__.py create mode 100644 src/anyvlm/outbound/client.py diff --git a/.env.example b/.env.example index 8150316..fa4de21 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="" + +######################## +## VLM NETWORK CLIENT ## +######################## +VLM_CLIENT_ID="example-client-id" +VLM_CLIENT_SECRET="example-client-secret" + + #################################### ## REQUIRED VLM RESPONSE SETTINGS ## #################################### 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..c684937 --- /dev/null +++ b/src/anyvlm/auth/token_provider.py @@ -0,0 +1,60 @@ +"""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" + + _token: str = "" + _token_expiry: datetime + + def __init__(self) -> None: # noqa: D107 + 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""" + response: Response = requests.post( + url=self.TOKEN_REQUEST_URL, + json={ + "client_id": os.getenv("VLM_CLIENT_ID"), + "client_secret": os.getenv("VLM_CLIENT_SECRET"), + "audience": self.AUDIENCE_URL, + "grant_type": "client_credentials", + }, + timeout=10, + ) + + if response.status_code != HTTPStatus.OK: + error_message: str = "Unable to fetch bearer token from Auth0" + raise TokenRetrievalError(error_message) + + data = response.json() + + self._token = data["access_token"] + self._token_expiry = datetime.now(tz=UTC) + timedelta( + seconds=data["expires_in"] + ) 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/client.py b/src/anyvlm/outbound/client.py new file mode 100644 index 0000000..016252d --- /dev/null +++ b/src/anyvlm/outbound/client.py @@ -0,0 +1,27 @@ +"""Handles sending authenticated outbound requests to the VLM Network""" + +from http import HTTPMethod +from typing import Any + +import requests + +from anyvlm.auth.token_provider import TokenProvider + + +class Client: + """Handles sending authenticated outbound requests to the VLM Network""" + + def __init__(self) -> None: # noqa: D107 + self.token_provider: TokenProvider = TokenProvider() + + async def request( + self, method_type: HTTPMethod, url: str, request_body: dict[Any, Any] | None + ) -> Any: # noqa: ANN401 + """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()}"}, + data=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""" From c71d9a930a05df750474e04d089e768f70344d2b Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Mon, 26 Oct 2026 14:59:47 -0400 Subject: [PATCH 02/13] adds tests --- pyproject.toml | 2 +- tests/unit/test_token_provider.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_token_provider.py diff --git a/pyproject.toml b/pyproject.toml index 798ca6f..0c8e66b 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/tests/unit/test_token_provider.py b/tests/unit/test_token_provider.py new file mode 100644 index 0000000..59d76af --- /dev/null +++ b/tests/unit/test_token_provider.py @@ -0,0 +1,54 @@ +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) -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "access_token": token, + "expires_in": expires_in, + } + return response + + +def test_fetches_token_on_initialization(): + with patch("anyvlm.auth.token_provider.requests.post") as mock_post: + mock_post.return_value = _token_response() + + provider = TokenProvider() + + assert provider.get_token() == "test-token" + mock_post.assert_called_once() + + +def test_fetches_new_token_when_current_token_is_expired(): + 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() + 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(): + 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() + + assert provider.get_token() == "initial-token" + assert ( + provider.get_token() == "initial-token" + ) # a second call should still return the same token + mock_post.assert_called_once() From af6b0de9086f23357419294f52178a1ef3778c8e Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Mon, 26 Oct 2026 16:18:12 -0400 Subject: [PATCH 03/13] make 'token_provider' an arg for 'VlmClient' instead of having the client instantiate its own --- src/anyvlm/outbound/{client.py => vlm_client.py} | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) rename src/anyvlm/outbound/{client.py => vlm_client.py} (62%) diff --git a/src/anyvlm/outbound/client.py b/src/anyvlm/outbound/vlm_client.py similarity index 62% rename from src/anyvlm/outbound/client.py rename to src/anyvlm/outbound/vlm_client.py index 016252d..d056aa2 100644 --- a/src/anyvlm/outbound/client.py +++ b/src/anyvlm/outbound/vlm_client.py @@ -4,24 +4,28 @@ from typing import Any import requests +from requests import Response from anyvlm.auth.token_provider import TokenProvider -class Client: +class VlmClient: """Handles sending authenticated outbound requests to the VLM Network""" - def __init__(self) -> None: # noqa: D107 - self.token_provider: TokenProvider = TokenProvider() + def __init__(self, token_provider: TokenProvider) -> None: # noqa: D107 + self.token_provider: TokenProvider = token_provider async def request( - self, method_type: HTTPMethod, url: str, request_body: dict[Any, Any] | None - ) -> Any: # noqa: ANN401 + 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()}"}, - data=request_body, + json=request_body, timeout=10, ) From 7b9070fece63c54ff8d5e21a87b65da5a5afebff Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 09:29:08 -0400 Subject: [PATCH 04/13] add margin to token expiry time --- src/anyvlm/auth/token_provider.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index c684937..e270cd7 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -17,6 +17,10 @@ class TokenProvider: 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 @@ -56,5 +60,5 @@ def _refresh_token(self) -> None: self._token = data["access_token"] self._token_expiry = datetime.now(tz=UTC) + timedelta( - seconds=data["expires_in"] + seconds=(data["expires_in"] - self.TOKEN_EXPIRY_MARGIN_SECONDS) ) From ab2638fd7135812fa941da08bd0a788e2ebe33ce Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 10:56:29 -0400 Subject: [PATCH 05/13] add documentation for 'VLM_CLIENT_ID' and 'VLM_CLIENT_SECRET' env vars --- .env.example | 6 +++--- docs/source/configuration/vlm.rst | 6 ++++++ docs/source/getting_started/docker_compose.rst | 9 +++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index fa4de21..59586be 100644 --- a/.env.example +++ b/.env.example @@ -9,9 +9,9 @@ ANYVLM_STORAGE_URI=postgresql://anyvlm:anyvlm-pw@localhost:5435/anyvlm ANYVLM_ANYVAR_URI="" -######################## -## VLM NETWORK CLIENT ## -######################## +############################################# +## REQUIRED VLM NETWORK CLIENT CREDENTIALS ## +############################################# VLM_CLIENT_ID="example-client-id" VLM_CLIENT_SECRET="example-client-secret" diff --git a/docs/source/configuration/vlm.rst b/docs/source/configuration/vlm.rst index ffa8a65..c50f32d 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 + - ``"kjnq3iuyththofdh88347"`` + * - ``VLM_SECRET_ID`` + - The `client_secret` provided to you by the VLM Network administrators + - ``"nlk34t98nvcnjkcf78r9shvfkidsxhfabn3947tghvdb9"`` 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 From 6603433323006ce81fa0b3ac18b93f5c1119ece4 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 11:00:59 -0400 Subject: [PATCH 06/13] adds '__init__' documentation --- src/anyvlm/auth/token_provider.py | 3 ++- src/anyvlm/outbound/vlm_client.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index e270cd7..0a00e69 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -24,7 +24,8 @@ class TokenProvider: _token: str = "" _token_expiry: datetime - def __init__(self) -> None: # noqa: D107 + def __init__(self) -> None: + """Initializes a TokenProvider to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM Network""" self.get_token() def get_token(self) -> str: diff --git a/src/anyvlm/outbound/vlm_client.py b/src/anyvlm/outbound/vlm_client.py index d056aa2..3fe2686 100644 --- a/src/anyvlm/outbound/vlm_client.py +++ b/src/anyvlm/outbound/vlm_client.py @@ -12,7 +12,11 @@ class VlmClient: """Handles sending authenticated outbound requests to the VLM Network""" - def __init__(self, token_provider: TokenProvider) -> None: # noqa: D107 + 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 async def request( From bf0de9d72d5b8f6eda9447e83871d412d1471754 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 12:21:03 -0400 Subject: [PATCH 07/13] make 'VlmClient.request' synchronous --- src/anyvlm/outbound/vlm_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anyvlm/outbound/vlm_client.py b/src/anyvlm/outbound/vlm_client.py index 3fe2686..f68dde4 100644 --- a/src/anyvlm/outbound/vlm_client.py +++ b/src/anyvlm/outbound/vlm_client.py @@ -19,7 +19,7 @@ def __init__(self, token_provider: TokenProvider) -> None: """ self.token_provider: TokenProvider = token_provider - async def request( + def request( self, method_type: HTTPMethod, url: str, From b43ec62f2d37ec98b82a8483b1fc660b4516af3f Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 12:30:00 -0400 Subject: [PATCH 08/13] adds test for 'VlmClient' --- tests/unit/test_vlm_client.py | 81 +++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/unit/test_vlm_client.py 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() From 59421e5034ca9f6d1b5ad99bd2f670478748751a Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 13:17:00 -0400 Subject: [PATCH 09/13] don't accidentally extend a token's lifetime beyond its expiration --- src/anyvlm/auth/token_provider.py | 3 ++- tests/unit/test_token_provider.py | 40 ++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index 0a00e69..0249ae9 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -42,6 +42,7 @@ def get_token(self) -> str: 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={ @@ -60,6 +61,6 @@ def _refresh_token(self) -> None: data = response.json() self._token = data["access_token"] - self._token_expiry = datetime.now(tz=UTC) + timedelta( + self._token_expiry = now + timedelta( seconds=(data["expires_in"] - self.TOKEN_EXPIRY_MARGIN_SECONDS) ) diff --git a/tests/unit/test_token_provider.py b/tests/unit/test_token_provider.py index 59d76af..3154f8b 100644 --- a/tests/unit/test_token_provider.py +++ b/tests/unit/test_token_provider.py @@ -1,11 +1,17 @@ +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) -> MagicMock: - response = MagicMock() +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, @@ -14,30 +20,30 @@ def _token_response(token: str = "test-token", expires_in: int = 3600) -> MagicM return response -def test_fetches_token_on_initialization(): +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() + provider: TokenProvider = TokenProvider() assert provider.get_token() == "test-token" mock_post.assert_called_once() -def test_fetches_new_token_when_current_token_is_expired(): +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() + 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(): +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"), @@ -45,10 +51,26 @@ def test_does_not_fetch_new_token_when_current_token_is_not_expired(): token="refreshed-token" ), # this token should NOT be returned, since the call should only be made once ] - provider = TokenProvider() + provider: TokenProvider = TokenProvider() assert provider.get_token() == "initial-token" assert ( provider.get_token() == "initial-token" - ) # a second call should still return the same 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 From 6582cf14c9b8ddb165b31962b7de746fa173e850 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 13:18:13 -0400 Subject: [PATCH 10/13] use better placeholders for vlm network cred documentation --- docs/source/configuration/vlm.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/configuration/vlm.rst b/docs/source/configuration/vlm.rst index c50f32d..52714a2 100644 --- a/docs/source/configuration/vlm.rst +++ b/docs/source/configuration/vlm.rst @@ -26,7 +26,7 @@ The VLM API presently requires declaration of several values for service identif - ``"org.anyvlm.gregor"`` * - ``VLM_CLIENT_ID`` - The `client_id` provided to you by the VLM Network administrators - - ``"kjnq3iuyththofdh88347"`` - * - ``VLM_SECRET_ID`` + - ``"example-client-id"`` + * - ``VLM_CLIENT_SECRET`` - The `client_secret` provided to you by the VLM Network administrators - - ``"nlk34t98nvcnjkcf78r9shvfkidsxhfabn3947tghvdb9"`` + - ``"example-client-secret"`` From ba70854dbf5df17b0f2799a41226d10362982bf6 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 13:30:34 -0400 Subject: [PATCH 11/13] adds HTTP status code on failed requests to obtain tokens from Auth0 --- src/anyvlm/auth/token_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index 0249ae9..eb9f33e 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -55,7 +55,7 @@ def _refresh_token(self) -> None: ) if response.status_code != HTTPStatus.OK: - error_message: str = "Unable to fetch bearer token from Auth0" + error_message: str = f"Unable to fetch bearer token from Auth0 - {response.status_code} response received" raise TokenRetrievalError(error_message) data = response.json() From ec9601d1e73b219fb7cc597c30bcb0999d813825 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 13:34:52 -0400 Subject: [PATCH 12/13] check for 'VLM_CLIENT_ID' and 'VLM_CLIENT_SECRET' env vars during 'TokenProvider' init --- src/anyvlm/auth/token_provider.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index eb9f33e..8cf95ae 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -26,6 +26,16 @@ class TokenProvider: 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: @@ -46,8 +56,8 @@ def _refresh_token(self) -> None: response: Response = requests.post( url=self.TOKEN_REQUEST_URL, json={ - "client_id": os.getenv("VLM_CLIENT_ID"), - "client_secret": os.getenv("VLM_CLIENT_SECRET"), + "client_id": self.client_id, + "client_secret": self.client_secret, "audience": self.AUDIENCE_URL, "grant_type": "client_credentials", }, From c4c2e8aaf3e761ad51ee1fcf516b66382e72efcd Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 13:44:40 -0400 Subject: [PATCH 13/13] mock vlm network cred env vars so tests don't fail --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) 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")