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.
- hawkapi_auth-0.2.0/CHANGELOG.md +32 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/PKG-INFO +1 -1
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/pyproject.toml +1 -1
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/src/hawkapi_auth/__init__.py +1 -1
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/src/hawkapi_auth/_deps.py +24 -3
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/src/hawkapi_auth/_passwords.py +11 -2
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/src/hawkapi_auth/_plugin.py +12 -3
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/src/hawkapi_auth/_tokens.py +17 -0
- hawkapi_auth-0.2.0/tests/test_security.py +76 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/tests/test_tokens.py +3 -3
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/uv.lock +1 -1
- hawkapi_auth-0.1.0/CHANGELOG.md +0 -11
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/.github/workflows/ci.yml +0 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/.github/workflows/release.yml +0 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/.gitignore +0 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/LICENSE +0 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/README.md +0 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/tests/__init__.py +0 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/tests/test_integration.py +0 -0
- {hawkapi_auth-0.1.0 → hawkapi_auth-0.2.0}/tests/test_passwords.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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)
|
hawkapi_auth-0.1.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|