diff --git a/src/anyvlm/main.py b/src/anyvlm/main.py index c6cfbf5..13af488 100644 --- a/src/anyvlm/main.py +++ b/src/anyvlm/main.py @@ -21,6 +21,7 @@ from anyvlm.anyvar.http_client import HttpAnyVarClient from anyvlm.anyvar.python_client import PythonAnyVarClient from anyvlm.config import get_config +from anyvlm.restapi.auth_manager import AuthManager from anyvlm.restapi.vlm import router as vlm_router from anyvlm.schemas.common import ( SERVICE_DESCRIPTION, @@ -139,6 +140,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: await _configure_logging() app.state.anyvar_client = create_anyvar_client() app.state.anyvlm_storage = create_anyvlm_storage() + app.state.auth_manager = AuthManager() yield app.state.anyvar_client.close() app.state.anyvlm_storage.close() diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py new file mode 100644 index 0000000..e77b196 --- /dev/null +++ b/src/anyvlm/restapi/auth_manager.py @@ -0,0 +1,180 @@ +"""Module to handle authentication of incoming match requests""" + +import logging +import os +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from http import HTTPStatus +from logging import Logger +from typing import Any + +import jwt +import requests +from fastapi import HTTPException, Request +from pydantic.dataclasses import dataclass +from requests.models import Response + +from anyvlm.utils.exceptions import Auth0Error + +_logger: Logger = logging.getLogger(__name__) + + +@dataclass +class VlmNetworkNodeMetadata: + """Houses metadata about a VLM Network node""" + + client_id: str + client_name: str + match_url: str | None + + +class AuthManager: + """Handles auth for incoming and outgoing requests""" + + BASE_VLM_AUTH_URL: str = "https://vlm-auth.us.auth0.com" + TOKEN_REQUEST_URL: str = BASE_VLM_AUTH_URL + "/oauth/token" + AUDIENCE: str = BASE_VLM_AUTH_URL + "/api/v2/" + KNOWN_NODES_REQUEST_URL: str = BASE_VLM_AUTH_URL + "/api/v2/clients" + + KNOWN_NODES_REFRESH_INTERVAL: timedelta = timedelta(hours=24) + + _known_nodes: list[VlmNetworkNodeMetadata] + _known_nodes_expiry: datetime + + _token: str = "" + _token_expiry: datetime + + def __init__(self) -> None: # noqa: D107 + self._refresh_known_nodes() + + def _get_or_refresh_value( + self, value_name: str, refresh_value: Callable[[], None] + ) -> Any: # noqa: ANN401 + """Get the specified value. If it has expired, refresh the value first. + NOTE: This function expects `value_name` to have a corresponding variable called `self.{value_name}_expiry` + that will be checked to determine if the value has expired. + + :param value_name: The variable name of the value to get. + :param refresh_value: The function to refresh the value + reset the expiry datetime, to use if the value is expired. + """ + expiry = getattr(self, f"{value_name}_expiry", datetime.min.replace(tzinfo=UTC)) + if (not getattr(self, value_name, None)) or (datetime.now(tz=UTC) >= expiry): + refresh_value() + return getattr(self, value_name) + + def _refresh_known_nodes(self) -> None: + """Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check""" + response: Response = requests.get( + url=self.KNOWN_NODES_REQUEST_URL, + headers={ + "Authorization": f"Bearer {self._get_or_refresh_value(value_name='_token', refresh_value=self._refresh_token)}" + }, + timeout=10, + ) + + if response.status_code == HTTPStatus.OK: + self._known_nodes = [] + else: + error_message: str = "Unable to fetch known VLM Network nodes from Auth0" + raise Auth0Error(error_message) + + data = response.json() + for entry in data: + self._known_nodes.append( + VlmNetworkNodeMetadata( + client_id=entry.get("client_id", ""), + client_name=entry.get("name", ""), + match_url=entry.get("client_metadata", {}).get("match_url"), + ) + ) + + self._known_nodes_expiry = ( + datetime.now(tz=UTC) + self.KNOWN_NODES_REFRESH_INTERVAL + ) + + def get_known_nodes(self) -> list[VlmNetworkNodeMetadata]: + """Retrieves a list of all known nodes on the VLM Network""" + return self._get_or_refresh_value( + value_name="_known_nodes", refresh_value=self._refresh_known_nodes + ) + + def _refresh_token(self) -> None: + """Sets a new JWT token and updates the token expiry time""" + response: Response = requests.post( + url=self.TOKEN_REQUEST_URL, + json={ + "client_id": os.getenv("VLM_CLIENT_ID"), + "client_secret": os.getenv("VLM_CLIENT_SECRET"), + "audience": self.AUDIENCE, + "grant_type": "client_credentials", + }, + timeout=10, + ) + + if response.status_code != HTTPStatus.OK: + error_message: str = "Unable to fetch bearer token from Auth0" + raise Auth0Error(error_message) + + data = response.json() + + self._token = data["access_token"] + self._token_expiry = datetime.now(tz=UTC) + timedelta( + seconds=data["expires_in"] + ) + + def get_token(self) -> str: + """Retrieve a current JWT token. Will set a new token if there + isn't currently one set, or if the current on is expired. + + :return: a JWT token + """ + return self._get_or_refresh_value( + value_name="_token", refresh_value=self._refresh_token + ) + + def authenticate_request(self, request: Request) -> None: + """Authenticate JWT token from incoming match requests + + :param request: The incoming request object + :return: None + :raises: HTTPException if unable to successfully decode the request's JWT 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: dict[str, Any] = jwt.decode( + jwt=token, + algorithms=["RS256"], + issuer=self.BASE_VLM_AUTH_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 + + client_id = decoded["azp"] + + message: str = f"Received match request from client_id '{client_id}'" + _logger.info(msg=message) + + if client_id not in self._known_nodes: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Unknown client ID" + ) diff --git a/src/anyvlm/restapi/authenticate_request.py b/src/anyvlm/restapi/authenticate_request.py deleted file mode 100644 index f9c6289..0000000 --- a/src/anyvlm/restapi/authenticate_request.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Module to handle authentication of incoming match requests""" - -import logging -from http import HTTPStatus -from logging import Logger -from typing import Any - -import jwt -from fastapi import HTTPException, Request - -_logger: Logger = logging.getLogger(__name__) - -VLM_AUTH_API = "https://vlm-auth.us.auth0.com/" - - -def authenticate_request(request: Request) -> None: - """Authenticate JWT token from incoming match requests - - :param request: The incoming request object - :return: None - :raises: HTTPException if unable to successfully decode the request's JWT 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: dict[str, Any] = jwt.decode( - jwt=token, - algorithms=["RS256"], - issuer=VLM_AUTH_API, - 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 - - client_id = decoded["azp"] - message: str = f"Received match request from client_id '{client_id}'" - _logger.info(msg=message) diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index 9d0152a..24f2e3d 100644 --- a/src/anyvlm/utils/exceptions.py +++ b/src/anyvlm/utils/exceptions.py @@ -19,3 +19,7 @@ class VcfIngestionError(Exception): class VariantLookupError(Exception): """Raised when a variant cannot be retrieved from AnyVar""" + + +class Auth0Error(Exception): + """Used when an error occurs with the Auth0 API""" diff --git a/tests/unit/test_auth_manager.py b/tests/unit/test_auth_manager.py new file mode 100644 index 0000000..57e93a5 --- /dev/null +++ b/tests/unit/test_auth_manager.py @@ -0,0 +1,288 @@ +"""Test AuthManager behavior.""" + +from datetime import UTC, datetime, timedelta +from http import HTTPStatus +from typing import Any + +import pytest +from fastapi import HTTPException, Request + +from anyvlm.restapi import auth_manager +from anyvlm.restapi.auth_manager import AuthManager, VlmNetworkNodeMetadata +from anyvlm.utils.exceptions import Auth0Error + + +class MockResponse: + """Minimal requests.Response stand-in for AuthManager tests.""" + + def __init__(self, status_code: int, data: object) -> None: + self.status_code = status_code + self._data = data + + def json(self) -> object: + return self._data + + +@pytest.fixture +def auth0_success(monkeypatch: pytest.MonkeyPatch) -> tuple[list[Any], list[Any]]: + """Patch successful Auth0 token and client-list responses.""" + post_calls: list[Any] = [] + get_calls: list[Any] = [] + nodes: list[dict[str, str | dict[str, str]] | dict[str, str | dict[Any, Any]]] = [ + { + "client_id": "client-1", + "name": "Node One", + "client_metadata": {"match_url": "https://node-one.example/match"}, + }, + { + "client_id": "client-2", + "name": "Node Two", + "client_metadata": {}, + }, + ] + + monkeypatch.setenv(name="VLM_CLIENT_ID", value="test-client") + monkeypatch.setenv(name="VLM_CLIENT_SECRET", value="test-secret") + + def post(*, url: str, json: dict[str, str], timeout: int) -> MockResponse: + post_calls.append({"url": url, "json": json, "timeout": timeout}) + return MockResponse( + status_code=HTTPStatus.OK, + data={"access_token": f"token-{len(post_calls)}", "expires_in": 60}, + ) + + def get(*, url: str, headers: dict[str, str], timeout: int) -> MockResponse: + get_calls.append({"url": url, "headers": headers, "timeout": timeout}) + return MockResponse(status_code=HTTPStatus.OK, data=nodes) + + monkeypatch.setattr(auth_manager.requests, "post", post) + monkeypatch.setattr(auth_manager.requests, "get", get) + + return post_calls, get_calls + + +def build_auth_manager() -> AuthManager: + """Build AuthManager instance without calling __init__.""" + return object.__new__(AuthManager) + + +def build_request(authorization: str) -> Request: + """Build a Request with an Authorization header.""" + return Request( + scope={ + "type": "http", + "method": "GET", + "path": "/anyvlm/variant_counts", + "headers": [(b"authorization", authorization.encode())], + } + ) + + +def test_init_fetches_token_and_known_nodes(auth0_success: tuple[list[Any], list[Any]]): + post_calls, get_calls = auth0_success + + manager: AuthManager = AuthManager() + + assert manager.get_token() == "token-1" + assert manager.get_known_nodes() == [ + VlmNetworkNodeMetadata( + client_id="client-1", + client_name="Node One", + match_url="https://node-one.example/match", + ), + VlmNetworkNodeMetadata( + client_id="client-2", client_name="Node Two", match_url=None + ), + ] + assert post_calls == [ + { + "url": AuthManager.TOKEN_REQUEST_URL, + "json": { + "client_id": "test-client", + "client_secret": "test-secret", + "audience": AuthManager.AUDIENCE, + "grant_type": "client_credentials", + }, + "timeout": 10, + } + ] + assert get_calls == [ + { + "url": AuthManager.KNOWN_NODES_REQUEST_URL, + "headers": {"Authorization": "Bearer token-1"}, + "timeout": 10, + } + ] + + +def test_get_token_uses_cached_token_before_expiry( + auth0_success: tuple[list[Any], list[Any]], +) -> None: + post_calls, _ = auth0_success + manager: AuthManager = AuthManager() + + assert manager.get_token() == "token-1" + + assert len(post_calls) == 1 + + +def test_get_token_refreshes_expired_token( + auth0_success: tuple[list[Any], list[Any]], monkeypatch: pytest.MonkeyPatch +) -> None: + post_calls, _ = auth0_success + manager: AuthManager = AuthManager() + monkeypatch.setattr( + manager, "_token_expiry", datetime.now(tz=UTC) - timedelta(seconds=1) + ) + + assert manager.get_token() == "token-2" + + assert len(post_calls) == 2 + + +def test_get_known_nodes_refreshes_expired_known_nodes( + auth0_success: tuple[list[Any], list[Any]], monkeypatch: pytest.MonkeyPatch +) -> None: + _, get_calls = auth0_success + manager: AuthManager = AuthManager() + monkeypatch.setattr( + manager, + "_known_nodes_expiry", + datetime.now(tz=UTC) - timedelta(seconds=1), + ) + + assert manager.get_known_nodes()[0].client_name == "Node One" + + assert len(get_calls) == 2 + + +def test_refresh_token_raises_auth0_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + auth_manager.requests, + "post", + lambda **_: MockResponse(status_code=HTTPStatus.UNAUTHORIZED, data={}), + ) + manager = build_auth_manager() + + with pytest.raises(Auth0Error, match="Unable to fetch bearer token from Auth0"): + manager.get_token() + + +def test_refresh_known_nodes_raises_auth0_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + auth_manager.requests, + "post", + lambda **_: MockResponse( + status_code=HTTPStatus.OK, + data={"access_token": "token", "expires_in": 60}, + ), + ) + monkeypatch.setattr( + auth_manager.requests, + "get", + lambda **_: MockResponse(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, data={}), + ) + + with pytest.raises( + Auth0Error, match="Unable to fetch known VLM Network nodes from Auth0" + ): + AuthManager() + + +def test_authenticate_request_accepts_known_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager: AuthManager = build_auth_manager() + monkeypatch.setattr( + manager, + "_known_nodes", + {"client-1": {"name": "Node One", "match_url": None}}, + raising=False, + ) + + def decode(**kwargs) -> dict[str, str]: + assert kwargs["jwt"] == "test-token" + assert kwargs["algorithms"] == ["RS256"] + assert kwargs["issuer"] == AuthManager.BASE_VLM_AUTH_URL + assert kwargs["options"] == { + "verify_signature": False, + "verify_iss": True, + "verify_exp": True, + "require": ["azp"], + } + return {"azp": "client-1"} + + monkeypatch.setattr(auth_manager.jwt, "decode", decode) + + assert ( + manager.authenticate_request( + request=build_request(authorization="Bearer test-token") + ) + is None + ) + + +@pytest.mark.parametrize( + argnames=("authorization", "detail"), + argvalues=[ + ("", "Invalid authorization header"), + ("Bearer", "Invalid authorization header"), + ("Basic test-token", "Invalid token scheme"), + ], +) +def test_authenticate_request_rejects_invalid_authorization_header( + authorization: str, detail: str +) -> None: + manager: AuthManager = build_auth_manager() + + with pytest.raises(HTTPException) as exc_info: + manager.authenticate_request(request=build_request(authorization)) + + assert exc_info.value.status_code == HTTPStatus.FORBIDDEN + assert exc_info.value.detail == detail + + +def test_authenticate_request_rejects_invalid_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager: AuthManager = build_auth_manager() + + def decode(**kwargs) -> dict[str, str]: + raise auth_manager.jwt.InvalidTokenError("bad token") + + monkeypatch.setattr(auth_manager.jwt, "decode", decode) + + with pytest.raises(HTTPException) as exc_info: + manager.authenticate_request( + request=build_request(authorization="Bearer test-token") + ) + + assert exc_info.value.status_code == HTTPStatus.FORBIDDEN + assert exc_info.value.detail == "Invalid token: bad token" + + +def test_authenticate_request_rejects_unknown_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager: AuthManager = build_auth_manager() + monkeypatch.setattr( + manager, + "_known_nodes", + {"client-1": {"name": "Node One", "match_url": None}}, + raising=False, + ) + monkeypatch.setattr( + auth_manager.jwt, + "decode", + lambda **_: {"azp": "unknown-client"}, + ) + + with pytest.raises(HTTPException) as exc_info: + manager.authenticate_request( + request=build_request(authorization="Bearer test-token") + ) + + assert exc_info.value.status_code == HTTPStatus.FORBIDDEN + assert exc_info.value.detail == "Unknown client ID"