Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +15 to +16

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex finding: Minor: Add outbound authentication documentation

These variables appear in the generated example environment page, but users have no setup or client usage instructions. Please document credential setup, the fixed Auth0 endpoint and audience, and a minimal client example under docs/, and include the new modules in the API reference. Clarify that credentials are required for outbound authentication; the existing inbound endpoints do not instantiate this provider.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex finding: Severity - Moderate (incorrect behavior under specific, realistic conditions); Blocking: Correct the credential setup instructions

Credential setup instructions are now present, but the configuration table names the secret VLM_SECRET_ID; the implementation reads VLM_CLIENT_SECRET. Following the table therefore sends no configured client secret to Auth0 and prevents token retrieval. Please change the documented name to VLM_CLIENT_SECRET.

Please also distinguish these outbound credentials from the settings required to launch the existing inbound service. The table’s introductory note currently says every listed value is required for startup, although the inbound service does not instantiate TokenProvider. These are corrections needed to make the setup instructions accurate, separate from the optional documentation additions in the original comment.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex finding: Severity - Low (limited impact, such as documentation or maintainability issues); Non-blocking: Add client usage guidance

The previously suggested fixed Auth0 endpoint and audience, minimal client example, and API-reference entries are still absent. Adding them would help developers construct and use the outbound client without reading its implementation. These additions are optional and can be left out; they do not need a separate issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoops, fixed the incorrect variable name in the env var documentation. But instructions re: how to obtain a token are not public information, that's something the VLM Network admins explain once you've been accepted in. So I've documented that, but won't add further info.

Also these aren't technically required for startup now, but they will be in #112 so no point in changing docs now just to change 'em back later.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex finding: Severity - Low (limited impact, such as documentation or maintainability issues); Non-blocking: Clarify when credentials are required

The variable-name correction fixes the blocking issue. The remaining documentation point is smaller: the configuration page says AnyVLM cannot start without VLM_CLIENT_ID and VLM_CLIENT_SECRET, but this PR only requires them when constructing the outbound token provider.

Consider wording this as: “These credentials are required for authenticated outbound requests; they are not currently required to start the inbound service.” I understand #112 may change that requirement. This clarification is optional and can be left out.



####################################
## REQUIRED VLM RESPONSE SETTINGS ##
####################################
Expand Down
6 changes: 6 additions & 0 deletions docs/source/configuration/vlm.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"``
9 changes: 9 additions & 0 deletions docs/source/getting_started/docker_compose.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ Prerequisites
=============

* `Docker Compose <https://docs.docker.com/compose/install/>`_
* 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
=====
Expand All @@ -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 </configuration/vlm>`.

Then, launch the application:

.. code-block:: bash
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/anyvlm/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Handles auth for requests on the VLM network"""
76 changes: 76 additions & 0 deletions src/anyvlm/auth/token_provider.py
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
jennifer-bowser marked this conversation as resolved.
Resolved
"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)
)
2 changes: 1 addition & 1 deletion src/anyvlm/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Comment thread
jennifer-bowser marked this conversation as resolved.
Resolved
"name": "Apache 2.0",
"url": "https://github.com/genomicmedlab/anyvlm/blob/main/LICENSE",
},
Expand Down
1 change: 1 addition & 0 deletions src/anyvlm/outbound/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Handles sending outbound requests to other VLM Network nodes"""
35 changes: 35 additions & 0 deletions src/anyvlm/outbound/vlm_client.py
Original file line number Diff line number Diff line change
@@ -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"""
Comment thread
jennifer-bowser marked this conversation as resolved.
Resolved
return requests.request(
method=method_type,
url=url,
headers={"Authorization": f"Bearer {self.token_provider.get_token()}"},
Comment thread
jennifer-bowser marked this conversation as resolved.
Resolved
Comment thread
jennifer-bowser marked this conversation as resolved.
Resolved
json=request_body,
timeout=10,
)
4 changes: 4 additions & 0 deletions src/anyvlm/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/test_token_provider.py
Original file line number Diff line number Diff line change
@@ -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
81 changes: 81 additions & 0 deletions tests/unit/test_vlm_client.py
Original file line number Diff line number Diff line change
@@ -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()
Loading