feat(auth): add server-controlled student and admin sessions

Replaces the Phase 2A pilot ?sid= query param with an HttpOnly,
SameSite=Lax session cookie. Gateway now derives student identity from
the session (via Platform API's /api/session/whoami) instead of trusting
a client-suppliable id, and checks ownership on every proxied HTTP and
WebSocket Lab request, not just the initial navigation.

Fixes a routing bug found during testing: the fallback route was using
'does this session own a running environment' to decide whether an
unprefixed /api/* call belonged to the Lab UI or the dashboard — true in
both places. Replaced with an explicit LAB_OWNED_PATHS allowlist of the
exact unprefixed paths the existing Lab UI actually calls.

e2e-phase2a.js rewritten to use a cookie jar instead of headers/query
params, with explicit impersonation-rejection tests added. 10/10 passing.
This commit is contained in:
2026-08-18 12:22:58 -05:00
parent 6b52c9e1d2
commit ae75bb4cbe
6 changed files with 257 additions and 294 deletions
+134 -170
View File
@@ -1,48 +1,51 @@
// RootNode Academy — Phase 2A Gateway.
// RootNode Academy — Gateway (Phase 2B revision).
//
// The only student-facing path to disposable Lab environments (docs/LAB_ARCHITECTURE.md §1/§6,
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md). Owns:
// - /api/environment/* — student-facing environment lifecycle glue, calls the Provisioner
// internally (shared secret never reaches the browser).
// - /lab/<token>/* — HTTP+WS proxy into a specific student's Lab container, resolved by
// opaque routing token, never a raw container IP/port.
// - everything else — proxied to the persistent identity/curriculum backend (roster,
// login, curriculum path, progress, and the platform-shell frontend), OR to the student's
// own Lab if a valid lab-session cookie is present (see "Routing design" in the doc above
// for why the cookie exists: the existing, unmodified Lab UI makes root-relative /api/*
// and /shell-ws calls that must resolve correctly once a browser has navigated into
// /lab/<token>/, without requiring any change to that UI's code).
// The only student-facing path to disposable Lab environments. Phase 2B changes from Phase 2A:
// - No longer calls the Provisioner directly. All environment orchestration (student launch/
// reset, admin prepare/destroy/reset) is consolidated in the Platform API
// (platform/api) — this Gateway proxies /api/environment/*, /api/admin/*, and everything
// else to it, the same way it proxied to the old "identity" instance in Phase 2A.
// - Lab ownership is no longer decided by a client-supplied ?sid= query param (Phase 2A
// pilot mechanism, trivially forgeable). It's derived from the browser's rn_session cookie,
// resolved server-side via the Platform API's session store
// (GET /api/session/whoami) — nothing client-controlled (URL, token, query param, header)
// can change who the Gateway thinks is asking. See
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
// - Ownership is verified on EVERY proxied Lab request (HTTP and WebSocket), not just the
// initial navigation — a short-TTL cache keeps this cheap.
const http = require('http');
const express = require('express');
const httpProxy = require('http-proxy');
const { provisioner } = require('./provisioner-client');
const { provisioner } = require('./provisioner-client'); // still used ONLY for the token->environment lookup a Lab request needs; all mutating calls (provision/reset/destroy) now live in the Platform API.
const envCache = require('./env-cache');
const PORT = Number(process.env.GATEWAY_PORT) || 4200;
const IDENTITY_BACKEND_URL = process.env.IDENTITY_BACKEND_URL || 'http://localhost:7554';
const LAB_SESSION_COOKIE = 'rn_lab';
const COOKIE_MAX_AGE_SECONDS = 8 * 60 * 60; // 8h — generous for a work session, not indefinite
const PLATFORM_API_URL = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const STUDENT_COOKIE = 'rn_session';
// Routing hint only — NOT a security check by itself (ownership is always re-verified via the
// session, see checkOwnership/resolveOwnedRunningEnvironment). Its only job is disambiguating
// "this unprefixed request came from inside the Lab UI" from "this came from the dashboard,"
// which "does the student currently have a running env" cannot do — that's true on the
// dashboard too, and naively using it hijacked EVERY unmatched dashboard request (including
// GET /api/session/whoami) to the student's Lab container once one existed. Found by testing.
const LAB_ACTIVE_COOKIE = 'rn_lab_active';
const SESSION_CACHE_TTL_MS = 5000;
const app = express();
// Deliberately NOT using express.json() globally: none of this Gateway's own routes read
// req.body (they only use headers/params/query), and body-parsing middleware consumes the
// request stream — which breaks http-proxy's re-streaming of proxied POST bodies (e.g.
// /api/roster) to the identity backend, causing them to hang forever waiting for a body that
// already got read. Found by testing: POST /api/roster through the Gateway hung indefinitely
// until this was removed.
// Deliberately no express.json() globally — see the Phase 2A note this carries forward:
// body-parsing middleware breaks http-proxy's re-streaming of proxied POST bodies.
const proxy = httpProxy.createProxyServer({ ws: true });
proxy.on('error', (err, req, res) => {
console.error('[proxy error]', err.message);
if (res && !res.headersSent && res.writeHead) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'lab_unreachable', message: 'The lab environment is not reachable right now.' }));
res.end(JSON.stringify({ error: 'unreachable' }));
} else if (res && res.destroy) {
res.destroy(); // WS socket case
res.destroy();
}
});
// ── Cookie helpers (no dependency — a handful of lines, not worth pulling in cookie-parser) ─
function parseCookies(req) {
const header = req.headers.cookie;
if (!header) return {};
@@ -50,15 +53,25 @@ function parseCookies(req) {
header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)])
);
}
function setLabSessionCookie(res, token) {
res.append('Set-Cookie', `${LAB_SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE_SECONDS}`);
// ── Session resolution — replaces Phase 2A's ?sid= entirely ────────────────────────────────
const sessionCache = new Map(); // cookie value -> { studentId, expiresAt }
async function resolveSessionStudentId(req) {
const token = parseCookies(req)[STUDENT_COOKIE];
if (!token) return null;
const cached = sessionCache.get(token);
if (cached && cached.expiresAt > Date.now()) return cached.studentId;
try {
const res = await fetch(`${PLATFORM_API_URL}/api/session/whoami`, { headers: { Cookie: `${STUDENT_COOKIE}=${token}` } });
if (res.status !== 200) return null;
const { student_id } = await res.json();
sessionCache.set(token, { studentId: student_id, expiresAt: Date.now() + SESSION_CACHE_TTL_MS });
return student_id;
} catch {
return null;
}
}
// ── Environment resolution ───────────────────────────────────────────────────────────────
// Never trusts a token alone for anything beyond "which container to proxy to" — ownership
// checks (student_id match) happen separately, at the API layer, per docs/PHASE2A_*.md
// "Security requirements": token-alone, student-id-alone, and env-id-alone are all explicitly
// documented as insufficient for production auth; this is pilot-grade, stated as such.
async function resolveByToken(token) {
const cached = envCache.get(token);
if (cached) return cached;
@@ -72,109 +85,28 @@ function isReachable(env) {
return env && env.status === 'running' && env.internal_ip;
}
// ── /api/environment/* — student-facing glue, calls the Provisioner internally ─────────────
const studentIdPattern = /^[a-zA-Z0-9_.-]+$/;
function requireStudentId(req, res) {
const studentId = req.get('X-Student-Id');
if (!studentId || !studentIdPattern.test(studentId)) {
res.status(400).json({ error: 'missing_student_id', message: 'X-Student-Id header is required.' });
return null;
}
return studentId;
function sendLabError(res, status, error, message) {
res.status(status).type('html').send(`<!doctype html><meta charset="utf-8">
<title>RootNode Academy</title>
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem">
<h2>${message}</h2>
<p style="color:#666">(${error})</p>
</body>`);
}
// env.routing_token is redacted ('[redacted]') on every Provisioner read path except the
// initial POST /environments response and the dedicated GET /environments/:id/token (see
// provisioner-client.js and the Provisioner's own server.js comment on that route) — so
// building a launch_path for anything other than a just-created environment means fetching
// the real token separately. Still never sent anywhere except this one field, for this one
// purpose.
async function publicEnvView(env) {
if (!env) return { status: 'none' };
// Deliberately minimal otherwise: never send internal_ip, container_id, network info,
// lab_host_id, or environment_id to the browser. See
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Security requirements."
const view = { status: env.status };
if (env.status === 'running') {
let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null;
if (!token) {
const { status, body } = await provisioner.getToken(env.environment_id);
if (status === 200) token = body.routing_token;
}
if (token) view.launch_path = `/lab/${token}/`;
}
if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.';
return view;
// Shared ownership check: valid session AND session's student owns this environment.
// Used identically by the explicit /lab/:token route, the cookie-fallback route, and the
// WebSocket upgrade handler — one rule, everywhere a Lab request could be routed.
async function checkOwnership(req, env) {
if (!env) return { ok: false, reason: 'invalid_token' };
const studentId = await resolveSessionStudentId(req);
if (!studentId) return { ok: false, reason: 'no_session' };
if (studentId !== env.student_id) return { ok: false, reason: 'forbidden' };
return { ok: true, studentId };
}
app.get('/api/environment/status', async (req, res) => {
const studentId = requireStudentId(req, res);
if (!studentId) return;
const { status, body } = await provisioner.listForStudent(studentId);
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error');
res.json(await publicEnvView(active));
});
app.post('/api/environment/launch', async (req, res) => {
const studentId = requireStudentId(req, res);
if (!studentId) return;
const existing = await provisioner.listForStudent(studentId);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) {
// Reuse — "check whether they already have a usable environment; if yes, reuse it."
return res.json(await publicEnvView(active));
}
const { status, body } = await provisioner.provision(studentId);
if (status === 409) {
// Race: someone else's concurrent request created one between our check and this call.
const retry = await provisioner.listForStudent(studentId);
return res.json(await publicEnvView(retry.body?.[0]));
}
if (status !== 201) {
return res.status(502).json({ error: 'provision_failed', message: body?.error || 'Failed to provision an environment.' });
}
// The un-redacted token IS available here directly (body.routing_token, from the fresh
// POST /environments response) — publicEnvView still works fine even without it since it
// falls back to the dedicated lookup, but passing it through avoids one extra round trip.
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
});
app.post('/api/environment/reset', async (req, res) => {
const studentId = requireStudentId(req, res);
if (!studentId) return;
const existing = await provisioner.listForStudent(studentId);
const env = existing.body?.find((e) => !['destroyed'].includes(e.status));
if (!env) return res.status(404).json({ error: 'no_environment', message: 'No environment to reset — launch one first.' });
if (env.student_id !== studentId) {
// Defense in depth — listForStudent is already scoped by student_id, so this should be
// unreachable, but ownership is verified explicitly rather than assumed from the query.
return res.status(403).json({ error: 'forbidden' });
}
// env.routing_token is redacted here (from listForStudent) — fetch the real one so the
// cache actually gets invalidated under the key requests are made with, not under the
// literal string '[redacted]'. Otherwise a request to the (unchanged, per reset semantics)
// token could serve a stale cached internal_ip for up to the cache TTL after reset.
const { body: tokenBody } = await provisioner.getToken(env.environment_id);
if (tokenBody?.routing_token) envCache.invalidate(tokenBody.routing_token);
const { status, body } = await provisioner.reset(env.environment_id);
if (status !== 200) return res.status(502).json({ error: 'reset_failed', message: body?.error });
res.json(await publicEnvView(body));
});
// ── Progress relay — the one place the Gateway does more than blind proxying ───────────────
// Scenario validation must run against the student's own live cluster (inside their Lab
// container) — it can't be re-run anywhere else. But the canonical progress record lives on
// the persistent identity instance (docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §1/§9 note on why
// the ephemeral Lab's own SQLite can't be where progress lives). So these two routes are
// handled explicitly instead of falling through to the blind /lab/:token proxy below: forward
// the real request to the Lab, return its real response to the browser unchanged, and — only
// on success — separately relay the outcome to the identity backend's progress store. Found
// necessary by testing: without this, a scenario solved in the Lab UI never showed up in the
// dashboard's curriculum progress at all, and would have been lost outright on lab reset.
// ── Progress relay (unchanged mechanism from Phase 2A, now passes environment_id +
// validation_count through so Platform API can log a real scenario_attempts row) ──────────
async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcome }) {
if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.');
const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}${scenarioPath}`, {
@@ -183,21 +115,27 @@ async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcom
body: JSON.stringify(req.body || {}),
});
const body = await labRes.json().catch(() => ({}));
res.status(labRes.status).json(body); // real response to the browser, unchanged
res.status(labRes.status).json(body);
const passed = extractOutcome(body);
if (passed !== null) {
fetch(`${IDENTITY_BACKEND_URL}/api/progress/record`, {
const validationCount = Array.isArray(body.checks) ? body.checks.length : undefined;
const token = parseCookies(req)[STUDENT_COOKIE];
fetch(`${PLATFORM_API_URL}/api/progress/record`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Student-Id': env.student_id },
body: JSON.stringify({ scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress' }),
headers: { 'Content-Type': 'application/json', Cookie: `${STUDENT_COOKIE}=${token}` },
body: JSON.stringify({
scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress',
environment_id: env.environment_id, validation_count: validationCount,
}),
}).catch((e) => console.error('[progress relay] failed:', e.message));
}
}
app.post('/lab/:token/api/scenarios/:scenarioId/validate', express.json(), async (req, res) => {
const env = await resolveByToken(req.params.token);
if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.');
const own = await checkOwnership(req, env);
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
await relayScenarioOutcome({
env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`,
extractOutcome: (body) => (typeof body.passed === 'boolean' ? body.passed : null),
@@ -206,7 +144,8 @@ app.post('/lab/:token/api/scenarios/:scenarioId/validate', express.json(), async
app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async (req, res) => {
const env = await resolveByToken(req.params.token);
if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.');
const own = await checkOwnership(req, env);
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
await relayScenarioOutcome({
env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`,
extractOutcome: (body) => (typeof body.correct === 'boolean' ? body.correct : null),
@@ -220,11 +159,9 @@ app.use('/lab/:token', async (req, res, next) => {
if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.');
const sid = req.query.sid;
if (sid && env.student_id !== sid) {
// Explicit ownership check when we have something to check against (see "Routing design"
// in docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md for why sid is only available as a query
// param on the initial navigation, not on every subsequent proxied request).
const own = await checkOwnership(req, env);
if (!own.ok) {
if (own.reason === 'no_session') return sendLabError(res, 401, 'no_session', 'Please log in.');
return sendLabError(res, 403, 'forbidden', 'This lab does not belong to your session.');
}
if (env.status === 'provisioning' || env.status === 'requested' || env.status === 'resetting') {
@@ -234,53 +171,80 @@ app.use('/lab/:token', async (req, res, next) => {
return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running. Try recreating it.');
}
setLabSessionCookie(res, token);
// Marks this browser as "currently inside a lab page" — the only thing the unprefixed
// fallback route below uses to decide whether to even attempt Lab routing. Path-scoped
// isn't useful here (the whole point is covering root-relative /api/* and /shell-ws calls
// the Lab UI makes), so Path=/ with a short lifetime instead — long enough to cover a work
// session, short enough that leaving the lab page eventually stops privileging Lab-routing
// for this browser.
res.append('Set-Cookie', `${LAB_ACTIVE_COOKIE}=1; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`);
req.url = req.url.replace(/^\/lab\/[^/]+/, '') || '/';
proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
});
function sendLabError(res, status, error, message) {
res.status(status).type('html').send(`<!doctype html><meta charset="utf-8">
<title>RootNode Academy</title>
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem">
<h2>${message}</h2>
<p style="color:#666">(${error})</p>
</body>`);
// Resolves "the environment this browser's session currently owns and can reach," for the
// unprefixed fallback case (no /lab/:token in the URL — the existing Lab UI's own root-relative
// calls once a browser is already inside a lab session). Shared by both the HTTP fallback and
// the WS upgrade handler so there's exactly one place this logic lives. Gated on
// LAB_ACTIVE_COOKIE first — see its definition above for why "has a running env" alone isn't
// a safe signal (it's true on the dashboard too).
async function resolveOwnedRunningEnvironment(req) {
const cookies = parseCookies(req);
if (!cookies[LAB_ACTIVE_COOKIE]) return null;
const token = cookies[STUDENT_COOKIE];
if (!token) return null;
const studentId = await resolveSessionStudentId(req);
if (!studentId) return null;
try {
const envRes = await fetch(`${PLATFORM_API_URL}/api/environment/status`, { headers: { Cookie: `${STUDENT_COOKIE}=${token}` } });
const envInfo = await envRes.json().catch(() => null);
if (envInfo?.status !== 'running' || !envInfo.launch_path) return null;
const lookupToken = envInfo.launch_path.split('/')[2];
const env = await resolveByToken(lookupToken);
return isReachable(env) && env.student_id === studentId ? env : null;
} catch {
return null;
}
}
// ── Default fallback — cookie-routed Lab traffic, or the identity/platform backend ─────────
// Paths the unmodified Lab UI actually calls unprefixed (see backend/server.js's route list
// and frontend/src/App.jsx et al.). LAB_ACTIVE_COOKIE alone isn't a safe enough signal — it
// persists for the rest of the browser's session once a student has EVER visited their lab,
// which would otherwise keep misrouting dashboard calls (GET /api/session/whoami,
// /api/environment/*, /api/admin/*, /api/roster*, ...) to the Lab long after the student left
// it. Found by testing. Everything NOT matching this allowlist always goes to the Platform
// API, regardless of LAB_ACTIVE_COOKIE.
const LAB_OWNED_PATHS = [/^\/api\/scenarios\b/, /^\/api\/bundles\b/, /^\/api\/sessions\b/, /^\/api\/cache\b/, /^\/api\/health\b/, /^\/shell-ws$/, /^\/assets\//];
function looksLikeLabPath(pathname) {
return LAB_OWNED_PATHS.some((re) => re.test(pathname));
}
// ── Default fallback — unprefixed Lab traffic for a browser already "inside" a lab session,
// or the Platform API (student dashboard, admin portal, roster/curriculum/progress) ────────
app.use(async (req, res) => {
const cookies = parseCookies(req);
const token = cookies[LAB_SESSION_COOKIE];
if (token) {
const env = await resolveByToken(token);
if (isReachable(env)) {
return proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
}
if (looksLikeLabPath(req.path)) {
const env = await resolveOwnedRunningEnvironment(req);
if (env) return proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
}
proxy.web(req, res, { target: IDENTITY_BACKEND_URL });
proxy.web(req, res, { target: PLATFORM_API_URL });
});
// ── WebSocket upgrade — same routing rules as HTTP, applied to the /shell-ws handshake ─────
// ── WebSocket upgrade — same ownership rule as HTTP, applied to the /shell-ws handshake ────
const server = http.createServer(app);
server.on('upgrade', async (req, socket, head) => {
const labMatch = req.url.match(/^\/lab\/([^/]+)(\/.*)?$/);
if (labMatch) {
const [, token, rest] = labMatch;
const env = await resolveByToken(token);
if (!isReachable(env)) return socket.destroy();
const own = await checkOwnership(req, env);
if (!own.ok || !isReachable(env)) return socket.destroy();
req.url = rest || '/';
return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
}
const cookies = parseCookies(req);
const token = cookies[LAB_SESSION_COOKIE];
if (token) {
const env = await resolveByToken(token);
if (isReachable(env)) {
return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
}
}
// Unprefixed WS (the existing Lab UI's /shell-ws, hit while "inside" a lab session).
const env = await resolveOwnedRunningEnvironment(req);
if (env) return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
socket.destroy();
});