09 - Authentication & Context System

Version: 0.2.0 Last Updated: 2026-06-28 Status: Draft - Design Decisions

This document captures the architectural decisions for authentication and context management in genro-asgi.

Overview

genro-asgi authenticates requests from the HTTP Authorization header against credentials declared in the configuration (bearer, basic, jwt). The same logic backs two entry points: BasicAuthMixin (composed into AsgiServer) and the standalone AuthMiddleware (registered under the name auth). Authorization (deciding whether a resolved handler is allowed) is delegated to genro-routes via router.node(...) filtering on auth_tags, channel, and env_capabilities. Browser-style cookie identity is a separate concern handled by SessionMiddleware, which populates scope["session"] — it is not part of the auth result.

Architecture Layers

┌─────────────────────────────────────────────────────────────┐
│                     REQUEST FLOW                             │
├─────────────────────────────────────────────────────────────┤
│  HTTP / WebSocket Request                                    │
│       ↓                                                      │
│  (optional) SessionMiddleware → cookie → scope["session"]   │
│       ↓                                                      │
│  Dispatcher demultiplexes on first path segment → app       │
│       ↓                                                      │
│  app.handle_request: server.authenticate(scope)             │
│       → scope["auth"] = {"identity","tags","backend"}|None  │
│       → filters = {auth_tags, channel_channel?}             │
│       ↓                                                      │
│  self.route.node(path, errors=ROUTER_ERRORS, **filters)     │
│       (genro-routes filters on auth_tags/channel/env_caps)  │
│       ↓                                                      │
│  Match? → handler(**query) / No match? → router error code  │
│       ↓                                                      │
│  ROUTER_ERRORS maps code → HTTPException (401/403/404/...)   │
└─────────────────────────────────────────────────────────────┘

Key Decisions

Decision 1: API-First Design

Decision: Auth failures surface as HTTP exceptions (with JSON-friendly detail), never as redirects from the routing/auth layer.

Rationale:

  • Modern SPA pattern: frontend handles 401/403 and decides UX

  • Same behavior for API clients and SPA

  • The routing layer (genro-routes) knows nothing about HTTP redirects

Consequence:

  • Credentials present but invalid/expired → HTTPException(401) with a WWW-Authenticate header

  • Resolved handler requires tags the caller lacks → HTTPForbidden (403)

  • Redirect to login is a frontend (or sys-app) responsibility

Decision 2: Header-Based Credential Backends (bearer, basic, jwt)

Decision: Credentials are read from the Authorization header and matched against backends declared in configuration. Three backends exist: static bearer tokens, HTTP basic, and jwt.

Rationale:

  • O(1) lookup at request time (dict keyed by token / base64 of user:pass)

  • jwt enables stateless, self-contained tokens carrying sub and tags

  • A bearer credential that is not a known static token falls back to JWT verification

Configuration is expressed with the authMiddleware element of the asgiconfig dialect grammar (see config/asgi_elements.py). In a config recipe::

def main(self, root):
    root.server(host="127.0.0.1", port=8000)
    root.authMiddleware(
        bearer={"api_key": {"token": "sk_live_abc123", "tags": "api,read"}},
        basic={"admin": {"password": "secret", "tags": "admin"}},
        jwt={"internal": {"secret": "my-jwt-secret", "algorithm": "HS256"}},
    )

AsgiServer._apply_authMiddleware(node) consumes that node and calls BasicAuthMixin.__init__(self, **node.attrs), so the server itself authenticates via server.authenticate(scope).

JWT payload structure (verified by _verify_jwt):

{
  "sub": "user_id",
  "tags": ["user", "admin"],
  "exp": 1234567890
}

sub becomes identity; tags becomes the tag list.

Decision 3: Two Authentication Entry Points, Same Logic

BasicAuthMixin (in server/auth_mixin.py) and AuthMiddleware (in middleware/authentication.py) implement the same backends.

Entry point

Use

BasicAuthMixin (composed into AsgiServer)

Default path. app.handle_request calls server.authenticate(scope).

AuthMiddleware (middleware_name = "auth", middleware_order = 400, middleware_default = False)

Standalone middleware. When a server is present in the chain it delegates to server.authenticate(scope); otherwise it runs its own _authenticate(scope). Sets scope["auth"] and scope["_filters"]["auth_tags"].

AuthMiddleware is disabled by default and is registered under the name auth for use in a middleware switch config.

Decision 4: Credentials Come Only From the Authorization Header

Decision: Both BasicAuthMixin._get_auth and AuthMiddleware._get_auth read credentials only from the Authorization header ("<type> <credentials>"). Neither extractor inspects cookies.

Consequence:

  • API clients send Authorization: Bearer <token> / Authorization: Basic <base64> / a JWT.

  • Browser cookie identity is not handled by the auth result. It is handled independently by SessionMiddleware (middleware_name = "session", middleware_order = 450), which reads the session cookie from scope["_headers"]["cookie"], reconnects or creates a Session, and writes it to scope["session"] (setting Set-Cookie on new sessions).

There is no hybrid “cookie-or-bearer” credential extractor: the two mechanisms are orthogonal — header → scope["auth"], cookie → scope["session"].

Decision 5: Authorization via genro-routes Filtering

Decision: Authorization is performed by genro-routes when resolving the handler, not by an HTTP-aware plugin in genro-asgi.

In AsgiApplication.handle_request:

auth = self.authenticate(scope)
scope["auth"] = auth
filters = {"auth_tags": auth["tags"] if auth is not None else []}
channel = scope.get("_headers", {}).get("x-channel", "").strip()
if channel:
    filters["channel_channel"] = channel
node = self.route.node(self.path_in_app(request.path), errors=ROUTER_ERRORS, **filters)

genro-routes evaluates the route’s metadata (auth_tags, channel constraints, env_capabilities) against the supplied filters and either returns the node or raises a router error code.

Request exposes the resolved context: request.auth_tags and request.env_capabilities are populated from scope["_filters"]; request.session returns scope["session"].

Decision 6: Router Error Codes → HTTP Status

Decision: The app maps genro-routes error codes to HTTP exceptions through the ROUTER_ERRORS table (in applications/asgi_application/asgi_application.py). There is no separate avatar-presence branch in the dispatcher; the router distinguishes “not authenticated” from “not authorized”.

ROUTER_ERRORS = {
    "not_found": HTTPNotFound,            # 404
    "not_authorized": HTTPForbidden,      # 403
    "not_authenticated": HTTPUnauthorized,# 401
    "not_available": HTTPServiceUnavailable, # 503
    "validation_error": HTTPBadRequest,   # 400
}

Scenario

Router code

Status

Credentials present but invalid/expired

(raised by authenticate)

401 + WWW-Authenticate

Route requires auth, caller has no/insufficient identity

not_authenticated

401

Authenticated but lacks required tags

not_authorized

403

No matching route

not_found

404

Path matched, correct tags

handler runs

Decision 7: The Request Context Object

Decision: handle_request builds a lightweight context object (ctx, a DictObj) and stores it on scope["ctx"]. It carries references to the surrounding instances.

ctx = DictObj()
ctx.server  = server
ctx.app     = self
ctx.request = request
ctx.session = scope.get("session")   # from SessionMiddleware, if mounted
ctx.avatar  = auth.get("avatar") if auth else None
# ctx._db set when the app has a configured database

Note: there is currently no AsgiContext/RoutingContext subclass in genro-asgi; the context is a plain attribute object. A typed context class is a possible future refinement (roadmap, not implemented).

Decision 8: Route-Level Authorization via Metadata

Decision: Auth requirements are declared on routes with @route metadata and enforced by genro-routes filtering.

@route("api")                              # no auth requirement
def health(self):
    return {"status": "ok"}

@route("api", meta_auth_tags=["user"])     # requires "user" tag
def get_my_orders(self):
    ...

@route(auth_rule="superadmin&has_jwt")     # boolean auth rule (see create_jwt)
def delete_user(self, user_id):
    ...

The exact metadata/rule syntax is owned by genro-routes; genro-asgi only forwards auth_tags, channel, and env_capabilities filters and maps the resulting error codes.

Decision 9: Built-in Login / Logout (sys app)

Decision: The server’s built-in ServerApplication (mounted under /_server/) ships login, logout, and session_info endpoints. (This corrects the earlier “no built-in login endpoint” decision.)

@route(meta_mime_type="application/json")
def login(self, username="", password=""):
    auth = self._server.verify_credentials(username, password)   # basic-auth check
    if auth is None:
        return {"error": "Invalid credentials"}
    session = self._server.session_store.create(auth=auth)
    return {"session_id": session.id, "identity": auth["identity"], "tags": auth["tags"]}

@route(meta_mime_type="application/json")
def logout(self, session_id=""):
    if session_id:
        self._server.session_store.delete(session_id)
    return {"status": "ok"}
  • POST /_server/login verifies username/password against the configured basic credentials (BasicAuthMixin.verify_credentials) and creates a server session.

  • POST /_server/logout deletes the session.

  • GET /_server/session_info returns identity/tags/timestamps for a session.

A create_jwt endpoint (guarded by auth_rule="superadmin&has_jwt") is also present for issuing JWTs from a configured jwt verifier.

Applications remain free to add their own login flows (OAuth, passkey, etc.) on top of, or instead of, these built-ins.

Future Considerations

Typed Context Class

A typed AsgiContext (and downstream subclasses for richer environments) could replace the current DictObj context. Not implemented today — the context is a plain attribute object on scope["ctx"].

Additional Auth Backends

New backends slot in as additional _configure_<type> / _auth_<type> methods on BasicAuthMixin / AuthMiddleware (the dispatch is name-based via getattr). Candidates: API-Key, OAuth2/OIDC, WebAuthn/passkey. Each would populate the same scope["auth"] shape.

Configuration Example

# config.py recipe (asgiconfig dialect)
def main(self, root):
    root.server(host="127.0.0.1", port=8000, reload=True)
    root.middleware(cors=True)                 # global middleware switches
    root.authMiddleware(                        # credential backends
        bearer={"api_key": {"token": "sk_live_abc123", "tags": "api,read"}},
        basic={"admin": {"password": "secret", "tags": "admin"}},
        jwt={"internal": {"secret": "${JWT_SECRET}", "algorithm": "HS256"}},
    )
    apps = root.applications(default="shop")
    apps.application(code="shop", app_class=Shop)

There is no top-level plugins section and no tags_key setting: the auth_tags filter key is fixed in code.

Summary

Aspect

Reality

Credential source

Authorization header only

Backends

bearer, basic, jwt (declared via authMiddleware)

Auth entry points

BasicAuthMixin (default) / AuthMiddleware (name auth, off by default)

scope["auth"] shape

{"identity", "tags", "backend"} or None

Authorization

genro-routes router.node(...) filtering on auth_tags/channel/env_capabilities

Error mapping

ROUTER_ERRORS → 401/403/404/503/400

Cookie identity

Separate: SessionMiddlewarescope["session"]

Context

DictObj on scope["ctx"] (no typed class yet)

Login/logout

Built-in /_server/login, /_server/logout, /_server/session_info

Config plugins/tags_key

Do not exist