Metadata-Version: 2.4
Name: nodus-auth
Version: 0.2.0
Summary: JWT, API key auth, bcrypt hashing, and scoped principals for AI-native platforms
Author: Shawn Knight
License: MIT
Project-URL: Homepage, https://github.com/Masterplanner25/nodus-auth
Project-URL: Repository, https://github.com/Masterplanner25/nodus-auth
Keywords: auth,jwt,api-key,bcrypt,nodus
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PyJWT>=2.15.1
Requires-Dist: passlib>=1.7.4
Requires-Dist: bcrypt<5.0,>=4.0.1
Requires-Dist: pydantic>=2.0.0
Requires-Dist: pydantic-settings>=2.14.2
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-mock>=3.0; extra == "dev"
Requires-Dist: cryptography>=3.4.0; extra == "dev"
Dynamic: license-file

# nodus-auth

**JWT issuance/validation, API key hashing, bcrypt passwords, and scoped
principals for AI-native platforms.**

Standalone auth primitives — no FastAPI required. All core functions work
with any Python web framework or no framework at all.

> **Status:** v0.2.0 — published on [PyPI](https://pypi.org/project/nodus-auth/).
>
> **0.1.x is not supported.** It signed and verified through `python-jose`,
> which carries an unfixed critical advisory (CVE-2026-85394). 0.2.0 moves to
> `PyJWT>=2.15.1` and refuses asymmetric key material under an HMAC
> algorithm — see [SECURITY.md](SECURITY.md).

---

## Install

```bash
pip install nodus-auth
```

> **bcrypt version note:** This package pins `bcrypt>=4.0.1,<5.0` because
> `passlib 1.7.4` is incompatible with bcrypt 5.x. Do not upgrade bcrypt
> beyond 4.x in the same environment.

---

## What it provides

| Component | Purpose |
|---|---|
| `KeyRing` / `create_access_token` / `decode_access_token` | JWT issuance and verification with key rotation |
| `hash_password` / `verify_password` | bcrypt password hashing via passlib |
| `generate_key` / `hash_key` | API key generation and SHA-256 storage hash |
| `AuthPrincipal` / `Scopes` | Resolved identity with scope-based access control |
| `LoginRequest` / `RegisterRequest` / `TokenResponse` | Pydantic request/response schemas |
| `parse_user_id` / `require_user_id` | UUID parsing helpers |
| `AuthSettings` | pydantic-settings config for SECRET_KEY, ALGORITHM, expiry |

---

## JWT tokens

```python
from nodus_auth import AuthSettings, create_access_token, decode_access_token, InvalidTokenError

settings = AuthSettings(SECRET_KEY="my-secret-key-at-least-32-bytes-long")
token = create_access_token({"sub": "user-123"}, settings=settings)

try:
    payload = decode_access_token(token, settings=settings)
    user_id = payload["sub"]
except InvalidTokenError:
    # expired, malformed, or wrong key
    ...
```

### Errors

Every error this package raises subclasses `AuthError`, so one `except` clause
covers it and you never have to name a type from the JWT library underneath.

| Error | When | Suggested response |
|---|---|---|
| `InvalidTokenError` | expired, malformed, or signature failed | 401 |
| `TokenCreationError` | the token could not be signed — e.g. an unsupported `ALGORITHM` | 500; it is your configuration |
| `InsecureKeyError` | asymmetric key material supplied for an HMAC algorithm | 500, and fix it before serving — see [SECURITY.md](SECURITY.md) |

`TokenCreationError` and `InsecureKeyError` are deliberately **not**
`InvalidTokenError`: both mean the server is misconfigured, and answering 401
would blame the client for an operator's mistake. `InsecureKeyError` in
particular means every token is forgeable, so it must not be absorbed by a 401
path.

### Key rotation

```python
from nodus_auth import KeyRing, create_access_token, decode_access_token, AuthSettings

key_v1 = "first-signing-key-at-least-32-bytes-long"
key_v2 = "second-signing-key-at-least-32-bytes-long"

ring = KeyRing(active=key_v1, grace_hours=24)
settings = AuthSettings(SECRET_KEY=key_v1)
token = create_access_token({"sub": "u1"}, settings=settings, key_ring=ring)

ring.rotate(key_v2)  # tokens signed with key_v1 still verify for 24 hours
payload = decode_access_token(token, settings=settings, key_ring=ring)  # OK
```

`decode_access_token` tries the active key first, then any keys within their
grace period. Tokens outside their grace window raise `InvalidTokenError`.

---

## Passwords

```python
from nodus_auth import hash_password, verify_password

hashed = hash_password("correct-horse-battery-staple")
assert verify_password("correct-horse-battery-staple", hashed)
assert not verify_password("wrong-password", hashed)
```

---

## API keys

```python
from nodus_auth import generate_key, hash_key

raw_key, key_hash = generate_key()
# Deliver raw_key to the caller once; store key_hash in the database
assert hash_key(raw_key) == key_hash
```

`generate_key()` uses `secrets.token_urlsafe`. The raw key is never stored;
the SHA-256 hash is used for all comparisons.

---

## Scoped principals

```python
from nodus_auth import AuthPrincipal, Scopes

principal = AuthPrincipal(
    user_id="user-123",
    auth_type="api_key",
    scopes=[Scopes.MEMORY_READ, Scopes.FLOW_EXECUTE],
)
assert principal.has_scope(Scopes.FLOW_EXECUTE)
assert not principal.has_scope(Scopes.PLATFORM_ADMIN)
```

`auth_type` is `"jwt"` or `"api_key"`. `Scopes` provides well-known constants:
`MEMORY_READ`, `MEMORY_WRITE`, `FLOW_EXECUTE`, `FLOW_CREATE`, `PLATFORM_ADMIN`.

---

## AuthSettings

```python
from nodus_auth import AuthSettings

# Reads from environment variables: SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
settings = AuthSettings()

# Or explicit:
settings = AuthSettings(
    SECRET_KEY="...",
    ALGORITHM="HS256",
    ACCESS_TOKEN_EXPIRE_MINUTES=60,
)
```

---

## User ID helpers

```python
from nodus_auth import parse_user_id, require_user_id, parse_user_ids
import uuid

parse_user_id("550e8400-e29b-41d4-a716-446655440000")  # → UUID
parse_user_id(None)                                     # → None
require_user_id("not-a-uuid")                           # raises ValueError
parse_user_ids(["uid-1", "bad", "uid-2"])              # → [UUID, UUID]  (skips failures)
```

---

## Dependencies

| Package | Version | Purpose |
|---|---|---|
| `PyJWT` | ≥2.15.1 | JWT encoding/decoding (floor is security-relevant — see SECURITY.md) |
| `passlib` | ≥1.7.4 | bcrypt password context |
| `bcrypt` | ≥4.0.1,<5.0 | bcrypt backend (passlib 1.7.4 incompatible with 5.x) |
| `pydantic` | ≥2.0.0 | Schemas |
| `pydantic-settings` | ≥2.14.2 | `AuthSettings` from env vars |

---

## Development

```bash
pip install -e ".[dev]"
pytest tests/ -q
```

---

## License

MIT — see [LICENSE](LICENSE).
