ndaedzo-shared-lib 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.
- jwt_auth/__init__.py +40 -0
- jwt_auth/config.py +92 -0
- jwt_auth/exceptions.py +32 -0
- jwt_auth/manager.py +251 -0
- jwt_auth/models.py +28 -0
- jwt_auth/py.typed +0 -0
- ndaedzo_shared_lib-0.1.0.dist-info/METADATA +146 -0
- ndaedzo_shared_lib-0.1.0.dist-info/RECORD +9 -0
- ndaedzo_shared_lib-0.1.0.dist-info/WHEEL +4 -0
jwt_auth/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""jwt_auth: production-ready JWT access/refresh token issuance and verification.
|
|
2
|
+
|
|
3
|
+
Typical usage:
|
|
4
|
+
|
|
5
|
+
from jwt_auth import JWTManager
|
|
6
|
+
|
|
7
|
+
manager = JWTManager() # reads configuration from the environment
|
|
8
|
+
tokens = manager.create_token_pair(subject="user-123")
|
|
9
|
+
payload = manager.verify_access_token(tokens.access_token)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .config import JWTSettings
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
ConfigurationError,
|
|
15
|
+
InvalidTokenError,
|
|
16
|
+
InvalidTokenTypeError,
|
|
17
|
+
JWTError,
|
|
18
|
+
TokenExpiredError,
|
|
19
|
+
TokenRevokedError,
|
|
20
|
+
)
|
|
21
|
+
from .manager import ACCESS_TOKEN_TYPE, REFRESH_TOKEN_TYPE, JWTManager
|
|
22
|
+
from .models import TokenPair, TokenPayload
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"JWTManager",
|
|
28
|
+
"JWTSettings",
|
|
29
|
+
"TokenPair",
|
|
30
|
+
"TokenPayload",
|
|
31
|
+
"JWTError",
|
|
32
|
+
"ConfigurationError",
|
|
33
|
+
"TokenExpiredError",
|
|
34
|
+
"InvalidTokenError",
|
|
35
|
+
"InvalidTokenTypeError",
|
|
36
|
+
"TokenRevokedError",
|
|
37
|
+
"ACCESS_TOKEN_TYPE",
|
|
38
|
+
"REFRESH_TOKEN_TYPE",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
jwt_auth/config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Environment-driven configuration for JWTManager."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
from .exceptions import ConfigurationError
|
|
10
|
+
|
|
11
|
+
_dotenv_loaded = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ensure_dotenv_loaded() -> None:
|
|
15
|
+
global _dotenv_loaded
|
|
16
|
+
if not _dotenv_loaded:
|
|
17
|
+
load_dotenv()
|
|
18
|
+
_dotenv_loaded = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_key_material(raw: str | None, path: str | None) -> str | None:
|
|
22
|
+
"""Resolve key material from either a raw PEM env var or a path to a PEM file.
|
|
23
|
+
|
|
24
|
+
Raw values may contain literal ``\\n`` sequences (common when a PEM is
|
|
25
|
+
stored as a single-line environment variable) and are unescaped.
|
|
26
|
+
"""
|
|
27
|
+
if raw:
|
|
28
|
+
return raw.replace("\\n", "\n")
|
|
29
|
+
if path:
|
|
30
|
+
key_path = Path(path)
|
|
31
|
+
if not key_path.is_file():
|
|
32
|
+
raise ConfigurationError(f"Key file not found: {key_path}")
|
|
33
|
+
return key_path.read_text(encoding="utf-8")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class JWTSettings:
|
|
39
|
+
"""Configuration for a JWTManager.
|
|
40
|
+
|
|
41
|
+
Prefer building this via `JWTSettings.from_env()`. Construct it directly
|
|
42
|
+
when wiring up tests or when configuration comes from somewhere other
|
|
43
|
+
than the process environment (e.g. a secrets manager).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
algorithm: str = "RS256"
|
|
47
|
+
private_key: str | None = None
|
|
48
|
+
public_key: str | None = None
|
|
49
|
+
secret_key: str | None = None
|
|
50
|
+
access_token_expire_minutes: int = 15
|
|
51
|
+
refresh_token_expire_days: int = 7
|
|
52
|
+
issuer: str | None = None
|
|
53
|
+
audience: str | None = None
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_env(cls, *, load_dotenv_file: bool = True) -> "JWTSettings":
|
|
57
|
+
"""Build settings from environment variables (optionally loading a .env file first).
|
|
58
|
+
|
|
59
|
+
Recognized variables:
|
|
60
|
+
JWT_ALGORITHM default "RS256"
|
|
61
|
+
JWT_PRIVATE_KEY / JWT_PRIVATE_KEY_PATH (asymmetric algorithms)
|
|
62
|
+
JWT_PUBLIC_KEY / JWT_PUBLIC_KEY_PATH (asymmetric algorithms)
|
|
63
|
+
JWT_SECRET_KEY (symmetric algorithms, e.g. HS256)
|
|
64
|
+
JWT_ACCESS_TOKEN_EXPIRE_MINUTES default 15
|
|
65
|
+
JWT_REFRESH_TOKEN_EXPIRE_DAYS default 7
|
|
66
|
+
JWT_ISSUER optional
|
|
67
|
+
JWT_AUDIENCE optional
|
|
68
|
+
"""
|
|
69
|
+
if load_dotenv_file:
|
|
70
|
+
_ensure_dotenv_loaded()
|
|
71
|
+
|
|
72
|
+
private_key = _read_key_material(
|
|
73
|
+
os.getenv("JWT_PRIVATE_KEY"), os.getenv("JWT_PRIVATE_KEY_PATH")
|
|
74
|
+
)
|
|
75
|
+
public_key = _read_key_material(
|
|
76
|
+
os.getenv("JWT_PUBLIC_KEY"), os.getenv("JWT_PUBLIC_KEY_PATH")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return cls(
|
|
80
|
+
algorithm=os.getenv("JWT_ALGORITHM", "RS256").upper(),
|
|
81
|
+
private_key=private_key,
|
|
82
|
+
public_key=public_key,
|
|
83
|
+
secret_key=os.getenv("JWT_SECRET_KEY") or None,
|
|
84
|
+
access_token_expire_minutes=int(
|
|
85
|
+
os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "15")
|
|
86
|
+
),
|
|
87
|
+
refresh_token_expire_days=int(
|
|
88
|
+
os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7")
|
|
89
|
+
),
|
|
90
|
+
issuer=os.getenv("JWT_ISSUER") or None,
|
|
91
|
+
audience=os.getenv("JWT_AUDIENCE") or None,
|
|
92
|
+
)
|
jwt_auth/exceptions.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Exception hierarchy for jwt_auth.
|
|
2
|
+
|
|
3
|
+
All exceptions raised by this library inherit from JWTError, so callers who
|
|
4
|
+
don't care about the distinction can catch a single type.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JWTError(Exception):
|
|
9
|
+
"""Base class for every exception raised by jwt_auth."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(JWTError):
|
|
13
|
+
"""Raised when a JWTManager is misconfigured (e.g. missing key material)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenExpiredError(JWTError):
|
|
17
|
+
"""Raised when a token's `exp` claim is in the past."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidTokenError(JWTError):
|
|
21
|
+
"""Raised when a token is malformed, has an invalid signature, or fails
|
|
22
|
+
standard claim validation (issuer, audience, not-before, etc.)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidTokenTypeError(JWTError):
|
|
26
|
+
"""Raised when a token's `type` claim doesn't match the operation being
|
|
27
|
+
performed (e.g. a refresh token presented where an access token is required)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TokenRevokedError(JWTError):
|
|
31
|
+
"""Raised when a token has been revoked, as reported by the manager's
|
|
32
|
+
`is_revoked` callback."""
|
jwt_auth/manager.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Core token issuance and verification logic."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import jwt as pyjwt
|
|
9
|
+
|
|
10
|
+
from .config import JWTSettings
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
ConfigurationError,
|
|
13
|
+
InvalidTokenError,
|
|
14
|
+
InvalidTokenTypeError,
|
|
15
|
+
TokenExpiredError,
|
|
16
|
+
TokenRevokedError,
|
|
17
|
+
)
|
|
18
|
+
from .models import TokenPair, TokenPayload
|
|
19
|
+
|
|
20
|
+
ACCESS_TOKEN_TYPE = "access"
|
|
21
|
+
REFRESH_TOKEN_TYPE = "refresh"
|
|
22
|
+
|
|
23
|
+
_ASYMMETRIC_ALGORITHMS = {
|
|
24
|
+
"RS256", "RS384", "RS512",
|
|
25
|
+
"ES256", "ES384", "ES512",
|
|
26
|
+
"PS256", "PS384", "PS512",
|
|
27
|
+
}
|
|
28
|
+
_SYMMETRIC_ALGORITHMS = {"HS256", "HS384", "HS512"}
|
|
29
|
+
_SUPPORTED_ALGORITHMS = _ASYMMETRIC_ALGORITHMS | _SYMMETRIC_ALGORITHMS
|
|
30
|
+
|
|
31
|
+
_RESERVED_CLAIMS = {"sub", "type", "jti", "iat", "nbf", "exp", "iss", "aud"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JWTManager:
|
|
35
|
+
"""Issues and verifies access/refresh token pairs.
|
|
36
|
+
|
|
37
|
+
For asymmetric algorithms (RS256 and friends) a manager only needs the
|
|
38
|
+
private key to *issue* tokens and only the public key to *verify* them,
|
|
39
|
+
so a downstream microservice that just needs to check tokens can be
|
|
40
|
+
configured with a public key alone.
|
|
41
|
+
|
|
42
|
+
An optional `is_revoked` callback lets callers plug in a denylist (e.g.
|
|
43
|
+
backed by Redis) to reject tokens by their `jti` before they've expired,
|
|
44
|
+
without this library taking a dependency on any particular store.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
settings: JWTSettings | None = None,
|
|
50
|
+
*,
|
|
51
|
+
is_revoked: Callable[[str], bool] | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.settings = settings or JWTSettings.from_env()
|
|
54
|
+
self._is_revoked = is_revoked
|
|
55
|
+
self._validate_settings()
|
|
56
|
+
|
|
57
|
+
def _validate_settings(self) -> None:
|
|
58
|
+
algorithm = self.settings.algorithm
|
|
59
|
+
if algorithm in _ASYMMETRIC_ALGORITHMS:
|
|
60
|
+
if not self.settings.private_key and not self.settings.public_key:
|
|
61
|
+
raise ConfigurationError(
|
|
62
|
+
f"Algorithm {algorithm} needs a private key (to sign), a public "
|
|
63
|
+
"key (to verify), or both. Set JWT_PRIVATE_KEY[_PATH] and/or "
|
|
64
|
+
"JWT_PUBLIC_KEY[_PATH]."
|
|
65
|
+
)
|
|
66
|
+
elif algorithm in _SYMMETRIC_ALGORITHMS:
|
|
67
|
+
if not self.settings.secret_key:
|
|
68
|
+
raise ConfigurationError(
|
|
69
|
+
f"Algorithm {algorithm} requires JWT_SECRET_KEY to be set."
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
raise ConfigurationError(
|
|
73
|
+
f"Unsupported algorithm: {algorithm!r}. Supported: "
|
|
74
|
+
f"{sorted(_SUPPORTED_ALGORITHMS)}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def _signing_key(self) -> str:
|
|
79
|
+
algorithm = self.settings.algorithm
|
|
80
|
+
if algorithm in _ASYMMETRIC_ALGORITHMS:
|
|
81
|
+
if not self.settings.private_key:
|
|
82
|
+
raise ConfigurationError(
|
|
83
|
+
f"No private key configured; this manager can verify {algorithm} "
|
|
84
|
+
"tokens but cannot issue them."
|
|
85
|
+
)
|
|
86
|
+
return self.settings.private_key
|
|
87
|
+
assert self.settings.secret_key is not None
|
|
88
|
+
return self.settings.secret_key
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def _verification_key(self) -> str:
|
|
92
|
+
algorithm = self.settings.algorithm
|
|
93
|
+
if algorithm in _ASYMMETRIC_ALGORITHMS:
|
|
94
|
+
key = self.settings.public_key or self.settings.private_key
|
|
95
|
+
assert key is not None
|
|
96
|
+
return key
|
|
97
|
+
assert self.settings.secret_key is not None
|
|
98
|
+
return self.settings.secret_key
|
|
99
|
+
|
|
100
|
+
def _build_payload(
|
|
101
|
+
self,
|
|
102
|
+
subject: str,
|
|
103
|
+
token_type: str,
|
|
104
|
+
expires_delta: timedelta,
|
|
105
|
+
extra_claims: dict[str, Any] | None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
if extra_claims:
|
|
108
|
+
collisions = _RESERVED_CLAIMS & extra_claims.keys()
|
|
109
|
+
if collisions:
|
|
110
|
+
raise ValueError(f"extra_claims may not override reserved claims: {collisions}")
|
|
111
|
+
|
|
112
|
+
now = datetime.now(timezone.utc)
|
|
113
|
+
payload: dict[str, Any] = {
|
|
114
|
+
"sub": subject,
|
|
115
|
+
"type": token_type,
|
|
116
|
+
"jti": str(uuid.uuid4()),
|
|
117
|
+
"iat": now,
|
|
118
|
+
"nbf": now,
|
|
119
|
+
"exp": now + expires_delta,
|
|
120
|
+
}
|
|
121
|
+
if self.settings.issuer:
|
|
122
|
+
payload["iss"] = self.settings.issuer
|
|
123
|
+
if self.settings.audience:
|
|
124
|
+
payload["aud"] = self.settings.audience
|
|
125
|
+
if extra_claims:
|
|
126
|
+
payload.update(extra_claims)
|
|
127
|
+
return payload
|
|
128
|
+
|
|
129
|
+
def create_access_token(
|
|
130
|
+
self, subject: str, extra_claims: dict[str, Any] | None = None
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Issue a short-lived access token for `subject` (typically a user id)."""
|
|
133
|
+
payload = self._build_payload(
|
|
134
|
+
subject,
|
|
135
|
+
ACCESS_TOKEN_TYPE,
|
|
136
|
+
timedelta(minutes=self.settings.access_token_expire_minutes),
|
|
137
|
+
extra_claims,
|
|
138
|
+
)
|
|
139
|
+
return pyjwt.encode(payload, self._signing_key, algorithm=self.settings.algorithm)
|
|
140
|
+
|
|
141
|
+
def create_refresh_token(
|
|
142
|
+
self, subject: str, extra_claims: dict[str, Any] | None = None
|
|
143
|
+
) -> str:
|
|
144
|
+
"""Issue a long-lived refresh token for `subject`."""
|
|
145
|
+
payload = self._build_payload(
|
|
146
|
+
subject,
|
|
147
|
+
REFRESH_TOKEN_TYPE,
|
|
148
|
+
timedelta(days=self.settings.refresh_token_expire_days),
|
|
149
|
+
extra_claims,
|
|
150
|
+
)
|
|
151
|
+
return pyjwt.encode(payload, self._signing_key, algorithm=self.settings.algorithm)
|
|
152
|
+
|
|
153
|
+
def create_token_pair(
|
|
154
|
+
self, subject: str, extra_claims: dict[str, Any] | None = None
|
|
155
|
+
) -> TokenPair:
|
|
156
|
+
"""Issue an access/refresh token pair in one call."""
|
|
157
|
+
return TokenPair(
|
|
158
|
+
access_token=self.create_access_token(subject, extra_claims),
|
|
159
|
+
refresh_token=self.create_refresh_token(subject, extra_claims),
|
|
160
|
+
expires_in=self.settings.access_token_expire_minutes * 60,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _decode(self, token: str) -> dict[str, Any]:
|
|
164
|
+
try:
|
|
165
|
+
return pyjwt.decode(
|
|
166
|
+
token,
|
|
167
|
+
self._verification_key,
|
|
168
|
+
algorithms=[self.settings.algorithm],
|
|
169
|
+
issuer=self.settings.issuer,
|
|
170
|
+
audience=self.settings.audience,
|
|
171
|
+
options={"require": ["exp", "iat", "sub", "jti", "type"]},
|
|
172
|
+
)
|
|
173
|
+
except pyjwt.ExpiredSignatureError as exc:
|
|
174
|
+
raise TokenExpiredError("Token has expired.") from exc
|
|
175
|
+
except pyjwt.InvalidTokenError as exc:
|
|
176
|
+
raise InvalidTokenError(f"Token is invalid: {exc}") from exc
|
|
177
|
+
|
|
178
|
+
def _verify(self, token: str, expected_type: str) -> TokenPayload:
|
|
179
|
+
payload = self._decode(token)
|
|
180
|
+
|
|
181
|
+
actual_type = payload.get("type")
|
|
182
|
+
if actual_type != expected_type:
|
|
183
|
+
raise InvalidTokenTypeError(
|
|
184
|
+
f"Expected a '{expected_type}' token but received '{actual_type}'."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if self._is_revoked is not None and self._is_revoked(payload["jti"]):
|
|
188
|
+
raise TokenRevokedError(f"Token '{payload['jti']}' has been revoked.")
|
|
189
|
+
|
|
190
|
+
return TokenPayload(
|
|
191
|
+
sub=payload["sub"],
|
|
192
|
+
jti=payload["jti"],
|
|
193
|
+
token_type=actual_type,
|
|
194
|
+
issued_at=int(payload["iat"]),
|
|
195
|
+
expires_at=int(payload["exp"]),
|
|
196
|
+
issuer=payload.get("iss"),
|
|
197
|
+
audience=payload.get("aud"),
|
|
198
|
+
claims={k: v for k, v in payload.items() if k not in _RESERVED_CLAIMS},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def verify_access_token(self, token: str) -> TokenPayload:
|
|
202
|
+
"""Verify signature, expiry, and type; raise if the token isn't a valid access token."""
|
|
203
|
+
return self._verify(token, ACCESS_TOKEN_TYPE)
|
|
204
|
+
|
|
205
|
+
def verify_refresh_token(self, token: str) -> TokenPayload:
|
|
206
|
+
"""Verify signature, expiry, and type; raise if the token isn't a valid refresh token."""
|
|
207
|
+
return self._verify(token, REFRESH_TOKEN_TYPE)
|
|
208
|
+
|
|
209
|
+
def refresh_access_token(
|
|
210
|
+
self, refresh_token: str, *, rotate_refresh_token: bool = False
|
|
211
|
+
) -> TokenPair:
|
|
212
|
+
"""Exchange a valid refresh token for a new access token.
|
|
213
|
+
|
|
214
|
+
With `rotate_refresh_token=True`, a new refresh token is also issued
|
|
215
|
+
(recommended for refresh token rotation); otherwise the original
|
|
216
|
+
refresh token is returned unchanged. Rotation requires pairing this
|
|
217
|
+
library with a store that invalidates the old refresh token's `jti`
|
|
218
|
+
(via the `is_revoked` callback) to prevent reuse after rotation.
|
|
219
|
+
"""
|
|
220
|
+
payload = self.verify_refresh_token(refresh_token)
|
|
221
|
+
extra_claims = payload.claims or None
|
|
222
|
+
|
|
223
|
+
new_refresh_token = refresh_token
|
|
224
|
+
if rotate_refresh_token:
|
|
225
|
+
new_refresh_token = self.create_refresh_token(payload.sub, extra_claims)
|
|
226
|
+
|
|
227
|
+
return TokenPair(
|
|
228
|
+
access_token=self.create_access_token(payload.sub, extra_claims),
|
|
229
|
+
refresh_token=new_refresh_token,
|
|
230
|
+
expires_in=self.settings.access_token_expire_minutes * 60,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def is_token_expired(self, token: str) -> bool:
|
|
234
|
+
"""Return True if the token's signature is valid but it has expired.
|
|
235
|
+
|
|
236
|
+
Raises InvalidTokenError for any other validation failure (bad
|
|
237
|
+
signature, malformed token, wrong issuer/audience, etc.).
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
self._decode(token)
|
|
241
|
+
except TokenExpiredError:
|
|
242
|
+
return True
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
def get_unverified_claims(self, token: str) -> dict[str, Any]:
|
|
246
|
+
"""Decode a token's claims WITHOUT verifying its signature or expiry.
|
|
247
|
+
|
|
248
|
+
Use only for non-authoritative purposes (logging, debugging). Never
|
|
249
|
+
use the result to make authorization decisions.
|
|
250
|
+
"""
|
|
251
|
+
return pyjwt.decode(token, options={"verify_signature": False})
|
jwt_auth/models.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Plain data types returned by JWTManager."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class TokenPair:
|
|
9
|
+
"""An access/refresh token pair, ready to hand back to a client."""
|
|
10
|
+
|
|
11
|
+
access_token: str
|
|
12
|
+
refresh_token: str
|
|
13
|
+
token_type: str = "Bearer"
|
|
14
|
+
expires_in: int = 0 # seconds until the access token expires
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TokenPayload:
|
|
19
|
+
"""The verified, decoded contents of a token."""
|
|
20
|
+
|
|
21
|
+
sub: str
|
|
22
|
+
jti: str
|
|
23
|
+
token_type: str
|
|
24
|
+
issued_at: int
|
|
25
|
+
expires_at: int
|
|
26
|
+
issuer: str | None = None
|
|
27
|
+
audience: str | None = None
|
|
28
|
+
claims: dict[str, Any] = field(default_factory=dict)
|
jwt_auth/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ndaedzo-shared-lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Production-ready JWT access/refresh token issuance and verification library for microservices.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: pyjwt[crypto]>=2.10.1
|
|
7
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# shared-lib
|
|
11
|
+
|
|
12
|
+
A production-ready JWT authentication library for issuing and verifying access and refresh tokens across microservices, built on [PyJWT](https://pyjwt.readthedocs.io/) and managed with [uv](https://docs.astral.sh/uv/).
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Access token + refresh token issuance, individually or as a pair
|
|
17
|
+
- Token verification with signature, expiry, not-before, issuer, and audience checks
|
|
18
|
+
- Strict token-type separation - a refresh token can never be used where an access token is expected, and vice versa
|
|
19
|
+
- Refresh token rotation (`refresh_access_token(..., rotate_refresh_token=True)`)
|
|
20
|
+
- Pluggable revocation - pass an `is_revoked(jti) -> bool` callback backed by whatever store you use (Redis, a database, ...) to reject tokens by ID before they expire
|
|
21
|
+
- Asymmetric (RS256/ES256/PS256) and symmetric (HS256) algorithm support, configured entirely via environment variables
|
|
22
|
+
- Typed dataclasses (`TokenPair`, `TokenPayload`) and a small, specific exception hierarchy instead of stringly-typed errors
|
|
23
|
+
- Full unittest suite and GitHub Actions CI
|
|
24
|
+
|
|
25
|
+
## Why RS256 for microservices
|
|
26
|
+
|
|
27
|
+
With a symmetric algorithm (HS256), every service that needs to verify a token must hold the exact same secret used to sign it - so the secret has to be distributed to every service, and any one of them leaking it lets an attacker forge tokens for the whole system.
|
|
28
|
+
|
|
29
|
+
With an asymmetric algorithm (RS256), only the service that issues tokens (e.g. an auth service) holds the private key. Every other service is configured with just the public key, which is enough to verify a token's signature but not to create new ones. This is the recommended default for a microservices setup and is what this library uses out of the box.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
This repo is managed with `uv`. From the project root:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv sync
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
To use `jwt_auth` from another project in this workspace/monorepo, add it as a path or git dependency with `uv add`.
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
1. Copy the example environment file and generate a dev key pair:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cp .env.example .env
|
|
47
|
+
uv run python scripts/generate_keys.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This writes `keys/private_key.pem` and `keys/public_key.pem` (both gitignored). The default `.env` already points `JWT_PRIVATE_KEY_PATH` / `JWT_PUBLIC_KEY_PATH` at these files.
|
|
51
|
+
|
|
52
|
+
2. Run the demo:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv run main.py
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
3. Use it in code:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from jwt_auth import JWTManager, TokenExpiredError, InvalidTokenError
|
|
62
|
+
|
|
63
|
+
manager = JWTManager() # reads configuration from the environment
|
|
64
|
+
|
|
65
|
+
tokens = manager.create_token_pair(subject="user-123", extra_claims={"role": "trainer"})
|
|
66
|
+
# tokens.access_token, tokens.refresh_token, tokens.expires_in
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
payload = manager.verify_access_token(tokens.access_token)
|
|
70
|
+
user_id = payload.sub
|
|
71
|
+
except TokenExpiredError:
|
|
72
|
+
... # ask the client to hit the refresh endpoint
|
|
73
|
+
except InvalidTokenError:
|
|
74
|
+
... # reject the request, log the attempt
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
4. Refreshing an access token:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
new_tokens = manager.refresh_access_token(refresh_token, rotate_refresh_token=True)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Auth service vs. downstream services
|
|
84
|
+
|
|
85
|
+
Because RS256 keys are asymmetric, a downstream service that only ever needs to *verify* tokens should be configured with just the public key - it will raise `ConfigurationError` if you try to issue a token with it:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# Auth service - has both keys, can issue and verify.
|
|
89
|
+
issuer = JWTManager() # JWT_PRIVATE_KEY_PATH and JWT_PUBLIC_KEY_PATH set
|
|
90
|
+
|
|
91
|
+
# Downstream service - only distribute the public key.
|
|
92
|
+
verifier = JWTManager(JWTSettings(algorithm="RS256", public_key=public_key_pem))
|
|
93
|
+
verifier.verify_access_token(incoming_token) # OK
|
|
94
|
+
verifier.create_access_token("user-1") # raises ConfigurationError
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Revocation
|
|
98
|
+
|
|
99
|
+
This library doesn't ship a storage backend, since that choice (Redis, Postgres, ...) belongs to the application. Instead, `JWTManager` accepts an `is_revoked` callback:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
def is_revoked(jti: str) -> bool:
|
|
103
|
+
return redis_client.sismember("revoked-jtis", jti)
|
|
104
|
+
|
|
105
|
+
manager = JWTManager(is_revoked=is_revoked)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Every `verify_access_token` / `verify_refresh_token` call runs the token's `jti` through this callback, so revoking a token (on logout, on rotation, or by an admin) just means adding its `jti` to your store.
|
|
109
|
+
|
|
110
|
+
## Configuration reference
|
|
111
|
+
|
|
112
|
+
All configuration is read from the environment (optionally via a `.env` file, loaded automatically the first time `JWTSettings.from_env()` runs).
|
|
113
|
+
|
|
114
|
+
| Variable | Required | Default | Notes |
|
|
115
|
+
|---|---|---|---|
|
|
116
|
+
| `JWT_ALGORITHM` | no | `RS256` | Any PyJWT-supported algorithm: `RS256/384/512`, `ES256/384/512`, `PS256/384/512`, `HS256/384/512` |
|
|
117
|
+
| `JWT_PRIVATE_KEY_PATH` / `JWT_PRIVATE_KEY` | for asymmetric algorithms, to issue tokens | - | Path to a PEM file, or the raw PEM (with `\n` escapes) |
|
|
118
|
+
| `JWT_PUBLIC_KEY_PATH` / `JWT_PUBLIC_KEY` | for asymmetric algorithms, to verify tokens | - | Path to a PEM file, or the raw PEM (with `\n` escapes) |
|
|
119
|
+
| `JWT_SECRET_KEY` | for symmetric algorithms | - | Shared secret |
|
|
120
|
+
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | no | `15` | Keep this short - access tokens are bearer credentials |
|
|
121
|
+
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | no | `7` | |
|
|
122
|
+
| `JWT_ISSUER` | no | - | Stamped as `iss` and enforced on verify when set |
|
|
123
|
+
| `JWT_AUDIENCE` | no | - | Stamped as `aud` and enforced on verify when set |
|
|
124
|
+
|
|
125
|
+
See `.env.example` for a template.
|
|
126
|
+
|
|
127
|
+
## Testing
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
uv run python -m unittest discover -s tests -t . -v
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Tests cover both HS256 and RS256 code paths, token-type separation, tampering/wrong-key/wrong-audience rejection, expiry, refresh rotation, revocation, and configuration validation - no network or external services required.
|
|
134
|
+
|
|
135
|
+
## CI
|
|
136
|
+
|
|
137
|
+
`.github/workflows/ci.yml` runs on every push and pull request to `main`: it lints with `ruff`, runs the full test suite across Python 3.10-3.13 via `uv`, and does a final build check. Update this library, push, and CI will catch regressions before they reach any service that depends on it.
|
|
138
|
+
|
|
139
|
+
## Security notes
|
|
140
|
+
|
|
141
|
+
- Keep access token lifetimes short (minutes) and refresh token lifetimes as short as your product allows (days, not months).
|
|
142
|
+
- Prefer RS256 (or another asymmetric algorithm) over HS256 whenever more than one service needs to verify tokens.
|
|
143
|
+
- Use `rotate_refresh_token=True` and pair it with the `is_revoked` callback so a stolen, already-rotated refresh token can be rejected on reuse.
|
|
144
|
+
- Never log full tokens. `TokenPayload.jti` is safe to log; the raw token string is a bearer credential.
|
|
145
|
+
- Always serve token endpoints over HTTPS.
|
|
146
|
+
- `keys/`, `.env`, and `*.pem` are gitignored - do not commit real key material or secrets.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
jwt_auth/__init__.py,sha256=PWqNElWOQQCeVQk_hVi2whKYv7RbSl1d88DmMxrqJvk,971
|
|
2
|
+
jwt_auth/config.py,sha256=Rxzlq1xl_QVFDkOGdO_h58Fn3Dn9ti3DTje7A1t2MUY,3183
|
|
3
|
+
jwt_auth/exceptions.py,sha256=MVZuzf-ugGwNoprO1yT-07fXgaKlvdhodg4_snvoSa4,1009
|
|
4
|
+
jwt_auth/manager.py,sha256=DU_5dFOrJ6Z5YtIF19mCpniQX99QOOXR_bJBTzbru8Q,9598
|
|
5
|
+
jwt_auth/models.py,sha256=1CY9gTLFjg4huyvoYwqIx3RPHbMeKp8xfEiI4nEnD-4,674
|
|
6
|
+
jwt_auth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
ndaedzo_shared_lib-0.1.0.dist-info/METADATA,sha256=D0bXj-WnNIClgo_5d_8Cka9xi-uKLiOiFQ-sWFMb87E,6934
|
|
8
|
+
ndaedzo_shared_lib-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
ndaedzo_shared_lib-0.1.0.dist-info/RECORD,,
|