From cd265163de14278c08b007afabce93c70ae3fcdb Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Mon, 5 Oct 2026 16:20:35 -0400 Subject: [PATCH 01/10] add logic to retrieve/refresh jwt token --- src/anyvlm/restapi/auth_manager.py | 103 +++++++++++++++++++++ src/anyvlm/restapi/authenticate_request.py | 53 ----------- 2 files changed, 103 insertions(+), 53 deletions(-) create mode 100644 src/anyvlm/restapi/auth_manager.py delete mode 100644 src/anyvlm/restapi/authenticate_request.py diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py new file mode 100644 index 0000000..013c55f --- /dev/null +++ b/src/anyvlm/restapi/auth_manager.py @@ -0,0 +1,103 @@ +"""Module to handle authentication of incoming match requests""" + +import logging +import os +from datetime import UTC, datetime +from http import HTTPStatus +from logging import Logger +from typing import Any + +import jwt +import requests +from fastapi import HTTPException, Request +from requests.models import Response + +_logger: Logger = logging.getLogger(__name__) + + +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_node_ids: list[str] + _token: str = "" + token_expiry: datetime + + def __init__(self) -> None: # noqa: D107 + self.known_node_ids = self.get_known_nodes() + + def get_known_nodes(self) -> list[str]: + """Retrieves a list of all known nodes on the VLM Network""" + return [] # TODO + + def set_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, + ) + + data = response.json() + + self._token = data["access_token"] + self.token_expiry = datetime.now(tz=UTC) + 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 + """ + if (not self._token) or (datetime.now(tz=UTC) >= self.token_expiry): + self.set_token() + return self._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) 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) From 7fd5d4ef9b1f07e6007f619e640c729ea8c7a581 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 14:10:46 -0400 Subject: [PATCH 02/10] create 'AuthManager' class w/ known nodes list retrieval --- src/anyvlm/restapi/auth_manager.py | 66 +++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py index 013c55f..8aed875 100644 --- a/src/anyvlm/restapi/auth_manager.py +++ b/src/anyvlm/restapi/auth_manager.py @@ -2,7 +2,8 @@ import logging import os -from datetime import UTC, datetime +from collections.abc import Callable +from datetime import UTC, datetime, timedelta from http import HTTPStatus from logging import Logger from typing import Any @@ -21,19 +22,62 @@ class AuthManager: 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: dict[str, dict[str, str | None]] + _known_nodes_expiry: datetime - known_node_ids: list[str] _token: str = "" - token_expiry: datetime + _token_expiry: datetime def __init__(self) -> None: # noqa: D107 - self.known_node_ids = self.get_known_nodes() + 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. + """ + if (not getattr(self, value_name)) or ( + datetime.now(tz=UTC) >= getattr(self, f"{value_name})_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, timeout=10) - def get_known_nodes(self) -> list[str]: + if response.status_code == HTTPStatus.OK: + self._known_nodes = {} + else: + raise Exception # noqa: TRY002 TODO: be more specific + + data = response.json() + for entry in data: + self._known_nodes[entry.get("client_id")] = { + "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) -> dict[str, list[str]]: """Retrieves a list of all known nodes on the VLM Network""" - return [] # TODO + return self._get_or_refresh_value( + value_name="known_nodes", refresh_value=self.refresh_known_nodes + ) - def set_token(self) -> None: + 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, @@ -49,7 +93,7 @@ def set_token(self) -> None: data = response.json() self._token = data["access_token"] - self.token_expiry = datetime.now(tz=UTC) + data["expires_in"] + self._token_expiry = datetime.now(tz=UTC) + data["expires_in"] def get_token(self) -> str: """Retrieve a current JWT token. Will set a new token if there @@ -57,9 +101,9 @@ def get_token(self) -> str: :return: a JWT token """ - if (not self._token) or (datetime.now(tz=UTC) >= self.token_expiry): - self.set_token() - return self._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 From a36c0f314a3a842abb0fd88031fe7c95f2035b5c Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 14:17:50 -0400 Subject: [PATCH 03/10] add client ID check to 'authenticate_request' --- src/anyvlm/restapi/auth_manager.py | 11 ++++++++++- src/anyvlm/utils/exceptions.py | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py index 8aed875..5d8926a 100644 --- a/src/anyvlm/restapi/auth_manager.py +++ b/src/anyvlm/restapi/auth_manager.py @@ -13,6 +13,8 @@ from fastapi import HTTPException, Request from requests.models import Response +from anyvlm.utils.exceptions import Auth0Error + _logger: Logger = logging.getLogger(__name__) @@ -58,7 +60,8 @@ def refresh_known_nodes(self) -> None: if response.status_code == HTTPStatus.OK: self._known_nodes = {} else: - raise Exception # noqa: TRY002 TODO: be more specific + error_message: str = "Unable to fetch known VLM Network nodes" + raise Auth0Error(error_message) data = response.json() for entry in data: @@ -143,5 +146,11 @@ def authenticate_request(self, request: Request) -> None: ) 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/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""" From 677f47a56509965f466c44723b08c899522da3cd Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 14:19:52 -0400 Subject: [PATCH 04/10] fix '_token_expiry' refresh --- src/anyvlm/restapi/auth_manager.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py index 5d8926a..eb5ddad 100644 --- a/src/anyvlm/restapi/auth_manager.py +++ b/src/anyvlm/restapi/auth_manager.py @@ -35,7 +35,7 @@ class AuthManager: _token_expiry: datetime def __init__(self) -> None: # noqa: D107 - self.refresh_known_nodes() + self._refresh_known_nodes() def _get_or_refresh_value( self, value_name: str, refresh_value: Callable[[], None] @@ -53,7 +53,7 @@ def _get_or_refresh_value( refresh_value() return getattr(self, value_name) - def refresh_known_nodes(self) -> None: + 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, timeout=10) @@ -77,10 +77,10 @@ def refresh_known_nodes(self) -> None: def get_known_nodes(self) -> dict[str, list[str]]: """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 + value_name="known_nodes", refresh_value=self._refresh_known_nodes ) - def refresh_token(self) -> None: + 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, @@ -96,7 +96,7 @@ def refresh_token(self) -> None: data = response.json() self._token = data["access_token"] - self._token_expiry = datetime.now(tz=UTC) + data["expires_in"] + self._token_expiry = datetime.now(tz=UTC) + timedelta(data["expires_in"]) def get_token(self) -> str: """Retrieve a current JWT token. Will set a new token if there @@ -105,7 +105,7 @@ def get_token(self) -> str: :return: a JWT token """ return self._get_or_refresh_value( - value_name="token", refresh_value=self.refresh_token + value_name="token", refresh_value=self._refresh_token ) def authenticate_request(self, request: Request) -> None: From dd200829909dcb89c7cfb3a836e49b9c2d1a669c Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 14:29:55 -0400 Subject: [PATCH 05/10] add auth to request to reteive node list --- src/anyvlm/restapi/auth_manager.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py index eb5ddad..ae28ccc 100644 --- a/src/anyvlm/restapi/auth_manager.py +++ b/src/anyvlm/restapi/auth_manager.py @@ -55,7 +55,13 @@ def _get_or_refresh_value( 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, timeout=10) + 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 = {} From 73341e1953c734a5b3f64906ad75e838e9f2aaf3 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 14:34:25 -0400 Subject: [PATCH 06/10] add an 'AuthManager' instance to app state --- src/anyvlm/main.py | 2 ++ 1 file changed, 2 insertions(+) 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() From b4e77669141f5a557b8ba18a75f667fb36715145 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 14:41:49 -0400 Subject: [PATCH 07/10] better error messages --- src/anyvlm/restapi/auth_manager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py index ae28ccc..52adfd7 100644 --- a/src/anyvlm/restapi/auth_manager.py +++ b/src/anyvlm/restapi/auth_manager.py @@ -66,7 +66,7 @@ def _refresh_known_nodes(self) -> None: if response.status_code == HTTPStatus.OK: self._known_nodes = {} else: - error_message: str = "Unable to fetch known VLM Network nodes" + error_message: str = "Unable to fetch known VLM Network nodes from Auth0" raise Auth0Error(error_message) data = response.json() @@ -99,6 +99,10 @@ def _refresh_token(self) -> None: 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"] From 369f58618289bca85723a2933afa65a192080164 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 15:13:50 -0400 Subject: [PATCH 08/10] add tests for 'AuthManager' --- src/anyvlm/restapi/auth_manager.py | 13 +- tests/unit/test_auth_manager.py | 261 +++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_auth_manager.py diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py index 52adfd7..837ee6c 100644 --- a/src/anyvlm/restapi/auth_manager.py +++ b/src/anyvlm/restapi/auth_manager.py @@ -47,9 +47,8 @@ def _get_or_refresh_value( :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. """ - if (not getattr(self, value_name)) or ( - datetime.now(tz=UTC) >= getattr(self, f"{value_name})_expiry") - ): + 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) @@ -83,7 +82,7 @@ def _refresh_known_nodes(self) -> None: def get_known_nodes(self) -> dict[str, list[str]]: """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 + value_name="_known_nodes", refresh_value=self._refresh_known_nodes ) def _refresh_token(self) -> None: @@ -106,7 +105,9 @@ def _refresh_token(self) -> None: data = response.json() self._token = data["access_token"] - self._token_expiry = datetime.now(tz=UTC) + timedelta(data["expires_in"]) + 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 @@ -115,7 +116,7 @@ def get_token(self) -> str: :return: a JWT token """ return self._get_or_refresh_value( - value_name="token", refresh_value=self._refresh_token + value_name="_token", refresh_value=self._refresh_token ) def authenticate_request(self, request: Request) -> None: diff --git a/tests/unit/test_auth_manager.py b/tests/unit/test_auth_manager.py new file mode 100644 index 0000000..912167b --- /dev/null +++ b/tests/unit/test_auth_manager.py @@ -0,0 +1,261 @@ +"""Test AuthManager behavior.""" + +from datetime import UTC, datetime, timedelta +from http import HTTPStatus + +import pytest +from fastapi import HTTPException, Request + +from anyvlm.restapi import auth_manager +from anyvlm.restapi.auth_manager import AuthManager +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): + """Patch successful Auth0 token and client-list responses.""" + post_calls = [] + get_calls = [] + nodes = [ + { + "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("VLM_CLIENT_ID", "test-client") + monkeypatch.setenv("VLM_CLIENT_SECRET", "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( + { + "type": "http", + "method": "GET", + "path": "/anyvlm/variant_counts", + "headers": [(b"authorization", authorization.encode())], + } + ) + + +def test_init_fetches_token_and_known_nodes(auth0_success): + post_calls, get_calls = auth0_success + + manager = AuthManager() + + assert manager.get_token() == "token-1" + assert manager.get_known_nodes() == { + "client-1": { + "name": "Node One", + "match_url": "https://node-one.example/match", + }, + "client-2": {"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): + post_calls, _ = auth0_success + manager = AuthManager() + + assert manager.get_token() == "token-1" + + assert len(post_calls) == 1 + + +def test_get_token_refreshes_expired_token(auth0_success, monkeypatch): + post_calls, _ = auth0_success + manager = 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, monkeypatch): + _, get_calls = auth0_success + manager = AuthManager() + monkeypatch.setattr( + manager, + "_known_nodes_expiry", + datetime.now(tz=UTC) - timedelta(seconds=1), + ) + + assert manager.get_known_nodes()["client-1"]["name"] == "Node One" + + assert len(get_calls) == 2 + + +def test_refresh_token_raises_auth0_error(monkeypatch): + 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): + 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): + manager = 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(build_request("Bearer test-token")) is None + + +@pytest.mark.parametrize( + ("authorization", "detail"), + [ + ("", "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 +): + manager = build_auth_manager() + + with pytest.raises(HTTPException) as exc_info: + manager.authenticate_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): + manager = 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(build_request("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): + manager = 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(build_request("Bearer test-token")) + + assert exc_info.value.status_code == HTTPStatus.FORBIDDEN + assert exc_info.value.detail == "Unknown client ID" From 8a75903b8330b74ef668adf0ff51d4f7379cd8f2 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 15:27:22 -0400 Subject: [PATCH 09/10] refactor to use 'VlmNetworkNodeMetadata' --- src/anyvlm/restapi/auth_manager.py | 27 +++++++++--- tests/unit/test_auth_manager.py | 68 +++++++++++++++++++----------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/src/anyvlm/restapi/auth_manager.py b/src/anyvlm/restapi/auth_manager.py index 837ee6c..e77b196 100644 --- a/src/anyvlm/restapi/auth_manager.py +++ b/src/anyvlm/restapi/auth_manager.py @@ -11,6 +11,7 @@ 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 @@ -18,6 +19,15 @@ _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""" @@ -28,7 +38,7 @@ class AuthManager: KNOWN_NODES_REFRESH_INTERVAL: timedelta = timedelta(hours=24) - _known_nodes: dict[str, dict[str, str | None]] + _known_nodes: list[VlmNetworkNodeMetadata] _known_nodes_expiry: datetime _token: str = "" @@ -63,23 +73,26 @@ def _refresh_known_nodes(self) -> None: ) if response.status_code == HTTPStatus.OK: - self._known_nodes = {} + 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[entry.get("client_id")] = { - "name": entry.get("name"), - "match_url": entry.get("client_metadata", {}).get("match_url"), - } + 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) -> dict[str, list[str]]: + 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 diff --git a/tests/unit/test_auth_manager.py b/tests/unit/test_auth_manager.py index 912167b..41b3a35 100644 --- a/tests/unit/test_auth_manager.py +++ b/tests/unit/test_auth_manager.py @@ -2,12 +2,13 @@ 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 +from anyvlm.restapi.auth_manager import AuthManager, VlmNetworkNodeMetadata from anyvlm.utils.exceptions import Auth0Error @@ -23,11 +24,11 @@ def json(self) -> object: @pytest.fixture -def auth0_success(monkeypatch): +def auth0_success(monkeypatch: pytest.MonkeyPatch) -> tuple[list[Any], list[Any]]: """Patch successful Auth0 token and client-list responses.""" - post_calls = [] - get_calls = [] - nodes = [ + 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", @@ -77,19 +78,22 @@ def build_request(authorization: str) -> Request: ) -def test_init_fetches_token_and_known_nodes(auth0_success): +def test_init_fetches_token_and_known_nodes(auth0_success: tuple[list[Any], list[Any]]): post_calls, get_calls = auth0_success manager = AuthManager() assert manager.get_token() == "token-1" - assert manager.get_known_nodes() == { - "client-1": { - "name": "Node One", - "match_url": "https://node-one.example/match", - }, - "client-2": {"name": "Node Two", "match_url": None}, - } + 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, @@ -111,7 +115,9 @@ def test_init_fetches_token_and_known_nodes(auth0_success): ] -def test_get_token_uses_cached_token_before_expiry(auth0_success): +def test_get_token_uses_cached_token_before_expiry( + auth0_success: tuple[list[Any], list[Any]], +) -> None: post_calls, _ = auth0_success manager = AuthManager() @@ -120,7 +126,9 @@ def test_get_token_uses_cached_token_before_expiry(auth0_success): assert len(post_calls) == 1 -def test_get_token_refreshes_expired_token(auth0_success, monkeypatch): +def test_get_token_refreshes_expired_token( + auth0_success: tuple[list[Any], list[Any]], monkeypatch: pytest.MonkeyPatch +) -> None: post_calls, _ = auth0_success manager = AuthManager() monkeypatch.setattr( @@ -132,7 +140,9 @@ def test_get_token_refreshes_expired_token(auth0_success, monkeypatch): assert len(post_calls) == 2 -def test_get_known_nodes_refreshes_expired_known_nodes(auth0_success, monkeypatch): +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() monkeypatch.setattr( @@ -141,12 +151,12 @@ def test_get_known_nodes_refreshes_expired_known_nodes(auth0_success, monkeypatc datetime.now(tz=UTC) - timedelta(seconds=1), ) - assert manager.get_known_nodes()["client-1"]["name"] == "Node One" + assert manager.get_known_nodes()[0].client_name == "Node One" assert len(get_calls) == 2 -def test_refresh_token_raises_auth0_error(monkeypatch): +def test_refresh_token_raises_auth0_error(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( auth_manager.requests, "post", @@ -158,7 +168,9 @@ def test_refresh_token_raises_auth0_error(monkeypatch): manager.get_token() -def test_refresh_known_nodes_raises_auth0_error(monkeypatch): +def test_refresh_known_nodes_raises_auth0_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr( auth_manager.requests, "post", @@ -179,7 +191,9 @@ def test_refresh_known_nodes_raises_auth0_error(monkeypatch): AuthManager() -def test_authenticate_request_accepts_known_client(monkeypatch): +def test_authenticate_request_accepts_known_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: manager = build_auth_manager() monkeypatch.setattr( manager, @@ -206,8 +220,8 @@ def decode(**kwargs) -> dict[str, str]: @pytest.mark.parametrize( - ("authorization", "detail"), - [ + argnames=("authorization", "detail"), + argvalues=[ ("", "Invalid authorization header"), ("Bearer", "Invalid authorization header"), ("Basic test-token", "Invalid token scheme"), @@ -215,7 +229,7 @@ def decode(**kwargs) -> dict[str, str]: ) def test_authenticate_request_rejects_invalid_authorization_header( authorization: str, detail: str -): +) -> None: manager = build_auth_manager() with pytest.raises(HTTPException) as exc_info: @@ -225,7 +239,9 @@ def test_authenticate_request_rejects_invalid_authorization_header( assert exc_info.value.detail == detail -def test_authenticate_request_rejects_invalid_token(monkeypatch): +def test_authenticate_request_rejects_invalid_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: manager = build_auth_manager() def decode(**kwargs) -> dict[str, str]: @@ -240,7 +256,9 @@ def decode(**kwargs) -> dict[str, str]: assert exc_info.value.detail == "Invalid token: bad token" -def test_authenticate_request_rejects_unknown_client(monkeypatch): +def test_authenticate_request_rejects_unknown_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: manager = build_auth_manager() monkeypatch.setattr( manager, From 4ad6385854ec45d7403899ecfdb63ca31a44f593 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 7 Oct 2026 15:29:59 -0400 Subject: [PATCH 10/10] stronger typing --- tests/unit/test_auth_manager.py | 39 ++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_auth_manager.py b/tests/unit/test_auth_manager.py index 41b3a35..57e93a5 100644 --- a/tests/unit/test_auth_manager.py +++ b/tests/unit/test_auth_manager.py @@ -41,8 +41,8 @@ def auth0_success(monkeypatch: pytest.MonkeyPatch) -> tuple[list[Any], list[Any] }, ] - monkeypatch.setenv("VLM_CLIENT_ID", "test-client") - monkeypatch.setenv("VLM_CLIENT_SECRET", "test-secret") + 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}) @@ -69,7 +69,7 @@ def build_auth_manager() -> 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", @@ -81,7 +81,7 @@ def build_request(authorization: str) -> Request: def test_init_fetches_token_and_known_nodes(auth0_success: tuple[list[Any], list[Any]]): post_calls, get_calls = auth0_success - manager = AuthManager() + manager: AuthManager = AuthManager() assert manager.get_token() == "token-1" assert manager.get_known_nodes() == [ @@ -119,7 +119,7 @@ def test_get_token_uses_cached_token_before_expiry( auth0_success: tuple[list[Any], list[Any]], ) -> None: post_calls, _ = auth0_success - manager = AuthManager() + manager: AuthManager = AuthManager() assert manager.get_token() == "token-1" @@ -130,7 +130,7 @@ def test_get_token_refreshes_expired_token( auth0_success: tuple[list[Any], list[Any]], monkeypatch: pytest.MonkeyPatch ) -> None: post_calls, _ = auth0_success - manager = AuthManager() + manager: AuthManager = AuthManager() monkeypatch.setattr( manager, "_token_expiry", datetime.now(tz=UTC) - timedelta(seconds=1) ) @@ -144,7 +144,7 @@ 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() + manager: AuthManager = AuthManager() monkeypatch.setattr( manager, "_known_nodes_expiry", @@ -194,7 +194,7 @@ def test_refresh_known_nodes_raises_auth0_error( def test_authenticate_request_accepts_known_client( monkeypatch: pytest.MonkeyPatch, ) -> None: - manager = build_auth_manager() + manager: AuthManager = build_auth_manager() monkeypatch.setattr( manager, "_known_nodes", @@ -216,7 +216,12 @@ def decode(**kwargs) -> dict[str, str]: monkeypatch.setattr(auth_manager.jwt, "decode", decode) - assert manager.authenticate_request(build_request("Bearer test-token")) is None + assert ( + manager.authenticate_request( + request=build_request(authorization="Bearer test-token") + ) + is None + ) @pytest.mark.parametrize( @@ -230,10 +235,10 @@ def decode(**kwargs) -> dict[str, str]: def test_authenticate_request_rejects_invalid_authorization_header( authorization: str, detail: str ) -> None: - manager = build_auth_manager() + manager: AuthManager = build_auth_manager() with pytest.raises(HTTPException) as exc_info: - manager.authenticate_request(build_request(authorization)) + manager.authenticate_request(request=build_request(authorization)) assert exc_info.value.status_code == HTTPStatus.FORBIDDEN assert exc_info.value.detail == detail @@ -242,7 +247,7 @@ def test_authenticate_request_rejects_invalid_authorization_header( def test_authenticate_request_rejects_invalid_token( monkeypatch: pytest.MonkeyPatch, ) -> None: - manager = build_auth_manager() + manager: AuthManager = build_auth_manager() def decode(**kwargs) -> dict[str, str]: raise auth_manager.jwt.InvalidTokenError("bad token") @@ -250,7 +255,9 @@ def decode(**kwargs) -> dict[str, str]: monkeypatch.setattr(auth_manager.jwt, "decode", decode) with pytest.raises(HTTPException) as exc_info: - manager.authenticate_request(build_request("Bearer test-token")) + 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" @@ -259,7 +266,7 @@ def decode(**kwargs) -> dict[str, str]: def test_authenticate_request_rejects_unknown_client( monkeypatch: pytest.MonkeyPatch, ) -> None: - manager = build_auth_manager() + manager: AuthManager = build_auth_manager() monkeypatch.setattr( manager, "_known_nodes", @@ -273,7 +280,9 @@ def test_authenticate_request_rejects_unknown_client( ) with pytest.raises(HTTPException) as exc_info: - manager.authenticate_request(build_request("Bearer test-token")) + 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"