ndaedzo-shared-lib 0.1.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.
- ndaedzo_shared_lib-0.1.0/.env.example +35 -0
- ndaedzo_shared_lib-0.1.0/.github/workflows/ci.yml +54 -0
- ndaedzo_shared_lib-0.1.0/.gitignore +37 -0
- ndaedzo_shared_lib-0.1.0/.python-version +1 -0
- ndaedzo_shared_lib-0.1.0/PKG-INFO +146 -0
- ndaedzo_shared_lib-0.1.0/README.md +137 -0
- ndaedzo_shared_lib-0.1.0/main.py +55 -0
- ndaedzo_shared_lib-0.1.0/pyproject.toml +31 -0
- ndaedzo_shared_lib-0.1.0/scripts/generate_keys.py +48 -0
- ndaedzo_shared_lib-0.1.0/src/jwt_auth/__init__.py +40 -0
- ndaedzo_shared_lib-0.1.0/src/jwt_auth/config.py +92 -0
- ndaedzo_shared_lib-0.1.0/src/jwt_auth/exceptions.py +32 -0
- ndaedzo_shared_lib-0.1.0/src/jwt_auth/manager.py +251 -0
- ndaedzo_shared_lib-0.1.0/src/jwt_auth/models.py +28 -0
- ndaedzo_shared_lib-0.1.0/src/jwt_auth/py.typed +0 -0
- ndaedzo_shared_lib-0.1.0/tests/__init__.py +0 -0
- ndaedzo_shared_lib-0.1.0/tests/helpers.py +22 -0
- ndaedzo_shared_lib-0.1.0/tests/test_config.py +67 -0
- ndaedzo_shared_lib-0.1.0/tests/test_manager_hs256.py +211 -0
- ndaedzo_shared_lib-0.1.0/tests/test_manager_rs256.py +68 -0
- ndaedzo_shared_lib-0.1.0/uv.lock +847 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copy this file to .env and fill in real values. Never commit .env.
|
|
2
|
+
|
|
3
|
+
# Algorithm used to sign and verify JWTs.
|
|
4
|
+
#
|
|
5
|
+
# RS256 (recommended for microservices): asymmetric. The service that issues
|
|
6
|
+
# tokens holds the private key; every other service only needs the public
|
|
7
|
+
# key to verify tokens, so a leak of a verifying service doesn't expose the
|
|
8
|
+
# signing key.
|
|
9
|
+
#
|
|
10
|
+
# HS256: symmetric and simpler, but every service that verifies tokens must
|
|
11
|
+
# hold the same shared secret as the issuer, which widens the blast radius
|
|
12
|
+
# of a leak. Only use it for a single-service setup or trusted internal use.
|
|
13
|
+
JWT_ALGORITHM=RS256
|
|
14
|
+
|
|
15
|
+
# --- RS256 / other asymmetric algorithms (RS384, RS512, ES256, ...) ---
|
|
16
|
+
# Provide EITHER the *_PATH variant (recommended) OR the raw PEM in the
|
|
17
|
+
# matching non-PATH variable below - not both. Generate a dev key pair with:
|
|
18
|
+
# uv run python scripts/generate_keys.py
|
|
19
|
+
JWT_PRIVATE_KEY_PATH=keys/private_key.pem
|
|
20
|
+
JWT_PUBLIC_KEY_PATH=keys/public_key.pem
|
|
21
|
+
# JWT_PRIVATE_KEY=
|
|
22
|
+
# JWT_PUBLIC_KEY=
|
|
23
|
+
|
|
24
|
+
# --- HS256 / other symmetric algorithms ---
|
|
25
|
+
# Required only when JWT_ALGORITHM is HS256/HS384/HS512.
|
|
26
|
+
JWT_SECRET_KEY=
|
|
27
|
+
|
|
28
|
+
# Token lifetimes.
|
|
29
|
+
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15
|
|
30
|
+
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
|
31
|
+
|
|
32
|
+
# Optional standard claims. When set, they are stamped onto every issued
|
|
33
|
+
# token and enforced on every verified token.
|
|
34
|
+
JWT_ISSUER=pt-web-app
|
|
35
|
+
JWT_AUDIENCE=pt-web-app-services
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
workflow_dispatch: {}
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- name: Check out repository
|
|
21
|
+
uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Install uv
|
|
24
|
+
uses: astral-sh/setup-uv@v4
|
|
25
|
+
with:
|
|
26
|
+
enable-cache: true
|
|
27
|
+
|
|
28
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
29
|
+
run: uv python install ${{ matrix.python-version }}
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: uv sync --python ${{ matrix.python-version }} --all-groups
|
|
33
|
+
|
|
34
|
+
- name: Lint with ruff
|
|
35
|
+
run: uv run ruff check .
|
|
36
|
+
|
|
37
|
+
- name: Run tests
|
|
38
|
+
run: uv run python -m unittest discover -s tests -t . -v
|
|
39
|
+
|
|
40
|
+
package:
|
|
41
|
+
name: Build package
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
needs: test
|
|
44
|
+
steps:
|
|
45
|
+
- name: Check out repository
|
|
46
|
+
uses: actions/checkout@v4
|
|
47
|
+
|
|
48
|
+
- name: Install uv
|
|
49
|
+
uses: astral-sh/setup-uv@v4
|
|
50
|
+
with:
|
|
51
|
+
enable-cache: true
|
|
52
|
+
|
|
53
|
+
- name: Build distribution
|
|
54
|
+
run: uv build
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Secrets and local config
|
|
2
|
+
.env
|
|
3
|
+
.env.*.local
|
|
4
|
+
keys/
|
|
5
|
+
*.pem
|
|
6
|
+
*.key
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*$py.class
|
|
12
|
+
*.egg-info/
|
|
13
|
+
.eggs/
|
|
14
|
+
dist/
|
|
15
|
+
build/
|
|
16
|
+
|
|
17
|
+
# Virtual environments
|
|
18
|
+
.venv/
|
|
19
|
+
venv/
|
|
20
|
+
env/
|
|
21
|
+
|
|
22
|
+
# Testing / coverage
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.coverage
|
|
26
|
+
.coverage.*
|
|
27
|
+
htmlcov/
|
|
28
|
+
|
|
29
|
+
# Type checkers
|
|
30
|
+
.mypy_cache/
|
|
31
|
+
.pyright/
|
|
32
|
+
|
|
33
|
+
# Editors / OS
|
|
34
|
+
.vscode/
|
|
35
|
+
.idea/
|
|
36
|
+
.DS_Store
|
|
37
|
+
Thumbs.db
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -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,137 @@
|
|
|
1
|
+
# shared-lib
|
|
2
|
+
|
|
3
|
+
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/).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Access token + refresh token issuance, individually or as a pair
|
|
8
|
+
- Token verification with signature, expiry, not-before, issuer, and audience checks
|
|
9
|
+
- Strict token-type separation - a refresh token can never be used where an access token is expected, and vice versa
|
|
10
|
+
- Refresh token rotation (`refresh_access_token(..., rotate_refresh_token=True)`)
|
|
11
|
+
- 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
|
|
12
|
+
- Asymmetric (RS256/ES256/PS256) and symmetric (HS256) algorithm support, configured entirely via environment variables
|
|
13
|
+
- Typed dataclasses (`TokenPair`, `TokenPayload`) and a small, specific exception hierarchy instead of stringly-typed errors
|
|
14
|
+
- Full unittest suite and GitHub Actions CI
|
|
15
|
+
|
|
16
|
+
## Why RS256 for microservices
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
This repo is managed with `uv`. From the project root:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv sync
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
To use `jwt_auth` from another project in this workspace/monorepo, add it as a path or git dependency with `uv add`.
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
1. Copy the example environment file and generate a dev key pair:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cp .env.example .env
|
|
38
|
+
uv run python scripts/generate_keys.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
2. Run the demo:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv run main.py
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
3. Use it in code:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from jwt_auth import JWTManager, TokenExpiredError, InvalidTokenError
|
|
53
|
+
|
|
54
|
+
manager = JWTManager() # reads configuration from the environment
|
|
55
|
+
|
|
56
|
+
tokens = manager.create_token_pair(subject="user-123", extra_claims={"role": "trainer"})
|
|
57
|
+
# tokens.access_token, tokens.refresh_token, tokens.expires_in
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
payload = manager.verify_access_token(tokens.access_token)
|
|
61
|
+
user_id = payload.sub
|
|
62
|
+
except TokenExpiredError:
|
|
63
|
+
... # ask the client to hit the refresh endpoint
|
|
64
|
+
except InvalidTokenError:
|
|
65
|
+
... # reject the request, log the attempt
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
4. Refreshing an access token:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
new_tokens = manager.refresh_access_token(refresh_token, rotate_refresh_token=True)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Auth service vs. downstream services
|
|
75
|
+
|
|
76
|
+
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:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Auth service - has both keys, can issue and verify.
|
|
80
|
+
issuer = JWTManager() # JWT_PRIVATE_KEY_PATH and JWT_PUBLIC_KEY_PATH set
|
|
81
|
+
|
|
82
|
+
# Downstream service - only distribute the public key.
|
|
83
|
+
verifier = JWTManager(JWTSettings(algorithm="RS256", public_key=public_key_pem))
|
|
84
|
+
verifier.verify_access_token(incoming_token) # OK
|
|
85
|
+
verifier.create_access_token("user-1") # raises ConfigurationError
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Revocation
|
|
89
|
+
|
|
90
|
+
This library doesn't ship a storage backend, since that choice (Redis, Postgres, ...) belongs to the application. Instead, `JWTManager` accepts an `is_revoked` callback:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
def is_revoked(jti: str) -> bool:
|
|
94
|
+
return redis_client.sismember("revoked-jtis", jti)
|
|
95
|
+
|
|
96
|
+
manager = JWTManager(is_revoked=is_revoked)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
## Configuration reference
|
|
102
|
+
|
|
103
|
+
All configuration is read from the environment (optionally via a `.env` file, loaded automatically the first time `JWTSettings.from_env()` runs).
|
|
104
|
+
|
|
105
|
+
| Variable | Required | Default | Notes |
|
|
106
|
+
|---|---|---|---|
|
|
107
|
+
| `JWT_ALGORITHM` | no | `RS256` | Any PyJWT-supported algorithm: `RS256/384/512`, `ES256/384/512`, `PS256/384/512`, `HS256/384/512` |
|
|
108
|
+
| `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) |
|
|
109
|
+
| `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) |
|
|
110
|
+
| `JWT_SECRET_KEY` | for symmetric algorithms | - | Shared secret |
|
|
111
|
+
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | no | `15` | Keep this short - access tokens are bearer credentials |
|
|
112
|
+
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | no | `7` | |
|
|
113
|
+
| `JWT_ISSUER` | no | - | Stamped as `iss` and enforced on verify when set |
|
|
114
|
+
| `JWT_AUDIENCE` | no | - | Stamped as `aud` and enforced on verify when set |
|
|
115
|
+
|
|
116
|
+
See `.env.example` for a template.
|
|
117
|
+
|
|
118
|
+
## Testing
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
uv run python -m unittest discover -s tests -t . -v
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
## CI
|
|
127
|
+
|
|
128
|
+
`.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.
|
|
129
|
+
|
|
130
|
+
## Security notes
|
|
131
|
+
|
|
132
|
+
- Keep access token lifetimes short (minutes) and refresh token lifetimes as short as your product allows (days, not months).
|
|
133
|
+
- Prefer RS256 (or another asymmetric algorithm) over HS256 whenever more than one service needs to verify tokens.
|
|
134
|
+
- 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.
|
|
135
|
+
- Never log full tokens. `TokenPayload.jti` is safe to log; the raw token string is a bearer credential.
|
|
136
|
+
- Always serve token endpoints over HTTPS.
|
|
137
|
+
- `keys/`, `.env`, and `*.pem` are gitignored - do not commit real key material or secrets.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Runnable demo of the jwt_auth library.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
uv run python scripts/generate_keys.py # first time only, creates keys/
|
|
5
|
+
uv run main.py
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from jwt_auth import (
|
|
9
|
+
InvalidTokenTypeError,
|
|
10
|
+
JWTManager,
|
|
11
|
+
TokenExpiredError,
|
|
12
|
+
TokenRevokedError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
# In a real service, is_revoked would look up a denylist (e.g. Redis) of
|
|
18
|
+
# jti's for tokens that were revoked before their natural expiry (logout,
|
|
19
|
+
# rotation, admin action, ...).
|
|
20
|
+
revoked_jtis: set[str] = set()
|
|
21
|
+
manager = JWTManager(is_revoked=lambda jti: jti in revoked_jtis)
|
|
22
|
+
|
|
23
|
+
tokens = manager.create_token_pair(subject="user-123", extra_claims={"role": "trainer"})
|
|
24
|
+
print(f"access_token: {tokens.access_token}")
|
|
25
|
+
print(f"refresh_token: {tokens.refresh_token}")
|
|
26
|
+
print(f"expires_in: {tokens.expires_in}s")
|
|
27
|
+
|
|
28
|
+
payload = manager.verify_access_token(tokens.access_token)
|
|
29
|
+
print(f"\nverified access token for subject={payload.sub!r} claims={payload.claims}")
|
|
30
|
+
|
|
31
|
+
refreshed = manager.refresh_access_token(tokens.refresh_token, rotate_refresh_token=True)
|
|
32
|
+
print(f"\nrotated refresh_token: {refreshed.refresh_token}")
|
|
33
|
+
|
|
34
|
+
old_refresh_payload = manager.verify_refresh_token(tokens.refresh_token)
|
|
35
|
+
revoked_jtis.add(old_refresh_payload.jti)
|
|
36
|
+
try:
|
|
37
|
+
manager.verify_refresh_token(tokens.refresh_token)
|
|
38
|
+
except TokenRevokedError as exc:
|
|
39
|
+
print(f"\nold refresh token correctly rejected after rotation: {exc}")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
manager.verify_access_token(tokens.refresh_token)
|
|
43
|
+
except InvalidTokenTypeError as exc:
|
|
44
|
+
print(f"refresh token correctly rejected when used as an access token: {exc}")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
manager.verify_access_token("not-a-jwt")
|
|
48
|
+
except TokenExpiredError:
|
|
49
|
+
pass
|
|
50
|
+
except Exception as exc: # noqa: BLE001 - demo only
|
|
51
|
+
print(f"malformed token correctly rejected: {exc}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ndaedzo-shared-lib"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Production-ready JWT access/refresh token issuance and verification library for microservices."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pyjwt[crypto]>=2.10.1",
|
|
9
|
+
"python-dotenv>=1.0.1",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[dependency-groups]
|
|
13
|
+
dev = [
|
|
14
|
+
"build>=1.6.0",
|
|
15
|
+
"ruff>=0.8.0",
|
|
16
|
+
"twine>=7.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/jwt_auth"]
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
line-length = 100
|
|
28
|
+
target-version = "py310"
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Generate an RSA key pair for RS256-signed JWTs.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
uv run python scripts/generate_keys.py [output_dir]
|
|
5
|
+
|
|
6
|
+
Writes private_key.pem and public_key.pem into `output_dir` (default: ./keys).
|
|
7
|
+
The private key must stay on the service that issues tokens; only distribute
|
|
8
|
+
the public key to services that need to verify them. Never commit
|
|
9
|
+
private_key.pem to version control (the keys/ directory is gitignored).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_key_pair(output_dir: Path, key_size: int = 2048) -> None:
|
|
20
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
|
|
23
|
+
|
|
24
|
+
private_path = output_dir / "private_key.pem"
|
|
25
|
+
public_path = output_dir / "public_key.pem"
|
|
26
|
+
|
|
27
|
+
private_path.write_bytes(
|
|
28
|
+
private_key.private_bytes(
|
|
29
|
+
encoding=serialization.Encoding.PEM,
|
|
30
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
31
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
public_path.write_bytes(
|
|
35
|
+
private_key.public_key().public_bytes(
|
|
36
|
+
encoding=serialization.Encoding.PEM,
|
|
37
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
print(f"Wrote {private_path}")
|
|
42
|
+
print(f"Wrote {public_path}")
|
|
43
|
+
print("Keep private_key.pem secret - it is gitignored, but verify that before committing.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("keys")
|
|
48
|
+
generate_key_pair(target)
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
)
|