File size: 5,514 Bytes
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""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))