Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b71ae48
implement auth for incoming requests
jennifer-bowser Oct 27, 2026
eab233c
first pass at writing tests
jennifer-bowser Oct 27, 2026
2b7312f
use custom 'AuthError' class to track error reasons & codes instead o…
jennifer-bowser Oct 27, 2026
e8d1bc7
modify tests to reflect new AuthError structure
jennifer-bowser Oct 27, 2026
0f4b2e7
fix duplicate imports
jennifer-bowser Oct 27, 2026
2f4092c
fix 'base_vlm_network_url' to include trailing slash
jennifer-bowser Oct 27, 2026
d035303
add explanatory comment
jennifer-bowser Oct 27, 2026
006a3df
fix some failing tests
jennifer-bowser Oct 27, 2026
03fd4b6
resolve merge conflicts
jennifer-bowser Oct 28, 2026
81b9bab
fix docstrings for 'JWTValidator' and 'RequestAuthenticator'
jennifer-bowser Oct 28, 2026
c66626f
renames 'VlmClient' > 'VLMClient' to align capitalization convention …
jennifer-bowser Oct 28, 2026
9da0a1f
add client-specific cooldowns for refreshing the node registry
jennifer-bowser Oct 28, 2026
84c24e2
adds cooldown for node registry refresh calls in 'RequestAuthenticator'
jennifer-bowser Oct 28, 2026
83a11a5
add tests ensuring cooldown in 'RequestAuthenticator' works properly
jennifer-bowser Oct 28, 2026
44e444a
make a fixture for the app and reset its state after each usage of a …
jennifer-bowser Oct 28, 2026
425f7aa
fix failing tests
jennifer-bowser Oct 28, 2026
6eb25bb
catch missing/malformed token 'exp' values
jennifer-bowser Oct 29, 2026
7d12984
update error handling in JWTValidator
jennifer-bowser Oct 29, 2026
8647619
add audience verification to token checks
jennifer-bowser Oct 29, 2026
b851b67
fixes failing test
jennifer-bowser Oct 29, 2026
718489f
adds test for incorrect audience
jennifer-bowser Oct 29, 2026
d55c1d0
resolve merge conflicts
jennifer-bowser Oct 29, 2026
f655c1b
forward vlm network cred env vars to docker
jennifer-bowser Oct 29, 2026
1e05144
fix trailing slashes in vlm network urls in 'constants.py'
jennifer-bowser Oct 29, 2026
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
2 changes: 2 additions & 0 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
2 changes: 2 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
13 changes: 11 additions & 2 deletions docs/source/getting_started/docker_compose.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 </configuration/vlm>`.
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 </configuration/vlm>`.
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:

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 56 additions & 0 deletions src/anyvlm/auth/jwt_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Handles validating and decoding JWT tokens"""

from typing import Any

import jwt
from fastapi import Request

from anyvlm.config import Settings, get_config
from anyvlm.utils.exceptions import AuthError, AuthErrorReason


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: `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)
try:
scheme, token = auth_header.strip().split(sep=" ")
except ValueError as e:
raise AuthError(error_reason=AuthErrorReason.MALFORMED_AUTH_HEADER) from e
if scheme.lower() != "bearer":
raise AuthError(error_reason=AuthErrorReason.INVALID_SCHEMA)

try:
decoded_claims: dict[str, Any] = jwt.decode(
jwt=token,
algorithms=["RS256"],
issuer=configs.vlm_network_base_url,
audience=configs.vlm_network_audience_url,
Comment on lines +38 to +39

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 - High (major failure in a supported workflow); Blocking: Use the current issuer and audience constants

Settings no longer defines vlm_network_base_url or vlm_network_audience_url, so every request reaching this call raises AttributeError before decoding and returns 500, including requests with valid tokens. Import and use the constants from anyvlm.utils.constants, and cover a bearer-token request using the real settings object. The current tests supply these removed attributes through MagicMock, which hides the failure.

options={
"verify_signature": False, # See PR comment for more details: https://github.com/GenomicMedLab/AnyVLM/pull/120
Comment thread
jennifer-bowser marked this conversation as resolved.
Resolved
"verify_iss": True,
"verify_exp": True,
"verify_aud": True,
"require": ["iss", "exp", "azp", "aud"],
},
)
except jwt.InvalidTokenError as e:
raise AuthError(error_reason=AuthErrorReason.INVALID_TOKEN) from e
Comment thread
jennifer-bowser marked this conversation as resolved.
Resolved

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
68 changes: 68 additions & 0 deletions src/anyvlm/auth/request_authenticator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Handles authenticating inbound requests"""

from datetime import UTC, datetime, timedelta
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 AuthError, AuthErrorReason


class RequestAuthenticator:
"""Handles authenticating inbound requests"""

NODE_REGISTRY_REFRESH_COOLDOWN_SECONDS = 30

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
self.last_node_refresh: datetime = datetime.now(tz=UTC)

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: `AuthError`, if unable to authenticate.
"""
decoded_token_claims: dict[str, Any] = (
self.jwt_validator.validate_and_decode_token(request)
)

client_id: Any = decoded_token_claims.get("azp")
self.verify_node_membership(client_id=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 current node registry
if client_id in self.node_registry.get_node_ids():
return

# 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
):
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)
35 changes: 34 additions & 1 deletion src/anyvlm/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -29,6 +34,7 @@
ServiceType,
)
from anyvlm.storage.base_storage import Storage
from anyvlm.utils.exceptions import AuthError
from anyvlm.utils.types import (
EndpointTag,
)
Expand Down Expand Up @@ -129,6 +135,21 @@ 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 AuthError as e:
raise HTTPException(
status_code=e.code, detail=e.error_reason, headers=e.header
) from e


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator:
"""Configure FastAPI instance lifespan.
Expand All @@ -139,6 +160,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 = TokenProvider()
vlm_client: VLMClient = VLMClient(token_provider)
node_registry: NodeRegistry = NodeRegistry(vlm_client)
Comment thread
jennifer-bowser marked this conversation as resolved.
Resolved
jwt_validator: JWTValidator = 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()
Expand All @@ -163,6 +195,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)

Expand Down
10 changes: 7 additions & 3 deletions src/anyvlm/outbound/node_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from requests import Response

from anyvlm.outbound.vlm_client import VlmClient
from anyvlm.outbound.vlm_client import VLMClient
from anyvlm.utils.constants import VLM_NETWORK_NODES_REQUEST_URL
from anyvlm.utils.exceptions import NodeRetrievalError

Expand All @@ -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
Expand All @@ -50,6 +50,10 @@ def get_nodes(self) -> list[VlmNode]:
self.refresh_nodes()
return self._nodes

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

Expand Down
2 changes: 1 addition & 1 deletion src/anyvlm/outbound/vlm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/anyvlm/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
65 changes: 65 additions & 0 deletions src/anyvlm/utils/exceptions.py
Original file line number Diff line number Diff line change
@@ -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"""
Expand Down Expand Up @@ -27,3 +31,64 @@ class TokenRetrievalError(Exception):

class NodeRetrievalError(Exception):
"""Raised when the list of nodes on the VLM network cannot be retrieved"""


class AuthErrorReason(StrEnum):
"""Defines the authentication error messages.
All reasons must have a corresponding code mapped in the 'AuthError' class
"""

MISSING_AUTH_HEADER = "Missing authorization request header"
MALFORMED_AUTH_HEADER = "Malformed authorization request header"
INVALID_SCHEMA = "Invalid token schema"
INVALID_TOKEN = "Invalid access 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"""

# Every reason in `AuthErrorReason` must have a corresponding code mapped here
reason_to_code_mapping: ClassVar[dict[AuthErrorReason, HTTPStatus]] = {
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.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.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.MISSING_AUTH_HEADER: {
"WWW-Authenticate": 'Bearer realm="AnyVLM"',
},
AuthErrorReason.MALFORMED_AUTH_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)
Loading
Loading