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 aWWW-AuthenticateheaderResolved 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)jwtenables stateless, self-contained tokens carryingsubandtagsA 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 |
|---|---|
|
Default path. |
|
Standalone middleware. When a server is present in the chain it delegates to |
AuthMiddleware is disabled by default and is registered under the name auth for use in a middleware switch config.
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 |
401 + |
Route requires auth, caller has no/insufficient identity |
|
401 |
Authenticated but lacks required tags |
|
403 |
No matching route |
|
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/RoutingContextsubclass in genro-asgi; the context is a plain attribute object. A typed context class is a possible future refinement (roadmap, not implemented).
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/loginverifies username/password against the configuredbasiccredentials (BasicAuthMixin.verify_credentials) and creates a server session.POST /_server/logoutdeletes the session.GET /_server/session_inforeturns 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 |
|
Backends |
|
Auth entry points |
|
|
|
Authorization |
genro-routes |
Error mapping |
|
Cookie identity |
Separate: |
Context |
|
Login/logout |
Built-in |
Config plugins/tags_key |
Do not exist |