| """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 |
| ABSOLUTE_SECONDS = 30 * 24 * 60 * 60 |
|
|
| _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 |
| |
| 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): |
| |
| |
| response.delete_cookie(COOKIE_NAME, path="/", httponly=True, samesite="lax", |
| secure=is_secure_request(request)) |
|
|