From b71ae48d3b0bfb60406db97b40a7e5fa0c687e33 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 27 Oct 2026 13:10:54 -0400 Subject: [PATCH 01/22] implement auth for incoming requests --- src/anyvlm/auth/jwt_validator.py | 52 ++++++++++++++++++++++++ src/anyvlm/auth/request_authenticator.py | 49 ++++++++++++++++++++++ src/anyvlm/main.py | 36 +++++++++++++++- src/anyvlm/outbound/node_registry.py | 10 +++-- src/anyvlm/utils/exceptions.py | 4 ++ 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 src/anyvlm/auth/jwt_validator.py create mode 100644 src/anyvlm/auth/request_authenticator.py diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py new file mode 100644 index 0000000..dfa2ead --- /dev/null +++ b/src/anyvlm/auth/jwt_validator.py @@ -0,0 +1,52 @@ +"""Handles validating and decoding JWT tokens""" + +from http import HTTPStatus +from typing import Any + +import jwt +from fastapi import HTTPException, Request + +from anyvlm.config import get_config + + +class JWTValidator: + """Handles validating and decoding JWT tokens""" + + def validate_and_decode_token(self, request: Request) -> dict[str, Any]: + """Validate and decode tokens from inbound requests. + + :param request: The inbound request containing the token to be validated/decoded + :return: The decoded token claims + :raises: `HTTPException`, if unable to validate or decode the request's token + """ + try: + scheme, token = ( + request.headers.get("Authorization", "").strip().split(sep=" ") + ) + except ValueError as e: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Invalid authorization header" + ) from e + if scheme.lower() != "bearer": + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Invalid token scheme" + ) + + try: + decoded_claims: dict[str, Any] = jwt.decode( + jwt=token, + algorithms=["RS256"], + issuer=get_config().base_vlm_network_url, + options={ + "verify_signature": False, + "verify_iss": True, + "verify_exp": True, + "require": ["azp"], + }, + ) + except jwt.InvalidTokenError as e: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail=f"Invalid token: {e}" + ) from e + + return decoded_claims diff --git a/src/anyvlm/auth/request_authenticator.py b/src/anyvlm/auth/request_authenticator.py new file mode 100644 index 0000000..c2868ab --- /dev/null +++ b/src/anyvlm/auth/request_authenticator.py @@ -0,0 +1,49 @@ +"""Handles authenticating inbound requests""" + +from typing import Any + +from fastapi import Request + +from anyvlm.auth.jwt_validator import JWTValidator +from anyvlm.outbound.node_registry import NodeRegistry +from anyvlm.utils.exceptions import AuthenticationError + + +class RequestAuthenticator: + """Handles authenticating inbound requests""" + + def __init__( # noqa: D107 + self, + jwt_validator: JWTValidator, + node_registry: NodeRegistry, + ) -> None: + self.jwt_validator: JWTValidator = jwt_validator + self.node_registry: NodeRegistry = node_registry + + def authenticate(self, request: Request) -> None: + """Authenticate a request: validate and decode request token + + confirm requesting node is known member of the VLM network. + + :param request: The inbound request to authenticate + :raises: `AuthenticationError`, if unable to authenticate. + """ + decoded_token_claims: dict[str, Any] = ( + self.jwt_validator.validate_and_decode_token(request) + ) + + client_id: str | None = decoded_token_claims.get("client_id") + + if not isinstance(client_id, str) or not client_id: + raise AuthenticationError("Missing client identity") + + # First check cached node list + if self._is_known_node(client_id=client_id): + return + + # If not in cached node list, refresh and try again + self.node_registry.refresh_nodes() + if not self._is_known_node(client_id): + raise AuthenticationError(f"Unrecognized VLM Network client: {client_id}") + + def _is_known_node(self, client_id: str) -> bool: + return client_id in self.node_registry.get_node_ids() diff --git a/src/anyvlm/main.py b/src/anyvlm/main.py index 6047486..8be03b9 100644 --- a/src/anyvlm/main.py +++ b/src/anyvlm/main.py @@ -11,7 +11,7 @@ import yaml from anyvar.anyvar import create_storage, create_translator from dotenv import load_dotenv -from fastapi import FastAPI, Request, Response, status +from fastapi import Depends, FastAPI, HTTPException, Request, Response, status from fastapi.exception_handlers import request_validation_exception_handler from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @@ -20,7 +20,12 @@ from anyvlm.anyvar.base_client import BaseAnyVarClient from anyvlm.anyvar.http_client import HttpAnyVarClient from anyvlm.anyvar.python_client import PythonAnyVarClient +from anyvlm.auth.jwt_validator import JWTValidator +from anyvlm.auth.request_authenticator import RequestAuthenticator +from anyvlm.auth.token_provider import TokenProvider from anyvlm.config import get_config +from anyvlm.outbound.node_registry import NodeRegistry +from anyvlm.outbound.vlm_client import VlmClient from anyvlm.restapi.vlm import router as vlm_router from anyvlm.schemas.common import ( SERVICE_DESCRIPTION, @@ -29,6 +34,7 @@ ServiceType, ) from anyvlm.storage.base_storage import Storage +from anyvlm.utils.exceptions import AuthenticationError from anyvlm.utils.types import ( EndpointTag, ) @@ -129,6 +135,22 @@ async def _configure_logging() -> None: _logger.info("Logging with default configs.") +def authenticate_inbound_request( + request: Request, +) -> None: + """Authenticate inbound requests. Validates/decodes JWT tokens and ensures request is from a known node""" + request_authenticator: RequestAuthenticator = ( + request.app.state.request_authenticator + ) + try: + request_authenticator.authenticate(request) + except AuthenticationError as e: + raise HTTPException( + status_code=401, + detail=str(e), + ) from e + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator: """Configure FastAPI instance lifespan. @@ -139,6 +161,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: await _configure_logging() app.state.anyvar_client = create_anyvar_client() app.state.anyvlm_storage = create_anyvlm_storage() + + token_provider = TokenProvider() + vlm_client = VlmClient(token_provider) + node_registry = NodeRegistry(vlm_client) + jwt_validator = JWTValidator() + + app.state.request_authenticator = RequestAuthenticator( + jwt_validator=jwt_validator, + node_registry=node_registry, + ) + yield app.state.anyvar_client.close() app.state.anyvlm_storage.close() @@ -163,6 +196,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: }, swagger_ui_parameters={"tryItOutEnabled": True}, lifespan=lifespan, + dependencies=[Depends(dependency=authenticate_inbound_request)], ) app.include_router(vlm_router, prefix=API_PREFIX) diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index b3a7137..cb29bab 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -35,16 +35,20 @@ def __init__(self, vlm_client: VlmClient) -> None: # noqa: D107 settings: Settings = get_config() self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "/api/v2/clients" - self._refresh_nodes() + self.refresh_nodes() def get_nodes(self) -> list[VlmNode]: """Get a list of known nodes on the VLM network. Uses the cached list if cache is set & unexpired.""" now: datetime = datetime.now(tz=UTC) if (not self._nodes) or (now >= self._nodes_expiry): - self._refresh_nodes() + self.refresh_nodes() return self._nodes - def _refresh_nodes(self) -> None: + def get_node_ids(self) -> list[str]: + """Get a list of the client_ids for all nodes on the VLM network""" + return [node.client_id for node in self.get_nodes()] + + def refresh_nodes(self) -> None: """Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check""" response: Response = self._vlm_client.request( method_type=HTTPMethod.GET, url=self.NODES_REQUEST_URL diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index 079fbc1..9f64330 100644 --- a/src/anyvlm/utils/exceptions.py +++ b/src/anyvlm/utils/exceptions.py @@ -27,3 +27,7 @@ class TokenRetrievalError(Exception): class NodeRetrievalError(Exception): """Raised when the list of nodes on the VLM network cannot be retrieved""" + + +class AuthenticationError(Exception): + """Raised when an error occurs during Authentication""" From eab233c20439a0e96d030a808131df824e6500e6 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 27 Oct 2026 14:47:21 -0400 Subject: [PATCH 02/22] first pass at writing tests --- tests/unit/auth/test_jwt_validator.py | 70 +++++++++++++++ tests/unit/auth/test_request_authenticator.py | 74 ++++++++++++++++ tests/unit/test_inbound_auth.py | 31 +++++++ tests/unit/test_node_registry.py | 87 +++++++++++++++++++ tests/unit/test_restapi.py | 2 + 5 files changed, 264 insertions(+) create mode 100644 tests/unit/auth/test_jwt_validator.py create mode 100644 tests/unit/auth/test_request_authenticator.py create mode 100644 tests/unit/test_inbound_auth.py create mode 100644 tests/unit/test_node_registry.py diff --git a/tests/unit/auth/test_jwt_validator.py b/tests/unit/auth/test_jwt_validator.py new file mode 100644 index 0000000..97eed6b --- /dev/null +++ b/tests/unit/auth/test_jwt_validator.py @@ -0,0 +1,70 @@ +from http import HTTPStatus +from unittest.mock import MagicMock, patch + +import jwt +import pytest +from fastapi import HTTPException + +from anyvlm.auth.jwt_validator import JWTValidator + + +def _request(authorization: str | None) -> MagicMock: + request = MagicMock() + request.headers.get.return_value = authorization or "" + return request + + +def test_decodes_valid_bearer_token(): + claims = {"client_id": "client-one", "azp": "client-one"} + with ( + patch("anyvlm.auth.jwt_validator.get_config") as get_config, + patch("anyvlm.auth.jwt_validator.jwt.decode", return_value=claims) as decode, + ): + get_config.return_value.base_vlm_network_url = "https://issuer.example" + + decoded_claims = JWTValidator().validate_and_decode_token( + _request("Bearer encoded-token") + ) + + assert decoded_claims == claims + decode.assert_called_once_with( + jwt="encoded-token", + algorithms=["RS256"], + issuer="https://issuer.example", + options={ + "verify_signature": False, + "verify_iss": True, + "verify_exp": True, + "require": ["azp"], + }, + ) + + +@pytest.mark.parametrize( + ("authorization", "detail"), + [ + (None, "Invalid authorization header"), + ("Bearer", "Invalid authorization header"), + ("Basic encoded-token", "Invalid token scheme"), + ], +) +def test_rejects_invalid_authorization_headers(authorization: str | None, detail: str): + with pytest.raises(HTTPException) as error: + JWTValidator().validate_and_decode_token(_request(authorization)) + + assert error.value.status_code == HTTPStatus.FORBIDDEN + assert error.value.detail == detail + + +def test_translates_jwt_errors_to_forbidden(): + with ( + patch( + "anyvlm.auth.jwt_validator.jwt.decode", + side_effect=jwt.InvalidTokenError("expired"), + ), + pytest.raises(HTTPException) as exc_info, + ): + JWTValidator().validate_and_decode_token(_request("Bearer token")) + + assert exc_info.value.status_code == HTTPStatus.FORBIDDEN + assert exc_info.value.detail == "Invalid token: expired" diff --git a/tests/unit/auth/test_request_authenticator.py b/tests/unit/auth/test_request_authenticator.py new file mode 100644 index 0000000..e07111a --- /dev/null +++ b/tests/unit/auth/test_request_authenticator.py @@ -0,0 +1,74 @@ +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from anyvlm.auth.request_authenticator import RequestAuthenticator +from anyvlm.utils.exceptions import AuthenticationError + + +def _authenticator( + claims: dict[Any, Any], node_ids: list[str] +) -> tuple[RequestAuthenticator, MagicMock, MagicMock]: + jwt_validator: MagicMock = MagicMock() + jwt_validator.validate_and_decode_token.return_value = claims + + node_registry: MagicMock = MagicMock() + node_registry.get_node_ids.return_value = node_ids + + return ( + RequestAuthenticator(jwt_validator=jwt_validator, node_registry=node_registry), + jwt_validator, + node_registry, + ) + + +def test_authenticates_known_node_without_refreshing_registry(): + request_authenticator, jwt_validator, node_registry = _authenticator( + claims={"client_id": "client-one"}, node_ids=["client-one"] + ) + + request_authenticator.authenticate(request=MagicMock()) + + jwt_validator.validate_and_decode_token.assert_called_once() + node_registry.refresh_nodes.assert_not_called() + node_registry.get_node_ids.assert_called_once() + + +def test_refreshes_registry_when_node_is_not_cached() -> None: + request_authenticator, _, node_registry = _authenticator( + claims={"client_id": "client-one"}, node_ids=[] + ) + node_registry.get_node_ids.side_effect = [[], ["client-one"]] + + request_authenticator.authenticate(request=MagicMock()) + + node_registry.refresh_nodes.assert_called_once() + assert node_registry.get_node_ids.call_count == 2 + + +def test_rejects_unknown_node_after_refresh() -> None: + request_, _, node_registry = _authenticator( + claims={"client_id": "unknown"}, node_ids=[] + ) + + with pytest.raises( + AuthenticationError, + match="Unrecognized VLM Network client: unknown", + ): + request_.authenticate(request=MagicMock()) + + node_registry.refresh_nodes.assert_called_once() + + +@pytest.mark.parametrize( + argnames="claims", argvalues=[{}, {"client_id": ""}, {"client_id": None}] +) +def test_rejects_missing_client_identity(claims: dict[Any, Any]) -> None: + request_, _, node_registry = _authenticator(claims, node_ids=[]) + + with pytest.raises(AuthenticationError, match="Missing client identity"): + request_.authenticate(request=MagicMock()) + + node_registry.get_node_ids.assert_not_called() + node_registry.refresh_nodes.assert_not_called() diff --git a/tests/unit/test_inbound_auth.py b/tests/unit/test_inbound_auth.py new file mode 100644 index 0000000..c172684 --- /dev/null +++ b/tests/unit/test_inbound_auth.py @@ -0,0 +1,31 @@ +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from anyvlm.main import authenticate_inbound_request +from anyvlm.utils.exceptions import AuthenticationError + + +def test_authenticate_inbound_request_delegates_to_app_authenticator(): + request = MagicMock() + request.app.state.request_authenticator = MagicMock() + + authenticate_inbound_request(request) + + request.app.state.request_authenticator.authenticate.assert_called_once_with( + request + ) + + +def test_authenticate_inbound_request_maps_authentication_errors_to_401(): + request = MagicMock() + request.app.state.request_authenticator.authenticate.side_effect = ( + AuthenticationError("not a known node") + ) + + with pytest.raises(HTTPException) as error: + authenticate_inbound_request(request) + + assert error.value.status_code == 401 + assert error.value.detail == "not a known node" diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py new file mode 100644 index 0000000..acfc353 --- /dev/null +++ b/tests/unit/test_node_registry.py @@ -0,0 +1,87 @@ +from datetime import UTC, datetime, timedelta +from http import HTTPMethod, HTTPStatus +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from anyvlm.outbound.node_registry import NodeRegistry +from anyvlm.utils.exceptions import NodeRetrievalError + + +def _response( + status_code: HTTPStatus = HTTPStatus.OK, data: list[Any] | None = None +) -> MagicMock: + response: MagicMock = MagicMock() + response.status_code = status_code + response.json.return_value = data or [] + return response + + +def test_loads_nodes_and_exposes_client_ids() -> None: + client: MagicMock = MagicMock() + client.request.return_value = _response( + data=[ + { + "client_id": "client-one", + "name": "One", + "client_metadata": {"match_url": "https://one.example"}, + }, + {"client_id": "client-two", "name": "Two"}, + ] + ) + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [ + "client-one", + "client-two", + ] + assert registry.get_node_ids() == ["client-one", "client-two"] + client.request.assert_called_once_with( + method_type=HTTPMethod.GET, + url="https://network.example/api/v2/clients", + ) + + +def test_refresh_nodes_replaces_cached_nodes() -> None: + client: MagicMock = MagicMock() + client.request.side_effect = [ + _response(data=[{"client_id": "old"}]), + _response(data=[{"client_id": "new"}]), + ] + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry.refresh_nodes() + + assert registry.get_node_ids() == ["new"] + + +def test_refreshes_expired_cache_on_get_nodes() -> None: + client: MagicMock = MagicMock() + client.request.side_effect = [ + _response(data=[{"client_id": "initial"}]), + _response(data=[{"client_id": "refreshed"}]), + ] + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry._nodes_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) + assert registry.get_node_ids() == ["refreshed"] + + assert client.request.call_count == 2 + + +def test_raises_when_node_retrieval_fails() -> None: + client: MagicMock = MagicMock() + client.request.return_value = _response(status_code=HTTPStatus.SERVICE_UNAVAILABLE) + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example" + with pytest.raises(NodeRetrievalError, match="Unable to fetch"): + NodeRegistry(vlm_client=client) diff --git a/tests/unit/test_restapi.py b/tests/unit/test_restapi.py index 048f94a..8e360cf 100644 --- a/tests/unit/test_restapi.py +++ b/tests/unit/test_restapi.py @@ -1,6 +1,7 @@ """Check basic functions of general endpoint(s)""" from pathlib import Path +from unittest.mock import MagicMock import jsonschema import pytest @@ -12,6 +13,7 @@ @pytest.fixture(scope="module") def restapi_client(): + vlm_restapi.state.request_authenticator = MagicMock() return TestClient(app=vlm_restapi) From 2b7312fbf805983232655fe044ef7a61debef2ad Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 27 Oct 2026 15:37:11 -0400 Subject: [PATCH 03/22] use custom 'AuthError' class to track error reasons & codes instead of raising HTTP errors in JWTValidator --- pyproject.toml | 1 + src/anyvlm/auth/jwt_validator.py | 16 ++--- src/anyvlm/auth/request_authenticator.py | 8 +-- src/anyvlm/main.py | 7 +-- src/anyvlm/utils/exceptions.py | 60 ++++++++++++++++++- tests/unit/auth/test_request_authenticator.py | 6 +- 6 files changed, 75 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f38e422..c5611f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,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", "S106", "S107", "SLF001"] +"src/anyvlm/utils/exceptions.py" = ["S105"] # allows using the word 'token' [tool.ruff.format] docstring-code-format = true diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py index dfa2ead..b93a7ed 100644 --- a/src/anyvlm/auth/jwt_validator.py +++ b/src/anyvlm/auth/jwt_validator.py @@ -1,12 +1,12 @@ """Handles validating and decoding JWT tokens""" -from http import HTTPStatus from typing import Any import jwt -from fastapi import HTTPException, Request +from fastapi import Request from anyvlm.config import get_config +from anyvlm.utils.exceptions import AuthError, AuthErrorReason class JWTValidator: @@ -24,13 +24,9 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: request.headers.get("Authorization", "").strip().split(sep=" ") ) except ValueError as e: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Invalid authorization header" - ) from e + raise AuthError(error_reason=AuthErrorReason.INVALID_HEADER) from e if scheme.lower() != "bearer": - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Invalid token scheme" - ) + raise AuthError(error_reason=AuthErrorReason.INVALID_SCHEMA) try: decoded_claims: dict[str, Any] = jwt.decode( @@ -45,8 +41,6 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: }, ) except jwt.InvalidTokenError as e: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail=f"Invalid token: {e}" - ) from e + raise AuthError(error_reason=AuthErrorReason.INVALID_TOKEN) from e return decoded_claims diff --git a/src/anyvlm/auth/request_authenticator.py b/src/anyvlm/auth/request_authenticator.py index c2868ab..8384856 100644 --- a/src/anyvlm/auth/request_authenticator.py +++ b/src/anyvlm/auth/request_authenticator.py @@ -6,7 +6,7 @@ from anyvlm.auth.jwt_validator import JWTValidator from anyvlm.outbound.node_registry import NodeRegistry -from anyvlm.utils.exceptions import AuthenticationError +from anyvlm.utils.exceptions import AuthError, AuthErrorReason class RequestAuthenticator: @@ -31,10 +31,10 @@ def authenticate(self, request: Request) -> None: self.jwt_validator.validate_and_decode_token(request) ) - client_id: str | None = decoded_token_claims.get("client_id") + client_id: str | None = decoded_token_claims.get("azp") if not isinstance(client_id, str) or not client_id: - raise AuthenticationError("Missing client identity") + raise AuthError(error_reason=AuthErrorReason.MISSING_CLIENT_ID) # First check cached node list if self._is_known_node(client_id=client_id): @@ -43,7 +43,7 @@ def authenticate(self, request: Request) -> None: # If not in cached node list, refresh and try again self.node_registry.refresh_nodes() if not self._is_known_node(client_id): - raise AuthenticationError(f"Unrecognized VLM Network client: {client_id}") + raise AuthError(error_reason=AuthErrorReason.UNRECOGNIZED_NODE) def _is_known_node(self, client_id: str) -> bool: return client_id in self.node_registry.get_node_ids() diff --git a/src/anyvlm/main.py b/src/anyvlm/main.py index 8be03b9..3a947d4 100644 --- a/src/anyvlm/main.py +++ b/src/anyvlm/main.py @@ -34,7 +34,7 @@ ServiceType, ) from anyvlm.storage.base_storage import Storage -from anyvlm.utils.exceptions import AuthenticationError +from anyvlm.utils.exceptions import AuthError from anyvlm.utils.types import ( EndpointTag, ) @@ -144,10 +144,9 @@ def authenticate_inbound_request( ) try: request_authenticator.authenticate(request) - except AuthenticationError as e: + except AuthError as e: raise HTTPException( - status_code=401, - detail=str(e), + status_code=e.code, detail=e.error_reason, headers=e.header ) from e diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index 9f64330..0a83c5d 100644 --- a/src/anyvlm/utils/exceptions.py +++ b/src/anyvlm/utils/exceptions.py @@ -1,5 +1,9 @@ """Defines custom exceptions for AnyVLM""" +from enum import StrEnum +from http import HTTPStatus +from typing import ClassVar + class IncompleteVariantError(Exception): """Raised when a variant is missing one or more properties required by AnyVLM""" @@ -29,5 +33,59 @@ class NodeRetrievalError(Exception): """Raised when the list of nodes on the VLM network cannot be retrieved""" -class AuthenticationError(Exception): +class AuthErrorReason(StrEnum): + """Defines the error messages""" + + INVALID_HEADER = "Invalid request header" + INVALID_SCHEMA = "Invalid token schema" + INVALID_TOKEN = "Invalid access token" + MISSING_TOKEN = "Missing bearer token" + EXPIRED_TOKEN = "Access token has expired" + MISSING_CLIENT_ID = "Missing client identity" + UNRECOGNIZED_NODE = "Client is not authorized for access" + + +class AuthError(Exception): """Raised when an error occurs during Authentication""" + + reason_to_code_mapping: ClassVar[dict[AuthErrorReason, HTTPStatus]] = { + AuthErrorReason.INVALID_HEADER: HTTPStatus.BAD_REQUEST, + AuthErrorReason.INVALID_SCHEMA: HTTPStatus.UNAUTHORIZED, + AuthErrorReason.INVALID_TOKEN: HTTPStatus.UNAUTHORIZED, + AuthErrorReason.MISSING_TOKEN: HTTPStatus.UNAUTHORIZED, + AuthErrorReason.EXPIRED_TOKEN: HTTPStatus.UNAUTHORIZED, + AuthErrorReason.MISSING_CLIENT_ID: HTTPStatus.UNAUTHORIZED, + AuthErrorReason.UNRECOGNIZED_NODE: HTTPStatus.FORBIDDEN, + } + + reason_to_header_mapping: ClassVar[dict[AuthErrorReason, dict[str, str]]] = { + AuthErrorReason.MISSING_TOKEN: { + "WWW-Authenticate": 'Bearer realm="AnyVLM"', + }, + AuthErrorReason.INVALID_TOKEN: { + "WWW-Authenticate": 'Bearer error="invalid_token"', + }, + AuthErrorReason.EXPIRED_TOKEN: { + "WWW-Authenticate": ( + 'Bearer error="invalid_token", ' + 'error_description="The access token expired"' + ), + }, + AuthErrorReason.MISSING_CLIENT_ID: { + "WWW-Authenticate": 'Bearer error="invalid_token"', + }, + AuthErrorReason.INVALID_HEADER: { + "WWW-Authenticate": 'Bearer realm="AnyVLM"', + }, + AuthErrorReason.INVALID_SCHEMA: { + "WWW-Authenticate": 'Bearer realm="AnyVLM"', + }, + } + + def __init__(self, error_reason: AuthErrorReason) -> None: # noqa: D107 + self.error_reason: AuthErrorReason = error_reason + self.code: HTTPStatus = self.reason_to_code_mapping[error_reason] + self.header: dict[str, str] | None = self.reason_to_header_mapping.get( + error_reason + ) + super().__init__(error_reason) diff --git a/tests/unit/auth/test_request_authenticator.py b/tests/unit/auth/test_request_authenticator.py index e07111a..f70cb58 100644 --- a/tests/unit/auth/test_request_authenticator.py +++ b/tests/unit/auth/test_request_authenticator.py @@ -4,7 +4,7 @@ import pytest from anyvlm.auth.request_authenticator import RequestAuthenticator -from anyvlm.utils.exceptions import AuthenticationError +from anyvlm.utils.exceptions import AuthError def _authenticator( @@ -53,7 +53,7 @@ def test_rejects_unknown_node_after_refresh() -> None: ) with pytest.raises( - AuthenticationError, + AuthError, match="Unrecognized VLM Network client: unknown", ): request_.authenticate(request=MagicMock()) @@ -67,7 +67,7 @@ def test_rejects_unknown_node_after_refresh() -> None: def test_rejects_missing_client_identity(claims: dict[Any, Any]) -> None: request_, _, node_registry = _authenticator(claims, node_ids=[]) - with pytest.raises(AuthenticationError, match="Missing client identity"): + with pytest.raises(AuthError, match="Missing client identity"): request_.authenticate(request=MagicMock()) node_registry.get_node_ids.assert_not_called() From e8d1bc7618eb5e6e2a9749dd7b9b3d154703d5cf Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 27 Oct 2026 15:54:18 -0400 Subject: [PATCH 04/22] modify tests to reflect new AuthError structure --- src/anyvlm/utils/exceptions.py | 5 +- tests/unit/auth/test_jwt_validator.py | 78 ++++++++++++------- tests/unit/auth/test_request_authenticator.py | 20 ++--- tests/unit/test_inbound_auth.py | 17 ++-- tests/unit/utils/test_auth_error.py | 6 ++ .../test_build_vlm_response.py | 0 .../{functions => utils}/test_ingest_vcf.py | 0 7 files changed, 79 insertions(+), 47 deletions(-) create mode 100644 tests/unit/utils/test_auth_error.py rename tests/unit/{functions => utils}/test_build_vlm_response.py (100%) rename tests/unit/{functions => utils}/test_ingest_vcf.py (100%) diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index 0a83c5d..3589920 100644 --- a/src/anyvlm/utils/exceptions.py +++ b/src/anyvlm/utils/exceptions.py @@ -34,7 +34,9 @@ class NodeRetrievalError(Exception): class AuthErrorReason(StrEnum): - """Defines the error messages""" + """Defines the authentication error messages. + All reasons must have a corresponding code mapped in the 'AuthError' class + """ INVALID_HEADER = "Invalid request header" INVALID_SCHEMA = "Invalid token schema" @@ -48,6 +50,7 @@ class AuthErrorReason(StrEnum): class AuthError(Exception): """Raised when an error occurs during Authentication""" + # Every reason in `AuthErrorReason` must have a corresponding code mapped here reason_to_code_mapping: ClassVar[dict[AuthErrorReason, HTTPStatus]] = { AuthErrorReason.INVALID_HEADER: HTTPStatus.BAD_REQUEST, AuthErrorReason.INVALID_SCHEMA: HTTPStatus.UNAUTHORIZED, diff --git a/tests/unit/auth/test_jwt_validator.py b/tests/unit/auth/test_jwt_validator.py index 97eed6b..90f1394 100644 --- a/tests/unit/auth/test_jwt_validator.py +++ b/tests/unit/auth/test_jwt_validator.py @@ -1,29 +1,31 @@ +from datetime import UTC, datetime, timedelta from http import HTTPStatus +from typing import Any from unittest.mock import MagicMock, patch import jwt import pytest -from fastapi import HTTPException from anyvlm.auth.jwt_validator import JWTValidator +from anyvlm.utils.exceptions import AuthError, AuthErrorReason def _request(authorization: str | None) -> MagicMock: - request = MagicMock() + request: MagicMock = MagicMock() request.headers.get.return_value = authorization or "" return request -def test_decodes_valid_bearer_token(): - claims = {"client_id": "client-one", "azp": "client-one"} +def test_decodes_valid_bearer_token() -> None: + claims: dict[str, str] = {"client_id": "client-one", "azp": "client-one"} with ( patch("anyvlm.auth.jwt_validator.get_config") as get_config, patch("anyvlm.auth.jwt_validator.jwt.decode", return_value=claims) as decode, ): get_config.return_value.base_vlm_network_url = "https://issuer.example" - decoded_claims = JWTValidator().validate_and_decode_token( - _request("Bearer encoded-token") + decoded_claims: dict[str, Any] = JWTValidator().validate_and_decode_token( + request=_request(authorization="Bearer encoded-token") ) assert decoded_claims == claims @@ -41,30 +43,52 @@ def test_decodes_valid_bearer_token(): @pytest.mark.parametrize( - ("authorization", "detail"), - [ - (None, "Invalid authorization header"), - ("Bearer", "Invalid authorization header"), - ("Basic encoded-token", "Invalid token scheme"), + argnames=("authorization", "reason"), + argvalues=[ + (None, AuthErrorReason.INVALID_HEADER), + ("Bearer", AuthErrorReason.INVALID_HEADER), + ("Basic encoded-token", AuthErrorReason.INVALID_SCHEMA), ], ) -def test_rejects_invalid_authorization_headers(authorization: str | None, detail: str): - with pytest.raises(HTTPException) as error: - JWTValidator().validate_and_decode_token(_request(authorization)) +def test_rejects_invalid_authorization_headers( + authorization: str | None, reason: AuthErrorReason +) -> None: + with pytest.raises(AuthError) as error: + JWTValidator().validate_and_decode_token(request=_request(authorization)) - assert error.value.status_code == HTTPStatus.FORBIDDEN - assert error.value.detail == detail + assert error.value.error_reason == reason + assert error.value.code == ( + HTTPStatus.BAD_REQUEST + if reason == AuthErrorReason.INVALID_HEADER + else HTTPStatus.UNAUTHORIZED + ) + assert error.value.header == {"WWW-Authenticate": 'Bearer realm="AnyVLM"'} -def test_translates_jwt_errors_to_forbidden(): - with ( - patch( - "anyvlm.auth.jwt_validator.jwt.decode", - side_effect=jwt.InvalidTokenError("expired"), - ), - pytest.raises(HTTPException) as exc_info, - ): - JWTValidator().validate_and_decode_token(_request("Bearer token")) +def test_rejects_an_expired_token() -> None: + issuer = "https://issuer.example" + expired_token = jwt.encode( + { + "azp": "client-one", + "iss": issuer, + "exp": datetime.now(tz=UTC) - timedelta(seconds=1), + }, + key="unused-signing-key", + algorithm="HS256", + ) + + get_config_patch = patch("anyvlm.auth.jwt_validator.get_config") + get_config = get_config_patch.start() + try: + get_config.return_value.base_vlm_network_url = issuer + + with pytest.raises(AuthError) as error: + JWTValidator().validate_and_decode_token( + request=_request(authorization=f"Bearer {expired_token}") + ) + finally: + get_config_patch.stop() - assert exc_info.value.status_code == HTTPStatus.FORBIDDEN - assert exc_info.value.detail == "Invalid token: expired" + assert error.value.error_reason == AuthErrorReason.INVALID_TOKEN + assert error.value.code == HTTPStatus.UNAUTHORIZED + assert error.value.header == {"WWW-Authenticate": 'Bearer error="invalid_token"'} diff --git a/tests/unit/auth/test_request_authenticator.py b/tests/unit/auth/test_request_authenticator.py index f70cb58..5329e1e 100644 --- a/tests/unit/auth/test_request_authenticator.py +++ b/tests/unit/auth/test_request_authenticator.py @@ -4,7 +4,7 @@ import pytest from anyvlm.auth.request_authenticator import RequestAuthenticator -from anyvlm.utils.exceptions import AuthError +from anyvlm.utils.exceptions import AuthError, AuthErrorReason def _authenticator( @@ -23,9 +23,9 @@ def _authenticator( ) -def test_authenticates_known_node_without_refreshing_registry(): +def test_authenticates_known_node_without_refreshing_registry() -> None: request_authenticator, jwt_validator, node_registry = _authenticator( - claims={"client_id": "client-one"}, node_ids=["client-one"] + claims={"azp": "client-one"}, node_ids=["client-one"] ) request_authenticator.authenticate(request=MagicMock()) @@ -37,7 +37,7 @@ def test_authenticates_known_node_without_refreshing_registry(): def test_refreshes_registry_when_node_is_not_cached() -> None: request_authenticator, _, node_registry = _authenticator( - claims={"client_id": "client-one"}, node_ids=[] + claims={"azp": "client-one"}, node_ids=[] ) node_registry.get_node_ids.side_effect = [[], ["client-one"]] @@ -48,26 +48,22 @@ def test_refreshes_registry_when_node_is_not_cached() -> None: def test_rejects_unknown_node_after_refresh() -> None: - request_, _, node_registry = _authenticator( - claims={"client_id": "unknown"}, node_ids=[] - ) + request_, _, node_registry = _authenticator(claims={"azp": "unknown"}, node_ids=[]) with pytest.raises( AuthError, - match="Unrecognized VLM Network client: unknown", + match=AuthErrorReason.UNRECOGNIZED_NODE, ): request_.authenticate(request=MagicMock()) node_registry.refresh_nodes.assert_called_once() -@pytest.mark.parametrize( - argnames="claims", argvalues=[{}, {"client_id": ""}, {"client_id": None}] -) +@pytest.mark.parametrize(argnames="claims", argvalues=[{}, {"azp": ""}, {"azp": None}]) def test_rejects_missing_client_identity(claims: dict[Any, Any]) -> None: request_, _, node_registry = _authenticator(claims, node_ids=[]) - with pytest.raises(AuthError, match="Missing client identity"): + with pytest.raises(AuthError, match=AuthErrorReason.MISSING_CLIENT_ID): request_.authenticate(request=MagicMock()) node_registry.get_node_ids.assert_not_called() diff --git a/tests/unit/test_inbound_auth.py b/tests/unit/test_inbound_auth.py index c172684..2eddfc3 100644 --- a/tests/unit/test_inbound_auth.py +++ b/tests/unit/test_inbound_auth.py @@ -4,10 +4,10 @@ from fastapi import HTTPException from anyvlm.main import authenticate_inbound_request -from anyvlm.utils.exceptions import AuthenticationError +from anyvlm.utils.exceptions import AuthError, AuthErrorReason -def test_authenticate_inbound_request_delegates_to_app_authenticator(): +def test_authenticate_inbound_request_delegates_to_app_authenticator() -> None: request = MagicMock() request.app.state.request_authenticator = MagicMock() @@ -18,14 +18,17 @@ def test_authenticate_inbound_request_delegates_to_app_authenticator(): ) -def test_authenticate_inbound_request_maps_authentication_errors_to_401(): +def test_authenticate_inbound_request_maps_authentication_errors_to_http_exceptions() -> ( + None +): request = MagicMock() - request.app.state.request_authenticator.authenticate.side_effect = ( - AuthenticationError("not a known node") + request.app.state.request_authenticator.authenticate.side_effect = AuthError( + AuthErrorReason.UNRECOGNIZED_NODE ) with pytest.raises(HTTPException) as error: authenticate_inbound_request(request) - assert error.value.status_code == 401 - assert error.value.detail == "not a known node" + assert error.value.status_code == 403 + assert error.value.detail == AuthErrorReason.UNRECOGNIZED_NODE + assert error.value.headers is None diff --git a/tests/unit/utils/test_auth_error.py b/tests/unit/utils/test_auth_error.py new file mode 100644 index 0000000..f873417 --- /dev/null +++ b/tests/unit/utils/test_auth_error.py @@ -0,0 +1,6 @@ +from anyvlm.utils.exceptions import AuthError, AuthErrorReason + + +def test_auth_error_contains_mappings_for_all_reasons() -> None: + for reason in AuthErrorReason: + assert reason in AuthError.reason_to_code_mapping diff --git a/tests/unit/functions/test_build_vlm_response.py b/tests/unit/utils/test_build_vlm_response.py similarity index 100% rename from tests/unit/functions/test_build_vlm_response.py rename to tests/unit/utils/test_build_vlm_response.py diff --git a/tests/unit/functions/test_ingest_vcf.py b/tests/unit/utils/test_ingest_vcf.py similarity index 100% rename from tests/unit/functions/test_ingest_vcf.py rename to tests/unit/utils/test_ingest_vcf.py From 0f4b2e7bec744350d0a3b44e15ce349309f9d563 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 27 Oct 2026 15:55:52 -0400 Subject: [PATCH 05/22] fix duplicate imports --- src/anyvlm/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/anyvlm/main.py b/src/anyvlm/main.py index 3a947d4..cca6c19 100644 --- a/src/anyvlm/main.py +++ b/src/anyvlm/main.py @@ -161,10 +161,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: app.state.anyvar_client = create_anyvar_client() app.state.anyvlm_storage = create_anyvlm_storage() - token_provider = TokenProvider() - vlm_client = VlmClient(token_provider) - node_registry = NodeRegistry(vlm_client) - jwt_validator = JWTValidator() + token_provider: TokenProvider = TokenProvider() + vlm_client: VlmClient = VlmClient(token_provider) + node_registry: NodeRegistry = NodeRegistry(vlm_client) + jwt_validator: JWTValidator = JWTValidator() app.state.request_authenticator = RequestAuthenticator( jwt_validator=jwt_validator, From 2f4092c70094c256fb351bdba67152d8a8960602 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 27 Oct 2026 16:04:51 -0400 Subject: [PATCH 06/22] fix 'base_vlm_network_url' to include trailing slash --- src/anyvlm/auth/jwt_validator.py | 2 +- src/anyvlm/auth/token_provider.py | 4 ++-- src/anyvlm/config.py | 2 +- src/anyvlm/outbound/node_registry.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py index b93a7ed..6b088a1 100644 --- a/src/anyvlm/auth/jwt_validator.py +++ b/src/anyvlm/auth/jwt_validator.py @@ -37,7 +37,7 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: "verify_signature": False, "verify_iss": True, "verify_exp": True, - "require": ["azp"], + "require": ["iss", "exp", "azp"], }, ) except jwt.InvalidTokenError as e: diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index 5110077..f0db3c9 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -19,8 +19,8 @@ class TokenProvider: def __init__(self) -> None: # noqa: D107 settings: Settings = get_config() - self.AUDIENCE_URL: str = settings.base_vlm_network_url + "/api/v2/" - self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "/oauth/token" + self.AUDIENCE_URL: str = settings.base_vlm_network_url + "api/v2/" + self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "oauth/token" self.get_token() diff --git a/src/anyvlm/config.py b/src/anyvlm/config.py index 6320f11..3f85fce 100644 --- a/src/anyvlm/config.py +++ b/src/anyvlm/config.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): anyvar_uri: str | None = None storage_uri: str = "postgresql://postgres@localhost:5432/anyvlm" logging_config: FilePath | None = None - base_vlm_network_url: str = "https://vlm-auth.us.auth0.com" + base_vlm_network_url: str = "https://vlm-auth.us.auth0.com/" @cache diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index cb29bab..1913498 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -33,7 +33,7 @@ def __init__(self, vlm_client: VlmClient) -> None: # noqa: D107 self._vlm_client = vlm_client settings: Settings = get_config() - self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "/api/v2/clients" + self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "api/v2/clients" self.refresh_nodes() From d035303450e0b7b94c1d814d5c1ea18dbfd6762b Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 27 Oct 2026 16:16:29 -0400 Subject: [PATCH 07/22] add explanatory comment --- src/anyvlm/auth/jwt_validator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py index 6b088a1..508b609 100644 --- a/src/anyvlm/auth/jwt_validator.py +++ b/src/anyvlm/auth/jwt_validator.py @@ -34,7 +34,7 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: algorithms=["RS256"], issuer=get_config().base_vlm_network_url, options={ - "verify_signature": False, + "verify_signature": False, # See PR comment for more details: https://github.com/GenomicMedLab/AnyVLM/pull/120 "verify_iss": True, "verify_exp": True, "require": ["iss", "exp", "azp"], From 006a3dfa2b5c94d37b82481edd4847462b184d78 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 27 Oct 2026 16:21:12 -0400 Subject: [PATCH 08/22] fix some failing tests --- tests/unit/auth/test_jwt_validator.py | 15 ++++++++++----- tests/unit/test_node_registry.py | 8 ++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/unit/auth/test_jwt_validator.py b/tests/unit/auth/test_jwt_validator.py index 90f1394..2d67443 100644 --- a/tests/unit/auth/test_jwt_validator.py +++ b/tests/unit/auth/test_jwt_validator.py @@ -17,12 +17,17 @@ def _request(authorization: str | None) -> MagicMock: def test_decodes_valid_bearer_token() -> None: - claims: dict[str, str] = {"client_id": "client-one", "azp": "client-one"} + claims: dict[str, Any] = { + "client_id": "client-one", + "azp": "client-one", + "iss": "https://issuer.example", + "exp": 2_000_000_000, + } with ( patch("anyvlm.auth.jwt_validator.get_config") as get_config, patch("anyvlm.auth.jwt_validator.jwt.decode", return_value=claims) as decode, ): - get_config.return_value.base_vlm_network_url = "https://issuer.example" + get_config.return_value.base_vlm_network_url = "https://issuer.example/" decoded_claims: dict[str, Any] = JWTValidator().validate_and_decode_token( request=_request(authorization="Bearer encoded-token") @@ -32,12 +37,12 @@ def test_decodes_valid_bearer_token() -> None: decode.assert_called_once_with( jwt="encoded-token", algorithms=["RS256"], - issuer="https://issuer.example", + issuer="https://issuer.example/", options={ "verify_signature": False, "verify_iss": True, "verify_exp": True, - "require": ["azp"], + "require": ["iss", "exp", "azp"], }, ) @@ -66,7 +71,7 @@ def test_rejects_invalid_authorization_headers( def test_rejects_an_expired_token() -> None: - issuer = "https://issuer.example" + issuer = "https://issuer.example/" expired_token = jwt.encode( { "azp": "client-one", diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py index acfc353..5999ce9 100644 --- a/tests/unit/test_node_registry.py +++ b/tests/unit/test_node_registry.py @@ -32,7 +32,7 @@ def test_loads_nodes_and_exposes_client_ids() -> None: ) with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example" + get_config.return_value.base_vlm_network_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) assert [node.client_id for node in registry.get_nodes()] == [ @@ -54,7 +54,7 @@ def test_refresh_nodes_replaces_cached_nodes() -> None: ] with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example" + get_config.return_value.base_vlm_network_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) registry.refresh_nodes() @@ -69,7 +69,7 @@ def test_refreshes_expired_cache_on_get_nodes() -> None: ] with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example" + get_config.return_value.base_vlm_network_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) registry._nodes_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) assert registry.get_node_ids() == ["refreshed"] @@ -82,6 +82,6 @@ def test_raises_when_node_retrieval_fails() -> None: client.request.return_value = _response(status_code=HTTPStatus.SERVICE_UNAVAILABLE) with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example" + get_config.return_value.base_vlm_network_url = "https://network.example/" with pytest.raises(NodeRetrievalError, match="Unable to fetch"): NodeRegistry(vlm_client=client) From 81b9bab813aa5e58f2ca8bb56be3c6959b0ba4ef Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 14:49:32 -0400 Subject: [PATCH 09/22] fix docstrings for 'JWTValidator' and 'RequestAuthenticator' --- src/anyvlm/auth/jwt_validator.py | 2 +- src/anyvlm/auth/request_authenticator.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py index 508b609..b77226a 100644 --- a/src/anyvlm/auth/jwt_validator.py +++ b/src/anyvlm/auth/jwt_validator.py @@ -17,7 +17,7 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: :param request: The inbound request containing the token to be validated/decoded :return: The decoded token claims - :raises: `HTTPException`, if unable to validate or decode the request's token + :raises: `AuthError`, if unable to validate or decode the request's token """ try: scheme, token = ( diff --git a/src/anyvlm/auth/request_authenticator.py b/src/anyvlm/auth/request_authenticator.py index 8384856..9921ac4 100644 --- a/src/anyvlm/auth/request_authenticator.py +++ b/src/anyvlm/auth/request_authenticator.py @@ -12,11 +12,18 @@ class RequestAuthenticator: """Handles authenticating inbound requests""" - def __init__( # noqa: D107 + def __init__( self, jwt_validator: JWTValidator, node_registry: NodeRegistry, ) -> None: + """Initializes a RequestAuthenticator to authenticate inbound requests from other VLM Network nodes + + :param jwt_validator: A 'JWTValidator' instance, used to validate/decode JWT tokens + :param node_registry: A 'NodeRegistry' instance, used to retrieve a list of all other nodes on the VLM Network + + :raises: `NodeRetrievalError` if unable to initialize a NodeRegistry + """ self.jwt_validator: JWTValidator = jwt_validator self.node_registry: NodeRegistry = node_registry @@ -25,7 +32,7 @@ def authenticate(self, request: Request) -> None: confirm requesting node is known member of the VLM network. :param request: The inbound request to authenticate - :raises: `AuthenticationError`, if unable to authenticate. + :raises: `AuthError`, if unable to authenticate. """ decoded_token_claims: dict[str, Any] = ( self.jwt_validator.validate_and_decode_token(request) From c66626f50c15c632f2e3dc8cccc4ca2b41737c76 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 14:50:38 -0400 Subject: [PATCH 10/22] renames 'VlmClient' > 'VLMClient' to align capitalization convention w/ 'JWTValidator' --- src/anyvlm/main.py | 4 ++-- src/anyvlm/outbound/node_registry.py | 6 +++--- src/anyvlm/outbound/vlm_client.py | 2 +- tests/unit/test_vlm_client.py | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/anyvlm/main.py b/src/anyvlm/main.py index cca6c19..e66d7bf 100644 --- a/src/anyvlm/main.py +++ b/src/anyvlm/main.py @@ -25,7 +25,7 @@ from anyvlm.auth.token_provider import TokenProvider from anyvlm.config import get_config from anyvlm.outbound.node_registry import NodeRegistry -from anyvlm.outbound.vlm_client import VlmClient +from anyvlm.outbound.vlm_client import VLMClient from anyvlm.restapi.vlm import router as vlm_router from anyvlm.schemas.common import ( SERVICE_DESCRIPTION, @@ -162,7 +162,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: app.state.anyvlm_storage = create_anyvlm_storage() token_provider: TokenProvider = TokenProvider() - vlm_client: VlmClient = VlmClient(token_provider) + vlm_client: VLMClient = VLMClient(token_provider) node_registry: NodeRegistry = NodeRegistry(vlm_client) jwt_validator: JWTValidator = JWTValidator() diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index 8fa7b12..2a670a4 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -8,7 +8,7 @@ from requests import Response from anyvlm.config import Settings, get_config -from anyvlm.outbound.vlm_client import VlmClient +from anyvlm.outbound.vlm_client import VLMClient from anyvlm.utils.exceptions import NodeRetrievalError @@ -31,9 +31,9 @@ class NodeRegistry: _nodes: list[VlmNode] _nodes_expiry: datetime - _vlm_client: VlmClient + _vlm_client: VLMClient - def __init__(self, vlm_client: VlmClient) -> None: + def __init__(self, vlm_client: VLMClient) -> None: """Initializes a list of all other nodes currently on the VLM Network :param vlm_client: A 'VlmClient' instance used to send outbound HTTP requests to the VLM Network diff --git a/src/anyvlm/outbound/vlm_client.py b/src/anyvlm/outbound/vlm_client.py index f68dde4..fd4d624 100644 --- a/src/anyvlm/outbound/vlm_client.py +++ b/src/anyvlm/outbound/vlm_client.py @@ -9,7 +9,7 @@ from anyvlm.auth.token_provider import TokenProvider -class VlmClient: +class VLMClient: """Handles sending authenticated outbound requests to the VLM Network""" def __init__(self, token_provider: TokenProvider) -> None: diff --git a/tests/unit/test_vlm_client.py b/tests/unit/test_vlm_client.py index a0e34a3..98b514c 100644 --- a/tests/unit/test_vlm_client.py +++ b/tests/unit/test_vlm_client.py @@ -5,7 +5,7 @@ import pytest from anyvlm.auth.token_provider import TokenProvider -from anyvlm.outbound.vlm_client import VlmClient +from anyvlm.outbound.vlm_client import VLMClient from anyvlm.utils.exceptions import TokenRetrievalError @@ -25,7 +25,7 @@ def test_request_includes_cached_token_in_authorization_header() -> None: token_provider: TokenProvider = TokenProvider() with patch("anyvlm.outbound.vlm_client.requests.request") as mock_request: - VlmClient(token_provider).request( + VLMClient(token_provider).request( method_type=HTTPMethod.GET, url="https://example.com/variants", request_body={"variant": "test"}, @@ -51,7 +51,7 @@ def test_request_includes_refreshed_token_in_authorization_header() -> None: 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( + VLMClient(token_provider).request( method_type=HTTPMethod.POST, url="https://example.com/variants" ) @@ -70,7 +70,7 @@ def test_request_does_not_send_request_when_token_retrieval_fails() -> None: token_provider.get_token.side_effect = TokenRetrievalError( "Unable to fetch bearer token from Auth0" ) - client: VlmClient = VlmClient(token_provider) + client: VLMClient = VLMClient(token_provider) with ( patch("anyvlm.outbound.vlm_client.requests.request") as mock_request, From 9da0a1f3cd9745832c79c1b0682f40b17f2c751b Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 15:38:18 -0400 Subject: [PATCH 11/22] add client-specific cooldowns for refreshing the node registry --- src/anyvlm/auth/request_authenticator.py | 59 ++++++++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/anyvlm/auth/request_authenticator.py b/src/anyvlm/auth/request_authenticator.py index 9921ac4..19eccf8 100644 --- a/src/anyvlm/auth/request_authenticator.py +++ b/src/anyvlm/auth/request_authenticator.py @@ -1,5 +1,7 @@ """Handles authenticating inbound requests""" +from collections import OrderedDict +from datetime import UTC, datetime, timedelta from typing import Any from fastapi import Request @@ -9,9 +11,28 @@ from anyvlm.utils.exceptions import AuthError, AuthErrorReason +class UnknownNodeCache: + """manage unknown client nodes who have attempted to make requests to our service""" + + _CACHE_MAX_SIZE = 1024 + + def __init__(self) -> None: + """Initializes a cache of unknown (non-VLM Network member) nodes that have attempted to make requests to our service""" + self.nodes: OrderedDict[str, datetime] = OrderedDict[str, datetime]() + + def add_node(self, client_id: str, cooldown_start: datetime) -> None: + """add a node to the cache""" + self.nodes[client_id] = cooldown_start + if len(self.nodes) >= self._CACHE_MAX_SIZE: + self.nodes.popitem(last=False) + + class RequestAuthenticator: """Handles authenticating inbound requests""" + REFRESH_COOLDOWN_SECONDS = 30 + UNKNOWN_NODE_CACHE_MAX_SIZE = 1024 + def __init__( self, jwt_validator: JWTValidator, @@ -26,6 +47,7 @@ def __init__( """ self.jwt_validator: JWTValidator = jwt_validator self.node_registry: NodeRegistry = node_registry + self.unknown_node_cache: UnknownNodeCache = UnknownNodeCache() def authenticate(self, request: Request) -> None: """Authenticate a request: validate and decode request token + @@ -38,19 +60,36 @@ def authenticate(self, request: Request) -> None: self.jwt_validator.validate_and_decode_token(request) ) - client_id: str | None = decoded_token_claims.get("azp") + client_id: Any = decoded_token_claims.get("azp") + self.verify_node_membership(client_id=client_id) - if not isinstance(client_id, str) or not client_id: + def verify_node_membership(self, client_id: Any) -> None: # noqa: ANN401 + """verify that the requesting client node is a known member of the VLM Network""" + # verify that client_id exists and is a string + if not client_id or not isinstance(client_id, str): raise AuthError(error_reason=AuthErrorReason.MISSING_CLIENT_ID) - # First check cached node list - if self._is_known_node(client_id=client_id): + # First, check current node registry + if client_id in self.node_registry.get_node_ids(): return - # If not in cached node list, refresh and try again - self.node_registry.refresh_nodes() - if not self._is_known_node(client_id): - raise AuthError(error_reason=AuthErrorReason.UNRECOGNIZED_NODE) + # If not in cached unknown node list, refresh the node registry and try again, + # unless last refresh for this ID was w/in the cool-down period + if ( + client_id not in self.unknown_node_cache.nodes + or self.unknown_node_cache.nodes[client_id] + <= datetime.now(tz=UTC) - timedelta(seconds=self.REFRESH_COOLDOWN_SECONDS) + ): + self.node_registry.refresh_nodes() + if client_id not in self.node_registry.get_node_ids(): + # If client_id STILL isn't in the node registry, + # check to see if it's on cooldown in our unknown node cache and add it if it's not + # then raise an error + if client_id not in self.unknown_node_cache.nodes: + self.unknown_node_cache.add_node( + client_id=client_id, cooldown_start=datetime.now(tz=UTC) + ) + + raise AuthError(error_reason=AuthErrorReason.UNRECOGNIZED_NODE) - def _is_known_node(self, client_id: str) -> bool: - return client_id in self.node_registry.get_node_ids() + raise AuthError(error_reason=AuthErrorReason.UNRECOGNIZED_NODE) From 84c24e2f292c2c8a57eaae686b182732f4b0c0df Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 15:46:31 -0400 Subject: [PATCH 12/22] adds cooldown for node registry refresh calls in 'RequestAuthenticator' --- src/anyvlm/auth/request_authenticator.py | 51 ++++++------------------ 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/src/anyvlm/auth/request_authenticator.py b/src/anyvlm/auth/request_authenticator.py index 19eccf8..981857e 100644 --- a/src/anyvlm/auth/request_authenticator.py +++ b/src/anyvlm/auth/request_authenticator.py @@ -1,6 +1,5 @@ """Handles authenticating inbound requests""" -from collections import OrderedDict from datetime import UTC, datetime, timedelta from typing import Any @@ -11,27 +10,10 @@ from anyvlm.utils.exceptions import AuthError, AuthErrorReason -class UnknownNodeCache: - """manage unknown client nodes who have attempted to make requests to our service""" - - _CACHE_MAX_SIZE = 1024 - - def __init__(self) -> None: - """Initializes a cache of unknown (non-VLM Network member) nodes that have attempted to make requests to our service""" - self.nodes: OrderedDict[str, datetime] = OrderedDict[str, datetime]() - - def add_node(self, client_id: str, cooldown_start: datetime) -> None: - """add a node to the cache""" - self.nodes[client_id] = cooldown_start - if len(self.nodes) >= self._CACHE_MAX_SIZE: - self.nodes.popitem(last=False) - - class RequestAuthenticator: """Handles authenticating inbound requests""" - REFRESH_COOLDOWN_SECONDS = 30 - UNKNOWN_NODE_CACHE_MAX_SIZE = 1024 + NODE_REGISTRY_REFRESH_COOLDOWN_SECONDS = 30 def __init__( self, @@ -47,7 +29,7 @@ def __init__( """ self.jwt_validator: JWTValidator = jwt_validator self.node_registry: NodeRegistry = node_registry - self.unknown_node_cache: UnknownNodeCache = UnknownNodeCache() + self.last_node_refresh: datetime = datetime.now(tz=UTC) def authenticate(self, request: Request) -> None: """Authenticate a request: validate and decode request token + @@ -73,23 +55,14 @@ def verify_node_membership(self, client_id: Any) -> None: # noqa: ANN401 if client_id in self.node_registry.get_node_ids(): return - # If not in cached unknown node list, refresh the node registry and try again, - # unless last refresh for this ID was w/in the cool-down period - if ( - client_id not in self.unknown_node_cache.nodes - or self.unknown_node_cache.nodes[client_id] - <= datetime.now(tz=UTC) - timedelta(seconds=self.REFRESH_COOLDOWN_SECONDS) + # If client isn't in current node registry, refresh the registry and try again, + # UNLESS last refresh was w/in the cool-down period (to protect against DDOS attacks) + if datetime.now(tz=UTC) <= self.last_node_refresh + timedelta( + seconds=self.NODE_REGISTRY_REFRESH_COOLDOWN_SECONDS ): - self.node_registry.refresh_nodes() - if client_id not in self.node_registry.get_node_ids(): - # If client_id STILL isn't in the node registry, - # check to see if it's on cooldown in our unknown node cache and add it if it's not - # then raise an error - if client_id not in self.unknown_node_cache.nodes: - self.unknown_node_cache.add_node( - client_id=client_id, cooldown_start=datetime.now(tz=UTC) - ) - - raise AuthError(error_reason=AuthErrorReason.UNRECOGNIZED_NODE) - - raise AuthError(error_reason=AuthErrorReason.UNRECOGNIZED_NODE) + raise AuthError(error_reason=AuthErrorReason.UNRECOGNIZED_NODE) + + self.last_node_refresh = datetime.now(tz=UTC) + self.node_registry.refresh_nodes() + if client_id not in self.node_registry.get_node_ids(): + raise AuthError(error_reason=AuthErrorReason.UNRECOGNIZED_NODE) From 83a11a52fb1f42bf933ecf53c9ff3893f2261d47 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 15:50:33 -0400 Subject: [PATCH 13/22] add tests ensuring cooldown in 'RequestAuthenticator' works properly --- tests/unit/auth/test_request_authenticator.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/unit/auth/test_request_authenticator.py b/tests/unit/auth/test_request_authenticator.py index 5329e1e..5dec3a3 100644 --- a/tests/unit/auth/test_request_authenticator.py +++ b/tests/unit/auth/test_request_authenticator.py @@ -1,3 +1,4 @@ +from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import MagicMock @@ -59,6 +60,53 @@ def test_rejects_unknown_node_after_refresh() -> None: node_registry.refresh_nodes.assert_called_once() +def test_rejects_unknown_clients_without_refreshing_during_cooldown() -> None: + request_authenticator, jwt_validator, node_registry = _authenticator( + claims={"azp": "client-one"}, node_ids=[] + ) + request_authenticator.last_node_refresh = datetime.now(tz=UTC) - timedelta( + seconds=request_authenticator.NODE_REGISTRY_REFRESH_COOLDOWN_SECONDS + 1 + ) + jwt_validator.validate_and_decode_token.side_effect = [ + {"azp": "client-one"}, + {"azp": "client-two"}, + ] + + for _ in range(2): + with pytest.raises( + AuthError, + match=AuthErrorReason.UNRECOGNIZED_NODE, + ): + request_authenticator.authenticate(request=MagicMock()) + + node_registry.refresh_nodes.assert_called_once() + + +def test_refreshes_registry_again_after_cooldown() -> None: + request_authenticator, _, node_registry = _authenticator( + claims={"azp": "unknown"}, node_ids=[] + ) + request = MagicMock() + + with pytest.raises( + AuthError, + match=AuthErrorReason.UNRECOGNIZED_NODE, + ): + request_authenticator.authenticate(request=request) + + request_authenticator.last_node_refresh = datetime.now(tz=UTC) - timedelta( + seconds=request_authenticator.NODE_REGISTRY_REFRESH_COOLDOWN_SECONDS + 1 + ) + + with pytest.raises( + AuthError, + match=AuthErrorReason.UNRECOGNIZED_NODE, + ): + request_authenticator.authenticate(request=request) + + assert node_registry.refresh_nodes.call_count == 2 + + @pytest.mark.parametrize(argnames="claims", argvalues=[{}, {"azp": ""}, {"azp": None}]) def test_rejects_missing_client_identity(claims: dict[Any, Any]) -> None: request_, _, node_registry = _authenticator(claims, node_ids=[]) From 44e444a3819e640870e5a416d25a6fc22f9dc1a9 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 16:15:50 -0400 Subject: [PATCH 14/22] make a fixture for the app and reset its state after each usage of a the fixture --- tests/conftest.py | 24 +++++++++++++++++++++ tests/unit/test_restapi.py | 14 ++---------- tests/unit/test_variant_counts_endpoint.py | 25 ++++++---------------- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4c29665..9a49988 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,15 @@ import json +from collections.abc import Generator from os import environ from pathlib import Path +from typing import Any +from unittest.mock import MagicMock import pytest from anyvar.anyvar import create_storage, create_translator from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.testclient import TestClient from ga4gh.core.models import iriReference from ga4gh.va_spec.base import StudyGroup from ga4gh.vrs import models @@ -12,6 +17,7 @@ from pydantic import BaseModel from anyvlm.anyvar.python_client import PythonAnyVarClient +from anyvlm.main import app as vlm_restapi from anyvlm.storage.postgres import PostgresObjectStore from anyvlm.utils.types import ( AncillaryResults, @@ -28,6 +34,24 @@ environ.setdefault("BEACON_HANDOVER_URL", "https://test.example.com") +def _reset_app_state(app: FastAPI) -> None: + state_attrs = ("request_authenticator", "anyvar_client", "anyvlm_storage") + for attr in state_attrs: + if hasattr(app.state, attr): + delattr(app.state, attr) + + +@pytest.fixture +def api_client() -> Generator[TestClient, Any, None]: + """Provide an API client with isolated application state.""" + _reset_app_state(app=vlm_restapi) + vlm_restapi.state.request_authenticator = MagicMock() + try: + yield TestClient(app=vlm_restapi) + finally: + _reset_app_state(app=vlm_restapi) + + @pytest.fixture(scope="session") def test_data_dir() -> Path: """Provide Path instance pointing to test data directory""" diff --git a/tests/unit/test_restapi.py b/tests/unit/test_restapi.py index 8e360cf..df26e67 100644 --- a/tests/unit/test_restapi.py +++ b/tests/unit/test_restapi.py @@ -1,24 +1,14 @@ """Check basic functions of general endpoint(s)""" from pathlib import Path -from unittest.mock import MagicMock import jsonschema -import pytest import yaml from fastapi.testclient import TestClient -from anyvlm.main import app as vlm_restapi - -@pytest.fixture(scope="module") -def restapi_client(): - vlm_restapi.state.request_authenticator = MagicMock() - return TestClient(app=vlm_restapi) - - -def test_service_info(restapi_client: TestClient, test_data_dir: Path): - response = restapi_client.get("/anyvlm/service-info") +def test_service_info(api_client: TestClient, test_data_dir: Path): + response = api_client.get("/anyvlm/service-info") response.raise_for_status() with (test_data_dir / "ga4gh-service-info" / "service-info.yaml").open() as f: diff --git a/tests/unit/test_variant_counts_endpoint.py b/tests/unit/test_variant_counts_endpoint.py index 85a2b7b..e448216 100644 --- a/tests/unit/test_variant_counts_endpoint.py +++ b/tests/unit/test_variant_counts_endpoint.py @@ -3,7 +3,6 @@ from http import HTTPStatus import pytest -from fastapi.testclient import TestClient from helpers import TEST_VARIANT from anyvlm.anyvar.base_client import AnyVarClientConnectionError @@ -132,6 +131,7 @@ def expected_response_with_no_match(beacon_handovers_and_meta): @pytest.fixture def client_with_populated_dbs( + api_client, anyvar_populated_python_client, populated_postgres_storage, ): @@ -139,16 +139,12 @@ def client_with_populated_dbs( vlm_restapi.state.anyvar_client = anyvar_populated_python_client vlm_restapi.state.anyvlm_storage = populated_postgres_storage - client = TestClient(vlm_restapi) - - yield client - - del vlm_restapi.state.anyvar_client - del vlm_restapi.state.anyvlm_storage + return api_client @pytest.fixture def client_with_registered_variant_no_caf( + api_client, anyvar_populated_python_client, postgres_storage, ): @@ -156,16 +152,12 @@ def client_with_registered_variant_no_caf( vlm_restapi.state.anyvar_client = anyvar_populated_python_client vlm_restapi.state.anyvlm_storage = postgres_storage - client = TestClient(vlm_restapi) - - yield client - - del vlm_restapi.state.anyvar_client - del vlm_restapi.state.anyvlm_storage + return api_client @pytest.fixture def client_with_variant_not_registered( + api_client, anyvar_minimal_populated_python_client, populated_postgres_storage, ): @@ -173,12 +165,7 @@ def client_with_variant_not_registered( vlm_restapi.state.anyvar_client = anyvar_minimal_populated_python_client vlm_restapi.state.anyvlm_storage = populated_postgres_storage - client = TestClient(vlm_restapi) - - yield client - - del vlm_restapi.state.anyvar_client - del vlm_restapi.state.anyvlm_storage + return api_client @pytest.mark.vcr From 425f7aad019483f3410ed6b1f83974d3b8d01009 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 16:30:46 -0400 Subject: [PATCH 15/22] fix failing tests --- tests/unit/auth/test_request_authenticator.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/unit/auth/test_request_authenticator.py b/tests/unit/auth/test_request_authenticator.py index 5dec3a3..0313e15 100644 --- a/tests/unit/auth/test_request_authenticator.py +++ b/tests/unit/auth/test_request_authenticator.py @@ -17,14 +17,21 @@ def _authenticator( node_registry: MagicMock = MagicMock() node_registry.get_node_ids.return_value = node_ids + request_authenticator: RequestAuthenticator = RequestAuthenticator( + jwt_validator=jwt_validator, node_registry=node_registry + ) + request_authenticator.last_node_refresh = datetime.now(tz=UTC) - timedelta( + seconds=request_authenticator.NODE_REGISTRY_REFRESH_COOLDOWN_SECONDS + ) + return ( - RequestAuthenticator(jwt_validator=jwt_validator, node_registry=node_registry), + request_authenticator, jwt_validator, node_registry, ) -def test_authenticates_known_node_without_refreshing_registry() -> None: +def test_authenticates_known_node_without_refreshing_registry(): request_authenticator, jwt_validator, node_registry = _authenticator( claims={"azp": "client-one"}, node_ids=["client-one"] ) From 6eb25bb1f03d8b1bf0e9552a1f03577eafa07a1a Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 09:30:10 -0400 Subject: [PATCH 16/22] catch missing/malformed token 'exp' values --- src/anyvlm/auth/jwt_validator.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py index b77226a..e0f3cab 100644 --- a/src/anyvlm/auth/jwt_validator.py +++ b/src/anyvlm/auth/jwt_validator.py @@ -43,4 +43,9 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: except jwt.InvalidTokenError as e: raise AuthError(error_reason=AuthErrorReason.INVALID_TOKEN) from e + try: + int(decoded_claims["exp"]) # catch malformed/empty 'exp' values + except TypeError as e: + raise AuthError(error_reason=AuthErrorReason.INVALID_TOKEN) from e + return decoded_claims From 7d129848a4b92ce92b6d33e7419643e52c2f1754 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 12:07:45 -0400 Subject: [PATCH 17/22] update error handling in JWTValidator --- src/anyvlm/auth/jwt_validator.py | 9 +++++---- src/anyvlm/utils/exceptions.py | 16 ++++++++-------- tests/unit/auth/test_jwt_validator.py | 10 +++++----- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py index e0f3cab..982b4e8 100644 --- a/src/anyvlm/auth/jwt_validator.py +++ b/src/anyvlm/auth/jwt_validator.py @@ -19,12 +19,13 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: :return: The decoded token claims :raises: `AuthError`, if unable to validate or decode the request's token """ + auth_header: str | None = request.headers.get("Authorization") + if not auth_header: + raise AuthError(error_reason=AuthErrorReason.MISSING_AUTH_HEADER) try: - scheme, token = ( - request.headers.get("Authorization", "").strip().split(sep=" ") - ) + scheme, token = auth_header.strip().split(sep=" ") except ValueError as e: - raise AuthError(error_reason=AuthErrorReason.INVALID_HEADER) from e + raise AuthError(error_reason=AuthErrorReason.MALFORMED_AUTH_HEADER) from e if scheme.lower() != "bearer": raise AuthError(error_reason=AuthErrorReason.INVALID_SCHEMA) diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index 3589920..1a4f04a 100644 --- a/src/anyvlm/utils/exceptions.py +++ b/src/anyvlm/utils/exceptions.py @@ -38,10 +38,10 @@ class AuthErrorReason(StrEnum): All reasons must have a corresponding code mapped in the 'AuthError' class """ - INVALID_HEADER = "Invalid request header" + MISSING_AUTH_HEADER = "Missing authorization request header" + MALFORMED_AUTH_HEADER = "Malformed authorization request header" INVALID_SCHEMA = "Invalid token schema" INVALID_TOKEN = "Invalid access token" - MISSING_TOKEN = "Missing bearer token" EXPIRED_TOKEN = "Access token has expired" MISSING_CLIENT_ID = "Missing client identity" UNRECOGNIZED_NODE = "Client is not authorized for access" @@ -52,19 +52,16 @@ class AuthError(Exception): # Every reason in `AuthErrorReason` must have a corresponding code mapped here reason_to_code_mapping: ClassVar[dict[AuthErrorReason, HTTPStatus]] = { - AuthErrorReason.INVALID_HEADER: HTTPStatus.BAD_REQUEST, + AuthErrorReason.MISSING_AUTH_HEADER: HTTPStatus.BAD_REQUEST, + AuthErrorReason.MALFORMED_AUTH_HEADER: HTTPStatus.BAD_REQUEST, AuthErrorReason.INVALID_SCHEMA: HTTPStatus.UNAUTHORIZED, AuthErrorReason.INVALID_TOKEN: HTTPStatus.UNAUTHORIZED, - AuthErrorReason.MISSING_TOKEN: HTTPStatus.UNAUTHORIZED, AuthErrorReason.EXPIRED_TOKEN: HTTPStatus.UNAUTHORIZED, AuthErrorReason.MISSING_CLIENT_ID: HTTPStatus.UNAUTHORIZED, AuthErrorReason.UNRECOGNIZED_NODE: HTTPStatus.FORBIDDEN, } reason_to_header_mapping: ClassVar[dict[AuthErrorReason, dict[str, str]]] = { - AuthErrorReason.MISSING_TOKEN: { - "WWW-Authenticate": 'Bearer realm="AnyVLM"', - }, AuthErrorReason.INVALID_TOKEN: { "WWW-Authenticate": 'Bearer error="invalid_token"', }, @@ -77,7 +74,10 @@ class AuthError(Exception): AuthErrorReason.MISSING_CLIENT_ID: { "WWW-Authenticate": 'Bearer error="invalid_token"', }, - AuthErrorReason.INVALID_HEADER: { + AuthErrorReason.MISSING_AUTH_HEADER: { + "WWW-Authenticate": 'Bearer realm="AnyVLM"', + }, + AuthErrorReason.MALFORMED_AUTH_HEADER: { "WWW-Authenticate": 'Bearer realm="AnyVLM"', }, AuthErrorReason.INVALID_SCHEMA: { diff --git a/tests/unit/auth/test_jwt_validator.py b/tests/unit/auth/test_jwt_validator.py index 2d67443..7afd349 100644 --- a/tests/unit/auth/test_jwt_validator.py +++ b/tests/unit/auth/test_jwt_validator.py @@ -50,8 +50,8 @@ def test_decodes_valid_bearer_token() -> None: @pytest.mark.parametrize( argnames=("authorization", "reason"), argvalues=[ - (None, AuthErrorReason.INVALID_HEADER), - ("Bearer", AuthErrorReason.INVALID_HEADER), + (None, AuthErrorReason.MISSING_AUTH_HEADER), + ("Bearer", AuthErrorReason.MALFORMED_AUTH_HEADER), ("Basic encoded-token", AuthErrorReason.INVALID_SCHEMA), ], ) @@ -63,9 +63,9 @@ def test_rejects_invalid_authorization_headers( assert error.value.error_reason == reason assert error.value.code == ( - HTTPStatus.BAD_REQUEST - if reason == AuthErrorReason.INVALID_HEADER - else HTTPStatus.UNAUTHORIZED + HTTPStatus.UNAUTHORIZED + if reason == AuthErrorReason.INVALID_SCHEMA + else HTTPStatus.BAD_REQUEST ) assert error.value.header == {"WWW-Authenticate": 'Bearer realm="AnyVLM"'} From 8647619b7c6f1d22a09040ffe1be22cb3c93055b Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 12:16:28 -0400 Subject: [PATCH 18/22] add audience verification to token checks --- src/anyvlm/auth/jwt_validator.py | 10 +++++++--- src/anyvlm/auth/token_provider.py | 2 +- src/anyvlm/config.py | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py index 982b4e8..1f65b06 100644 --- a/src/anyvlm/auth/jwt_validator.py +++ b/src/anyvlm/auth/jwt_validator.py @@ -5,7 +5,7 @@ import jwt from fastapi import Request -from anyvlm.config import get_config +from anyvlm.config import Settings, get_config from anyvlm.utils.exceptions import AuthError, AuthErrorReason @@ -19,6 +19,8 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: :return: The decoded token claims :raises: `AuthError`, if unable to validate or decode the request's token """ + configs: Settings = get_config() + auth_header: str | None = request.headers.get("Authorization") if not auth_header: raise AuthError(error_reason=AuthErrorReason.MISSING_AUTH_HEADER) @@ -33,12 +35,14 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: decoded_claims: dict[str, Any] = jwt.decode( jwt=token, algorithms=["RS256"], - issuer=get_config().base_vlm_network_url, + issuer=configs.base_vlm_network_url, + audience=configs.vlm_network_audience_url, options={ "verify_signature": False, # See PR comment for more details: https://github.com/GenomicMedLab/AnyVLM/pull/120 "verify_iss": True, "verify_exp": True, - "require": ["iss", "exp", "azp"], + "verify_aud": True, + "require": ["iss", "exp", "azp", "aud"], }, ) except jwt.InvalidTokenError as e: diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index 15a5375..a1b730f 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -24,7 +24,7 @@ class TokenProvider: def __init__(self) -> None: """Initializes a TokenProvider to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM Network""" settings: Settings = get_config() - self.AUDIENCE_URL: str = settings.base_vlm_network_url + "api/v2/" + self.AUDIENCE_URL: str = settings.vlm_network_audience_url self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "oauth/token" self.get_token() diff --git a/src/anyvlm/config.py b/src/anyvlm/config.py index 3f85fce..d14791f 100644 --- a/src/anyvlm/config.py +++ b/src/anyvlm/config.py @@ -28,6 +28,7 @@ class Settings(BaseSettings): storage_uri: str = "postgresql://postgres@localhost:5432/anyvlm" logging_config: FilePath | None = None base_vlm_network_url: str = "https://vlm-auth.us.auth0.com/" + vlm_network_audience_url: str = base_vlm_network_url + "api/v2/" @cache From b851b67fca186726f1ed1d39d434fc587ea277b5 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 12:27:14 -0400 Subject: [PATCH 19/22] fixes failing test --- src/anyvlm/auth/jwt_validator.py | 2 +- src/anyvlm/auth/token_provider.py | 2 +- src/anyvlm/config.py | 4 ++-- src/anyvlm/outbound/node_registry.py | 2 +- tests/unit/auth/test_jwt_validator.py | 21 ++++++++++++++------- tests/unit/test_node_registry.py | 14 +++++++------- 6 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/anyvlm/auth/jwt_validator.py b/src/anyvlm/auth/jwt_validator.py index 1f65b06..6d58e9f 100644 --- a/src/anyvlm/auth/jwt_validator.py +++ b/src/anyvlm/auth/jwt_validator.py @@ -35,7 +35,7 @@ def validate_and_decode_token(self, request: Request) -> dict[str, Any]: decoded_claims: dict[str, Any] = jwt.decode( jwt=token, algorithms=["RS256"], - issuer=configs.base_vlm_network_url, + issuer=configs.vlm_network_base_url, audience=configs.vlm_network_audience_url, options={ "verify_signature": False, # See PR comment for more details: https://github.com/GenomicMedLab/AnyVLM/pull/120 diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index a1b730f..82ce084 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -25,7 +25,7 @@ def __init__(self) -> None: """Initializes a TokenProvider to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM Network""" settings: Settings = get_config() self.AUDIENCE_URL: str = settings.vlm_network_audience_url - self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "oauth/token" + self.TOKEN_REQUEST_URL: str = settings.vlm_network_base_url + "oauth/token" self.get_token() diff --git a/src/anyvlm/config.py b/src/anyvlm/config.py index d14791f..1a221c3 100644 --- a/src/anyvlm/config.py +++ b/src/anyvlm/config.py @@ -27,8 +27,8 @@ class Settings(BaseSettings): anyvar_uri: str | None = None storage_uri: str = "postgresql://postgres@localhost:5432/anyvlm" logging_config: FilePath | None = None - base_vlm_network_url: str = "https://vlm-auth.us.auth0.com/" - vlm_network_audience_url: str = base_vlm_network_url + "api/v2/" + vlm_network_base_url: str = "https://vlm-auth.us.auth0.com/" + vlm_network_audience_url: str = vlm_network_base_url + "api/v2/" @cache diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index 2a670a4..3bd3b03 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -41,7 +41,7 @@ def __init__(self, vlm_client: VLMClient) -> None: self._vlm_client = vlm_client settings: Settings = get_config() - self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "api/v2/clients" + self.NODES_REQUEST_URL: str = settings.vlm_network_base_url + "api/v2/clients" self.refresh_nodes() diff --git a/tests/unit/auth/test_jwt_validator.py b/tests/unit/auth/test_jwt_validator.py index 7afd349..55d2c2a 100644 --- a/tests/unit/auth/test_jwt_validator.py +++ b/tests/unit/auth/test_jwt_validator.py @@ -17,17 +17,21 @@ def _request(authorization: str | None) -> MagicMock: def test_decodes_valid_bearer_token() -> None: + issuer: str = "https://issuer.example/" + audience: str = issuer + "api" claims: dict[str, Any] = { "client_id": "client-one", "azp": "client-one", - "iss": "https://issuer.example", + "iss": issuer, + "aud": audience, "exp": 2_000_000_000, } with ( patch("anyvlm.auth.jwt_validator.get_config") as get_config, patch("anyvlm.auth.jwt_validator.jwt.decode", return_value=claims) as decode, ): - get_config.return_value.base_vlm_network_url = "https://issuer.example/" + get_config.return_value.vlm_network_base_url = issuer + get_config.return_value.vlm_network_audience_url = audience decoded_claims: dict[str, Any] = JWTValidator().validate_and_decode_token( request=_request(authorization="Bearer encoded-token") @@ -37,12 +41,14 @@ def test_decodes_valid_bearer_token() -> None: decode.assert_called_once_with( jwt="encoded-token", algorithms=["RS256"], - issuer="https://issuer.example/", + issuer=issuer, + audience=audience, options={ "verify_signature": False, "verify_iss": True, "verify_exp": True, - "require": ["iss", "exp", "azp"], + "verify_aud": True, + "require": ["iss", "exp", "azp", "aud"], }, ) @@ -72,10 +78,11 @@ def test_rejects_invalid_authorization_headers( def test_rejects_an_expired_token() -> None: issuer = "https://issuer.example/" - expired_token = jwt.encode( - { + expired_token: str = jwt.encode( + payload={ "azp": "client-one", "iss": issuer, + "aud": issuer + "/api", "exp": datetime.now(tz=UTC) - timedelta(seconds=1), }, key="unused-signing-key", @@ -85,7 +92,7 @@ def test_rejects_an_expired_token() -> None: get_config_patch = patch("anyvlm.auth.jwt_validator.get_config") get_config = get_config_patch.start() try: - get_config.return_value.base_vlm_network_url = issuer + get_config.return_value.vlm_network_base_url = issuer with pytest.raises(AuthError) as error: JWTValidator().validate_and_decode_token( diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py index 7114b8f..d8c381f 100644 --- a/tests/unit/test_node_registry.py +++ b/tests/unit/test_node_registry.py @@ -32,7 +32,7 @@ def test_loads_nodes_and_exposes_client_ids() -> None: ) with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" + get_config.return_value.vlm_network_base_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) assert registry.get_node_ids() == [ @@ -53,7 +53,7 @@ def test_loads_nodes_from_multiple_pages() -> None: ] with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" + get_config.return_value.vlm_network_base_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) assert registry.get_node_ids() == [ @@ -79,7 +79,7 @@ def test_refresh_nodes_replaces_cached_nodes() -> None: ] with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" + get_config.return_value.vlm_network_base_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) registry.refresh_nodes() @@ -94,7 +94,7 @@ def test_refreshes_expired_cache_on_get_nodes() -> None: ] with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" + get_config.return_value.vlm_network_base_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) registry._nodes_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] assert registry.get_node_ids() == ["refreshed"] @@ -107,7 +107,7 @@ def test_raises_when_node_retrieval_fails() -> None: client.request.return_value = _response(status_code=HTTPStatus.SERVICE_UNAVAILABLE) with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" + get_config.return_value.vlm_network_base_url = "https://network.example/" with pytest.raises(NodeRetrievalError, match="Unable to fetch"): NodeRegistry(vlm_client=client) @@ -135,7 +135,7 @@ def test_registry_excludes_self_when_other_nodes_exist( ) with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" + get_config.return_value.vlm_network_base_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) assert registry.get_node_ids() == [ @@ -165,7 +165,7 @@ def test_registry_returns_empty_list_when_only_self_exists( ) with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" + get_config.return_value.vlm_network_base_url = "https://network.example/" registry: NodeRegistry = NodeRegistry(vlm_client=client) assert registry.get_node_ids() == [] From 718489fc382298cad0403e222f1a7657a28a800b Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 12:30:37 -0400 Subject: [PATCH 20/22] adds test for incorrect audience --- tests/unit/auth/test_jwt_validator.py | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/auth/test_jwt_validator.py b/tests/unit/auth/test_jwt_validator.py index 55d2c2a..cedf393 100644 --- a/tests/unit/auth/test_jwt_validator.py +++ b/tests/unit/auth/test_jwt_validator.py @@ -104,3 +104,33 @@ def test_rejects_an_expired_token() -> None: assert error.value.error_reason == AuthErrorReason.INVALID_TOKEN assert error.value.code == HTTPStatus.UNAUTHORIZED assert error.value.header == {"WWW-Authenticate": 'Bearer error="invalid_token"'} + + +def test_rejects_incorrect_audience() -> None: + issuer = "https://issuer.example/" + token_with_wrong_audience: str = jwt.encode( + payload={ + "azp": "client-one", + "iss": issuer, + "aud": issuer + "/api", + "exp": datetime.now(tz=UTC) - timedelta(seconds=1), + }, + key="unused-signing-key", + algorithm="HS256", + ) + + get_config_patch = patch("anyvlm.auth.jwt_validator.get_config") + get_config = get_config_patch.start() + try: + get_config.return_value.vlm_network_audience_url = "https://issuer-2.example/" + + with pytest.raises(AuthError) as error: + JWTValidator().validate_and_decode_token( + request=_request(authorization=f"Bearer {token_with_wrong_audience}") + ) + finally: + get_config_patch.stop() + + assert error.value.error_reason == AuthErrorReason.INVALID_TOKEN + assert error.value.code == HTTPStatus.UNAUTHORIZED + assert error.value.header == {"WWW-Authenticate": 'Bearer error="invalid_token"'} From f655c1b7dccaaf3becd8a37cf08590d2a215f870 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 15:05:44 -0400 Subject: [PATCH 21/22] forward vlm network cred env vars to docker --- compose.dev.yaml | 2 ++ compose.yaml | 2 ++ docs/source/getting_started/docker_compose.rst | 13 +++++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/compose.dev.yaml b/compose.dev.yaml index f08c3c8..322f7d4 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -18,6 +18,8 @@ services: - --app-dir=/app/src environment: - ANYVLM_STORAGE_URI=postgresql://anyvlm:anyvlm-pw@anyvlm_db:5432/anyvlm + - VLM_CLIENT_ID=${VLM_CLIENT_ID:?VLM_CLIENT_ID must be set} + - VLM_CLIENT_SECRET=${VLM_CLIENT_SECRET:?VLM_CLIENT_SECRET must be set} - HANDOVER_TYPE_ID=CUSTOM:GREGoR-NCH - HANDOVER_TYPE_LABEL=GREGoR AnyVLM Reference - BEACON_HANDOVER_URL=https://variants.gregorconsortium.org/ diff --git a/compose.yaml b/compose.yaml index 5f04f6c..517aa33 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,8 @@ services: - 127.0.0.1:8080:8000 environment: - ANYVLM_STORAGE_URI=postgresql://anyvlm:anyvlm-pw@anyvlm_db:5432/anyvlm + - VLM_CLIENT_ID=${VLM_CLIENT_ID:?VLM_CLIENT_ID must be set} + - VLM_CLIENT_SECRET=${VLM_CLIENT_SECRET:?VLM_CLIENT_SECRET must be set} - HANDOVER_TYPE_ID=CUSTOM:GREGoR-NCH - HANDOVER_TYPE_LABEL=GREGoR AnyVLM Reference - BEACON_HANDOVER_URL=https://variants.gregorconsortium.org/ diff --git a/docs/source/getting_started/docker_compose.rst b/docs/source/getting_started/docker_compose.rst index bc4d8c8..80a1e5e 100644 --- a/docs/source/getting_started/docker_compose.rst +++ b/docs/source/getting_started/docker_compose.rst @@ -32,8 +32,17 @@ 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 `. +Create a `.env` file from the included `.env.example` template: + +.. code-block:: console + + % cp .env.example .env + +Then set the required environment variables as outlined in the :doc:`VLM API Configuration page `. +In particular, replace the example values for ``VLM_CLIENT_ID`` and ``VLM_CLIENT_SECRET`` with the credentials +provided by the VLM Network administrators. + +Docker Compose reads these values from the project ``.env`` file and forwards them to the AnyVLM service at runtime. Then, launch the application: From 1e051449524cf11e4db8bd9308f02edd55a13eea Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 15:43:46 -0400 Subject: [PATCH 22/22] fix trailing slashes in vlm network urls in 'constants.py' --- src/anyvlm/utils/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/anyvlm/utils/constants.py b/src/anyvlm/utils/constants.py index 7fbc354..a7ba8f5 100644 --- a/src/anyvlm/utils/constants.py +++ b/src/anyvlm/utils/constants.py @@ -3,5 +3,5 @@ # VLM Network URLs VLM_NETWORK_ROOT_URL: str = "https://vlm-auth.us.auth0.com/" VLM_NETWORK_AUDIENCE_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/" -VLM_NETWORK_TOKEN_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "oauth/token/" -VLM_NETWORK_NODES_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/clients/" +VLM_NETWORK_TOKEN_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "oauth/token" +VLM_NETWORK_NODES_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/clients"