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.
@@ -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
+ )