Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 0 additions & 8 deletions fia_api/core/auth/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,11 @@
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from fia_api.core.auth import AUTH_URL
from fia_api.core.cache import cache_get_json, cache_set_json, hash_key
from fia_api.core.exceptions import AuthError

logger = logging.getLogger(__name__)

DEV_MODE = bool(os.environ.get("DEV_MODE", False)) # noqa: PLW1508
AUTH_VERIFY_CACHE_TTL_SECONDS = int(os.environ.get("AUTH_VERIFY_CACHE_TTL_SECONDS", "60"))


@dataclass
Expand Down Expand Up @@ -79,15 +77,9 @@ def _is_jwt_access_token_valid(access_token: str) -> bool:
"""
logger.info("Checking if JWT access token is valid")
try:
cache_key = f"fia_api:auth:verify:{hash_key(access_token)}"
cached = cache_get_json(cache_key)
if cached is True:
return True

response = requests.post(f"{AUTH_URL}/verify", json={"token": access_token}, timeout=30)
if response.status_code == HTTPStatus.OK:
logger.info("JWT was valid")
cache_set_json(cache_key, True, AUTH_VERIFY_CACHE_TTL_SECONDS)
return True
return False
except RuntimeError:
Expand Down
41 changes: 36 additions & 5 deletions fia_api/core/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import hashlib
import json
import logging
import os
Expand Down Expand Up @@ -64,6 +63,17 @@ def _create_client() -> Redis | None:


def get_valkey_client() -> Redis | None:
"""
Get or create a Valkey (Redis) client instance.

Returns a shared Redis client if Valkey is configured and available.
The client is lazily initialized on first access and cached for reuse.
If the connection fails or Valkey is not configured, it returns None and
disables further connection attempts.

:return: Redis client instance if available, None otherwise
"""

state = _valkey_state()
if state.disabled:
return None
Expand All @@ -87,6 +97,17 @@ def _disable_cache(exc: Exception) -> None:


def cache_get_json(key: str) -> Any | None:
"""
Retrieve and deserialize a JSON value from the Valkey cache.

Attempts to fetch a cached value by key and parse it as JSON. If the cache
is unavailable, the key doesn't exist, or the value cannot be parsed as JSON,
returns None. Automatically disables the cache on connection errors.

:param key: The cache key to retrieve
:return: Deserialized JSON value if found and valid, None otherwise
"""

client = get_valkey_client()
if client is None:
return None
Expand All @@ -110,6 +131,20 @@ def cache_get_json(key: str) -> Any | None:


def cache_set_json(key: str, value: Any, ttl_seconds: int) -> None:
"""
Store a JSON-serializable value in the Valkey cache with a time-to-live.

Serializes the provided value to JSON and stores it in the cache with an
expiration time. If the cache is unavailable, the value cannot be serialized
to JSON, or the TTL is non-positive, the operation is silently skipped.
Automatically disables the cache on connection errors.

:param key: The cache key under which to store the value
:param value: Any JSON-serializable value to cache
:param ttl_seconds: Time-to-live in seconds; must be positive
:return: None
"""

if ttl_seconds <= 0:
return
client = get_valkey_client()
Expand All @@ -123,7 +158,3 @@ def cache_set_json(key: str, value: Any, ttl_seconds: int) -> None:
client.setex(key, ttl_seconds, payload)
except RedisError as exc:
_disable_cache(exc)


def hash_key(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
42 changes: 0 additions & 42 deletions test/core/auth/test_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,48 +64,6 @@ def test_is_jwt_access_token_valid_raises_returns_invalid(mock_post):
assert not jwtbearer._is_jwt_access_token_valid(TOKEN)


def test_is_jwt_access_token_valid_uses_cache_short_circuit():
"""Test cached token avoids network request."""
with (
patch("fia_api.core.auth.tokens.cache_get_json", return_value=True) as mock_cache,
patch("fia_api.core.auth.tokens.requests.post") as mock_post,
):
jwtbearer = JWTAPIBearer()
assert jwtbearer._is_jwt_access_token_valid(TOKEN)
mock_cache.assert_called_once()
mock_post.assert_not_called()


def test_is_jwt_access_token_valid_sets_cache_on_success():
"""Test cache set on successful verification."""
response = Mock()
response.status_code = HTTPStatus.OK
with (
patch("fia_api.core.auth.tokens.cache_get_json", return_value=None),
patch("fia_api.core.auth.tokens.cache_set_json") as mock_cache_set,
patch("fia_api.core.auth.tokens.requests.post", return_value=response),
patch("fia_api.core.auth.tokens.hash_key", return_value="abc123"),
patch("fia_api.core.auth.tokens.AUTH_VERIFY_CACHE_TTL_SECONDS", 120),
):
jwtbearer = JWTAPIBearer()
assert jwtbearer._is_jwt_access_token_valid(TOKEN)
mock_cache_set.assert_called_once_with("fia_api:auth:verify:abc123", True, 120)


def test_is_jwt_access_token_valid_does_not_cache_on_failure():
"""Test cache not set when verification fails."""
response = Mock()
response.status_code = HTTPStatus.FORBIDDEN
with (
patch("fia_api.core.auth.tokens.cache_get_json", return_value=None),
patch("fia_api.core.auth.tokens.cache_set_json") as mock_cache_set,
patch("fia_api.core.auth.tokens.requests.post", return_value=response),
):
jwtbearer = JWTAPIBearer()
assert not jwtbearer._is_jwt_access_token_valid(TOKEN)
mock_cache_set.assert_not_called()


def test_is_api_token_valid_check_against_env_var():
api_key = str(mock.MagicMock())
os.environ["FIA_API_API_KEY"] = api_key
Expand Down
5 changes: 0 additions & 5 deletions test/core/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
cache_get_json,
cache_set_json,
get_valkey_client,
hash_key,
)

TTL_SECONDS = 30
Expand Down Expand Up @@ -201,7 +200,3 @@ def test_cache_get_json_returns_none_if_raw_is_none():
mock_client.get.return_value = None
with patch("fia_api.core.cache.get_valkey_client", return_value=mock_client):
assert cache_get_json("key") is None


def test_hash_key_returns_sha256_hex():
assert hash_key("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"