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.
@@ -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,9 @@
1
+ python-jose>=3.5.0
2
+ passlib>=1.7.4
3
+ bcrypt<5.0,>=4.0.1
4
+ pydantic>=2.0.0
5
+ pydantic-settings>=2.0.0
6
+
7
+ [dev]
8
+ pytest>=8.0
9
+ pytest-mock>=3.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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]