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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/JWT_CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 6 additions & 0 deletions fastapi_cachex/session/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
18 changes: 18 additions & 0 deletions fastapi_cachex/session/token_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions tests/session/test_token_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Loading