hawkapi-auth 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-05-16
4
+
5
+ Security hardening.
6
+
7
+ - 401 responses now return a generic ``"Invalid or expired token"`` detail; the
8
+ underlying PyJWT message is logged at DEBUG instead of leaked to clients
9
+ (CWE-201).
10
+ - ``TokenIssuer`` rejects HMAC secrets shorter than 32 bytes per RFC 7518 §3.2
11
+ (CWE-1022).
12
+ - ``requires_scopes`` uses explicit ``scope`` → ``scopes`` precedence so an
13
+ explicit empty ``scope`` no longer silently falls back (CWE-840).
14
+ - ``requires_scopes()`` with no arguments raises ``ValueError`` at construction
15
+ rather than producing a permissive dependency (CWE-284).
16
+ - Reserved JWT claims (``exp``, ``iat``, ``jti``, ``type``, ``sub``, ``iss``,
17
+ ``aud``, ``nbf``) supplied via ``extra_claims`` are dropped with a warning
18
+ instead of overwriting issuer-controlled values.
19
+ - ``PasswordHasher`` now uses explicit OWASP-aligned argon2id parameters
20
+ (``time_cost=3``, ``memory_cost=65536``, ``parallelism=4``).
21
+ - The active-issuer registry uses ``WeakKeyDictionary`` to avoid the ``id(app)``
22
+ ABA hazard.
23
+
24
+ ## 0.1.0 — 2026-05-16
25
+
26
+ Initial release.
27
+
28
+ - JWT access + refresh tokens (HS256/384/512, RS*, ES*).
29
+ - argon2id password hashing with `needs_rehash`.
30
+ - DI guards: `requires_user`, `requires_claims`, `requires_scopes`.
31
+ - In-memory `RevocationList` with lazy expiry sweep.
32
+ - `init_auth(app, config=...)` plugin entry point.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hawkapi-auth
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: JWT auth for HawkAPI — access + refresh tokens, password hashing, DI guards
5
5
  Project-URL: Homepage, https://pypi.org/project/hawkapi-auth/
6
6
  Project-URL: Repository, https://github.com/ashimov/hawkapi-auth
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hawkapi-auth"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "JWT auth for HawkAPI — access + refresh tokens, password hashing, DI guards"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
6
6
 
7
7
  from hawkapi_auth._deps import requires_claims, requires_scopes, requires_user
8
8
  from hawkapi_auth._passwords import hash_password, needs_rehash, verify_password
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  from typing import Any
6
7
 
7
8
  from hawkapi.exceptions import HTTPException
@@ -9,6 +10,8 @@ from hawkapi.requests.request import Request
9
10
 
10
11
  from hawkapi_auth._tokens import TokenError
11
12
 
13
+ logger = logging.getLogger("hawkapi_auth")
14
+
12
15
 
13
16
  def _bearer_token(request: Request) -> str | None:
14
17
  auth = request.headers.get("authorization")
@@ -48,7 +51,12 @@ async def requires_user(request: Request) -> str:
48
51
  try:
49
52
  claims = issuer.verify_access(token)
50
53
  except TokenError as exc:
51
- raise HTTPException(401, detail=str(exc), headers={"WWW-Authenticate": "Bearer"}) from exc
54
+ logger.debug("token verification failed: %s", exc)
55
+ raise HTTPException(
56
+ 401,
57
+ detail="Invalid or expired token",
58
+ headers={"WWW-Authenticate": "Bearer"},
59
+ ) from None
52
60
  sub = claims.get("sub")
53
61
  if not isinstance(sub, str):
54
62
  raise HTTPException(401, detail="Token has no subject")
@@ -68,7 +76,12 @@ async def requires_claims(request: Request) -> dict[str, Any]:
68
76
  try:
69
77
  return issuer.verify_access(token)
70
78
  except TokenError as exc:
71
- raise HTTPException(401, detail=str(exc), headers={"WWW-Authenticate": "Bearer"}) from exc
79
+ logger.debug("token verification failed: %s", exc)
80
+ raise HTTPException(
81
+ 401,
82
+ detail="Invalid or expired token",
83
+ headers={"WWW-Authenticate": "Bearer"},
84
+ ) from None
72
85
 
73
86
 
74
87
  def requires_scopes(*required: str):
@@ -82,10 +95,18 @@ def requires_scopes(*required: str):
82
95
  @app.get("/admin", dependencies=[Depends(requires_scopes("admin"))])
83
96
  async def admin(): ...
84
97
  """
98
+ if not required:
99
+ raise ValueError("requires_scopes() must receive at least one scope")
85
100
 
86
101
  async def _dep(request: Request) -> dict[str, Any]:
87
102
  claims = await requires_claims(request)
88
- raw = claims.get("scope") or claims.get("scopes") or []
103
+ # Explicit precedence: "scope" beats "scopes" even if "scope" is empty.
104
+ if "scope" in claims:
105
+ raw = claims["scope"]
106
+ elif "scopes" in claims:
107
+ raw = claims["scopes"]
108
+ else:
109
+ raw = []
89
110
  granted: set[str] = set()
90
111
  if isinstance(raw, str):
91
112
  granted = set(raw.split())
@@ -2,10 +2,19 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from argon2 import PasswordHasher
5
+ from argon2 import PasswordHasher, Type
6
6
  from argon2.exceptions import VerifyMismatchError
7
7
 
8
- _hasher = PasswordHasher()
8
+ # Explicit OWASP-aligned parameters (argon2id, 64MiB memory, 3 iterations,
9
+ # parallelism 4). Hash output 32 bytes, salt 16 bytes.
10
+ _hasher = PasswordHasher(
11
+ time_cost=3,
12
+ memory_cost=65536,
13
+ parallelism=4,
14
+ hash_len=32,
15
+ salt_len=16,
16
+ type=Type.ID,
17
+ )
9
18
 
10
19
 
11
20
  def hash_password(password: str) -> str:
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  from typing import Any
7
+ from weakref import WeakKeyDictionary
6
8
 
7
9
  from hawkapi_auth._tokens import JWTConfig, RevocationList, TokenIssuer
8
10
 
@@ -13,7 +15,9 @@ class _StateNamespace:
13
15
  auth: Any # set by init_auth
14
16
 
15
17
 
16
- _ACTIVE_ISSUERS: dict[int, TokenIssuer] = {}
18
+ # WeakKeyDictionary avoids the ABA hazard where ``id(app)`` is reused
19
+ # after the original app object is garbage-collected.
20
+ _ACTIVE_ISSUERS: WeakKeyDictionary[Any, TokenIssuer] = WeakKeyDictionary()
17
21
  _LAST_ISSUER: list[TokenIssuer | None] = [None]
18
22
 
19
23
 
@@ -24,7 +28,10 @@ def resolve_issuer(app: Any) -> TokenIssuer | None:
24
28
  ``scope["app"]``, so the DI dependency uses a module-level registry.
25
29
  """
26
30
  if app is not None:
27
- issuer = _ACTIVE_ISSUERS.get(id(app))
31
+ try:
32
+ issuer = _ACTIVE_ISSUERS.get(app)
33
+ except TypeError:
34
+ issuer = None
28
35
  if issuer is not None:
29
36
  return issuer
30
37
  return _LAST_ISSUER[0]
@@ -56,7 +63,9 @@ def init_auth(
56
63
  if getattr(app, "state", None) is None:
57
64
  app.state = _StateNamespace()
58
65
  app.state.auth = issuer
59
- _ACTIVE_ISSUERS[id(app)] = issuer
66
+ with contextlib.suppress(TypeError):
67
+ # Non-weakrefable app — fall back to last-attached lookup only.
68
+ _ACTIVE_ISSUERS[app] = issuer
60
69
  _LAST_ISSUER[0] = issuer
61
70
  return issuer
62
71
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  import secrets
6
7
  import time
7
8
  import uuid
@@ -11,6 +12,12 @@ from typing import Any
11
12
  import jwt
12
13
  from jwt.exceptions import InvalidTokenError
13
14
 
15
+ logger = logging.getLogger("hawkapi_auth")
16
+
17
+ _RESERVED_CLAIMS: frozenset[str] = frozenset(
18
+ {"exp", "iat", "jti", "type", "sub", "iss", "aud", "nbf"}
19
+ )
20
+
14
21
 
15
22
  class TokenError(Exception):
16
23
  """Raised when a token fails validation."""
@@ -82,6 +89,14 @@ class TokenIssuer:
82
89
  self.revocation.revoke(jti, exp)
83
90
 
84
91
  def _issue(self, subject: str, token_type: str, ttl: int, extra: dict[str, Any]) -> str:
92
+ # Drop any caller-supplied keys that collide with reserved JWT claims —
93
+ # callers must not be able to forge ``exp``, ``type``, ``sub`` etc.
94
+ dropped = [k for k in extra if k in _RESERVED_CLAIMS]
95
+ if dropped:
96
+ logger.warning(
97
+ "ignoring caller-supplied reserved claim(s): %s", ", ".join(sorted(dropped))
98
+ )
99
+ extra = {k: v for k, v in extra.items() if k not in _RESERVED_CLAIMS}
85
100
  now = int(time.time())
86
101
  payload: dict[str, Any] = {
87
102
  "sub": subject,
@@ -98,6 +113,8 @@ class TokenIssuer:
98
113
  key = self.config.private_key or self.config.secret
99
114
  if not key:
100
115
  raise TokenError("JWTConfig has no secret or private_key")
116
+ if self.config.algorithm.startswith("HS") and len(key.encode("utf-8")) < 32:
117
+ raise TokenError("HMAC secret must be at least 32 bytes (RFC 7518 §3.2)")
101
118
  return jwt.encode(payload, key, algorithm=self.config.algorithm)
102
119
 
103
120
  def _verify(
@@ -0,0 +1,76 @@
1
+ """Regression tests for 0.2.0 hardening fixes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import pytest
8
+ from hawkapi import Depends, HawkAPI
9
+ from hawkapi.testing import TestClient
10
+
11
+ from hawkapi_auth import (
12
+ JWTConfig,
13
+ TokenError,
14
+ TokenIssuer,
15
+ init_auth,
16
+ random_secret,
17
+ requires_scopes,
18
+ requires_user,
19
+ )
20
+
21
+
22
+ @pytest.fixture
23
+ def app() -> HawkAPI:
24
+ app = HawkAPI(openapi_url=None, docs_url=None, redoc_url=None, scalar_url=None)
25
+ init_auth(app, config=JWTConfig(secret=random_secret()))
26
+
27
+ @app.get("/me")
28
+ async def me(user_id: str = Depends(requires_user)) -> dict[str, str]:
29
+ return {"user_id": user_id}
30
+
31
+ @app.get("/admin")
32
+ async def admin(c: dict[str, Any] = Depends(requires_scopes("admin"))) -> dict[str, str]:
33
+ return {"who": c["sub"]}
34
+
35
+ return app
36
+
37
+
38
+ def test_invalid_token_returns_generic_detail(app: HawkAPI) -> None:
39
+ """No library-internal error message must leak through the 401 detail."""
40
+ client = TestClient(app)
41
+ r = client.get("/me", headers={"Authorization": "Bearer not.a.jwt"})
42
+ assert r.status_code == 401
43
+ assert r.json()["detail"] == "Invalid or expired token"
44
+
45
+
46
+ def test_short_hmac_secret_rejected() -> None:
47
+ """HS256 with <32-byte secret must refuse to issue."""
48
+ issuer = TokenIssuer(config=JWTConfig(secret="abc"))
49
+ with pytest.raises(TokenError, match="HMAC secret"):
50
+ issuer.issue_access("u")
51
+
52
+
53
+ def test_scope_takes_precedence_over_scopes(app: HawkAPI) -> None:
54
+ """Explicit empty ``scope`` must NOT silently fall back to ``scopes``."""
55
+ issuer = app.state.auth # type: ignore[attr-defined]
56
+ token = issuer.issue_access("alice", scope="", scopes=["admin"])
57
+ client = TestClient(app)
58
+ r = client.get("/admin", headers={"Authorization": f"Bearer {token}"})
59
+ assert r.status_code == 403
60
+
61
+
62
+ def test_requires_scopes_zero_args_raises() -> None:
63
+ """``requires_scopes()`` with no scopes is a programmer error."""
64
+ with pytest.raises(ValueError, match="at least one scope"):
65
+ requires_scopes()
66
+
67
+
68
+ def test_extra_claims_cannot_shadow_reserved() -> None:
69
+ """Caller-supplied reserved claims (exp/type/sub/...) must be ignored."""
70
+ issuer = TokenIssuer(config=JWTConfig(secret=random_secret()))
71
+ token = issuer.issue_access("u", exp=0, type="admin", sub="attacker")
72
+ # _verify treats exp=0 as long-expired — caller injection must NOT take effect.
73
+ claims = issuer.verify_access(token)
74
+ assert claims["type"] == "access"
75
+ assert claims["sub"] == "u"
76
+ assert claims["exp"] > 0
@@ -59,13 +59,13 @@ def test_extra_claims_carried_through(issuer: TokenIssuer) -> None:
59
59
 
60
60
 
61
61
  def test_audience_required_when_configured() -> None:
62
- issuer = TokenIssuer(config=JWTConfig(secret="s", audience="api"))
62
+ issuer = TokenIssuer(config=JWTConfig(secret=random_secret(), audience="api"))
63
63
  token = issuer.issue_access("u")
64
64
  assert issuer.verify_access(token)["aud"] == "api"
65
65
 
66
66
 
67
67
  def test_issuer_required_when_configured() -> None:
68
- issuer = TokenIssuer(config=JWTConfig(secret="s", issuer="hawk"))
68
+ issuer = TokenIssuer(config=JWTConfig(secret=random_secret(), issuer="hawk"))
69
69
  token = issuer.issue_access("u")
70
70
  assert issuer.verify_access(token)["iss"] == "hawk"
71
71
 
@@ -80,7 +80,7 @@ def test_revoked_refresh_rejected() -> None:
80
80
 
81
81
 
82
82
  def test_revoke_without_revocation_list_errors() -> None:
83
- issuer = TokenIssuer(config=JWTConfig(secret="s"))
83
+ issuer = TokenIssuer(config=JWTConfig(secret=random_secret()))
84
84
  token = issuer.issue_refresh("u")
85
85
  with pytest.raises(TokenError, match="RevocationList"):
86
86
  issuer.revoke_refresh(token)
@@ -160,7 +160,7 @@ wheels = [
160
160
 
161
161
  [[package]]
162
162
  name = "hawkapi-auth"
163
- version = "0.1.0"
163
+ version = "0.2.0"
164
164
  source = { editable = "." }
165
165
  dependencies = [
166
166
  { name = "argon2-cffi" },
@@ -1,11 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.0 — 2026-05-16
4
-
5
- Initial release.
6
-
7
- - JWT access + refresh tokens (HS256/384/512, RS*, ES*).
8
- - argon2id password hashing with `needs_rehash`.
9
- - DI guards: `requires_user`, `requires_claims`, `requires_scopes`.
10
- - In-memory `RevocationList` with lazy expiry sweep.
11
- - `init_auth(app, config=...)` plugin entry point.
File without changes
File without changes
File without changes