diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fefd5..fc1078c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,13 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. registered replaced the others and, for a while, requests used caches that could not see each other's entries. The lazy set-up and the `@cache` fallback now happen under one lock. ([#76](https://github.com/allen0099/FastAPI-CacheX/issues/76)) +- A JWT session configured with an asymmetric `jwt_algorithm` (`RS*`, `ES*`, + `PS*`, `EdDSA`) and the built-in serializer now fails when the + `SessionManager` is built, with a `ValueError` that names the fix. The + built-in serializer only has the `secret_key` string, so such a configuration + was accepted at startup and then failed inside PyJWT on the first + `create_session()`. Only the HMAC algorithms work without a custom + `token_serializer`; configurations that pass one are unaffected. ([#86](https://github.com/allen0099/FastAPI-CacheX/issues/86)) ### Documentation diff --git a/docs/JWT_CLAIMS.md b/docs/JWT_CLAIMS.md index 74f83d8..7fa0b95 100644 --- a/docs/JWT_CLAIMS.md +++ b/docs/JWT_CLAIMS.md @@ -37,7 +37,7 @@ The implementation lives in [`fastapi_cachex/session/token_serializers.py`](http | `session_ttl` | `3600` | Session lifetime in seconds; used for `exp` when the session has no `expires_at` | > [!NOTE] -> **Asymmetric algorithms:** `jwt_algorithm` accepts `HS*`, `RS*`, `ES*`, `PS*` and `EdDSA`, but the built-in serializer signs and verifies with the single `secret_key` string. In practice only the HMAC algorithms (`HS256`, `HS384`, `HS512`) work out of the box; an asymmetric algorithm needs a custom serializer that encodes with a private key and decodes with the matching public key (see [Extension Guide](#extension-guide-adding-custom-claims)). +> **Asymmetric algorithms:** `jwt_algorithm` accepts `HS*`, `RS*`, `ES*`, `PS*` and `EdDSA`, but the built-in serializer signs and verifies with the single `secret_key` string, so it only supports the HMAC algorithms (`HS256`, `HS384`, `HS512`). Building a `SessionManager` with an asymmetric algorithm and no custom serializer raises `ValueError`. To use one, pass a custom `token_serializer` that encodes with a private key and decodes with the matching public key (see [Extension Guide](#extension-guide-adding-custom-claims)). ### Standard Claims That Are Not Implemented diff --git a/docs/SESSION.md b/docs/SESSION.md index 175e9e0..7acc956 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -464,7 +464,9 @@ config = SessionConfig( `jwt_algorithm` must be one of `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512` or `EdDSA`; anything else (including `none`) raises a -`ValidationError`. The same `secret_key` is used to sign and verify tokens. +`ValidationError`. The built-in serializer signs and verifies with the same `secret_key`, so it +only supports `HS256`, `HS384` and `HS512`: with an asymmetric algorithm, `SessionManager` raises +`ValueError` unless you pass a custom `token_serializer` that holds the key pair. Security notes: diff --git a/fastapi_cachex/session/config.py b/fastapi_cachex/session/config.py index 52bf4a7..142ee4e 100644 --- a/fastapi_cachex/session/config.py +++ b/fastapi_cachex/session/config.py @@ -30,6 +30,12 @@ } ) +# The algorithms the built-in `JWTTokenSerializer` can use: it signs and +# verifies with the single `secret_key` string. The asymmetric ones above need +# a private key to sign and a public key to verify, so they only work with a +# custom `token_serializer`. +JWT_HMAC_ALGORITHMS = frozenset({"HS256", "HS384", "HS512"}) + class SessionConfig(BaseModel): """Session configuration settings.""" diff --git a/fastapi_cachex/session/token_serializers.py b/fastapi_cachex/session/token_serializers.py index 8f5a854..d32a6b6 100644 --- a/fastapi_cachex/session/token_serializers.py +++ b/fastapi_cachex/session/token_serializers.py @@ -18,6 +18,7 @@ from typing import Any from typing import Protocol +from .config import JWT_HMAC_ALGORITHMS from .models import SessionToken if TYPE_CHECKING: # Import for typing only to avoid circular import concerns @@ -109,7 +110,24 @@ def __init__(self, config: SessionConfig, jwt_module: Any | None = None) -> None config: Session configuration instance. jwt_module: Optional JWT-compatible module providing ``encode`` and ``decode``; defaults to importing ``jwt`` (PyJWT). + + Raises: + ValueError: If ``config.jwt_algorithm`` is asymmetric. This + serializer signs and verifies with the ``secret_key`` string, + which only the HMAC algorithms can use; an asymmetric + algorithm needs a custom ``token_serializer`` that holds the + key pair. """ + if config.jwt_algorithm not in JWT_HMAC_ALGORITHMS: + supported = ", ".join(sorted(JWT_HMAC_ALGORITHMS)) + msg = ( + f"jwt_algorithm {config.jwt_algorithm!r} needs a private/public " + f"key pair, but the built-in JWT serializer signs with secret_key; " + f"use one of {supported}, or pass a custom token_serializer to " + f"SessionManager" + ) + raise ValueError(msg) + if jwt_module is not None: self.jwt_encoder = jwt_module else: diff --git a/tests/session/test_token_serializers.py b/tests/session/test_token_serializers.py index 49e87e0..63e9963 100644 --- a/tests/session/test_token_serializers.py +++ b/tests/session/test_token_serializers.py @@ -5,7 +5,9 @@ import pytest from pydantic import SecretStr +from fastapi_cachex.backends import MemoryBackend from fastapi_cachex.session.config import SessionConfig +from fastapi_cachex.session.manager import SessionManager from fastapi_cachex.session.models import SessionToken from fastapi_cachex.session.token_serializers import JWTTokenSerializer from fastapi_cachex.session.token_serializers import SimpleTokenSerializer @@ -242,3 +244,66 @@ def decode(self, token_str: str, **kwargs: object) -> dict[str, object]: assert token.session_id == "session-id" assert int(token.issued_at.timestamp()) == 1700000000 + + +@pytest.mark.parametrize( + "algorithm", + ["RS256", "RS512", "ES256", "ES384", "PS256", "EdDSA"], +) +def test_jwt_serializer_rejects_asymmetric_algorithms(algorithm: str) -> None: + """The built-in serializer only has `secret_key`, so it cannot sign RS/ES/PS/EdDSA. + + The config accepted these, and the first `create_session()` then failed + inside PyJWT. They must fail when the manager is built instead. + """ + config = SessionConfig( + secret_key=SecretStr("a" * 32), + token_format="jwt", + jwt_algorithm=algorithm, + ) + + with pytest.raises(ValueError, match="custom token_serializer"): + JWTTokenSerializer(config, jwt_module=StubJWTModule()) + + +def test_session_manager_fails_at_startup_for_asymmetric_jwt() -> None: + """The error surfaces when `SessionManager` is built, not on first use.""" + config = SessionConfig( + secret_key=SecretStr("a" * 32), + token_format="jwt", + jwt_algorithm="RS256", + ) + + with pytest.raises(ValueError, match="RS256"): + SessionManager(MemoryBackend(), config) + + +def test_session_manager_accepts_asymmetric_jwt_with_custom_serializer() -> None: + """A custom serializer that holds the key pair is still allowed.""" + config = SessionConfig( + secret_key=SecretStr("a" * 32), + token_format="jwt", + jwt_algorithm="RS256", + ) + serializer = SimpleTokenSerializer() + + manager = SessionManager(MemoryBackend(), config, token_serializer=serializer) + + assert manager._serializer is serializer + + +@pytest.mark.parametrize("algorithm", ["HS256", "HS384", "HS512"]) +def test_jwt_serializer_round_trips_hmac_algorithms(algorithm: str) -> None: + """The HMAC algorithms work end to end with real PyJWT.""" + pytest.importorskip("jwt") + config = SessionConfig( + secret_key=SecretStr("a" * 32), + token_format="jwt", + jwt_algorithm=algorithm, + ) + serializer = JWTTokenSerializer(config) + token = SessionToken( + session_id="sid-1", signature="", issued_at=datetime.now(timezone.utc) + ) + + assert serializer.from_string(serializer.to_string(token)).session_id == "sid-1"