hawkapi-auth 0.1.0__py3-none-any.whl
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/__init__.py +32 -0
- hawkapi_auth/_deps.py +102 -0
- hawkapi_auth/_passwords.py +40 -0
- hawkapi_auth/_plugin.py +64 -0
- hawkapi_auth/_tokens.py +168 -0
- hawkapi_auth-0.1.0.dist-info/METADATA +210 -0
- hawkapi_auth-0.1.0.dist-info/RECORD +9 -0
- hawkapi_auth-0.1.0.dist-info/WHEEL +4 -0
- hawkapi_auth-0.1.0.dist-info/licenses/LICENSE +21 -0
hawkapi_auth/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""hawkapi-auth — JWT auth (access + refresh) + password hashing for HawkAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from hawkapi_auth._deps import requires_claims, requires_scopes, requires_user
|
|
8
|
+
from hawkapi_auth._passwords import hash_password, needs_rehash, verify_password
|
|
9
|
+
from hawkapi_auth._plugin import init_auth
|
|
10
|
+
from hawkapi_auth._tokens import (
|
|
11
|
+
JWTConfig,
|
|
12
|
+
RevocationList,
|
|
13
|
+
TokenError,
|
|
14
|
+
TokenIssuer,
|
|
15
|
+
random_secret,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"JWTConfig",
|
|
20
|
+
"RevocationList",
|
|
21
|
+
"TokenError",
|
|
22
|
+
"TokenIssuer",
|
|
23
|
+
"__version__",
|
|
24
|
+
"hash_password",
|
|
25
|
+
"init_auth",
|
|
26
|
+
"needs_rehash",
|
|
27
|
+
"random_secret",
|
|
28
|
+
"requires_claims",
|
|
29
|
+
"requires_scopes",
|
|
30
|
+
"requires_user",
|
|
31
|
+
"verify_password",
|
|
32
|
+
]
|
hawkapi_auth/_deps.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""DI dependencies — extract + verify access tokens from incoming requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from hawkapi.exceptions import HTTPException
|
|
8
|
+
from hawkapi.requests.request import Request
|
|
9
|
+
|
|
10
|
+
from hawkapi_auth._tokens import TokenError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _bearer_token(request: Request) -> str | None:
|
|
14
|
+
auth = request.headers.get("authorization")
|
|
15
|
+
if not auth:
|
|
16
|
+
return None
|
|
17
|
+
parts = auth.split(" ", 1)
|
|
18
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
19
|
+
return None
|
|
20
|
+
return parts[1].strip() or None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _issuer_from_request(request: Request) -> Any:
|
|
24
|
+
from hawkapi_auth._plugin import resolve_issuer # noqa: PLC0415
|
|
25
|
+
|
|
26
|
+
return resolve_issuer(request.scope.get("app"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def requires_user(request: Request) -> str:
|
|
30
|
+
"""DI helper: validate the ``Authorization: Bearer <jwt>`` header.
|
|
31
|
+
|
|
32
|
+
Returns the ``sub`` claim of the access token. Raises ``HTTPException(401)``
|
|
33
|
+
on missing / invalid / revoked tokens.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
@app.get("/me")
|
|
37
|
+
async def me(user_id: str = Depends(requires_user)):
|
|
38
|
+
return await db.fetch_user(user_id)
|
|
39
|
+
"""
|
|
40
|
+
token = _bearer_token(request)
|
|
41
|
+
if token is None:
|
|
42
|
+
raise HTTPException(
|
|
43
|
+
401, detail="Missing bearer token", headers={"WWW-Authenticate": "Bearer"}
|
|
44
|
+
)
|
|
45
|
+
issuer = _issuer_from_request(request)
|
|
46
|
+
if issuer is None:
|
|
47
|
+
raise HTTPException(500, detail="hawkapi-auth not initialised")
|
|
48
|
+
try:
|
|
49
|
+
claims = issuer.verify_access(token)
|
|
50
|
+
except TokenError as exc:
|
|
51
|
+
raise HTTPException(401, detail=str(exc), headers={"WWW-Authenticate": "Bearer"}) from exc
|
|
52
|
+
sub = claims.get("sub")
|
|
53
|
+
if not isinstance(sub, str):
|
|
54
|
+
raise HTTPException(401, detail="Token has no subject")
|
|
55
|
+
return sub
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def requires_claims(request: Request) -> dict[str, Any]:
|
|
59
|
+
"""DI helper that returns the full set of verified claims, not just ``sub``."""
|
|
60
|
+
token = _bearer_token(request)
|
|
61
|
+
if token is None:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
401, detail="Missing bearer token", headers={"WWW-Authenticate": "Bearer"}
|
|
64
|
+
)
|
|
65
|
+
issuer = _issuer_from_request(request)
|
|
66
|
+
if issuer is None:
|
|
67
|
+
raise HTTPException(500, detail="hawkapi-auth not initialised")
|
|
68
|
+
try:
|
|
69
|
+
return issuer.verify_access(token)
|
|
70
|
+
except TokenError as exc:
|
|
71
|
+
raise HTTPException(401, detail=str(exc), headers={"WWW-Authenticate": "Bearer"}) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def requires_scopes(*required: str):
|
|
75
|
+
"""Build a DI dependency that gates on the ``scope`` claim.
|
|
76
|
+
|
|
77
|
+
The access token must carry a ``scope`` claim — either a space-separated
|
|
78
|
+
string (OAuth2 style) or a list of strings. All entries in *required* must
|
|
79
|
+
be present.
|
|
80
|
+
|
|
81
|
+
Usage:
|
|
82
|
+
@app.get("/admin", dependencies=[Depends(requires_scopes("admin"))])
|
|
83
|
+
async def admin(): ...
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
async def _dep(request: Request) -> dict[str, Any]:
|
|
87
|
+
claims = await requires_claims(request)
|
|
88
|
+
raw = claims.get("scope") or claims.get("scopes") or []
|
|
89
|
+
granted: set[str] = set()
|
|
90
|
+
if isinstance(raw, str):
|
|
91
|
+
granted = set(raw.split())
|
|
92
|
+
elif isinstance(raw, list):
|
|
93
|
+
granted = {s for s in raw if isinstance(s, str)}
|
|
94
|
+
missing = [s for s in required if s not in granted]
|
|
95
|
+
if missing:
|
|
96
|
+
raise HTTPException(403, detail=f"Missing scopes: {', '.join(missing)}")
|
|
97
|
+
return claims
|
|
98
|
+
|
|
99
|
+
return _dep
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = ["requires_user", "requires_claims", "requires_scopes"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Password hashing helpers — argon2id by default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argon2 import PasswordHasher
|
|
6
|
+
from argon2.exceptions import VerifyMismatchError
|
|
7
|
+
|
|
8
|
+
_hasher = PasswordHasher()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hash_password(password: str) -> str:
|
|
12
|
+
"""Return an argon2id hash of *password* (PHC string format)."""
|
|
13
|
+
return _hasher.hash(password)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_password(password: str, stored_hash: str) -> bool:
|
|
17
|
+
"""Constant-time check of *password* against a stored argon2 hash.
|
|
18
|
+
|
|
19
|
+
Returns False on mismatch or invalid hash format. Never raises so it is
|
|
20
|
+
safe to use directly in handler logic.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
return _hasher.verify(stored_hash, password)
|
|
24
|
+
except (VerifyMismatchError, Exception):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def needs_rehash(stored_hash: str) -> bool:
|
|
29
|
+
"""True when *stored_hash* should be re-hashed with current parameters.
|
|
30
|
+
|
|
31
|
+
Call this after a successful login and re-hash the password if it returns
|
|
32
|
+
True — protects against algorithm / parameter upgrades over time.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
return _hasher.check_needs_rehash(stored_hash)
|
|
36
|
+
except Exception:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["hash_password", "verify_password", "needs_rehash"]
|
hawkapi_auth/_plugin.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""``init_auth`` — wires a JWT issuer into the HawkAPI app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from hawkapi_auth._tokens import JWTConfig, RevocationList, TokenIssuer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _StateNamespace:
|
|
11
|
+
"""Lightweight attribute bag for ``app.state`` when none exists."""
|
|
12
|
+
|
|
13
|
+
auth: Any # set by init_auth
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_ACTIVE_ISSUERS: dict[int, TokenIssuer] = {}
|
|
17
|
+
_LAST_ISSUER: list[TokenIssuer | None] = [None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_issuer(app: Any) -> TokenIssuer | None:
|
|
21
|
+
"""Look up the active TokenIssuer for *app*, or the last-attached as a fallback.
|
|
22
|
+
|
|
23
|
+
Mirrors the lookup pattern from hawkapi-cache: TestClient does not populate
|
|
24
|
+
``scope["app"]``, so the DI dependency uses a module-level registry.
|
|
25
|
+
"""
|
|
26
|
+
if app is not None:
|
|
27
|
+
issuer = _ACTIVE_ISSUERS.get(id(app))
|
|
28
|
+
if issuer is not None:
|
|
29
|
+
return issuer
|
|
30
|
+
return _LAST_ISSUER[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init_auth(
|
|
34
|
+
app: Any,
|
|
35
|
+
*,
|
|
36
|
+
config: JWTConfig,
|
|
37
|
+
revocation: RevocationList | None = None,
|
|
38
|
+
) -> TokenIssuer:
|
|
39
|
+
"""Mount a :class:`TokenIssuer` on ``app.state.auth`` and register lookup.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from hawkapi import HawkAPI
|
|
43
|
+
from hawkapi_auth import init_auth, JWTConfig, random_secret
|
|
44
|
+
|
|
45
|
+
app = HawkAPI()
|
|
46
|
+
init_auth(app, config=JWTConfig(secret=random_secret()))
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
After this call:
|
|
50
|
+
|
|
51
|
+
* ``app.state.auth`` is the :class:`TokenIssuer`.
|
|
52
|
+
* ``Depends(requires_user)`` resolves the ``sub`` claim from
|
|
53
|
+
``Authorization: Bearer …`` headers.
|
|
54
|
+
"""
|
|
55
|
+
issuer = TokenIssuer(config=config, revocation=revocation)
|
|
56
|
+
if getattr(app, "state", None) is None:
|
|
57
|
+
app.state = _StateNamespace()
|
|
58
|
+
app.state.auth = issuer
|
|
59
|
+
_ACTIVE_ISSUERS[id(app)] = issuer
|
|
60
|
+
_LAST_ISSUER[0] = issuer
|
|
61
|
+
return issuer
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["init_auth", "resolve_issuer"]
|
hawkapi_auth/_tokens.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""JWT access + refresh token issue/verify."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import jwt
|
|
12
|
+
from jwt.exceptions import InvalidTokenError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TokenError(Exception):
|
|
16
|
+
"""Raised when a token fails validation."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class JWTConfig:
|
|
21
|
+
"""JWT signing configuration.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
secret: HMAC secret for symmetric algorithms (HS256/384/512). For RS*
|
|
25
|
+
/ ES* use ``private_key`` + ``public_key`` instead.
|
|
26
|
+
algorithm: Signing algorithm. ``HS256`` by default.
|
|
27
|
+
access_ttl_seconds: Lifetime of issued access tokens. 15 minutes default.
|
|
28
|
+
refresh_ttl_seconds: Lifetime of issued refresh tokens. 30 days default.
|
|
29
|
+
issuer: Optional ``iss`` claim.
|
|
30
|
+
audience: Optional ``aud`` claim (string or list of strings).
|
|
31
|
+
private_key: PEM-encoded private key when using asymmetric algorithms.
|
|
32
|
+
public_key: PEM-encoded public key when using asymmetric algorithms.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
secret: str = ""
|
|
36
|
+
algorithm: str = "HS256"
|
|
37
|
+
access_ttl_seconds: int = 15 * 60
|
|
38
|
+
refresh_ttl_seconds: int = 30 * 24 * 60 * 60
|
|
39
|
+
issuer: str | None = None
|
|
40
|
+
audience: str | list[str] | None = None
|
|
41
|
+
private_key: str = ""
|
|
42
|
+
public_key: str = ""
|
|
43
|
+
# Token-type claim values — exposed so consumers can match on them.
|
|
44
|
+
access_type: str = "access"
|
|
45
|
+
refresh_type: str = "refresh"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class TokenIssuer:
|
|
50
|
+
"""Issue + verify JWTs against a :class:`JWTConfig`.
|
|
51
|
+
|
|
52
|
+
Stateless. For refresh-token revocation use :class:`RevocationList`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
config: JWTConfig
|
|
56
|
+
revocation: RevocationList | None = None
|
|
57
|
+
|
|
58
|
+
def issue_access(self, subject: str, **extra_claims: Any) -> str:
|
|
59
|
+
return self._issue(
|
|
60
|
+
subject, self.config.access_type, self.config.access_ttl_seconds, extra_claims
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def issue_refresh(self, subject: str, **extra_claims: Any) -> str:
|
|
64
|
+
return self._issue(
|
|
65
|
+
subject, self.config.refresh_type, self.config.refresh_ttl_seconds, extra_claims
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def verify_access(self, token: str) -> dict[str, Any]:
|
|
69
|
+
return self._verify(token, expected_type=self.config.access_type)
|
|
70
|
+
|
|
71
|
+
def verify_refresh(self, token: str) -> dict[str, Any]:
|
|
72
|
+
return self._verify(token, expected_type=self.config.refresh_type)
|
|
73
|
+
|
|
74
|
+
def revoke_refresh(self, token: str) -> None:
|
|
75
|
+
if self.revocation is None:
|
|
76
|
+
raise TokenError("no RevocationList configured")
|
|
77
|
+
claims = self._verify(token, expected_type=self.config.refresh_type, allow_revoked=True)
|
|
78
|
+
jti = claims.get("jti")
|
|
79
|
+
if not isinstance(jti, str):
|
|
80
|
+
raise TokenError("token has no jti claim")
|
|
81
|
+
exp = int(claims.get("exp", 0))
|
|
82
|
+
self.revocation.revoke(jti, exp)
|
|
83
|
+
|
|
84
|
+
def _issue(self, subject: str, token_type: str, ttl: int, extra: dict[str, Any]) -> str:
|
|
85
|
+
now = int(time.time())
|
|
86
|
+
payload: dict[str, Any] = {
|
|
87
|
+
"sub": subject,
|
|
88
|
+
"iat": now,
|
|
89
|
+
"exp": now + ttl,
|
|
90
|
+
"jti": uuid.uuid4().hex,
|
|
91
|
+
"type": token_type,
|
|
92
|
+
**extra,
|
|
93
|
+
}
|
|
94
|
+
if self.config.issuer:
|
|
95
|
+
payload["iss"] = self.config.issuer
|
|
96
|
+
if self.config.audience:
|
|
97
|
+
payload["aud"] = self.config.audience
|
|
98
|
+
key = self.config.private_key or self.config.secret
|
|
99
|
+
if not key:
|
|
100
|
+
raise TokenError("JWTConfig has no secret or private_key")
|
|
101
|
+
return jwt.encode(payload, key, algorithm=self.config.algorithm)
|
|
102
|
+
|
|
103
|
+
def _verify(
|
|
104
|
+
self, token: str, *, expected_type: str, allow_revoked: bool = False
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
key = self.config.public_key or self.config.secret
|
|
107
|
+
if not key:
|
|
108
|
+
raise TokenError("JWTConfig has no secret or public_key")
|
|
109
|
+
options: dict[str, Any] = {}
|
|
110
|
+
if self.config.audience is None:
|
|
111
|
+
options["verify_aud"] = False
|
|
112
|
+
try:
|
|
113
|
+
claims: dict[str, Any] = jwt.decode(
|
|
114
|
+
token,
|
|
115
|
+
key,
|
|
116
|
+
algorithms=[self.config.algorithm],
|
|
117
|
+
audience=self.config.audience,
|
|
118
|
+
issuer=self.config.issuer,
|
|
119
|
+
options=options, # type: ignore[arg-type]
|
|
120
|
+
)
|
|
121
|
+
except InvalidTokenError as exc:
|
|
122
|
+
raise TokenError(str(exc)) from exc
|
|
123
|
+
if claims.get("type") != expected_type:
|
|
124
|
+
raise TokenError(f"expected {expected_type!r} token, got {claims.get('type')!r}")
|
|
125
|
+
if not allow_revoked and self.revocation is not None:
|
|
126
|
+
jti = claims.get("jti")
|
|
127
|
+
if isinstance(jti, str) and self.revocation.is_revoked(jti):
|
|
128
|
+
raise TokenError("token revoked")
|
|
129
|
+
return claims
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class RevocationList:
|
|
134
|
+
"""In-memory revoked-token registry. Keys are JTI claims; values are expiry.
|
|
135
|
+
|
|
136
|
+
Expired entries are dropped lazily on every access. For multi-process
|
|
137
|
+
deployments use a Redis-backed implementation (TODO v0.2.0).
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
_store: dict[str, int] = field(default_factory=dict)
|
|
141
|
+
|
|
142
|
+
def revoke(self, jti: str, exp_timestamp: int) -> None:
|
|
143
|
+
self._sweep()
|
|
144
|
+
self._store[jti] = exp_timestamp
|
|
145
|
+
|
|
146
|
+
def is_revoked(self, jti: str) -> bool:
|
|
147
|
+
self._sweep()
|
|
148
|
+
return jti in self._store
|
|
149
|
+
|
|
150
|
+
def _sweep(self) -> None:
|
|
151
|
+
now = int(time.time())
|
|
152
|
+
dead = [k for k, exp in self._store.items() if exp <= now]
|
|
153
|
+
for k in dead:
|
|
154
|
+
self._store.pop(k, None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def random_secret(length: int = 64) -> str:
|
|
158
|
+
"""Return a URL-safe random secret suitable for JWTConfig.secret."""
|
|
159
|
+
return secrets.token_urlsafe(length)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = [
|
|
163
|
+
"JWTConfig",
|
|
164
|
+
"RevocationList",
|
|
165
|
+
"TokenError",
|
|
166
|
+
"TokenIssuer",
|
|
167
|
+
"random_secret",
|
|
168
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hawkapi-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JWT auth for HawkAPI — access + refresh tokens, password hashing, DI guards
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/hawkapi-auth/
|
|
6
|
+
Project-URL: Repository, https://github.com/ashimov/hawkapi-auth
|
|
7
|
+
Project-URL: Issues, https://github.com/ashimov/hawkapi-auth/issues
|
|
8
|
+
Author-email: HawkAPI Contributors <hawkapi@users.noreply.github.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 HawkAPI Contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: argon2,auth,authentication,bcrypt,hawkapi,jwt
|
|
32
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
33
|
+
Classifier: Framework :: AsyncIO
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
40
|
+
Classifier: Topic :: Security
|
|
41
|
+
Classifier: Typing :: Typed
|
|
42
|
+
Requires-Python: >=3.12
|
|
43
|
+
Requires-Dist: argon2-cffi>=23.1
|
|
44
|
+
Requires-Dist: hawkapi>=0.1.7
|
|
45
|
+
Requires-Dist: pyjwt>=2.8
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
48
|
+
Requires-Dist: pyright>=1.1; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# hawkapi-auth
|
|
55
|
+
|
|
56
|
+
JWT auth for [HawkAPI](https://github.com/ashimov/HawkAPI). Access + refresh tokens, argon2id password hashing, DI guards, scope-based access control.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install hawkapi-auth
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quickstart
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from hawkapi import Depends, HawkAPI, HTTPException
|
|
68
|
+
from hawkapi_auth import (
|
|
69
|
+
JWTConfig,
|
|
70
|
+
hash_password,
|
|
71
|
+
init_auth,
|
|
72
|
+
random_secret,
|
|
73
|
+
requires_user,
|
|
74
|
+
verify_password,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
app = HawkAPI()
|
|
78
|
+
init_auth(app, config=JWTConfig(secret=random_secret()))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.post("/register")
|
|
82
|
+
async def register(email: str, password: str):
|
|
83
|
+
await db.create_user(email=email, password_hash=hash_password(password))
|
|
84
|
+
return {"ok": True}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.post("/login")
|
|
88
|
+
async def login(email: str, password: str):
|
|
89
|
+
user = await db.find_user(email)
|
|
90
|
+
if not user or not verify_password(password, user.password_hash):
|
|
91
|
+
raise HTTPException(401, detail="Invalid credentials")
|
|
92
|
+
issuer = app.state.auth
|
|
93
|
+
return {
|
|
94
|
+
"access_token": issuer.issue_access(user.id),
|
|
95
|
+
"refresh_token": issuer.issue_refresh(user.id),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.get("/me")
|
|
100
|
+
async def me(user_id: str = Depends(requires_user)):
|
|
101
|
+
return await db.fetch_user(user_id)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Token issue / verify
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
issuer = app.state.auth # TokenIssuer
|
|
108
|
+
|
|
109
|
+
access = issuer.issue_access("user-1", role="admin", scope="read write")
|
|
110
|
+
refresh = issuer.issue_refresh("user-1")
|
|
111
|
+
|
|
112
|
+
claims = issuer.verify_access(access) # raises TokenError on bad token
|
|
113
|
+
claims = issuer.verify_refresh(refresh) # ditto, plus checks the token type
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`issue_access` / `issue_refresh` accept arbitrary keyword claims (`role`, `scope`, anything JSON-serialisable).
|
|
117
|
+
|
|
118
|
+
## JWTConfig
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
JWTConfig(
|
|
122
|
+
secret="…", # HMAC secret for HS256/384/512
|
|
123
|
+
algorithm="HS256",
|
|
124
|
+
access_ttl_seconds=15 * 60,
|
|
125
|
+
refresh_ttl_seconds=30 * 24 * 60 * 60,
|
|
126
|
+
issuer="my-service", # optional iss claim
|
|
127
|
+
audience="my-api", # optional aud claim
|
|
128
|
+
private_key="", # RS*/ES* — PEM
|
|
129
|
+
public_key="",
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Use `random_secret()` to mint one. Store it outside of git.
|
|
134
|
+
|
|
135
|
+
## DI guards
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from hawkapi_auth import requires_user, requires_claims, requires_scopes
|
|
139
|
+
|
|
140
|
+
@app.get("/me")
|
|
141
|
+
async def me(user_id: str = Depends(requires_user)):
|
|
142
|
+
...
|
|
143
|
+
|
|
144
|
+
@app.get("/dump")
|
|
145
|
+
async def dump(claims: dict = Depends(requires_claims)):
|
|
146
|
+
...
|
|
147
|
+
|
|
148
|
+
@app.get("/admin", dependencies=[Depends(requires_scopes("admin"))])
|
|
149
|
+
async def admin():
|
|
150
|
+
...
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`requires_scopes(*scopes)` expects either a space-separated `scope` claim or a list under `scope` / `scopes`. Missing scopes → 403.
|
|
154
|
+
|
|
155
|
+
## Refresh + revocation
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from hawkapi_auth import RevocationList
|
|
159
|
+
|
|
160
|
+
rev = RevocationList()
|
|
161
|
+
init_auth(app, config=JWTConfig(secret=...), revocation=rev)
|
|
162
|
+
|
|
163
|
+
@app.post("/refresh")
|
|
164
|
+
async def refresh(refresh_token: str):
|
|
165
|
+
issuer = app.state.auth
|
|
166
|
+
claims = issuer.verify_refresh(refresh_token)
|
|
167
|
+
return {"access_token": issuer.issue_access(claims["sub"])}
|
|
168
|
+
|
|
169
|
+
@app.post("/logout")
|
|
170
|
+
async def logout(refresh_token: str):
|
|
171
|
+
app.state.auth.revoke_refresh(refresh_token)
|
|
172
|
+
return {"ok": True}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`RevocationList` is in-memory only. For multi-process deployments, swap in a Redis-backed implementation (planned in v0.2.0).
|
|
176
|
+
|
|
177
|
+
## Password hashing
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from hawkapi_auth import hash_password, verify_password, needs_rehash
|
|
181
|
+
|
|
182
|
+
h = hash_password("hunter2") # argon2id
|
|
183
|
+
ok = verify_password("hunter2", h) # constant-time, returns bool
|
|
184
|
+
if needs_rehash(h):
|
|
185
|
+
h = hash_password("hunter2") # re-hash after a successful login
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`verify_password` never raises — safe to use directly in handler bodies.
|
|
189
|
+
|
|
190
|
+
## What's not included (v0.2.0 roadmap)
|
|
191
|
+
|
|
192
|
+
* Social OAuth providers (Google / GitHub / Discord / Microsoft).
|
|
193
|
+
* Email-based password reset + verification flows.
|
|
194
|
+
* Pre-built user model and storage.
|
|
195
|
+
* Redis-backed `RevocationList`.
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
git clone https://github.com/ashimov/hawkapi-auth.git
|
|
201
|
+
cd hawkapi-auth
|
|
202
|
+
uv sync --extra dev
|
|
203
|
+
uv run pytest -q
|
|
204
|
+
uv run ruff check . && uv run ruff format --check .
|
|
205
|
+
uv run pyright src/
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
hawkapi_auth/__init__.py,sha256=wszL8wfaZObC3F8X8Y92uuGPr8ypWVvrNF5amuQUsWU,749
|
|
2
|
+
hawkapi_auth/_deps.py,sha256=IgHC0bVXfqGRGgchWDLqmjMPb-bgzi25csN6OQVZxPc,3479
|
|
3
|
+
hawkapi_auth/_passwords.py,sha256=5mTy64MP3icz3hRwufLgWVOpLp5XY3v3OM_pnikdZds,1195
|
|
4
|
+
hawkapi_auth/_plugin.py,sha256=3d3ybjllXf6sUVzZvS8VS523HfG1DUHCkpTQ1ZSVnZw,1783
|
|
5
|
+
hawkapi_auth/_tokens.py,sha256=9HtRxbqgQm1-dQqfFugeraeepvkiavw3d_HetlrhNo8,5755
|
|
6
|
+
hawkapi_auth-0.1.0.dist-info/METADATA,sha256=JlK9jvSD1H8lfuyP69F_ZD27EmXDomu9a7LmbvmZwsw,6654
|
|
7
|
+
hawkapi_auth-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
hawkapi_auth-0.1.0.dist-info/licenses/LICENSE,sha256=_RpjhvsfLqqeG_gv2cRatjIxCTGXTpXhKU9jqLZXYa4,1077
|
|
9
|
+
hawkapi_auth-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HawkAPI Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|