aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/session_harness.mjs
blob: 0334813a486f0ee360b1774ff065eaabf686e2c5 (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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// Run the shipped session code against a hub that behaves like the real one.
//
// The defect this guards was not subtle once seen: the hub rotates refresh
// tokens — it revokes the one presented and returns a replacement, and a
// revoked token presented again revokes the entire family — and the client kept
// only the access token out of that response. So the refresh token was spent on
// first use and the second attempt locked the account out of renewal
// altogether, leaving signing out and back in as the only way to carry on.
//
// The fake hub below enforces exactly that rule, which is the point: a stub
// that happily accepts the same refresh token twice would have passed against
// the broken client.
import { readFileSync } from 'fs';

const app = readFileSync(process.argv[2], 'utf8');
const cfg = JSON.parse(process.argv[3] || '{}');
const { scenario = 'expired-access' } = cfg;

// ── The pieces of app.js under test, lifted as text ──────────────────────────
const between = (from, to) => {
  const i = app.indexOf(from);
  if (i < 0) throw new Error(`not found: ${from}`);
  const j = app.indexOf(to, i);
  if (j < 0) throw new Error(`not found: ${to}`);
  return app.slice(i, j);
};
const sessionBlock = between('let _auth = loadAuth();', '\n// ── Hub API');
const hubFetchFn = between('async function hubFetch(', '\nexport {');

// ── The world ────────────────────────────────────────────────────────────────
const store = new Map();
const localStorage = {
  getItem: (k) => (store.has(k) ? store.get(k) : null),
  setItem: (k, v) => store.set(k, v),
  removeItem: (k) => store.delete(k),
};
const AUTH_KEY = 'mb_auth';
const HUB = '';
const TOKEN_RENEW_MARGIN_S = Number(app.match(/const TOKEN_RENEW_MARGIN_S = (\d+)/)[1]);

const jwt = (secondsLeft) => {
  const payload = Buffer.from(JSON.stringify({
    exp: Math.floor(Date.now() / 1000) + secondsLeft,
  })).toString('base64url');
  return `head.${payload}.sig`;
};

// The hub. One live refresh token at a time; presenting a revoked one kills the
// family, as the real endpoint does.
let live = 'RT-1';
let issued = 1;
let familyRevoked = false;
const seen = [];
let refreshCalls = 0;

const fakeFetch = async (url, opts = {}) => {
  if (url.endsWith('/v1/users/token/refresh')) {
    refreshCalls++;
    const presented = JSON.parse(opts.body).refresh_token;
    seen.push(presented);
    if (familyRevoked || presented !== live) {
      familyRevoked = true;
      return { ok: false, status: 401,
               json: async () => ({ detail: 'Token reuse detected — family revoked' }) };
    }
    live = `RT-${++issued}`;
    return {
      ok: true, status: 200,
      json: async () => ({ access_token: jwt(3600), refresh_token: live,
                           token_type: 'bearer', expires_in: 3600 }),
    };
  }
  // Any other endpoint: 401 unless the bearer is a token that has life left.
  const auth = (opts.headers || {})['Authorization'] || '';
  const token = auth.replace('Bearer ', '');
  let alive = false;
  try {
    alive = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).exp
            > Math.floor(Date.now() / 1000);
  } catch { alive = false; }
  calls.push({ url, token, alive });
  if (!alive) {
    return { ok: false, status: 401, json: async () => ({ detail: 'Token expired or invalid' }) };
  }
  return { ok: true, status: 200, json: async () => ({ ok: true }) };
};
const calls = [];

let _bundleKey = null;
const _clearKeyDB = () => {};
function loadAuth() {
  try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; }
}
function saveAuth(auth) {
  if (auth) localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
  else { localStorage.removeItem(AUTH_KEY); _bundleKey = null; _clearKeyDB(); }
}

// The session starts with an access token that expired an hour ago and a
// refresh token that is still perfectly good — a tab reopened the next morning.
const startingLife = scenario === 'fresh-access' ? 3600
                   : scenario === 'nearly-expired' ? 60
                   : -60;
localStorage.setItem(AUTH_KEY, JSON.stringify({
  username: 'someone', userId: 'u1', role: 'user',
  token: jwt(startingLife), refreshToken: 'RT-1',
}));

const ctx = {
  localStorage, AUTH_KEY, HUB, TOKEN_RENEW_MARGIN_S,
  loadAuth, saveAuth, fetch: fakeFetch, atob: (s) => Buffer.from(s, 'base64').toString('binary'),
  console,
  // Hub calls go through the platform adapter since the interface started
  // shipping in a package: in a browser it is `fetch`, in the application it is
  // the main process, because an `app://` origin is refused by CORS. The
  // harness models the browser side, which is the one whose renewal logic this
  // exercises.
  platform: { apiFetch: (...args) => fakeFetch(...args) },
};
const fns = new Function(...Object.keys(ctx),
  sessionBlock + '\n' + hubFetchFn +
  '\n return {hubFetch, refreshAccessToken, ensureFreshToken, tokenLifeLeft,' +
  ' getAuth: () => _auth};')(...Object.values(ctx));

// ── The run ──────────────────────────────────────────────────────────────────
const out = { scenario };
const stored = () => JSON.parse(localStorage.getItem(AUTH_KEY) || 'null');

if (scenario === 'concurrent') {
  // Two requests hit a dead token at the same instant. If each renews on its
  // own, the second presents a token the first has already spent, and the hub
  // tears down the family.
  const before = stored().token;
  // Either may reject once the family is gone; the run still has to report.
  await Promise.allSettled([
    fns.hubFetch('/v1/groups/mine', { token: before }),
    fns.hubFetch('/v1/users/me', { token: before }),
  ]);
} else if (scenario === 'repeat') {
  // Renew several times over. Each response carries a new refresh token, and
  // keeping the old one would be caught on the very next attempt.
  for (let i = 0; i < 4; i++) {
    await fns.refreshAccessToken();
    // Age the access token again so the next one really has to renew. A
    // rejected renewal signs the session out, and there is then nothing left
    // to age — which is the outcome under test, not a reason to stop.
    const a = stored();
    if (!a) break;
    localStorage.setItem(AUTH_KEY, JSON.stringify({ ...a, token: jwt(-60) }));
    const live_ = fns.getAuth();
    if (live_) live_.token = jwt(-60);
  }
} else if (scenario === 'refresh-rejected') {
  localStorage.setItem(AUTH_KEY, JSON.stringify({ ...stored(), refreshToken: 'RT-STALE' }));
  fns.getAuth().refreshToken = 'RT-STALE';
  try { await fns.hubFetch('/v1/groups/mine', { token: stored().token }); }
  catch (e) { out.threw = String(e.message); }
} else {
  await fns.ensureFreshToken();
  await fns.hubFetch('/v1/groups/mine', { token: stored() ? stored().token : null });
}

out.refreshCalls = refreshCalls;
out.refreshTokensPresented = seen;
out.familyRevoked = familyRevoked;
out.signedOut = stored() === null;
out.storedRefreshToken = stored() ? stored().refreshToken : null;
out.apiCalls = calls.map(c => ({ url: c.url, accepted: c.alive }));
out.everRejected = calls.some(c => !c.alive);
out.finalCallAccepted = calls.length ? calls[calls.length - 1].alive : null;
console.log(JSON.stringify(out));