summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/auth_race_probe.py
blob: f3abb832b4424c6505426a07c0a9f0bce5714cd3 (plain) (blame)
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
#!/usr/bin/env python3
"""
Signing out while a token renewal is in flight must not leave a half a session.

A tab left open overnight is exactly this race: the idle watch signs the browser
out at the same moment the renewal fires — one second apart in the hub's log.
`refreshAccessToken` then came back to an `_auth` that was already null and
wrote `{ ..._auth, token, refreshToken }`; spreading null is silent, so what
landed in localStorage was a token with **no identity at all**.

The app believes that object is a session. It renders the signed-in interface
from it and throws on the first field it reads — `user.username[0]` — so every
load afterwards is a blank page, and it is stored, so it survives reloads,
cache clearing and the device restarting. A reader has no way back but clearing
the site's data, which nothing on screen used to mention.

Driven against the shipped `hub-client.js` in a real browser, with `fetch`
stubbed so the renewal can be held open across the sign-out.

    auth_race_probe.py

Prints JSON.
"""

import http.server
import json
import socketserver
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path

STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
PORT = 8767
RECORDS = []
socketserver.TCPServer.allow_reuse_address = True

PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
<script type="module">
import { loadAuth, setAuth, refreshAccessToken } from '/hub-client.js';

const post = (o) => fetch.__real('/log', { method: 'POST', body: JSON.stringify(o) });
const out = {};

// Kept aside before the stub goes in: the report has to get out.
const realFetch = window.fetch.bind(window);
window.fetch.__real = realFetch;

(async () => {
  try {
    // ── the renewal, held open across a sign-out ────────────────────────────
    let release;
    const gate = new Promise((r) => { release = r; });
    window.fetch = async (url) => {
      if (String(url).indexOf('/v1/users/token/refresh') >= 0) {
        await gate;
        return { ok: true, json: async () => ({
          access_token: 'renewed-access', refresh_token: 'renewed-refresh' }) };
      }
      return { ok: false, status: 404, json: async () => ({}) };
    };
    window.fetch.__real = realFetch;

    setAuth({ username: 'someone', userId: 'u-1', token: 'old', refreshToken: 'r-1' });
    const inFlight = refreshAccessToken();
    // The idle watch, landing while the request is out.
    setAuth(null);
    release();
    await inFlight;

    out.afterTheRace = localStorage.getItem('mb_auth');

    // ── a browser already holding one heals itself ──────────────────────────
    localStorage.setItem('mb_auth', JSON.stringify({ token: 'x', refreshToken: 'y' }));
    out.poisonedLoads = loadAuth();
    out.poisonedLeftBehind = localStorage.getItem('mb_auth');

    // ── and a real session is still a session ───────────────────────────────
    localStorage.setItem('mb_auth', JSON.stringify(
      { username: 'someone', userId: 'u-1', token: 't', refreshToken: 'r', role: 'user' }));
    const good = loadAuth();
    out.goodLoads = good && good.username;
    out.goodLeftAlone = !!localStorage.getItem('mb_auth');

    post(out);
  } catch (err) {
    post({ error: String((err && err.stack) || err) });
  }
})();
</script></body></html>"""


class H(http.server.BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def do_POST(self):
        length = int(self.headers.get("Content-Length") or 0)
        if self.path == "/log":
            RECORDS.append(json.loads(self.rfile.read(length).decode()))
        else:
            self.rfile.read(length)
        self.send_response(204)
        self.end_headers()

    def _send(self, body: bytes, ctype: str) -> None:
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        path = self.path.split("?")[0]
        if path == "/":
            self._send(PAGE.encode(), "text/html; charset=utf-8")
            return
        asset = (STATIC / path.lstrip("/")).resolve()
        if not str(asset).startswith(str(STATIC)) or not asset.is_file():
            self.send_response(404)
            self.end_headers()
            return
        self._send(asset.read_bytes(), "text/javascript")


def main() -> int:
    with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
        threading.Thread(target=srv.serve_forever, daemon=True).start()
        with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
            proc = subprocess.Popen(
                ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
                 f"--user-data-dir={profile}", f"http://127.0.0.1:{PORT}/"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            for _ in range(300):
                if RECORDS:
                    break
                time.sleep(0.1)
            proc.terminate()
            try:
                proc.wait(timeout=10)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait()
    if not RECORDS:
        print(json.dumps({"error": "no measurement"}), file=sys.stderr)
        return 1
    print(json.dumps(RECORDS[0], indent=1))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())