Skip to content

Repository files navigation


lauren-guards: batteries-included authentication & authorization guards for the lauren web framework.

CI Package version Supported Python versions License Ruff


Documentation: https://lauren-framework.github.io/lauren-guards/

Source Code: https://github.com/lauren-framework/lauren-guards


lauren-guards is an authentication and authorization add-on for the lauren 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 ≥ 1.0.0.

Installation

$ pip install lauren-guards

Optional extras for heavier dependencies:

$ 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:

Example

Create it

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

$ 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

$ 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

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

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

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

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

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:

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):

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:

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:

@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

$ 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.

About

lauren-guards: batteries-included authentication and authorization guards for the lauren web framework.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages