nodus-auth 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.
- nodus_auth-0.1.0/LICENSE +21 -0
- nodus_auth-0.1.0/PKG-INFO +201 -0
- nodus_auth-0.1.0/README.md +174 -0
- nodus_auth-0.1.0/nodus_auth/__init__.py +63 -0
- nodus_auth-0.1.0/nodus_auth/config.py +13 -0
- nodus_auth-0.1.0/nodus_auth/jwt.py +133 -0
- nodus_auth-0.1.0/nodus_auth/keys.py +25 -0
- nodus_auth-0.1.0/nodus_auth/password.py +16 -0
- nodus_auth-0.1.0/nodus_auth/schemas.py +72 -0
- nodus_auth-0.1.0/nodus_auth/user_ids.py +35 -0
- nodus_auth-0.1.0/nodus_auth.egg-info/PKG-INFO +201 -0
- nodus_auth-0.1.0/nodus_auth.egg-info/SOURCES.txt +19 -0
- nodus_auth-0.1.0/nodus_auth.egg-info/dependency_links.txt +1 -0
- nodus_auth-0.1.0/nodus_auth.egg-info/requires.txt +9 -0
- nodus_auth-0.1.0/nodus_auth.egg-info/top_level.txt +1 -0
- nodus_auth-0.1.0/pyproject.toml +41 -0
- nodus_auth-0.1.0/setup.cfg +4 -0
- nodus_auth-0.1.0/tests/test_jwt.py +99 -0
- nodus_auth-0.1.0/tests/test_keys.py +42 -0
- nodus_auth-0.1.0/tests/test_password.py +26 -0
- nodus_auth-0.1.0/tests/test_schemas.py +94 -0
nodus_auth-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shawn Knight
|
|
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.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodus-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JWT, API key auth, bcrypt hashing, and scoped principals for AI-native platforms
|
|
5
|
+
Author: Shawn Knight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Masterplanner25/nodus-auth
|
|
8
|
+
Project-URL: Repository, https://github.com/Masterplanner25/nodus-auth
|
|
9
|
+
Keywords: auth,jwt,api-key,bcrypt,nodus
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: python-jose>=3.5.0
|
|
19
|
+
Requires-Dist: passlib>=1.7.4
|
|
20
|
+
Requires-Dist: bcrypt<5.0,>=4.0.1
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# nodus-auth
|
|
29
|
+
|
|
30
|
+
**JWT issuance/validation, API key hashing, bcrypt passwords, and scoped
|
|
31
|
+
principals for AI-native platforms.**
|
|
32
|
+
|
|
33
|
+
Standalone auth primitives — no FastAPI required. All core functions work
|
|
34
|
+
with any Python web framework or no framework at all.
|
|
35
|
+
|
|
36
|
+
> **Status:** v0.1.0 — prepared, not yet published.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install nodus-auth
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **bcrypt version note:** This package pins `bcrypt>=4.0.1,<5.0` because
|
|
47
|
+
> `passlib 1.7.4` is incompatible with bcrypt 5.x. Do not upgrade bcrypt
|
|
48
|
+
> beyond 4.x in the same environment.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## What it provides
|
|
53
|
+
|
|
54
|
+
| Component | Purpose |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `KeyRing` / `create_access_token` / `decode_access_token` | JWT issuance and verification with key rotation |
|
|
57
|
+
| `hash_password` / `verify_password` | bcrypt password hashing via passlib |
|
|
58
|
+
| `generate_key` / `hash_key` | API key generation and SHA-256 storage hash |
|
|
59
|
+
| `AuthPrincipal` / `Scopes` | Resolved identity with scope-based access control |
|
|
60
|
+
| `LoginRequest` / `RegisterRequest` / `TokenResponse` | Pydantic request/response schemas |
|
|
61
|
+
| `parse_user_id` / `require_user_id` | UUID parsing helpers |
|
|
62
|
+
| `AuthSettings` | pydantic-settings config for SECRET_KEY, ALGORITHM, expiry |
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## JWT tokens
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from nodus_auth import AuthSettings, create_access_token, decode_access_token, InvalidTokenError
|
|
70
|
+
|
|
71
|
+
settings = AuthSettings(SECRET_KEY="my-secret-key-32-chars-minimum")
|
|
72
|
+
token = create_access_token({"sub": "user-123"}, settings=settings)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
payload = decode_access_token(token, settings=settings)
|
|
76
|
+
user_id = payload["sub"]
|
|
77
|
+
except InvalidTokenError:
|
|
78
|
+
# expired, malformed, or wrong key
|
|
79
|
+
...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Key rotation
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from nodus_auth import KeyRing, create_access_token, decode_access_token, AuthSettings
|
|
86
|
+
|
|
87
|
+
ring = KeyRing(active="key-v1", grace_hours=24)
|
|
88
|
+
settings = AuthSettings(SECRET_KEY="key-v1")
|
|
89
|
+
token = create_access_token({"sub": "u1"}, settings=settings, key_ring=ring)
|
|
90
|
+
|
|
91
|
+
ring.rotate("key-v2") # tokens signed with key-v1 still verify for 24 hours
|
|
92
|
+
payload = decode_access_token(token, settings=settings, key_ring=ring) # OK
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`decode_access_token` tries the active key first, then any keys within their
|
|
96
|
+
grace period. Tokens outside their grace window raise `InvalidTokenError`.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Passwords
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from nodus_auth import hash_password, verify_password
|
|
104
|
+
|
|
105
|
+
hashed = hash_password("correct-horse-battery-staple")
|
|
106
|
+
assert verify_password("correct-horse-battery-staple", hashed)
|
|
107
|
+
assert not verify_password("wrong-password", hashed)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## API keys
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from nodus_auth import generate_key, hash_key
|
|
116
|
+
|
|
117
|
+
raw_key, key_hash = generate_key()
|
|
118
|
+
# Deliver raw_key to the caller once; store key_hash in the database
|
|
119
|
+
assert hash_key(raw_key) == key_hash
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`generate_key()` uses `secrets.token_urlsafe`. The raw key is never stored;
|
|
123
|
+
the SHA-256 hash is used for all comparisons.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Scoped principals
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from nodus_auth import AuthPrincipal, Scopes
|
|
131
|
+
|
|
132
|
+
principal = AuthPrincipal(
|
|
133
|
+
user_id="user-123",
|
|
134
|
+
auth_type="api_key",
|
|
135
|
+
scopes=[Scopes.MEMORY_READ, Scopes.FLOW_EXECUTE],
|
|
136
|
+
)
|
|
137
|
+
assert principal.has_scope(Scopes.FLOW_EXECUTE)
|
|
138
|
+
assert not principal.has_scope(Scopes.PLATFORM_ADMIN)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`auth_type` is `"jwt"` or `"api_key"`. `Scopes` provides well-known constants:
|
|
142
|
+
`MEMORY_READ`, `MEMORY_WRITE`, `FLOW_EXECUTE`, `FLOW_CREATE`, `PLATFORM_ADMIN`.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## AuthSettings
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from nodus_auth import AuthSettings
|
|
150
|
+
|
|
151
|
+
# Reads from environment variables: SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
|
152
|
+
settings = AuthSettings()
|
|
153
|
+
|
|
154
|
+
# Or explicit:
|
|
155
|
+
settings = AuthSettings(
|
|
156
|
+
SECRET_KEY="...",
|
|
157
|
+
ALGORITHM="HS256",
|
|
158
|
+
ACCESS_TOKEN_EXPIRE_MINUTES=60,
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## User ID helpers
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from nodus_auth import parse_user_id, require_user_id, parse_user_ids
|
|
168
|
+
import uuid
|
|
169
|
+
|
|
170
|
+
parse_user_id("550e8400-e29b-41d4-a716-446655440000") # → UUID
|
|
171
|
+
parse_user_id(None) # → None
|
|
172
|
+
require_user_id("not-a-uuid") # raises ValueError
|
|
173
|
+
parse_user_ids(["uid-1", "bad", "uid-2"]) # → [UUID, UUID] (skips failures)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Dependencies
|
|
179
|
+
|
|
180
|
+
| Package | Version | Purpose |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `python-jose` | ≥3.5.0 | JWT encoding/decoding |
|
|
183
|
+
| `passlib` | ≥1.7.4 | bcrypt password context |
|
|
184
|
+
| `bcrypt` | ≥4.0.1,<5.0 | bcrypt backend (passlib 1.7.4 incompatible with 5.x) |
|
|
185
|
+
| `pydantic` | ≥2.0.0 | Schemas |
|
|
186
|
+
| `pydantic-settings` | ≥2.0.0 | `AuthSettings` from env vars |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pip install -e ".[dev]"
|
|
194
|
+
pytest tests/ -q
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# nodus-auth
|
|
2
|
+
|
|
3
|
+
**JWT issuance/validation, API key hashing, bcrypt passwords, and scoped
|
|
4
|
+
principals for AI-native platforms.**
|
|
5
|
+
|
|
6
|
+
Standalone auth primitives — no FastAPI required. All core functions work
|
|
7
|
+
with any Python web framework or no framework at all.
|
|
8
|
+
|
|
9
|
+
> **Status:** v0.1.0 — prepared, not yet published.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install nodus-auth
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
> **bcrypt version note:** This package pins `bcrypt>=4.0.1,<5.0` because
|
|
20
|
+
> `passlib 1.7.4` is incompatible with bcrypt 5.x. Do not upgrade bcrypt
|
|
21
|
+
> beyond 4.x in the same environment.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## What it provides
|
|
26
|
+
|
|
27
|
+
| Component | Purpose |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `KeyRing` / `create_access_token` / `decode_access_token` | JWT issuance and verification with key rotation |
|
|
30
|
+
| `hash_password` / `verify_password` | bcrypt password hashing via passlib |
|
|
31
|
+
| `generate_key` / `hash_key` | API key generation and SHA-256 storage hash |
|
|
32
|
+
| `AuthPrincipal` / `Scopes` | Resolved identity with scope-based access control |
|
|
33
|
+
| `LoginRequest` / `RegisterRequest` / `TokenResponse` | Pydantic request/response schemas |
|
|
34
|
+
| `parse_user_id` / `require_user_id` | UUID parsing helpers |
|
|
35
|
+
| `AuthSettings` | pydantic-settings config for SECRET_KEY, ALGORITHM, expiry |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## JWT tokens
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from nodus_auth import AuthSettings, create_access_token, decode_access_token, InvalidTokenError
|
|
43
|
+
|
|
44
|
+
settings = AuthSettings(SECRET_KEY="my-secret-key-32-chars-minimum")
|
|
45
|
+
token = create_access_token({"sub": "user-123"}, settings=settings)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
payload = decode_access_token(token, settings=settings)
|
|
49
|
+
user_id = payload["sub"]
|
|
50
|
+
except InvalidTokenError:
|
|
51
|
+
# expired, malformed, or wrong key
|
|
52
|
+
...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Key rotation
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from nodus_auth import KeyRing, create_access_token, decode_access_token, AuthSettings
|
|
59
|
+
|
|
60
|
+
ring = KeyRing(active="key-v1", grace_hours=24)
|
|
61
|
+
settings = AuthSettings(SECRET_KEY="key-v1")
|
|
62
|
+
token = create_access_token({"sub": "u1"}, settings=settings, key_ring=ring)
|
|
63
|
+
|
|
64
|
+
ring.rotate("key-v2") # tokens signed with key-v1 still verify for 24 hours
|
|
65
|
+
payload = decode_access_token(token, settings=settings, key_ring=ring) # OK
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`decode_access_token` tries the active key first, then any keys within their
|
|
69
|
+
grace period. Tokens outside their grace window raise `InvalidTokenError`.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Passwords
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from nodus_auth import hash_password, verify_password
|
|
77
|
+
|
|
78
|
+
hashed = hash_password("correct-horse-battery-staple")
|
|
79
|
+
assert verify_password("correct-horse-battery-staple", hashed)
|
|
80
|
+
assert not verify_password("wrong-password", hashed)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## API keys
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from nodus_auth import generate_key, hash_key
|
|
89
|
+
|
|
90
|
+
raw_key, key_hash = generate_key()
|
|
91
|
+
# Deliver raw_key to the caller once; store key_hash in the database
|
|
92
|
+
assert hash_key(raw_key) == key_hash
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`generate_key()` uses `secrets.token_urlsafe`. The raw key is never stored;
|
|
96
|
+
the SHA-256 hash is used for all comparisons.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Scoped principals
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from nodus_auth import AuthPrincipal, Scopes
|
|
104
|
+
|
|
105
|
+
principal = AuthPrincipal(
|
|
106
|
+
user_id="user-123",
|
|
107
|
+
auth_type="api_key",
|
|
108
|
+
scopes=[Scopes.MEMORY_READ, Scopes.FLOW_EXECUTE],
|
|
109
|
+
)
|
|
110
|
+
assert principal.has_scope(Scopes.FLOW_EXECUTE)
|
|
111
|
+
assert not principal.has_scope(Scopes.PLATFORM_ADMIN)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`auth_type` is `"jwt"` or `"api_key"`. `Scopes` provides well-known constants:
|
|
115
|
+
`MEMORY_READ`, `MEMORY_WRITE`, `FLOW_EXECUTE`, `FLOW_CREATE`, `PLATFORM_ADMIN`.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## AuthSettings
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from nodus_auth import AuthSettings
|
|
123
|
+
|
|
124
|
+
# Reads from environment variables: SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
|
125
|
+
settings = AuthSettings()
|
|
126
|
+
|
|
127
|
+
# Or explicit:
|
|
128
|
+
settings = AuthSettings(
|
|
129
|
+
SECRET_KEY="...",
|
|
130
|
+
ALGORITHM="HS256",
|
|
131
|
+
ACCESS_TOKEN_EXPIRE_MINUTES=60,
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## User ID helpers
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from nodus_auth import parse_user_id, require_user_id, parse_user_ids
|
|
141
|
+
import uuid
|
|
142
|
+
|
|
143
|
+
parse_user_id("550e8400-e29b-41d4-a716-446655440000") # → UUID
|
|
144
|
+
parse_user_id(None) # → None
|
|
145
|
+
require_user_id("not-a-uuid") # raises ValueError
|
|
146
|
+
parse_user_ids(["uid-1", "bad", "uid-2"]) # → [UUID, UUID] (skips failures)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Dependencies
|
|
152
|
+
|
|
153
|
+
| Package | Version | Purpose |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `python-jose` | ≥3.5.0 | JWT encoding/decoding |
|
|
156
|
+
| `passlib` | ≥1.7.4 | bcrypt password context |
|
|
157
|
+
| `bcrypt` | ≥4.0.1,<5.0 | bcrypt backend (passlib 1.7.4 incompatible with 5.x) |
|
|
158
|
+
| `pydantic` | ≥2.0.0 | Schemas |
|
|
159
|
+
| `pydantic-settings` | ≥2.0.0 | `AuthSettings` from env vars |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pip install -e ".[dev]"
|
|
167
|
+
pytest tests/ -q
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""nodus-auth — JWT, API key auth, bcrypt, scoped principals.
|
|
2
|
+
|
|
3
|
+
Core JWT:
|
|
4
|
+
KeyRing — two-slot signing key ring with rotation support
|
|
5
|
+
create_access_token — encode a JWT with configurable expiry
|
|
6
|
+
decode_access_token — decode + verify a JWT (tries all keys in ring)
|
|
7
|
+
InvalidTokenError — raised on malformed / expired tokens
|
|
8
|
+
|
|
9
|
+
Password:
|
|
10
|
+
hash_password — bcrypt hash via passlib
|
|
11
|
+
verify_password — bcrypt verify
|
|
12
|
+
|
|
13
|
+
API key utilities:
|
|
14
|
+
hash_key — SHA-256 hex digest of a raw key
|
|
15
|
+
generate_key — generate (raw_key, key_hash) pair
|
|
16
|
+
|
|
17
|
+
Schemas:
|
|
18
|
+
LoginRequest — Pydantic model
|
|
19
|
+
RegisterRequest — Pydantic model
|
|
20
|
+
TokenResponse — Pydantic model
|
|
21
|
+
AuthPrincipal — resolved identity (jwt or api_key)
|
|
22
|
+
Scopes — well-known scope string constants
|
|
23
|
+
|
|
24
|
+
Utilities:
|
|
25
|
+
parse_user_id — parse str/UUID/None → UUID | None
|
|
26
|
+
require_user_id — parse, raise ValueError on failure
|
|
27
|
+
parse_user_ids — batch parse, skip failures
|
|
28
|
+
|
|
29
|
+
Config:
|
|
30
|
+
AuthSettings — pydantic-settings for SECRET_KEY, ALGORITHM, expiry
|
|
31
|
+
"""
|
|
32
|
+
from .config import AuthSettings
|
|
33
|
+
from .jwt import InvalidTokenError, KeyRing, create_access_token, decode_access_token
|
|
34
|
+
from .keys import generate_key, hash_key
|
|
35
|
+
from .password import hash_password, verify_password
|
|
36
|
+
from .schemas import AuthPrincipal, LoginRequest, RegisterRequest, Scopes, TokenResponse
|
|
37
|
+
from .user_ids import parse_user_id, parse_user_ids, require_user_id
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Config
|
|
41
|
+
"AuthSettings",
|
|
42
|
+
# JWT
|
|
43
|
+
"InvalidTokenError",
|
|
44
|
+
"KeyRing",
|
|
45
|
+
"create_access_token",
|
|
46
|
+
"decode_access_token",
|
|
47
|
+
# Keys
|
|
48
|
+
"generate_key",
|
|
49
|
+
"hash_key",
|
|
50
|
+
# Password
|
|
51
|
+
"hash_password",
|
|
52
|
+
"verify_password",
|
|
53
|
+
# Schemas
|
|
54
|
+
"AuthPrincipal",
|
|
55
|
+
"LoginRequest",
|
|
56
|
+
"RegisterRequest",
|
|
57
|
+
"Scopes",
|
|
58
|
+
"TokenResponse",
|
|
59
|
+
# User IDs
|
|
60
|
+
"parse_user_id",
|
|
61
|
+
"parse_user_ids",
|
|
62
|
+
"require_user_id",
|
|
63
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthSettings(BaseSettings):
|
|
7
|
+
"""Minimal auth configuration. Reads from environment variables by default."""
|
|
8
|
+
|
|
9
|
+
SECRET_KEY: str = "dev-secret-change-in-production"
|
|
10
|
+
ALGORITHM: str = "HS256"
|
|
11
|
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
|
|
12
|
+
|
|
13
|
+
model_config = {"env_prefix": "", "extra": "ignore"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""JWT token creation, decoding, and key rotation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from jose import JWTError, jwt as _jwt
|
|
10
|
+
|
|
11
|
+
from .config import AuthSettings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidTokenError(Exception):
|
|
15
|
+
"""Raised when a JWT cannot be decoded, is expired, or fails verification."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KeyRing:
|
|
19
|
+
"""Two-slot JWT signing key ring supporting zero-downtime rotation.
|
|
20
|
+
|
|
21
|
+
Signing always uses the active key. Verification tries active first, then
|
|
22
|
+
previous (within the grace window) so tokens issued with the old key remain
|
|
23
|
+
valid while clients refresh.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
active: str,
|
|
29
|
+
previous: Optional[str] = None,
|
|
30
|
+
grace_hours: int = 24,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._lock = threading.RLock()
|
|
33
|
+
self._active = active
|
|
34
|
+
self._previous = previous
|
|
35
|
+
self._previous_expires: Optional[datetime] = None
|
|
36
|
+
self._grace_hours = grace_hours
|
|
37
|
+
if previous:
|
|
38
|
+
self._previous_expires = datetime.now(timezone.utc) + timedelta(
|
|
39
|
+
hours=grace_hours
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def active_key(self) -> str:
|
|
44
|
+
with self._lock:
|
|
45
|
+
return self._active
|
|
46
|
+
|
|
47
|
+
def rotate(self, new_key: str) -> None:
|
|
48
|
+
"""Promote active → previous (with expiry), set *new_key* as active."""
|
|
49
|
+
with self._lock:
|
|
50
|
+
if new_key == self._active:
|
|
51
|
+
return
|
|
52
|
+
self._previous = self._active
|
|
53
|
+
self._previous_expires = datetime.now(timezone.utc) + timedelta(
|
|
54
|
+
hours=self._grace_hours
|
|
55
|
+
)
|
|
56
|
+
self._active = new_key
|
|
57
|
+
|
|
58
|
+
def verify_keys(self) -> list[str]:
|
|
59
|
+
"""Return keys to try for verification, most recent first."""
|
|
60
|
+
with self._lock:
|
|
61
|
+
keys = [self._active]
|
|
62
|
+
if self._previous and self._previous_expires:
|
|
63
|
+
if datetime.now(timezone.utc) < self._previous_expires:
|
|
64
|
+
keys.append(self._previous)
|
|
65
|
+
else:
|
|
66
|
+
self._previous = None
|
|
67
|
+
self._previous_expires = None
|
|
68
|
+
return keys
|
|
69
|
+
|
|
70
|
+
def reload_from_env(self) -> bool:
|
|
71
|
+
"""Reload active key from SECRET_KEY env var. Returns True if key changed."""
|
|
72
|
+
new_key = os.getenv("SECRET_KEY", "")
|
|
73
|
+
if not new_key or new_key == self._active:
|
|
74
|
+
return False
|
|
75
|
+
self.rotate(new_key)
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_access_token(
|
|
80
|
+
data: dict,
|
|
81
|
+
*,
|
|
82
|
+
settings: Optional[AuthSettings] = None,
|
|
83
|
+
key_ring: Optional[KeyRing] = None,
|
|
84
|
+
expires_delta: Optional[timedelta] = None,
|
|
85
|
+
token_version: int = 0,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Encode a JWT access token.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
data: Claims to encode (e.g. ``{"sub": user_id}``).
|
|
91
|
+
settings: Auth settings; defaults to ``AuthSettings()`` (reads env vars).
|
|
92
|
+
key_ring: Optional pre-configured KeyRing. When provided, the active
|
|
93
|
+
key from the ring is used instead of ``settings.SECRET_KEY``.
|
|
94
|
+
expires_delta: Token lifetime. Defaults to
|
|
95
|
+
``settings.ACCESS_TOKEN_EXPIRE_MINUTES``.
|
|
96
|
+
token_version: Stamped as ``"tv"`` claim for token invalidation.
|
|
97
|
+
"""
|
|
98
|
+
cfg = settings or AuthSettings()
|
|
99
|
+
signing_key = key_ring.active_key if key_ring is not None else cfg.SECRET_KEY
|
|
100
|
+
to_encode = dict(data)
|
|
101
|
+
to_encode["tv"] = token_version
|
|
102
|
+
expire = datetime.now(timezone.utc) + (
|
|
103
|
+
expires_delta or timedelta(minutes=cfg.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
104
|
+
)
|
|
105
|
+
to_encode["exp"] = expire
|
|
106
|
+
return _jwt.encode(to_encode, signing_key, algorithm=cfg.ALGORITHM)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def decode_access_token(
|
|
110
|
+
token: str,
|
|
111
|
+
*,
|
|
112
|
+
settings: Optional[AuthSettings] = None,
|
|
113
|
+
key_ring: Optional[KeyRing] = None,
|
|
114
|
+
) -> dict:
|
|
115
|
+
"""Decode and verify a JWT access token.
|
|
116
|
+
|
|
117
|
+
When a ``KeyRing`` is provided, all keys in the ring are tried in order
|
|
118
|
+
(active first, then previous within its grace window). This allows tokens
|
|
119
|
+
signed with a recently-rotated key to remain valid during the grace period.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
InvalidTokenError: If the token is malformed, expired, or cannot be
|
|
123
|
+
verified by any available key.
|
|
124
|
+
"""
|
|
125
|
+
cfg = settings or AuthSettings()
|
|
126
|
+
keys = key_ring.verify_keys() if key_ring is not None else [cfg.SECRET_KEY]
|
|
127
|
+
last_exc: Optional[Exception] = None
|
|
128
|
+
for key in keys:
|
|
129
|
+
try:
|
|
130
|
+
return _jwt.decode(token, key, algorithms=[cfg.ALGORITHM])
|
|
131
|
+
except JWTError as exc:
|
|
132
|
+
last_exc = exc
|
|
133
|
+
raise InvalidTokenError("Invalid or expired token") from last_exc
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Platform API key generation and hashing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
_KEY_PREFIX = "nodus_"
|
|
8
|
+
_KEY_TOKEN_BYTES = 32 # 32 bytes → 43-char url-safe base64
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hash_key(raw_key: str) -> str:
|
|
12
|
+
"""Return the SHA-256 hex digest of *raw_key*."""
|
|
13
|
+
return hashlib.sha256(raw_key.encode()).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_key(*, prefix: str = _KEY_PREFIX) -> tuple[str, str]:
|
|
17
|
+
"""Generate a new platform API key.
|
|
18
|
+
|
|
19
|
+
Returns ``(raw_key, key_hash)``. *raw_key* is the plaintext key to
|
|
20
|
+
deliver to the caller — it is never stored. *key_hash* is the SHA-256
|
|
21
|
+
hex digest to store in the database.
|
|
22
|
+
"""
|
|
23
|
+
token = secrets.token_urlsafe(_KEY_TOKEN_BYTES)
|
|
24
|
+
raw_key = f"{prefix}{token}"
|
|
25
|
+
return raw_key, hash_key(raw_key)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Password hashing and verification using bcrypt via passlib."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from passlib.context import CryptContext
|
|
5
|
+
|
|
6
|
+
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def hash_password(password: str) -> str:
|
|
10
|
+
"""Return a bcrypt hash of *password*."""
|
|
11
|
+
return _pwd_context.hash(password)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
15
|
+
"""Return True if *plain* matches the bcrypt *hashed* value."""
|
|
16
|
+
return _pwd_context.verify(plain, hashed)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Auth request/response schemas and principal types."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ── Pydantic request/response models ─────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
class LoginRequest(BaseModel):
|
|
13
|
+
email: str
|
|
14
|
+
password: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RegisterRequest(BaseModel):
|
|
18
|
+
email: str
|
|
19
|
+
password: str
|
|
20
|
+
username: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TokenResponse(BaseModel):
|
|
24
|
+
access_token: str
|
|
25
|
+
token_type: str = "bearer"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Scope constants ───────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
class Scopes:
|
|
31
|
+
"""Well-known platform scope strings."""
|
|
32
|
+
|
|
33
|
+
FLOW_READ = "flow.read"
|
|
34
|
+
FLOW_EXECUTE = "flow.execute"
|
|
35
|
+
MEMORY_READ = "memory.read"
|
|
36
|
+
MEMORY_WRITE = "memory.write"
|
|
37
|
+
AGENT_RUN = "agent.run"
|
|
38
|
+
WEBHOOK_MANAGE = "webhook.manage"
|
|
39
|
+
PLATFORM_ADMIN = "platform.admin"
|
|
40
|
+
|
|
41
|
+
ALL: list[str] = [
|
|
42
|
+
FLOW_READ,
|
|
43
|
+
FLOW_EXECUTE,
|
|
44
|
+
MEMORY_READ,
|
|
45
|
+
MEMORY_WRITE,
|
|
46
|
+
AGENT_RUN,
|
|
47
|
+
WEBHOOK_MANAGE,
|
|
48
|
+
PLATFORM_ADMIN,
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Principal dataclass ───────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AuthPrincipal:
|
|
56
|
+
"""Resolved authentication identity.
|
|
57
|
+
|
|
58
|
+
``auth_type="jwt"`` — JWT Bearer token; ``has_scope`` always returns True.
|
|
59
|
+
``auth_type="api_key"`` — Platform API key; scope-restricted.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
user_id: str
|
|
63
|
+
auth_type: str # "jwt" | "api_key"
|
|
64
|
+
scopes: list[str] = field(default_factory=list)
|
|
65
|
+
key_id: str | None = None
|
|
66
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
def has_scope(self, scope: str) -> bool:
|
|
69
|
+
"""Return True if this principal holds *scope*."""
|
|
70
|
+
if self.auth_type == "jwt":
|
|
71
|
+
return True # JWT users carry full trust
|
|
72
|
+
return scope in self.scopes or Scopes.PLATFORM_ADMIN in self.scopes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""UUID user ID parsing utilities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_user_id(value: str | uuid.UUID | None) -> uuid.UUID | None:
|
|
9
|
+
"""Parse *value* into a UUID, returning None on failure."""
|
|
10
|
+
if value is None or value == "":
|
|
11
|
+
return None
|
|
12
|
+
if isinstance(value, uuid.UUID):
|
|
13
|
+
return value
|
|
14
|
+
try:
|
|
15
|
+
return uuid.UUID(str(value))
|
|
16
|
+
except (TypeError, ValueError, AttributeError):
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_user_id(value: str | uuid.UUID | None) -> uuid.UUID:
|
|
21
|
+
"""Parse *value* into a UUID. Raises ``ValueError`` if parsing fails."""
|
|
22
|
+
parsed = parse_user_id(value)
|
|
23
|
+
if parsed is None:
|
|
24
|
+
raise ValueError("user_id is required")
|
|
25
|
+
return parsed
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_user_ids(values: Iterable[str | uuid.UUID | None]) -> list[uuid.UUID]:
|
|
29
|
+
"""Parse an iterable of values, silently skipping unparseable entries."""
|
|
30
|
+
parsed: list[uuid.UUID] = []
|
|
31
|
+
for value in values:
|
|
32
|
+
user_id = parse_user_id(value)
|
|
33
|
+
if user_id is not None:
|
|
34
|
+
parsed.append(user_id)
|
|
35
|
+
return parsed
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodus-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JWT, API key auth, bcrypt hashing, and scoped principals for AI-native platforms
|
|
5
|
+
Author: Shawn Knight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Masterplanner25/nodus-auth
|
|
8
|
+
Project-URL: Repository, https://github.com/Masterplanner25/nodus-auth
|
|
9
|
+
Keywords: auth,jwt,api-key,bcrypt,nodus
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: python-jose>=3.5.0
|
|
19
|
+
Requires-Dist: passlib>=1.7.4
|
|
20
|
+
Requires-Dist: bcrypt<5.0,>=4.0.1
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# nodus-auth
|
|
29
|
+
|
|
30
|
+
**JWT issuance/validation, API key hashing, bcrypt passwords, and scoped
|
|
31
|
+
principals for AI-native platforms.**
|
|
32
|
+
|
|
33
|
+
Standalone auth primitives — no FastAPI required. All core functions work
|
|
34
|
+
with any Python web framework or no framework at all.
|
|
35
|
+
|
|
36
|
+
> **Status:** v0.1.0 — prepared, not yet published.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install nodus-auth
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **bcrypt version note:** This package pins `bcrypt>=4.0.1,<5.0` because
|
|
47
|
+
> `passlib 1.7.4` is incompatible with bcrypt 5.x. Do not upgrade bcrypt
|
|
48
|
+
> beyond 4.x in the same environment.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## What it provides
|
|
53
|
+
|
|
54
|
+
| Component | Purpose |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `KeyRing` / `create_access_token` / `decode_access_token` | JWT issuance and verification with key rotation |
|
|
57
|
+
| `hash_password` / `verify_password` | bcrypt password hashing via passlib |
|
|
58
|
+
| `generate_key` / `hash_key` | API key generation and SHA-256 storage hash |
|
|
59
|
+
| `AuthPrincipal` / `Scopes` | Resolved identity with scope-based access control |
|
|
60
|
+
| `LoginRequest` / `RegisterRequest` / `TokenResponse` | Pydantic request/response schemas |
|
|
61
|
+
| `parse_user_id` / `require_user_id` | UUID parsing helpers |
|
|
62
|
+
| `AuthSettings` | pydantic-settings config for SECRET_KEY, ALGORITHM, expiry |
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## JWT tokens
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from nodus_auth import AuthSettings, create_access_token, decode_access_token, InvalidTokenError
|
|
70
|
+
|
|
71
|
+
settings = AuthSettings(SECRET_KEY="my-secret-key-32-chars-minimum")
|
|
72
|
+
token = create_access_token({"sub": "user-123"}, settings=settings)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
payload = decode_access_token(token, settings=settings)
|
|
76
|
+
user_id = payload["sub"]
|
|
77
|
+
except InvalidTokenError:
|
|
78
|
+
# expired, malformed, or wrong key
|
|
79
|
+
...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Key rotation
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from nodus_auth import KeyRing, create_access_token, decode_access_token, AuthSettings
|
|
86
|
+
|
|
87
|
+
ring = KeyRing(active="key-v1", grace_hours=24)
|
|
88
|
+
settings = AuthSettings(SECRET_KEY="key-v1")
|
|
89
|
+
token = create_access_token({"sub": "u1"}, settings=settings, key_ring=ring)
|
|
90
|
+
|
|
91
|
+
ring.rotate("key-v2") # tokens signed with key-v1 still verify for 24 hours
|
|
92
|
+
payload = decode_access_token(token, settings=settings, key_ring=ring) # OK
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`decode_access_token` tries the active key first, then any keys within their
|
|
96
|
+
grace period. Tokens outside their grace window raise `InvalidTokenError`.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Passwords
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from nodus_auth import hash_password, verify_password
|
|
104
|
+
|
|
105
|
+
hashed = hash_password("correct-horse-battery-staple")
|
|
106
|
+
assert verify_password("correct-horse-battery-staple", hashed)
|
|
107
|
+
assert not verify_password("wrong-password", hashed)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## API keys
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from nodus_auth import generate_key, hash_key
|
|
116
|
+
|
|
117
|
+
raw_key, key_hash = generate_key()
|
|
118
|
+
# Deliver raw_key to the caller once; store key_hash in the database
|
|
119
|
+
assert hash_key(raw_key) == key_hash
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`generate_key()` uses `secrets.token_urlsafe`. The raw key is never stored;
|
|
123
|
+
the SHA-256 hash is used for all comparisons.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Scoped principals
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from nodus_auth import AuthPrincipal, Scopes
|
|
131
|
+
|
|
132
|
+
principal = AuthPrincipal(
|
|
133
|
+
user_id="user-123",
|
|
134
|
+
auth_type="api_key",
|
|
135
|
+
scopes=[Scopes.MEMORY_READ, Scopes.FLOW_EXECUTE],
|
|
136
|
+
)
|
|
137
|
+
assert principal.has_scope(Scopes.FLOW_EXECUTE)
|
|
138
|
+
assert not principal.has_scope(Scopes.PLATFORM_ADMIN)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`auth_type` is `"jwt"` or `"api_key"`. `Scopes` provides well-known constants:
|
|
142
|
+
`MEMORY_READ`, `MEMORY_WRITE`, `FLOW_EXECUTE`, `FLOW_CREATE`, `PLATFORM_ADMIN`.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## AuthSettings
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from nodus_auth import AuthSettings
|
|
150
|
+
|
|
151
|
+
# Reads from environment variables: SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
|
152
|
+
settings = AuthSettings()
|
|
153
|
+
|
|
154
|
+
# Or explicit:
|
|
155
|
+
settings = AuthSettings(
|
|
156
|
+
SECRET_KEY="...",
|
|
157
|
+
ALGORITHM="HS256",
|
|
158
|
+
ACCESS_TOKEN_EXPIRE_MINUTES=60,
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## User ID helpers
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from nodus_auth import parse_user_id, require_user_id, parse_user_ids
|
|
168
|
+
import uuid
|
|
169
|
+
|
|
170
|
+
parse_user_id("550e8400-e29b-41d4-a716-446655440000") # → UUID
|
|
171
|
+
parse_user_id(None) # → None
|
|
172
|
+
require_user_id("not-a-uuid") # raises ValueError
|
|
173
|
+
parse_user_ids(["uid-1", "bad", "uid-2"]) # → [UUID, UUID] (skips failures)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Dependencies
|
|
179
|
+
|
|
180
|
+
| Package | Version | Purpose |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `python-jose` | ≥3.5.0 | JWT encoding/decoding |
|
|
183
|
+
| `passlib` | ≥1.7.4 | bcrypt password context |
|
|
184
|
+
| `bcrypt` | ≥4.0.1,<5.0 | bcrypt backend (passlib 1.7.4 incompatible with 5.x) |
|
|
185
|
+
| `pydantic` | ≥2.0.0 | Schemas |
|
|
186
|
+
| `pydantic-settings` | ≥2.0.0 | `AuthSettings` from env vars |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pip install -e ".[dev]"
|
|
194
|
+
pytest tests/ -q
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
nodus_auth/__init__.py
|
|
5
|
+
nodus_auth/config.py
|
|
6
|
+
nodus_auth/jwt.py
|
|
7
|
+
nodus_auth/keys.py
|
|
8
|
+
nodus_auth/password.py
|
|
9
|
+
nodus_auth/schemas.py
|
|
10
|
+
nodus_auth/user_ids.py
|
|
11
|
+
nodus_auth.egg-info/PKG-INFO
|
|
12
|
+
nodus_auth.egg-info/SOURCES.txt
|
|
13
|
+
nodus_auth.egg-info/dependency_links.txt
|
|
14
|
+
nodus_auth.egg-info/requires.txt
|
|
15
|
+
nodus_auth.egg-info/top_level.txt
|
|
16
|
+
tests/test_jwt.py
|
|
17
|
+
tests/test_keys.py
|
|
18
|
+
tests/test_password.py
|
|
19
|
+
tests/test_schemas.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodus_auth
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nodus-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "JWT, API key auth, bcrypt hashing, and scoped principals for AI-native platforms"
|
|
9
|
+
authors = [{ name = "Shawn Knight" }]
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
keywords = ["auth", "jwt", "api-key", "bcrypt", "nodus"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"python-jose>=3.5.0",
|
|
23
|
+
"passlib>=1.7.4",
|
|
24
|
+
"bcrypt>=4.0.1,<5.0",
|
|
25
|
+
"pydantic>=2.0.0",
|
|
26
|
+
"pydantic-settings>=2.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=8.0", "pytest-mock>=3.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/Masterplanner25/nodus-auth"
|
|
34
|
+
Repository = "https://github.com/Masterplanner25/nodus-auth"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["."]
|
|
38
|
+
include = ["nodus_auth*"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from nodus_auth import (
|
|
9
|
+
AuthSettings,
|
|
10
|
+
InvalidTokenError,
|
|
11
|
+
KeyRing,
|
|
12
|
+
create_access_token,
|
|
13
|
+
decode_access_token,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def settings():
|
|
19
|
+
return AuthSettings(SECRET_KEY="test-secret-key")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── create / decode round-trip ────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def test_round_trip(settings):
|
|
25
|
+
token = create_access_token({"sub": "user-1"}, settings=settings)
|
|
26
|
+
payload = decode_access_token(token, settings=settings)
|
|
27
|
+
assert payload["sub"] == "user-1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_token_version_in_claims(settings):
|
|
31
|
+
token = create_access_token({"sub": "u1"}, settings=settings, token_version=7)
|
|
32
|
+
payload = decode_access_token(token, settings=settings)
|
|
33
|
+
assert payload["tv"] == 7
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_expired_token_raises(settings):
|
|
37
|
+
token = create_access_token(
|
|
38
|
+
{"sub": "u1"}, settings=settings, expires_delta=timedelta(seconds=-1)
|
|
39
|
+
)
|
|
40
|
+
with pytest.raises(InvalidTokenError):
|
|
41
|
+
decode_access_token(token, settings=settings)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_wrong_key_raises(settings):
|
|
45
|
+
token = create_access_token({"sub": "u1"}, settings=settings)
|
|
46
|
+
wrong = AuthSettings(SECRET_KEY="completely-different-key")
|
|
47
|
+
with pytest.raises(InvalidTokenError):
|
|
48
|
+
decode_access_token(token, settings=wrong)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_malformed_token_raises(settings):
|
|
52
|
+
with pytest.raises(InvalidTokenError):
|
|
53
|
+
decode_access_token("not.a.jwt", settings=settings)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── KeyRing rotation ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def test_key_ring_round_trip(settings):
|
|
59
|
+
ring = KeyRing(active=settings.SECRET_KEY)
|
|
60
|
+
token = create_access_token({"sub": "u1"}, settings=settings, key_ring=ring)
|
|
61
|
+
payload = decode_access_token(token, settings=settings, key_ring=ring)
|
|
62
|
+
assert payload["sub"] == "u1"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_key_ring_grace_period(settings):
|
|
66
|
+
ring = KeyRing(active=settings.SECRET_KEY, grace_hours=1)
|
|
67
|
+
# Mint token with old key
|
|
68
|
+
token = create_access_token({"sub": "u1"}, settings=settings, key_ring=ring)
|
|
69
|
+
# Rotate to new key
|
|
70
|
+
ring.rotate("new-secret-key")
|
|
71
|
+
# Token signed with old key still verifiable during grace period
|
|
72
|
+
payload = decode_access_token(token, settings=settings, key_ring=ring)
|
|
73
|
+
assert payload["sub"] == "u1"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_key_ring_expired_previous_key_rejected():
|
|
77
|
+
ring = KeyRing(active="key-A", grace_hours=0)
|
|
78
|
+
cfg = AuthSettings(SECRET_KEY="key-A")
|
|
79
|
+
token = create_access_token({"sub": "u1"}, settings=cfg, key_ring=ring)
|
|
80
|
+
ring.rotate("key-B")
|
|
81
|
+
# grace_hours=0 means previous expires immediately; wait for it
|
|
82
|
+
time.sleep(0.01)
|
|
83
|
+
cfg_b = AuthSettings(SECRET_KEY="key-B")
|
|
84
|
+
with pytest.raises(InvalidTokenError):
|
|
85
|
+
decode_access_token(token, settings=cfg_b, key_ring=ring)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_key_ring_active_key_property(settings):
|
|
89
|
+
ring = KeyRing(active=settings.SECRET_KEY)
|
|
90
|
+
assert ring.active_key == settings.SECRET_KEY
|
|
91
|
+
ring.rotate("new-key")
|
|
92
|
+
assert ring.active_key == "new-key"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_key_ring_rotate_noop_on_same_key(settings):
|
|
96
|
+
ring = KeyRing(active=settings.SECRET_KEY)
|
|
97
|
+
ring.rotate(settings.SECRET_KEY)
|
|
98
|
+
assert ring.active_key == settings.SECRET_KEY
|
|
99
|
+
assert ring._previous is None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from nodus_auth import generate_key, hash_key
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_hash_key_is_deterministic():
|
|
5
|
+
assert hash_key("abc") == hash_key("abc")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_hash_key_differs_for_different_inputs():
|
|
9
|
+
assert hash_key("abc") != hash_key("xyz")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_hash_key_is_hex_64_chars():
|
|
13
|
+
h = hash_key("test")
|
|
14
|
+
assert len(h) == 64
|
|
15
|
+
int(h, 16) # must be valid hex
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_generate_key_returns_tuple():
|
|
19
|
+
raw, hashed = generate_key()
|
|
20
|
+
assert isinstance(raw, str)
|
|
21
|
+
assert isinstance(hashed, str)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_generate_key_hash_matches_raw():
|
|
25
|
+
raw, hashed = generate_key()
|
|
26
|
+
assert hash_key(raw) == hashed
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_generate_key_default_prefix():
|
|
30
|
+
raw, _ = generate_key()
|
|
31
|
+
assert raw.startswith("nodus_")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_generate_key_custom_prefix():
|
|
35
|
+
raw, _ = generate_key(prefix="myapp_")
|
|
36
|
+
assert raw.startswith("myapp_")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_generate_key_unique():
|
|
40
|
+
raw1, _ = generate_key()
|
|
41
|
+
raw2, _ = generate_key()
|
|
42
|
+
assert raw1 != raw2
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from nodus_auth import hash_password, verify_password
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_hash_is_not_plaintext():
|
|
5
|
+
h = hash_password("secret")
|
|
6
|
+
assert h != "secret"
|
|
7
|
+
assert h.startswith("$2b$")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_verify_correct_password():
|
|
11
|
+
h = hash_password("correct-horse")
|
|
12
|
+
assert verify_password("correct-horse", h) is True
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_verify_wrong_password():
|
|
16
|
+
h = hash_password("correct-horse")
|
|
17
|
+
assert verify_password("wrong-horse", h) is False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_different_hashes_for_same_password():
|
|
21
|
+
h1 = hash_password("same")
|
|
22
|
+
h2 = hash_password("same")
|
|
23
|
+
# bcrypt uses a random salt
|
|
24
|
+
assert h1 != h2
|
|
25
|
+
assert verify_password("same", h1) is True
|
|
26
|
+
assert verify_password("same", h2) is True
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from nodus_auth import (
|
|
6
|
+
AuthPrincipal,
|
|
7
|
+
LoginRequest,
|
|
8
|
+
RegisterRequest,
|
|
9
|
+
Scopes,
|
|
10
|
+
TokenResponse,
|
|
11
|
+
parse_user_id,
|
|
12
|
+
parse_user_ids,
|
|
13
|
+
require_user_id,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
def test_login_request():
|
|
20
|
+
req = LoginRequest(email="a@b.com", password="pw")
|
|
21
|
+
assert req.email == "a@b.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_register_request_optional_username():
|
|
25
|
+
req = RegisterRequest(email="a@b.com", password="pw")
|
|
26
|
+
assert req.username is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_token_response_default_type():
|
|
30
|
+
r = TokenResponse(access_token="tok")
|
|
31
|
+
assert r.token_type == "bearer"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Scopes ────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def test_scopes_all_contains_all_constants():
|
|
37
|
+
assert Scopes.PLATFORM_ADMIN in Scopes.ALL
|
|
38
|
+
assert Scopes.FLOW_EXECUTE in Scopes.ALL
|
|
39
|
+
assert len(Scopes.ALL) == 7
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── AuthPrincipal ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def test_jwt_principal_has_all_scopes():
|
|
45
|
+
p = AuthPrincipal(user_id="u1", auth_type="jwt")
|
|
46
|
+
assert p.has_scope(Scopes.FLOW_READ) is True
|
|
47
|
+
assert p.has_scope("anything") is True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_api_key_principal_scope_check():
|
|
51
|
+
p = AuthPrincipal(user_id="u1", auth_type="api_key", scopes=[Scopes.MEMORY_READ])
|
|
52
|
+
assert p.has_scope(Scopes.MEMORY_READ) is True
|
|
53
|
+
assert p.has_scope(Scopes.FLOW_EXECUTE) is False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_api_key_with_platform_admin_has_all_scopes():
|
|
57
|
+
p = AuthPrincipal(user_id="u1", auth_type="api_key", scopes=[Scopes.PLATFORM_ADMIN])
|
|
58
|
+
assert p.has_scope(Scopes.FLOW_EXECUTE) is True
|
|
59
|
+
assert p.has_scope(Scopes.MEMORY_WRITE) is True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── user_ids ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def test_parse_user_id_from_string():
|
|
65
|
+
uid = uuid.uuid4()
|
|
66
|
+
assert parse_user_id(str(uid)) == uid
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_parse_user_id_from_uuid():
|
|
70
|
+
uid = uuid.uuid4()
|
|
71
|
+
assert parse_user_id(uid) is uid
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_parse_user_id_none():
|
|
75
|
+
assert parse_user_id(None) is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_parse_user_id_empty():
|
|
79
|
+
assert parse_user_id("") is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_parse_user_id_invalid():
|
|
83
|
+
assert parse_user_id("not-a-uuid") is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_require_user_id_raises_on_invalid():
|
|
87
|
+
with pytest.raises(ValueError):
|
|
88
|
+
require_user_id("bad")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_parse_user_ids_skips_invalid():
|
|
92
|
+
uid = uuid.uuid4()
|
|
93
|
+
result = parse_user_ids([str(uid), "bad", None, uid])
|
|
94
|
+
assert result == [uid, uid]
|