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: 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 new file mode 100644 index 0000000..6d58e9f --- /dev/null +++ b/src/anyvlm/auth/jwt_validator.py @@ -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, + options={ + "verify_signature": False, # See PR comment for more details: https://github.com/GenomicMedLab/AnyVLM/pull/120 + "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 + + 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 diff --git a/src/anyvlm/auth/request_authenticator.py b/src/anyvlm/auth/request_authenticator.py new file mode 100644 index 0000000..981857e --- /dev/null +++ b/src/anyvlm/auth/request_authenticator.py @@ -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) diff --git a/src/anyvlm/main.py b/src/anyvlm/main.py index 6047486..e66d7bf 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 AuthError from anyvlm.utils.types import ( EndpointTag, ) @@ -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. @@ -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) + 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() @@ -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) diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index eb5eea5..b29cd0f 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -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 @@ -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 @@ -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 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/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" diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index 079fbc1..1a4f04a 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""" @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index f873101..f179a9a 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, @@ -30,6 +36,24 @@ environ.setdefault("VLM_CLIENT_SECRET", "example-client-secret") +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/auth/test_jwt_validator.py b/tests/unit/auth/test_jwt_validator.py new file mode 100644 index 0000000..cedf393 --- /dev/null +++ b/tests/unit/auth/test_jwt_validator.py @@ -0,0 +1,136 @@ +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 anyvlm.auth.jwt_validator import JWTValidator +from anyvlm.utils.exceptions import AuthError, AuthErrorReason + + +def _request(authorization: str | None) -> MagicMock: + request: MagicMock = MagicMock() + request.headers.get.return_value = authorization or "" + return request + + +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": 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.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") + ) + + assert decoded_claims == claims + decode.assert_called_once_with( + jwt="encoded-token", + algorithms=["RS256"], + issuer=issuer, + audience=audience, + options={ + "verify_signature": False, + "verify_iss": True, + "verify_exp": True, + "verify_aud": True, + "require": ["iss", "exp", "azp", "aud"], + }, + ) + + +@pytest.mark.parametrize( + argnames=("authorization", "reason"), + argvalues=[ + (None, AuthErrorReason.MISSING_AUTH_HEADER), + ("Bearer", AuthErrorReason.MALFORMED_AUTH_HEADER), + ("Basic encoded-token", AuthErrorReason.INVALID_SCHEMA), + ], +) +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.error_reason == reason + assert error.value.code == ( + HTTPStatus.UNAUTHORIZED + if reason == AuthErrorReason.INVALID_SCHEMA + else HTTPStatus.BAD_REQUEST + ) + assert error.value.header == {"WWW-Authenticate": 'Bearer realm="AnyVLM"'} + + +def test_rejects_an_expired_token() -> None: + issuer = "https://issuer.example/" + 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", + 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_base_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 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"'} diff --git a/tests/unit/auth/test_request_authenticator.py b/tests/unit/auth/test_request_authenticator.py new file mode 100644 index 0000000..0313e15 --- /dev/null +++ b/tests/unit/auth/test_request_authenticator.py @@ -0,0 +1,125 @@ +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from anyvlm.auth.request_authenticator import RequestAuthenticator +from anyvlm.utils.exceptions import AuthError, AuthErrorReason + + +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 + + 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 ( + request_authenticator, + jwt_validator, + node_registry, + ) + + +def test_authenticates_known_node_without_refreshing_registry(): + request_authenticator, jwt_validator, node_registry = _authenticator( + claims={"azp": "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={"azp": "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={"azp": "unknown"}, node_ids=[]) + + with pytest.raises( + AuthError, + match=AuthErrorReason.UNRECOGNIZED_NODE, + ): + request_.authenticate(request=MagicMock()) + + 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=[]) + + with pytest.raises(AuthError, match=AuthErrorReason.MISSING_CLIENT_ID): + 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..2eddfc3 --- /dev/null +++ b/tests/unit/test_inbound_auth.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from anyvlm.main import authenticate_inbound_request +from anyvlm.utils.exceptions import AuthError, AuthErrorReason + + +def test_authenticate_inbound_request_delegates_to_app_authenticator() -> None: + 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_http_exceptions() -> ( + None +): + request = MagicMock() + 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 == 403 + assert error.value.detail == AuthErrorReason.UNRECOGNIZED_NODE + assert error.value.headers is None diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py index 00e05a0..0a39c61 100644 --- a/tests/unit/test_node_registry.py +++ b/tests/unit/test_node_registry.py @@ -34,7 +34,7 @@ def test_loads_nodes_and_exposes_client_ids() -> None: registry: NodeRegistry = NodeRegistry(vlm_client=client) - assert [node.client_id for node in registry.get_nodes()] == [ + assert registry.get_node_ids() == [ "client-one", "client-two", ] @@ -53,7 +53,7 @@ def test_loads_nodes_from_multiple_pages() -> None: registry: NodeRegistry = NodeRegistry(vlm_client=client) - assert [node.client_id for node in registry.get_nodes()] == [ + assert registry.get_node_ids() == [ "client-one", ] * 100 + ["client-two"] assert client.request.call_args_list == [ @@ -78,7 +78,7 @@ def test_refresh_nodes_replaces_cached_nodes() -> None: registry: NodeRegistry = NodeRegistry(vlm_client=client) registry.refresh_nodes() - assert [node.client_id for node in registry.get_nodes()] == ["new"] + assert registry.get_node_ids() == ["new"] def test_refreshes_expired_cache_on_get_nodes() -> None: @@ -127,7 +127,7 @@ def test_registry_excludes_self_when_other_nodes_exist( registry: NodeRegistry = NodeRegistry(vlm_client=client) - assert [node.client_id for node in registry.get_nodes()] == [ + assert registry.get_node_ids() == [ "client-one", ] client.request.assert_called_once_with( @@ -155,7 +155,7 @@ def test_registry_returns_empty_list_when_only_self_exists( registry: NodeRegistry = NodeRegistry(vlm_client=client) - assert [node.client_id for node in registry.get_nodes()] == [] + assert registry.get_node_ids() == [] client.request.assert_called_once_with( method_type=HTTPMethod.GET, url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", diff --git a/tests/unit/test_restapi.py b/tests/unit/test_restapi.py index 048f94a..df26e67 100644 --- a/tests/unit/test_restapi.py +++ b/tests/unit/test_restapi.py @@ -3,20 +3,12 @@ from pathlib import Path 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(): - 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 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, 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