loopable / api /aios_session.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
092334a verified
Raw
History Blame Contribute Delete
5.51 kB
"""aios_session.py — X3: the signed, STATELESS session cookie (EXIT-3a, 2026-07-30).
aios_session = base64url(json {t,u,e,i,x}) . base64url(HMAC-SHA256)
`t` tenant slug · `u` username · `e` the user's revocation epoch · `i` idle deadline ·
`x` absolute expiry. Signed with `AIOS_SESSION_SECRET` using stdlib `hmac` — no new dependency,
and nothing to store server-side, which is the whole point: EXIT-4 requires a process that holds
no per-tenant state, and a session TABLE is per-tenant state. D2's Postgres sessions-MIRROR (an
audit view, not the source of truth) arrives with C-2 and is deliberately deferred.
WHY STATELESS STILL REVOKES. A cookie nobody can delete sounds like a cookie nobody can revoke.
The answer is the `epoch` integer on the user record (`core.users.epoch` / `bump_epoch`): the
cookie carries the epoch it was minted under, every verification re-reads the account's current
epoch, and a password change or a deactivation bumps it — so every outstanding cookie for that
user dies at once, without a session table.
⚠ NO FALLBACK SECRET, EVER. With `AIOS_SESSION_SECRET` unset this module mints a RANDOM
per-process key: sessions then die on restart, which is an inconvenience. A hardcoded default
would instead let anyone who has read this repo forge a cookie for any user of any tenant — an
inconvenience is the correct trade against a forgery key. `EPHEMERAL_SECRET` records which
happened so the app can say so at startup and a gate can assert it.
"""
import base64
import hmac
import json
import os
import secrets
import time
from hashlib import sha256
COOKIE_NAME = "aios_session"
IDLE_SECONDS = 8 * 60 * 60 # D2: reissued on every authenticated request
ABSOLUTE_SECONDS = 30 * 24 * 60 * 60 # D2: a hard ceiling no amount of activity extends
_env_secret = os.environ.get("AIOS_SESSION_SECRET") or ""
EPHEMERAL_SECRET = not _env_secret
_SECRET = _env_secret.encode("utf-8") if _env_secret else secrets.token_bytes(32)
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(txt: str) -> bytes:
return base64.urlsafe_b64decode(txt + "=" * (-len(txt) % 4))
def _sign(payload: bytes) -> str:
return _b64(hmac.new(_SECRET, payload, sha256).digest())
def mint(tenant, username, epoch, now=None, absolute_expiry=None):
"""A fresh cookie value. `absolute_expiry` carries over on reissue so refreshing the idle
window can never extend the 30-day ceiling — that would make the ceiling unreachable."""
now = int(now if now is not None else time.time())
claims = {"t": str(tenant), "u": str(username), "e": int(epoch),
"i": now + IDLE_SECONDS,
"x": int(absolute_expiry if absolute_expiry is not None
else now + ABSOLUTE_SECONDS)}
payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_sign(payload)}", claims
def read(value, now=None):
"""Parse + verify a cookie value → claims dict, or None.
None for EVERY failure mode — bad shape, bad signature, expired, unparseable. The caller
answers 401 and must not learn which: an error that distinguishes "signature wrong" from
"expired" tells a forger whether their key is right.
"""
if not value or not isinstance(value, str) or value.count(".") != 1:
return None
body, sig = value.split(".", 1)
try:
payload = _unb64(body)
except Exception:
return None
# compare_digest on the SIGNATURE, always — a byte-wise early exit is a timing oracle.
if not hmac.compare_digest(sig, _sign(payload)):
return None
try:
claims = json.loads(payload.decode("utf-8"))
except Exception:
return None
if not isinstance(claims, dict):
return None
if not isinstance(claims.get("u"), str) or not isinstance(claims.get("t"), str):
return None
if not claims["u"] or not claims["t"]:
return None
try:
idle, absolute, epoch = int(claims["i"]), int(claims["x"]), int(claims["e"])
except (KeyError, TypeError, ValueError):
return None
now = int(now if now is not None else time.time())
if now >= idle or now >= absolute:
return None
claims["e"], claims["i"], claims["x"] = epoch, idle, absolute
return claims
def is_secure_request(request):
"""Secure flag ON unless this is plain-HTTP localhost. Set unconditionally in production;
unset on a local http:// dev origin, where a Secure cookie is simply never sent back and
login would appear to succeed and then not work."""
host = (request.headers.get("host") or "").split(":")[0].lower()
proto = (request.headers.get("x-forwarded-proto")
or request.url.scheme or "http").split(",")[0].strip().lower()
if proto == "https":
return True
return host not in ("localhost", "127.0.0.1", "[::1]", "::1")
def set_cookie(response, request, value):
response.set_cookie(
COOKIE_NAME, value, httponly=True, samesite="lax",
secure=is_secure_request(request), max_age=ABSOLUTE_SECONDS, path="/")
def clear_cookie(response, request):
# Same attributes as when it was set: a browser matches on path/secure/samesite when
# deleting, and a mismatched delete silently leaves the cookie in place.
response.delete_cookie(COOKIE_NAME, path="/", httponly=True, samesite="lax",
secure=is_secure_request(request))