Metadata-Version: 2.4
Name: lauren-guards
Version: 0.1.1
Summary: Batteries-included authentication and authorization guards for the Lauren web framework — Basic, Bearer, JWT, OAuth2 introspection, API key, session cookie, role/scope authorization, CSRF, IP allowlist.
Author: Platform Engineering Team
License: MIT
Project-URL: Documentation, https://lauren-framework.github.io/lauren-guards/
Project-URL: Source, https://github.com/lauren-framework/lauren-guards
Project-URL: Changelog, https://github.com/lauren-framework/lauren-guards/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/lauren-framework/lauren-guards/issues
Keywords: lauren,guards,authentication,authorization,auth,jwt,oauth2,csrf,rbac
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: lauren>=1.0.0
Provides-Extra: bcrypt
Requires-Dist: bcrypt>=4.0; extra == "bcrypt"
Provides-Extra: argon2
Requires-Dist: argon2-cffi>=23.0; extra == "argon2"
Provides-Extra: jwt
Requires-Dist: pyjwt>=2.8; extra == "jwt"
Requires-Dist: cryptography>=41.0; extra == "jwt"
Provides-Extra: http
Requires-Dist: httpx>=0.27; extra == "http"
Provides-Extra: all
Requires-Dist: bcrypt>=4.0; extra == "all"
Requires-Dist: argon2-cffi>=23.0; extra == "all"
Requires-Dist: pyjwt>=2.8; extra == "all"
Requires-Dist: cryptography>=41.0; extra == "all"
Requires-Dist: httpx>=0.27; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: bcrypt>=4.0; extra == "dev"
Requires-Dist: argon2-cffi>=23.0; extra == "dev"
Requires-Dist: pyjwt>=2.8; extra == "dev"
Requires-Dist: cryptography>=41.0; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Dynamic: license-file

<p align="center">
    <em>lauren-guards: batteries-included authentication &amp; authorization guards for the <a href="https://py-lauren.dev">lauren</a> web framework.</em>
</p>
<p align="center">
<a href="https://github.com/lauren-framework/lauren-guards/actions/workflows/ci.yml">
    <img src="https://github.com/lauren-framework/lauren-guards/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI">
</a>
<a href="https://pypi.org/project/lauren-guards">
    <img src="https://img.shields.io/pypi/v/lauren-guards?color=%2334D058&label=pypi%20package" alt="Package version">
</a>
<a href="https://pypi.org/project/lauren-guards">
    <img src="https://img.shields.io/pypi/pyversions/lauren-guards.svg?color=%2334D058" alt="Supported Python versions">
</a>
<a href="https://github.com/lauren-framework/lauren-guards/blob/main/LICENSE">
    <img src="https://img.shields.io/github/license/lauren-framework/lauren-guards.svg?color=%2334D058" alt="License">
</a>
<a href="https://github.com/astral-sh/ruff">
    <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
</a>
</p>

---

**Documentation**: <a href="https://lauren-framework.github.io/lauren-guards/" target="_blank">https://lauren-framework.github.io/lauren-guards/</a>

**Source Code**: <a href="https://github.com/lauren-framework/lauren-guards" target="_blank">https://github.com/lauren-framework/lauren-guards</a>

---

lauren-guards is an authentication and authorization add-on for the
[lauren](https://py-lauren.dev) Python web framework. Every guard is a factory function
that returns a class satisfying `lauren.GuardProtocol` — drop the result directly into
`@use_guards(...)` and lauren's startup validator checks the wiring before the first
request.

The key features are:

* **Six authentication guards**: HTTP Basic, Bearer Token, API Key, JWT (HS / RS / ES +
  JWKS auto-rotation), OAuth 2.0 Introspection (RFC 7662), Session Cookie.
* **Three authorization guards**: `require_authenticated`, `require_roles`,
  `require_scopes`.
* **Two cross-cutting guards**: CSRF (double-submit cookie), IP allowlist (CIDR ranges +
  optional trusted-proxy `X-Forwarded-For`).
* **Password utilities**: `BcryptHasher`, `Argon2Hasher`, and `generate_token()` for
  cryptographically-secure random IDs.
* **Sessions**: `InMemorySessionStore` + a `SessionStore` protocol for plugging in Redis
  or Postgres in production.
* **`@public` decorator**: opt individual routes out of guard protection without
  changing the controller or guard configuration.
* **Startup-validated**: all factories are decorated with `@injectable(scope=SINGLETON)`
  so misconfigurations fail at `LaurenFactory.create(...)`, not at runtime.

## Requirements

Python **3.11**, **3.12**, **3.13**, and **3.14** are supported. Requires
[lauren](https://pypi.org/project/lauren) ≥ 1.0.0.

## Installation

```console
$ pip install lauren-guards
```

Optional extras for heavier dependencies:

```console
$ pip install "lauren-guards[jwt]"     # adds PyJWT + cryptography (jwt_bearer)
$ pip install "lauren-guards[http]"    # adds httpx (oauth2_introspection, JWKS URL)
$ pip install "lauren-guards[bcrypt]"  # adds bcrypt (BcryptHasher)
$ pip install "lauren-guards[argon2]"  # adds argon2-cffi (Argon2Hasher)
$ pip install "lauren-guards[all]"     # all of the above
```

## Documentation

The full documentation is published to GitHub Pages and covers every guard, the
principal record, sessions, password hashing, and the complete API reference:

- **Home** — [https://lauren-framework.github.io/lauren-guards/](https://lauren-framework.github.io/lauren-guards/)
- **Getting Started** — [https://lauren-framework.github.io/lauren-guards/getting-started/](https://lauren-framework.github.io/lauren-guards/getting-started/)
- **Reference** — [https://lauren-framework.github.io/lauren-guards/reference/](https://lauren-framework.github.io/lauren-guards/reference/)

## Example

### Create it

```python
from lauren import LaurenFactory, controller, get, post, module, use_guards, Json
from lauren_guards import AuthUser, bearer_token, require_roles, require_scopes, public


async def verify_token(token: str) -> AuthUser | None:
    # Replace with a real database / cache lookup.
    if token == "good-token":
        return AuthUser(id="u-42", roles=("user",), scopes=("items.read", "items.write"))
    return None


BearerGuard = bearer_token(verify=verify_token)


@use_guards(BearerGuard)
@controller("/items")
class ItemController:
    @get("/")
    @use_guards(require_scopes("items.read"))
    async def list_items(self) -> dict:
        return {"items": []}

    @post("/")
    @use_guards(require_scopes("items.write"))
    async def create_item(self) -> dict:
        return {"created": True}, 201

    @get("/admin")
    @use_guards(require_roles("admin"))
    async def admin_view(self) -> dict:
        return {"access": "granted"}

    @get("/status")
    @public                            # exempt from BearerGuard
    async def status(self) -> dict:
        return {"status": "ok"}


@module(controllers=[ItemController])
class AppModule:
    pass


app = LaurenFactory.create(AppModule, docs_url="/docs")
```

### Run it

```console
$ uvicorn main:app --reload

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     [lauren] startup complete: 1 module, 1 controller, 4 routes
```

### Check it

```console
$ curl http://127.0.0.1:8000/items/status
{"status": "ok"}

$ curl http://127.0.0.1:8000/items/
{"detail": "Unauthorized"}    # 401 — missing token

$ curl http://127.0.0.1:8000/items/ -H "Authorization: Bearer good-token"
{"items": []}                  # 200 — authenticated
```

## Guard catalog

| Guard | Use when… | Extras |
|---|---|---|
| `bearer_token` | Opaque server-issued tokens (sessions, API tokens). | — |
| `basic_auth` | Simple admin endpoints protected by username/password. | — |
| `api_key` | Service-to-service API keys via header or query param. | — |
| `jwt_bearer` | Self-contained JWTs (HS/RS/ES + JWKS auto-rotation). | `[jwt]` |
| `oauth2_introspection` | Opaque OAuth 2.0 tokens validated via RFC 7662. | `[http]` |
| `session_cookie` | Browser sessions backed by `SessionStore`. | — |
| `require_authenticated` | Any handler that just needs *somebody* logged in. | — |
| `require_roles` | RBAC: gate by named roles (`admin`, `ops`, …). | — |
| `require_scopes` | OAuth-style scopes (`items.read`, `users.write`). | — |
| `csrf` | State-changing endpoints behind cookie auth. | — |
| `ip_allowlist` | Internal endpoints behind a known proxy / VPN. | — |

## Examples

### JWT Bearer with JWKS rotation

```python
from lauren_guards import jwt_bearer

# Symmetric HMAC (HS256) — for services that share a secret.
HsGuard = jwt_bearer(secret="super-secret", algorithms=["HS256"])

# Asymmetric with auto-fetched JWKS (Auth0, Cognito, Keycloak, etc.).
RsGuard = jwt_bearer(
    jwks_url="https://example.auth0.com/.well-known/jwks.json",
    algorithms=["RS256"],
    issuer="https://example.auth0.com/",
    audience="https://api.example.com",
    jwks_cache_seconds=300,
)
```

`jwt_bearer` accepts exactly **one** of `secret=`, `public_key=`, or `jwks_url=`; pass
`algorithms=` (default `("HS256",)`) to restrict which signing algorithms the guard
accepts. Roles and scopes are extracted from configurable claims (`role_claim=`,
`scope_claim=` — a string key or a callable for nested paths like Keycloak's
`realm_access.roles`).

### HTTP Basic with `WWW-Authenticate`

```python
from lauren import LaurenFactory
from lauren_guards import AuthUser, basic_auth, basic_auth_challenge_handler, BcryptHasher

hasher = BcryptHasher()


async def verify(username: str, password: str) -> AuthUser | None:
    user = await db.find_user(username)
    if user is None or not hasher.verify(password, user.password_hash):
        return None
    return AuthUser(id=user.id, roles=user.roles)


BasicGuard = basic_auth(verify=verify)

app = LaurenFactory.create(
    AppModule,
    global_exception_handlers=[basic_auth_challenge_handler],
)
```

`basic_auth_challenge_handler` attaches `WWW-Authenticate: Basic realm="..."` to every
`401` response so browsers show the native credential dialog. The realm advertised is the
one configured on the guard that produced the 401.

### Session cookies

```python
from lauren import Response
from lauren_guards import InMemorySessionStore, session_cookie, sign_cookie

store = InMemorySessionStore()
SESSION_SECRET = "my-signing-secret"

SessGuard = session_cookie(store=store, secret=SESSION_SECRET)


# In a login handler — create the session and set the cookie.
async def login(username: str) -> Response:
    session = await store.create(user_id=username, data={"roles": ("user",)}, ttl_seconds=3600)
    signed = sign_cookie(session.id, secret=SESSION_SECRET)
    return Response.json({"ok": True}).with_cookie(
        "lauren_session", signed,
        http_only=True, secure=True, same_site="lax",
    )
```

`store.create(...)` returns a `Session` record — use `session.id` when signing the cookie.
`session_cookie` verifies the HMAC signature on every request before asking the store for
the session, so a client can't forge a session id without the server-side `secret`.
Swap `InMemorySessionStore` for a Redis-backed implementation in multi-worker production
by implementing the three-method `SessionStore` protocol (`create`, `get`, `delete`).

### CSRF protection

```python
from lauren_guards import csrf

CsrfGuard = csrf(cookie_name="csrf_token", header_name="x-csrf-token")
```

Double-submit-cookie pattern: the server issues a token in a cookie; the client must
echo it as a header on state-changing requests. Mismatched or absent pairs are rejected.
`GET`, `HEAD`, `OPTIONS`, and `TRACE` are exempt by default (override with
`safe_methods=`); guard state-changing methods behind cookie auth with this.

### IP allowlist

```python
from lauren_guards import ip_allowlist

InternalGuard = ip_allowlist(
    allow=["10.0.0.0/8", "192.168.1.0/24"],
)
```

`allow` (keyword-only) accepts CIDR strings; bare host IPs are auto-promoted to `/32` or
`/128`. Behind a load balancer, pass `trusted_proxies=[...]` (the proxy's CIDR ranges) so
the guard walks `X-Forwarded-For` and uses the first untrusted hop as the client IP —
without it, the guard silently uses the direct ASGI peer.

### `@public` routes

Opt individual routes out of a controller-level guard without changing the guard or
controller configuration:

```python
from lauren_guards import public


@use_guards(BearerGuard)
@controller("/api")
class ApiController:
    @get("/status")
    @public                    # exempt — no token needed
    async def health(self) -> dict:
        return {"status": "ok"}

    @get("/profile")
    async def profile(self) -> dict:   # requires token
        return {"user": "..."}
```

`@public` marks the handler with the `IS_PUBLIC_KEY` metadata flag and swaps in a
`NullGuard` that always allows; guards that cooperate with the flag (via
`ctx.get_metadata(IS_PUBLIC_KEY, False)`) skip authentication on that route.

## The `AuthUser` record

Every authentication guard writes an `AuthUser` to `request.state.user`. Authorization
guards read it. It is a `slots` dataclass (mutable):

```python
from dataclasses import dataclass, field
from typing import Any


@dataclass(slots=True)
class AuthUser:
    id: str                              # stable principal identifier
    roles: tuple[str, ...] = ()          # RBAC role strings
    scopes: tuple[str, ...] = ()         # OAuth-style scope strings
    claims: dict[str, Any] = field(default_factory=dict)  # full credential payload
    credential_type: str = "unknown"     # "bearer" | "jwt" | "basic" | …
```

Read the authenticated user in any handler:

```python
from lauren import Request


@get("/me")
async def me(self, request: Request) -> dict:
    user = request.state.user
    return {"id": user.id, "roles": list(user.roles)}
```

## Guard composition

Guards run in the order they appear in `@use_guards(...)`, outermost first — and
class-level guards always run before route-level ones:

```python
@use_guards(jwt_bearer(secret="..."), require_scopes("admin"))
@controller("/admin")
class AdminController:
    @get("/users")
    @use_guards(ip_allowlist(allow=["10.0.0.0/8"]))
    async def list_users(self) -> dict: ...
```

First-listed guards run first (outermost); class-level guards always run before route-level
ones. The effective chain for `GET /admin/users` is:

1. `jwt_bearer` — validates the JWT and populates `request.state.user`
2. `require_scopes("admin")` — checks the user's scopes
3. `ip_allowlist` — checks the source IP

The rule of thumb for what a guard should do on failure:

* **Missing or malformed credential** → raise `UnauthorizedError` (401)
* **Authenticated but not permitted** → raise `ForbiddenError` (403)

All guards in this package raise these errors — they never silently return `False`,
because lauren treats a `False` return as a generic rejection without the structured
`detail` payload that `UnauthorizedError` / `ForbiddenError` carry.

## Development

```console
$ uv tool install prek      # one-time
$ prek install              # wires up the git hook
$ nox                       # lint + tests (166 passing) + typecheck
```

## License

This project is licensed under the terms of the [MIT license](LICENSE).
