Sessions and login

A signed cookie says who the user is and until when. Login writes it, middleware reads it, logout deletes it.

Stario has no accounts system and no session store. It has cookie helpers in stario.cookies, middleware, and c.state. That is enough for a session that fits in your head: login puts a signed cookie on the browser, middleware verifies it on every request and puts the user on c.state, and logout clears it. There is no table of sessions to clean up, because the cookie is the session.

This page builds that in about a hundred lines you own. AuthUser and AuthSession below are your types, not imports. If you would rather start from a working version with a login screen and a users table, the stario-auth skill in stario-create has one.

AuthUser is what a verified cookie says: a user id and an expiry. Add fields such as a display name if handlers need them without a database query.

AuthSession holds the secret and the cookie settings, and knows how to encode, decode, issue, and clear the cookie. Build one in bootstrap, where the secret is loaded.

import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from typing import Literal
 
from stario import Context, Writer
from stario.cookies import delete_cookie, set_cookie
 
 
@dataclass(frozen=True, slots=True)
class AuthUser:
    user_id: str
    expires_at: float  # Unix time
 
 
@dataclass(frozen=True, slots=True)
class AuthSession:
    secret: bytes
    state_key: str = "user"
    cookie_name: str = "session"
    max_age_seconds: int = 60 * 60 * 24 * 7
    refresh_within_seconds: int = 60 * 60 * 24
    secure: bool = False
    samesite: Literal["lax", "strict", "none"] = "lax"
 
    def encode(self, user_id: str, expires_at: float) -> str:
        body = json.dumps({"uid": user_id, "exp": expires_at}, separators=(",", ":")).encode()
        sig = hmac.new(self.secret, body, hashlib.sha256).digest()
        return _b64(body) + "." + _b64(sig)
 
    def decode(self, token: str) -> AuthUser | None:
        try:
            body_b64, sig_b64 = token.split(".", 1)
            body, sig = _unb64(body_b64), _unb64(sig_b64)
            if not hmac.compare_digest(hmac.new(self.secret, body, hashlib.sha256).digest(), sig):
                return None
            data = json.loads(body)
            exp = float(data["exp"])
            if time.time() >= exp:
                return None
            return AuthUser(user_id=str(data["uid"]), expires_at=exp)
        except (ValueError, KeyError):
            return None
 
    def user(self, c: Context) -> AuthUser | None:
        raw = c.req.cookies.get(self.cookie_name)
        return self.decode(raw) if raw else None
 
    def issue(self, w: Writer, user_id: str) -> None:
        expires_at = time.time() + self.max_age_seconds
        set_cookie(
            w,
            self.cookie_name,
            self.encode(user_id, expires_at),
            max_age=self.max_age_seconds,
            secure=self.secure,
            httponly=True,
            samesite=self.samesite,
        )
 
    def clear(self, w: Writer) -> None:
        delete_cookie(w, self.cookie_name, secure=self.secure, httponly=True, samesite=self.samesite)
 
 
def _b64(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")
 
 
def _unb64(text: str) -> bytes:
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))

The cookie is base64(json).base64(hmac). Anyone can read the JSON; nobody without the secret can change it, because the HMAC would no longer match. hmac.compare_digest keeps the comparison constant-time. The cookie is HttpOnly, so page scripts cannot read it, and SameSite=Lax, so other sites cannot post with it.

2. Two pieces of middleware#

attach_user reads the cookie and puts the result on c.state, refreshing the cookie when it is close to expiry. require_user does the same and redirects to the login page when there is no user. Both take the session and return middleware, which is a function from handler to handler.

import time
 
from stario import Context, Handler, Middleware, Writer
 
from app.sessions import AuthSession
 
 
def attach_user(session: AuthSession) -> Middleware:
    def middleware(inner: Handler) -> Handler:
        async def handler(c: Context, w: Writer) -> None:
            user = session.user(c)
            c.state[session.state_key] = user
            if user is not None and user.expires_at - time.time() < session.refresh_within_seconds:
                session.issue(w, user.user_id)
            await inner(c, w)
 
        return handler
 
    return middleware
 
 
def require_user(session: AuthSession, *, login_url: str) -> Middleware:
    def middleware(inner: Handler) -> Handler:
        async def handler(c: Context, w: Writer) -> None:
            user = session.user(c)
            c.state[session.state_key] = user
            if user is None:
                w.redirect(login_url, status=303)
                return
            await inner(c, w)
 
        return handler
 
    return middleware

A handler behind either reads c.state["user"] and gets an AuthUser or None.

3. Login and logout#

The login page is a form. The POST reads it, checks the credentials with code you own, issues the cookie, and redirects with a 303 so the browser follows with a GET. Logout clears the cookie and redirects the same way.

from urllib.parse import parse_qs
 
from stario import Context, Writer
from stario import h
 
from app.sessions import AuthSession
from app.urls import HOME, LOGIN, LOGIN_POST, LOGOUT
from app.users import check_password  # your code: (email, password) -> user id or None
 
 
def login_view():
    return h.Form(
        {"method": "post", "action": LOGIN_POST.href()},
        h.Input({"name": "email", "type": "email", "autocomplete": "username"}),
        h.Input({"name": "password", "type": "password", "autocomplete": "current-password"}),
        h.Button({"type": "submit"}, "Sign in"),
    )
 
 
async def login_get(c: Context, w: Writer) -> None:
    if c.state.get("user") is not None:
        w.redirect(HOME.href(), status=303)
        return
    w.html(login_view())
 
 
def login_post(session: AuthSession):
    async def handler(c: Context, w: Writer) -> None:
        form = parse_qs((await c.req.body()).decode())
        email = form.get("email", [""])[0]
        password = form.get("password", [""])[0]
        user_id = await check_password(email, password)
        if user_id is None:
            w.text("Wrong email or password.", status=401)
            return
        session.issue(w, user_id)
        w.redirect(HOME.href(), status=303)
 
    return handler
 
 
def logout(session: AuthSession):
    async def handler(c: Context, w: Writer) -> None:
        session.clear(w)
        w.redirect(LOGIN.href(), status=303)
 
    return handler

The factories close over the AuthSession, the way Passing dependencies describes: the dependency arrives as an argument and the closure holds it. login_get needs nothing, so it is a plain handler.

4. Wire it in bootstrap#

Routes are values in app/urls.py. LOGIN and LOGIN_POST are two routes on one path.

from stario import Route
 
HOME = Route("GET /")
LOGIN = Route("GET /login")
LOGIN_POST = Route("POST /login")
LOGOUT = Route("POST /logout")
DASHBOARD = Route("GET /app/dashboard")

bootstrap loads the secret, builds the session, and registers everything. The home page needs attach_user only; the dashboard needs require_user.

import os
 
from stario import App, Span
 
from app.handlers import dashboard, home, login_get, login_post, logout
from app.middleware import attach_user, require_user
from app.sessions import AuthSession
from app.urls import DASHBOARD, HOME, LOGIN, LOGIN_POST, LOGOUT
 
 
async def bootstrap(app: App, span: Span):
    secret = os.environ.get("SESSION_SECRET", "").encode()
    if len(secret) < 32:
        raise RuntimeError("Set SESSION_SECRET to a long random value (32+ bytes).")
    session = AuthSession(secret=secret, secure=True)
 
    app.use("/app", require_user(session, login_url=LOGIN.href()))
 
    app.add(HOME, home, middleware=[attach_user(session)])
    app.add(LOGIN, login_get, middleware=[attach_user(session)])
    app.add(LOGIN_POST, login_post(session))
    app.add(LOGOUT, logout(session))
    app.add(DASHBOARD, dashboard)
    yield

app.use("/app", …) protects every route under /app in one line, wherever it sits in bootstrap. Per-route middleware=[…] is for the pages that only need to know who is there. In both forms the first middleware listed is the outermost on the way in, as Routing describes.

By the way#

  • Rotating SESSION_SECRET signs every user out at once, which is also the fastest way to revoke every session.

  • The cookie is signed, not encrypted. Put ids in it, not data you would not show the user.

  • secure=True requires HTTPS, which Deployment puts in front of the app. Leave it False only on localhost.

  • A session cookie does not protect against CSRF by itself. SameSite=Lax covers plain navigations; a form that changes state should still carry a token you check.

  • The w.text(..., status=401) here returns a plain line. Error handling shows how to render it as a page, or you can re-render the form with a message instead.