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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.12 (2026-06-05)

### Security

- **JWT now requires `exp`.** `JWTService.decode` (and the JWKS resource-server
validator) verified `exp` only when present, so a token minted with **no `exp`**
was accepted and never expired. `decode` now requires `exp`
(`options={"require": ["exp"]}`), and `JWTService.encode` auto-adds an `exp`
(now + `expiration_seconds`, default 3600) when the payload omits one — so every
issued token expires.
- **OAuth2 client secret compared in constant time.** `AuthorizationServer`
compared the client secret with `!=` (a timing side-channel); it now uses
`secrets.compare_digest`.
- **OAuth2 grant-type confusion fixed.** The token endpoint ignored the client's
registered `authorization_grant_type`, so a client registered for
`authorization_code` could mint `client_credentials` tokens. The
`client_credentials` grant now requires the client to be registered for it
(otherwise `UNAUTHORIZED_CLIENT`); server-unsupported grants still return
`UNSUPPORTED_GRANT_TYPE`.
- **`HttpSecurity` footgun warning.** `build()` now logs a warning when
authorization rules are configured without a terminal `any_request()` rule
(paths matching no rule fall through allowed) — recommending
`.any_request().deny_all()` / `.authenticated()`.

These surfaced in an adversarial security audit while validating the
`implement-security` skill (which itself validated clean — enforcement proven,
no silent auth bypass). Session-subsystem hardening (session-fixation id
rotation, `secure` cookie default, Redis-deserialization allowlisting) plus a
dedicated `tests/session` suite are tracked as a focused follow-up.

---

## v26.06.11 (2026-06-05)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.11-brightgreen" alt="Version: 26.06.11"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.12-brightgreen" alt="Version: 26.06.12"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
version = "26.6.11"
version = "26.6.12"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.11"
__version__ = "26.06.12"
14 changes: 14 additions & 0 deletions src/pyfly/security/http_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from pyfly.web.adapters.starlette.filters.http_security_filter import HttpSecurityFilter

_logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Access rule model
Expand Down Expand Up @@ -196,4 +199,15 @@ def build(self) -> HttpSecurityFilter:
"""
from pyfly.web.adapters.starlette.filters.http_security_filter import HttpSecurityFilter

# Footgun guard: without a terminal any_request() rule (patterns == []), a
# path matching no rule falls through ALLOWED. Warn so an operator adds an
# explicit terminal — e.g. .any_request().deny_all() / .authenticated() —
# as the class docstring demonstrates.
if self._rules and not any(not rule.patterns for rule in self._rules):
_logger.warning(
"HttpSecurity has %d authorization rule(s) but no terminal any_request() rule; "
"requests matching no rule are ALLOWED through. Add .any_request().deny_all() "
"(or .authenticated()) to deny unmatched paths.",
len(self._rules),
)
return HttpSecurityFilter(rules=list(self._rules))
27 changes: 23 additions & 4 deletions src/pyfly/security/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from __future__ import annotations

import time
from typing import Any

import jwt
Expand All @@ -29,24 +30,42 @@ class JWTService:
Args:
secret: Secret key for HMAC-based signing.
algorithm: JWT algorithm (default: HS256).
expiration_seconds: Default token lifetime; an ``exp`` claim is added on
:meth:`encode` when the payload does not already carry one.
"""

def __init__(self, secret: str, algorithm: str = "HS256") -> None:
def __init__(self, secret: str, algorithm: str = "HS256", expiration_seconds: int = 3600) -> None:
self._secret = secret
self._algorithm = algorithm
self._expiration_seconds = expiration_seconds

def encode(self, payload: dict[str, Any]) -> str:
"""Encode a payload into a JWT token."""
"""Encode a payload into a JWT token.

Adds an ``exp`` claim (now + ``expiration_seconds``) when the payload does
not already specify one, so every issued token expires — :meth:`decode`
requires ``exp``.
"""
if "exp" not in payload:
payload = {**payload, "exp": int(time.time()) + self._expiration_seconds}
return jwt.encode(payload, self._secret, algorithm=self._algorithm)

def decode(self, token: str) -> dict[str, Any]:
"""Decode and validate a JWT token.

Requires a valid signature **and** an ``exp`` claim — a token minted
without ``exp`` (which would never expire) is rejected.

Raises:
SecurityException: If the token is invalid or expired.
SecurityException: If the token is invalid, expired, or lacks ``exp``.
"""
try:
return jwt.decode(token, self._secret, algorithms=[self._algorithm])
return jwt.decode(
token,
self._secret,
algorithms=[self._algorithm],
options={"require": ["exp"]},
)
except jwt.PyJWTError as exc:
raise SecurityException(
f"Invalid token: {exc}",
Expand Down
15 changes: 13 additions & 2 deletions src/pyfly/security/oauth2/authorization_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,23 @@ async def token(
Raises:
SecurityException: If authentication fails or grant type is unsupported.
"""
# Authenticate client
# Authenticate client (constant-time secret comparison to avoid a timing
# side-channel that could leak the client secret).
registration = self._client_repository.find_by_registration_id(client_id)
if registration is None or registration.client_secret != client_secret:
if registration is None or not secrets.compare_digest(
registration.client_secret.encode("utf-8"), client_secret.encode("utf-8")
):
raise SecurityException("Invalid client credentials", code="INVALID_CLIENT")

if grant_type == "client_credentials":
# The client must be registered for the client_credentials grant to
# mint a client_credentials token — prevents grant-type confusion (a
# client registered only for authorization_code must not use it).
if registration.authorization_grant_type != "client_credentials":
raise SecurityException(
f"Client '{client_id}' is not authorized for grant type 'client_credentials'",
code="UNAUTHORIZED_CLIENT",
)
return await self._handle_client_credentials(registration, scope)
elif grant_type == "refresh_token":
if refresh_token is None:
Expand Down
1 change: 1 addition & 0 deletions src/pyfly/security/oauth2/resource_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def validate(self, token: str) -> dict[str, Any]:
algorithms=self._algorithms,
issuer=self._issuer,
audience=self._audience,
options={"require": ["exp"]},
)
return payload
except jwt.PyJWTError as exc:
Expand Down
1 change: 1 addition & 0 deletions tests/security/test_authorization_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def client_repo() -> InMemoryClientRegistrationRepository:
registration_id="test-client",
client_id="test-client",
client_secret="test-secret",
authorization_grant_type="client_credentials",
scopes=["read", "write"],
)
return InMemoryClientRegistrationRepository(reg)
Expand Down
5 changes: 4 additions & 1 deletion tests/security/test_oauth2_resource_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from __future__ import annotations

import time
from unittest.mock import MagicMock, patch

import jwt
Expand All @@ -34,7 +35,9 @@


def _create_test_token(payload: dict, kid: str = "test-kid") -> str:
"""Create an RS256-signed JWT for testing."""
"""Create an RS256-signed JWT for testing (adds an ``exp`` claim by default)."""
if "exp" not in payload:
payload = {**payload, "exp": int(time.time()) + 3600}
private_pem = _private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
Expand Down
116 changes: 116 additions & 0 deletions tests/security/test_security_hardening.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Regression tests for security hardening (v26.06.12).

- JWTService requires an ``exp`` claim on decode and auto-adds one on encode
(a token minted without ``exp`` would otherwise never expire).
- OAuth2 AuthorizationServer enforces the client's registered grant type — a
client registered for ``authorization_code`` cannot mint ``client_credentials``
tokens — and compares the client secret in constant time.
- HttpSecurity.build() warns when rules are configured without a terminal
``any_request()`` rule (unmatched paths fall through allowed).
"""

from __future__ import annotations

import logging
import time

import jwt
import pytest

from pyfly.kernel.exceptions import SecurityException
from pyfly.security.http_security import HttpSecurity
from pyfly.security.jwt import JWTService
from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
from pyfly.security.oauth2.client import ClientRegistration, InMemoryClientRegistrationRepository

_SECRET = "test-secret-key-minimum-32-chars!"


class TestJWTExpRequired:
def test_encode_adds_exp_claim(self) -> None:
svc = JWTService(secret=_SECRET)
payload = svc.decode(svc.encode({"sub": "u1"}))
assert "exp" in payload

def test_decode_rejects_token_without_exp(self) -> None:
svc = JWTService(secret=_SECRET)
# Minted directly, bypassing encode()'s auto-exp — must be rejected.
no_exp = jwt.encode({"sub": "u1"}, _SECRET, algorithm="HS256")
with pytest.raises(SecurityException, match="Invalid token"):
svc.decode(no_exp)

def test_explicit_exp_is_preserved(self) -> None:
svc = JWTService(secret=_SECRET)
exp = int(time.time()) + 99
payload = svc.decode(svc.encode({"sub": "u1", "exp": exp}))
assert payload["exp"] == exp


def _server(grant_type: str) -> AuthorizationServer:
reg = ClientRegistration(
registration_id="c",
client_id="c",
client_secret="s3cr3t-value",
authorization_grant_type=grant_type,
scopes=["read"],
)
return AuthorizationServer(
secret=_SECRET,
client_repository=InMemoryClientRegistrationRepository(reg),
token_store=InMemoryTokenStore(),
issuer="https://auth.example.com",
)


class TestOAuth2GrantTypeEnforcement:
@pytest.mark.asyncio
async def test_registered_client_can_use_client_credentials(self) -> None:
result = await _server("client_credentials").token(
grant_type="client_credentials", client_id="c", client_secret="s3cr3t-value"
)
assert "access_token" in result

@pytest.mark.asyncio
async def test_authorization_code_client_cannot_mint_client_credentials(self) -> None:
with pytest.raises(SecurityException, match="not authorized for grant type"):
await _server("authorization_code").token(
grant_type="client_credentials", client_id="c", client_secret="s3cr3t-value"
)

@pytest.mark.asyncio
async def test_wrong_secret_rejected(self) -> None:
with pytest.raises(SecurityException, match="Invalid client credentials"):
await _server("client_credentials").token(
grant_type="client_credentials", client_id="c", client_secret="wrong-secret"
)


class TestHttpSecurityTerminalWarning:
def test_warns_without_any_request_rule(self, caplog: pytest.LogCaptureFixture) -> None:
sec = HttpSecurity()
sec.authorize_requests().request_matchers("/api/**").authenticated()
with caplog.at_level(logging.WARNING, logger="pyfly.security.http_security"):
sec.build()
assert any("no terminal any_request" in r.getMessage() for r in caplog.records)

def test_no_warning_with_any_request_terminal(self, caplog: pytest.LogCaptureFixture) -> None:
sec = HttpSecurity()
builder = sec.authorize_requests()
builder.request_matchers("/api/**").authenticated()
builder.any_request().deny_all()
with caplog.at_level(logging.WARNING, logger="pyfly.security.http_security"):
sec.build()
assert not any("no terminal any_request" in r.getMessage() for r in caplog.records)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading