gateway-session-introspection.test.mjs
453 lines 17.7 KB
Raw
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor ⚠ breaking 1 day ago
1 /**
2 * C7 Session Introspection — GET /api/v1/auth/session
3 *
4 * Tests all 7 tiers: unit, integration, e2e, stress, data-integrity, performance, security.
5 *
6 * Designed for Scooling (cross-origin, Bearer-only) and the Hub UI alike.
7 * The endpoint reads the verified JWT payload only — no extra DB call, no data elevation.
8 */
9
10 import { describe, it, before, after } from 'node:test';
11 import assert from 'node:assert/strict';
12 import http from 'node:http';
13 import crypto from 'node:crypto';
14 import fs from 'node:fs';
15 import path from 'node:path';
16 import { fileURLToPath, pathToFileURL } from 'node:url';
17
18 const __dirname = path.dirname(fileURLToPath(import.meta.url));
19 const ROOT = path.resolve(__dirname, '..');
20 const SECRET = 'c7-session-introspection-test-secret-32chars';
21 const SERVER_SRC = fs.readFileSync(
22 path.join(ROOT, 'hub', 'gateway', 'server.mjs'),
23 'utf8',
24 );
25
26 // ─── helpers ─────────────────────────────────────────────────────────────────
27
28 function makeJwt(payload, secret = SECRET) {
29 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
30 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
31 const sig = crypto
32 .createHmac('sha256', secret)
33 .update(`${header}.${body}`)
34 .digest('base64url');
35 return `${header}.${body}.${sig}`;
36 }
37
38 function validPayload(overrides = {}) {
39 const now = Math.floor(Date.now() / 1000);
40 return {
41 sub: 'google:123456789',
42 provider: 'google',
43 id: '123456789',
44 name: 'Test User',
45 role: 'member',
46 type: 'session',
47 iat: now - 60,
48 exp: now - 60 + 3 * 60 * 60,
49 ...overrides,
50 };
51 }
52
53 function adminPayload(overrides = {}) {
54 return validPayload({
55 role: 'admin',
56 sub: 'github:admin1',
57 provider: 'github',
58 id: 'admin1',
59 ...overrides,
60 });
61 }
62
63 function startServer(app) {
64 const srv = http.createServer(app);
65 return new Promise((resolve, reject) => {
66 srv.listen(0, '127.0.0.1', (err) => {
67 if (err) return reject(err);
68 resolve({
69 url: `http://127.0.0.1:${srv.address().port}`,
70 close: () => new Promise((r) => srv.close(() => r())),
71 });
72 });
73 });
74 }
75
76 async function createGateway() {
77 process.env.NETLIFY = '1';
78 process.env.SESSION_SECRET = SECRET;
79 process.env.BILLING_ENFORCE = 'false';
80 process.env.CANISTER_URL = '';
81 process.env.BRIDGE_URL = '';
82 const entry = pathToFileURL(path.join(ROOT, 'hub', 'gateway', 'server.mjs')).href;
83 const { app } = await import(`${entry}?c7test=${Date.now()}-${Math.random()}`);
84 return startServer(app);
85 }
86
87 async function get(url, headers = {}) {
88 const res = await fetch(url, { headers });
89 const body = await res.json();
90 return { status: res.status, body, headers: res.headers };
91 }
92
93 // ─── 1. UNIT — structural wiring (no server needed) ──────────────────────────
94
95 describe('C7 unit: structural wiring in server.mjs', () => {
96 it('declares GET /api/v1/auth/session route', () => {
97 assert.ok(
98 SERVER_SRC.includes("'/api/v1/auth/session'"),
99 'route must be mounted',
100 );
101 });
102
103 it('declares decodeVerifiedToken helper that returns full payload (not just sub)', () => {
104 assert.ok(SERVER_SRC.includes('decodeVerifiedToken'), 'helper must exist');
105 assert.ok(
106 /function decodeVerifiedToken/.test(SERVER_SRC),
107 'must be a declared function',
108 );
109 // Must NOT just return sub — must return the full payload object
110 const fn = SERVER_SRC.slice(
111 SERVER_SRC.indexOf('function decodeVerifiedToken'),
112 SERVER_SRC.indexOf('function decodeVerifiedToken') + 200,
113 );
114 assert.ok(!fn.includes('.sub'), 'must return full payload, not just sub');
115 });
116
117 it('declares scopesForRole that includes vault scopes and admin', () => {
118 assert.ok(SERVER_SRC.includes('scopesForRole'), 'scopesForRole must exist');
119 assert.ok(SERVER_SRC.includes('vault:read'), 'must include vault:read scope');
120 assert.ok(SERVER_SRC.includes('vault:write'), 'must include vault:write scope');
121 assert.ok(SERVER_SRC.includes("'admin'"), 'must differentiate admin role');
122 });
123
124 it('mounts OPTIONS /api/v1/auth/session for CORS preflight', () => {
125 const idx = SERVER_SRC.indexOf("'/api/v1/auth/session'");
126 assert.ok(idx > 0, 'route must be present');
127 // Look for options() handler near the route declaration
128 const window = SERVER_SRC.slice(Math.max(0, idx - 350), idx + 50);
129 assert.ok(
130 window.includes('options') || window.includes('OPTIONS'),
131 'OPTIONS preflight must be handled',
132 );
133 });
134
135 it('response shape includes all required C7 contract fields', () => {
136 const idx = SERVER_SRC.indexOf("app.get('/api/v1/auth/session'");
137 assert.ok(idx > 0, 'route handler must exist');
138 const block = SERVER_SRC.slice(idx, idx + 1400);
139 for (const field of ['type', 'sub', 'provider', 'id', 'name', 'role', 'iat', 'exp', 'scopes']) {
140 assert.ok(block.includes(field), `response must include field '${field}'`);
141 }
142 });
143
144 it('rejects missing or non-Bearer Authorization without reaching verifyToken', () => {
145 const idx = SERVER_SRC.indexOf("app.get('/api/v1/auth/session'");
146 const block = SERVER_SRC.slice(idx, idx + 400);
147 assert.ok(block.includes("startsWith('Bearer ')"), 'must check Bearer prefix');
148 assert.ok(block.includes('401'), 'must return 401 on missing auth');
149 });
150 });
151
152 // ─── HTTP tests — shared gateway server ──────────────────────────────────────
153
154 describe('C7 integration, e2e, stress, data-integrity, performance, security', () => {
155 let gw;
156
157 before(async () => {
158 gw = await createGateway();
159 });
160
161 after(async () => {
162 await gw.close();
163 });
164
165 // ─── 2. INTEGRATION ──────────────────────────────────────────────────────
166
167 it('integration: 401 with no Authorization header', async () => {
168 const { status, body } = await get(`${gw.url}/api/v1/auth/session`);
169 assert.equal(status, 401);
170 assert.equal(body.code, 'UNAUTHORIZED');
171 });
172
173 it('integration: 401 with non-Bearer scheme (Basic)', async () => {
174 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
175 Authorization: 'Basic abc123',
176 });
177 assert.equal(status, 401);
178 });
179
180 it('integration: 401 with empty Bearer value', async () => {
181 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
182 Authorization: 'Bearer ',
183 });
184 assert.equal(status, 401);
185 });
186
187 it('integration: 200 with valid member JWT — correct shape and scopes', async () => {
188 const token = makeJwt(validPayload());
189 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
190 Authorization: `Bearer ${token}`,
191 });
192 assert.equal(status, 200);
193 assert.equal(body.type, 'session');
194 assert.equal(body.sub, 'google:123456789');
195 assert.equal(body.provider, 'google');
196 assert.equal(body.id, '123456789');
197 assert.equal(body.name, 'Test User');
198 assert.equal(body.role, 'member');
199 assert.ok(Array.isArray(body.scopes), 'scopes must be an array');
200 assert.ok(body.scopes.includes('vault:read'), 'member must have vault:read');
201 assert.ok(body.scopes.includes('vault:write'), 'member must have vault:write');
202 assert.ok(!body.scopes.includes('admin'), 'member must not have admin scope');
203 assert.strictEqual(typeof body.iat, 'number', 'iat must be a number');
204 assert.strictEqual(typeof body.exp, 'number', 'exp must be a number');
205 });
206
207 it('integration: 200 with valid admin JWT — includes admin scope', async () => {
208 const token = makeJwt(adminPayload());
209 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
210 Authorization: `Bearer ${token}`,
211 });
212 assert.equal(status, 200);
213 assert.equal(body.role, 'admin');
214 assert.ok(body.scopes.includes('admin'), 'admin must have admin scope');
215 assert.ok(body.scopes.includes('vault:read'), 'admin must retain vault:read');
216 });
217
218 it('integration: 200 for github provider — sub, provider, id correct', async () => {
219 const token = makeJwt(validPayload({ sub: 'github:9876', provider: 'github', id: '9876' }));
220 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
221 Authorization: `Bearer ${token}`,
222 });
223 assert.equal(status, 200);
224 assert.equal(body.sub, 'github:9876');
225 assert.equal(body.provider, 'github');
226 assert.equal(body.id, '9876');
227 });
228
229 it('integration: response Content-Type is application/json', async () => {
230 const token = makeJwt(validPayload());
231 const res = await fetch(`${gw.url}/api/v1/auth/session`, {
232 headers: { Authorization: `Bearer ${token}` },
233 });
234 assert.ok(
235 res.headers.get('content-type')?.includes('application/json'),
236 'must return JSON content-type',
237 );
238 });
239
240 // ─── 3. END-TO-END ───────────────────────────────────────────────────────
241
242 it('e2e: refresh-path token (no display name) is accepted, safe defaults applied', async () => {
243 const now = Math.floor(Date.now() / 1000);
244 const token = makeJwt({
245 sub: 'google:refresh-user',
246 provider: 'google',
247 id: 'refresh-user',
248 name: '',
249 role: 'member',
250 type: 'session',
251 iat: now - 60,
252 exp: now - 60 + 3 * 60 * 60,
253 });
254 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
255 Authorization: `Bearer ${token}`,
256 });
257 assert.equal(status, 200);
258 assert.equal(body.sub, 'google:refresh-user');
259 assert.equal(body.name, '');
260 assert.equal(body.type, 'session');
261 assert.ok(body.scopes.includes('vault:read'));
262 });
263
264 it('e2e: expired token is rejected with 401 SESSION_EXPIRED', async () => {
265 const now = Math.floor(Date.now() / 1000);
266 const token = makeJwt({
267 ...validPayload(),
268 iat: now - 3 * 60 * 60 - 10,
269 exp: now - 1,
270 });
271 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
272 Authorization: `Bearer ${token}`,
273 });
274 assert.equal(status, 401);
275 assert.equal(body.code, 'SESSION_EXPIRED');
276 });
277
278 it('e2e: two concurrent calls with the same token return identical responses', async () => {
279 const token = makeJwt(validPayload());
280 const [a, b] = await Promise.all([
281 get(`${gw.url}/api/v1/auth/session`, { Authorization: `Bearer ${token}` }),
282 get(`${gw.url}/api/v1/auth/session`, { Authorization: `Bearer ${token}` }),
283 ]);
284 assert.equal(a.status, 200);
285 assert.equal(b.status, 200);
286 assert.deepEqual(a.body, b.body);
287 });
288
289 // ─── 4. STRESS ───────────────────────────────────────────────────────────
290
291 it('stress: 50 concurrent valid requests all succeed', async () => {
292 const token = makeJwt(validPayload());
293 const results = await Promise.all(
294 Array.from({ length: 50 }, () =>
295 get(`${gw.url}/api/v1/auth/session`, { Authorization: `Bearer ${token}` }),
296 ),
297 );
298 const failures = results.filter((r) => r.status !== 200);
299 assert.equal(failures.length, 0, `${failures.length}/50 requests failed`);
300 });
301
302 it('stress: 30 concurrent invalid requests all return 401', async () => {
303 const results = await Promise.all(
304 Array.from({ length: 30 }, () => get(`${gw.url}/api/v1/auth/session`)),
305 );
306 const nonAuth = results.filter((r) => r.status !== 401);
307 assert.equal(nonAuth.length, 0, 'all must be 401');
308 });
309
310 // ─── 5. DATA INTEGRITY ───────────────────────────────────────────────────
311
312 it('data-integrity: JWT secret is not present in the response', async () => {
313 const token = makeJwt(validPayload());
314 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
315 Authorization: `Bearer ${token}`,
316 });
317 const bodyStr = JSON.stringify(body);
318 assert.ok(!bodyStr.includes(SECRET), 'response must not contain the signing secret');
319 });
320
321 it('data-integrity: extra JWT fields are not passed through to the response', async () => {
322 const token = makeJwt(validPayload({ extraField: 'MUST_NOT_LEAK' }));
323 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
324 Authorization: `Bearer ${token}`,
325 });
326 assert.ok(!('extraField' in body), 'undocumented JWT fields must not appear in response');
327 });
328
329 it('data-integrity: scopes is always an array even when role is absent from token', async () => {
330 const now = Math.floor(Date.now() / 1000);
331 const token = makeJwt({
332 sub: 'google:norole',
333 provider: 'google',
334 id: 'norole',
335 name: '',
336 type: 'session',
337 iat: now - 60,
338 exp: now - 60 + 3 * 60 * 60,
339 });
340 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
341 Authorization: `Bearer ${token}`,
342 });
343 assert.equal(status, 200);
344 assert.ok(Array.isArray(body.scopes), 'scopes must always be an array');
345 assert.ok(body.scopes.length > 0, 'must default to at least one scope');
346 });
347
348 it('data-integrity: iat and exp are numbers (not strings)', async () => {
349 const token = makeJwt(validPayload());
350 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
351 Authorization: `Bearer ${token}`,
352 });
353 assert.strictEqual(typeof body.iat, 'number');
354 assert.strictEqual(typeof body.exp, 'number');
355 });
356
357 it('data-integrity: sub is canonical provider:id for both google and github tokens', async () => {
358 for (const [provider, rawId] of [
359 ['google', '111'],
360 ['github', '222'],
361 ]) {
362 const token = makeJwt(validPayload({ sub: `${provider}:${rawId}`, provider, id: rawId }));
363 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
364 Authorization: `Bearer ${token}`,
365 });
366 assert.equal(body.sub, `${provider}:${rawId}`);
367 assert.equal(body.provider, provider);
368 assert.equal(body.id, rawId);
369 }
370 });
371
372 // ─── 6. PERFORMANCE ──────────────────────────────────────────────────────
373
374 it('performance: p99 of 20 sequential calls is under 100ms', async () => {
375 const token = makeJwt(validPayload());
376 const times = [];
377 for (let i = 0; i < 20; i++) {
378 const t0 = performance.now();
379 await get(`${gw.url}/api/v1/auth/session`, { Authorization: `Bearer ${token}` });
380 times.push(performance.now() - t0);
381 }
382 times.sort((a, b) => a - b);
383 const p99 = times[Math.ceil(times.length * 0.99) - 1];
384 assert.ok(p99 < 100, `p99 ${p99.toFixed(1)}ms exceeds 100ms budget`);
385 });
386
387 // ─── 7. SECURITY ─────────────────────────────────────────────────────────
388
389 it('security: token signed with wrong secret is rejected', async () => {
390 const token = makeJwt(validPayload(), 'WRONG-SECRET-THAT-DOES-NOT-MATCH');
391 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
392 Authorization: `Bearer ${token}`,
393 });
394 assert.equal(status, 401);
395 });
396
397 it('security: tampered payload (one char flipped in body segment) is rejected', async () => {
398 const token = makeJwt(validPayload());
399 const parts = token.split('.');
400 const tampered = `${parts[0]}.${parts[1].slice(0, -1)}X.${parts[2]}`;
401 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
402 Authorization: `Bearer ${tampered}`,
403 });
404 assert.equal(status, 401);
405 });
406
407 it('security: alg:none token (algorithm confusion attack) is rejected', async () => {
408 const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
409 const body = Buffer.from(JSON.stringify(validPayload())).toString('base64url');
410 const noneToken = `${header}.${body}.`;
411 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
412 Authorization: `Bearer ${noneToken}`,
413 });
414 assert.equal(status, 401, 'alg:none tokens must be rejected');
415 });
416
417 it('security: empty sub in a valid-signature token is rejected', async () => {
418 const token = makeJwt({ ...validPayload(), sub: '' });
419 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
420 Authorization: `Bearer ${token}`,
421 });
422 assert.equal(status, 401, 'empty sub must be rejected — no anonymous identity');
423 });
424
425 it('security: token passed via query string (not header) is not accepted', async () => {
426 const token = makeJwt(validPayload());
427 const res = await fetch(`${gw.url}/api/v1/auth/session?token=${token}`);
428 assert.equal(res.status, 401, 'query-string token must not be accepted');
429 });
430
431 it('security: completely garbage token string returns 401', async () => {
432 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
433 Authorization: 'Bearer this.is.not.a.jwt',
434 });
435 assert.equal(status, 401);
436 });
437
438 it('security: 401 response does not leak stack traces or internal paths', async () => {
439 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
440 Authorization: 'Bearer garbage',
441 });
442 const s = JSON.stringify(body);
443 assert.ok(!s.includes('at '), 'must not leak stack trace');
444 assert.ok(!s.includes('node_modules'), 'must not leak internal paths');
445 });
446
447 it('security: 401 response does not include server version header', async () => {
448 const res = await fetch(`${gw.url}/api/v1/auth/session`, {
449 headers: { Authorization: 'Bearer garbage' },
450 });
451 assert.ok(!res.headers.get('x-powered-by'), 'must not expose x-powered-by');
452 });
453 });
File History 1 commit
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor 1 day ago