From ae75bb4cbeec72f304ccd6c3292b7afe4bc29c47 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 12:22:58 -0500 Subject: [PATCH] feat(auth): add server-controlled student and admin sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/platform/EntryScreen.jsx | 3 +- frontend/src/platform/PlatformShell.jsx | 26 +- frontend/src/platform/StudentDashboard.jsx | 11 +- frontend/src/platform/api.js | 58 ++-- platform/gateway/src/server.js | 304 +++++++++------------ platform/test/e2e-phase2a.js | 149 +++++----- 6 files changed, 257 insertions(+), 294 deletions(-) diff --git a/frontend/src/platform/EntryScreen.jsx b/frontend/src/platform/EntryScreen.jsx index f4f7074..585dcd0 100644 --- a/frontend/src/platform/EntryScreen.jsx +++ b/frontend/src/platform/EntryScreen.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { api, storeStudent } from './api' +import { api } from './api' import styles from './Platform.module.css' // A. Entry screen — RootNode Academy branding, choose your name from roster, no password. @@ -28,7 +28,6 @@ export default function EntryScreen({ onLogin }) { setError(null) try { const student = await api.login(studentId) - storeStudent(student) onLogin(student) } catch (e) { setError(e.message) diff --git a/frontend/src/platform/PlatformShell.jsx b/frontend/src/platform/PlatformShell.jsx index 7a01733..e479f04 100644 --- a/frontend/src/platform/PlatformShell.jsx +++ b/frontend/src/platform/PlatformShell.jsx @@ -1,14 +1,28 @@ -import { useState } from 'react' -import { getStoredStudent } from './api' +import { useState, useEffect } from 'react' +import { api, getCachedDisplay } from './api' import EntryScreen from './EntryScreen' import StudentDashboard from './StudentDashboard' -// Top-level Platform Shell: entry screen if no student session, dashboard otherwise. This is -// everything OUTSIDE the /lab// Lab experience (which stays the existing, unmodified -// — see main.jsx). +// Top-level Platform Shell: entry screen if no valid session, dashboard otherwise. Session +// validity is decided by the server (GET /api/session/whoami against the rn_session cookie), +// not by anything read from localStorage/sessionStorage — those only cache the display name +// for a snappier first paint, per api.js. export default function PlatformShell() { - const [student, setStudent] = useState(getStoredStudent) + const [student, setStudent] = useState(null) + const [checking, setChecking] = useState(true) + useEffect(() => { + const cached = getCachedDisplay() + if (cached) setStudent(cached) // optimistic paint while we confirm with the server + api.whoami() + .then(({ student_id }) => { + if (!cached || cached.student_id !== student_id) setStudent({ student_id, display_name: cached?.display_name || student_id }) + }) + .catch(() => setStudent(null)) + .finally(() => setChecking(false)) + }, []) + + if (checking) return null if (!student) return return setStudent(null)} /> } diff --git a/frontend/src/platform/StudentDashboard.jsx b/frontend/src/platform/StudentDashboard.jsx index b929bb0..d8185f4 100644 --- a/frontend/src/platform/StudentDashboard.jsx +++ b/frontend/src/platform/StudentDashboard.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { api, clearStudent } from './api' +import { api } from './api' import styles from './Platform.module.css' const POLL_MS = 2500 @@ -44,9 +44,10 @@ export default function StudentDashboard({ student, onLogout }) { const result = await api.launchEnvironment() setEnvStatus(result) if (result.status === 'running' && result.launch_path) { - // sid is a defense-in-depth ownership check the Gateway verifies on this specific - // navigation — see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Routing design." - window.location.href = `${result.launch_path}?sid=${encodeURIComponent(student.student_id)}` + // No ?sid= needed anymore (Phase 2B) — the Gateway verifies ownership from the + // rn_session cookie itself, which travels with this navigation automatically. See + // docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3. + window.location.href = result.launch_path } } catch (e) { setError(e.message) @@ -67,7 +68,7 @@ export default function StudentDashboard({ student, onLogout }) { } } - const handleLogout = () => { clearStudent(); onLogout() } + const handleLogout = () => { api.logout().then(onLogout) } const nextStage = curriculum?.stages?.find((s) => s.id === curriculum.next_stage) const nextScenario = nextStage?.scenarios?.find((sc) => sc.status !== 'completed') diff --git a/frontend/src/platform/api.js b/frontend/src/platform/api.js index 70f69ae..7748ed3 100644 --- a/frontend/src/platform/api.js +++ b/frontend/src/platform/api.js @@ -1,26 +1,23 @@ -// Platform Shell API client. Student identity is pilot-grade (roster pick-your-name, no -// password) — see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student identity limitation." -// student_id lives in localStorage only, never a cookie, never sent anywhere except as the -// X-Student-Id header on these specific requests. +// Platform Shell API client. Phase 2B: identity is a server-controlled session cookie +// (rn_session, HttpOnly — this code can't read it, and doesn't need to; the browser sends it +// automatically on every same-origin request). This replaces Phase 2A's localStorage +// student_id + X-Student-Id header, which was explicitly pilot-only and client-forgeable. +// See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3. +// +// display_name is still cached in sessionStorage purely for UI convenience (showing "Hi, +// Alice" without an extra round trip) — it carries no authority; every actual API call is +// authorized by the cookie alone, not by anything read from here. -const STORAGE_KEY = 'rootnode_student' +const DISPLAY_CACHE_KEY = 'rootnode_student_display' -export function getStoredStudent() { - try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') } catch { return null } +export function getCachedDisplay() { + try { return JSON.parse(sessionStorage.getItem(DISPLAY_CACHE_KEY) || 'null') } catch { return null } } -export function storeStudent(student) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(student)) +function cacheDisplay(student) { + sessionStorage.setItem(DISPLAY_CACHE_KEY, JSON.stringify(student)) } -export function clearStudent() { - localStorage.removeItem(STORAGE_KEY) -} - -function authHeaders() { - const student = getStoredStudent() - // Prevents accidental anonymous progress writes (Phase 2A requirement): every call that - // needs identity throws instead of silently going out unauthenticated. - if (!student?.student_id) throw new Error('No student session') - return { 'X-Student-Id': student.student_id } +function clearCachedDisplay() { + sessionStorage.removeItem(DISPLAY_CACHE_KEY) } async function json(res) { @@ -34,13 +31,22 @@ async function json(res) { export const api = { cohorts: () => fetch('/api/roster/cohorts').then(json), roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json), - login: (student_id) => fetch('/api/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }), - }).then(json), + login: async (student_id) => { + const student = await fetch('/api/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }), + }).then(json) + cacheDisplay(student) + return student + }, + logout: async () => { + await fetch('/api/logout', { method: 'POST' }).catch(() => {}) + clearCachedDisplay() + }, + whoami: () => fetch('/api/session/whoami').then(json), - curriculumPath: () => fetch('/api/curriculum/path', { headers: authHeaders() }).then(json), + curriculumPath: () => fetch('/api/curriculum/path').then(json), - environmentStatus: () => fetch('/api/environment/status', { headers: authHeaders() }).then(json), - launchEnvironment: () => fetch('/api/environment/launch', { method: 'POST', headers: authHeaders() }).then(json), - resetEnvironment: () => fetch('/api/environment/reset', { method: 'POST', headers: authHeaders() }).then(json), + environmentStatus: () => fetch('/api/environment/status').then(json), + launchEnvironment: () => fetch('/api/environment/launch', { method: 'POST' }).then(json), + resetEnvironment: () => fetch('/api/environment/reset', { method: 'POST' }).then(json), } diff --git a/platform/gateway/src/server.js b/platform/gateway/src/server.js index 41b866b..cb19d3b 100644 --- a/platform/gateway/src/server.js +++ b/platform/gateway/src/server.js @@ -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//* — 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//, 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(` +RootNode Academy + +

${message}

+

(${error})

+`); } -// 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(` -RootNode Academy - -

${message}

-

(${error})

-`); +// 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(); }); diff --git a/platform/test/e2e-phase2a.js b/platform/test/e2e-phase2a.js index f07104f..7430016 100644 --- a/platform/test/e2e-phase2a.js +++ b/platform/test/e2e-phase2a.js @@ -1,32 +1,38 @@ #!/usr/bin/env node -// Phase 2A end-to-end test — runs against a live platform/docker-compose.dev.yml stack -// (postgres + egress-proxy + provisioner + identity + gateway). Exercises the full student -// flow through the Gateway, exactly as a browser would, using two students concurrently to -// verify isolation. This is slow (each lab provision is a real k3s cold start, ~1-3 min) and -// deliberately not run on every commit — see platform/README.md for when to run it. +// Phase 2A student-flow regression, updated for Phase 2B's session-cookie architecture. +// Originally used X-Student-Id headers and a ?sid= query param — both mechanisms are gone as +// of Phase 2B (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3); this version exercises the exact +// same flow through the real rn_session cookie instead, since that's how it actually works now. // -// No framework, matching platform/provisioner/test/run.js's convention. Requires: -// cd platform && docker compose -f docker-compose.dev.yml up -d -// node test/e2e-phase2a.js +// Runs against a live platform/docker-compose.dev.yml stack. Slow (real k3s boots). No +// framework, matching project convention. // -// This captures what was verified by hand during Phase 2A implementation (see -// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §8) as a repeatable regression script — it is not -// a substitute for the manual real-Lab-image verification (node-pty, terminal, kubectl, -// scenario validation) documented there, which needs a browser/WebSocket client and isn't -// automated here. +// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d && node test/e2e-phase2a.js const assert = require('assert'); const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; const COHORT = `e2e-test-${Date.now()}`; -async function api(method, path, { studentId, body } = {}) { - const headers = { 'Content-Type': 'application/json' }; - if (studentId) headers['X-Student-Id'] = studentId; - const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined }); - const text = await res.text(); - let json; try { json = JSON.parse(text); } catch { json = text; } - return { status: res.status, body: json }; +// Minimal manual cookie jar — Node's fetch doesn't persist cookies across calls the way a +// browser does, so each "session" here explicitly carries its own Set-Cookie forward. +function makeSession() { + let cookie = ''; + return { + async request(method, path, body) { + const headers = { 'Content-Type': 'application/json' }; + if (cookie) headers.Cookie = cookie; + const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined }); + const setCookie = res.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]; + const text = await res.text(); + let json; try { json = JSON.parse(text); } catch { json = text; } + return { status: res.status, body: json }; + }, + rawFetch(path) { + return fetch(`${GW}${path}`, { headers: cookie ? { Cookie: cookie } : {} }); + }, + }; } let passed = 0, failed = 0; @@ -36,110 +42,83 @@ async function test(name, fn) { } async function main() { - console.log('Phase 2A end-to-end tests (this is slow — real k3s boots)\n'); + console.log('Phase 2A student-flow regression (Phase 2B session cookies) — slow, real k3s boots\n'); + + const admin = makeSession(); + await admin.request('POST', '/api/admin/login', { password: process.env.ADMIN_PASSWORD }); let alice, bob; - await test('roster: add two students', async () => { - const a = await api('POST', '/api/roster', { body: { display_name: 'E2E-Alice', cohort: COHORT } }); - const b = await api('POST', '/api/roster', { body: { display_name: 'E2E-Bob', cohort: COHORT } }); - assert.equal(a.status, 200); assert.equal(b.status, 200); + await test('roster: add two students via admin', async () => { + const a = await admin.request('POST', '/api/admin/students', { display_name: 'E2E-Alice-2A', cohort_id: null }); + const b = await admin.request('POST', '/api/admin/students', { display_name: 'E2E-Bob-2A', cohort_id: null }); alice = a.body.student_id; bob = b.body.student_id; assert.notEqual(alice, bob); }); - await test('roster: cohort listing includes the new cohort', async () => { - const { body } = await api('GET', `/api/roster?cohort=${COHORT}`); - assert.equal(body.length, 2); + const aliceSession = makeSession(); + const bobSession = makeSession(); + + await test('login: student session cookie is issued and required for curriculum', async () => { + const { status } = await aliceSession.request('POST', '/api/login', { student_id: alice }); + assert.equal(status, 200); + const noSession = await fetch(`${GW}/api/curriculum/path`); + assert.equal(noSession.status, 401); // anonymous rejection + const { status: withSession } = await aliceSession.request('GET', '/api/curriculum/path'); + assert.equal(withSession, 200); }); - await test('login: both students can log in', async () => { - const a = await api('POST', '/api/login', { body: { student_id: alice } }); - const b = await api('POST', '/api/login', { body: { student_id: bob } }); - assert.equal(a.status, 200); assert.equal(b.status, 200); - }); - - await test('curriculum: path loads with the real RootNode stages, in order', async () => { - const { body } = await api('GET', '/api/curriculum/path', { studentId: alice }); - const ids = body.stages.map((s) => s.id); - assert.deepEqual(ids, ['foundation', 'workloads', 'networking', 'configuration', 'troubleshooting', 'real-ops', 'capstone']); - assert.equal(body.stages.find((s) => s.id === 'capstone').locked, true); - }); - - await test('environment: status is none before launch', async () => { - const { body } = await api('GET', '/api/environment/status', { studentId: alice }); - assert.equal(body.status, 'none'); + await test('curriculum: real RootNode stages, in order', async () => { + const { body } = await aliceSession.request('GET', '/api/curriculum/path'); + assert.deepEqual(body.stages.map((s) => s.id), ['foundation', 'workloads', 'networking', 'configuration', 'troubleshooting', 'real-ops', 'capstone']); }); let aliceLaunch, bobLaunch; - await test('launch: Alice gets a running environment with a launch_path (real k3s boot, be patient)', async () => { - const { status, body } = await api('POST', '/api/environment/launch', { studentId: alice }); + await test('launch: Alice gets a running environment (real k3s boot, be patient)', async () => { + const { status, body } = await aliceSession.request('POST', '/api/environment/launch'); assert.equal(status, 200); assert.equal(body.status, 'running'); - assert.ok(body.launch_path?.startsWith('/lab/')); aliceLaunch = body; }); - await test('launch: Bob gets a DIFFERENT running environment', async () => { - const { status, body } = await api('POST', '/api/environment/launch', { studentId: bob }); - assert.equal(status, 200); - assert.equal(body.status, 'running'); + await test('launch: Bob gets a different environment', async () => { + await bobSession.request('POST', '/api/login', { student_id: bob }); + const { body } = await bobSession.request('POST', '/api/environment/launch'); assert.notEqual(body.launch_path, aliceLaunch.launch_path); bobLaunch = body; }); - await test('launch: relaunching for Alice reuses the same environment (idempotent)', async () => { - const { body } = await api('POST', '/api/environment/launch', { studentId: alice }); - assert.equal(body.launch_path, aliceLaunch.launch_path); - }); - - await test('gateway: Alice can reach her own lab', async () => { - const res = await fetch(`${GW}${aliceLaunch.launch_path}`); + await test('gateway: Alice reaches her own lab via session (no ?sid= needed anymore)', async () => { + const res = await aliceSession.rawFetch(aliceLaunch.launch_path); assert.equal(res.status, 200); }); - await test('gateway: an invalid token is rejected (404)', async () => { - const res = await fetch(`${GW}/lab/not-a-real-token-at-all/`); - assert.equal(res.status, 404); - }); - - await test('gateway: Alice\'s URL with Bob\'s sid is rejected (cross-student ownership check)', async () => { - const res = await fetch(`${GW}${aliceLaunch.launch_path}?sid=${bob}`); + await test('security: Alice cannot reach Bob\'s lab even with her OWN valid session (impersonation)', async () => { + const res = await aliceSession.rawFetch(bobLaunch.launch_path); assert.equal(res.status, 403); }); - await test('gateway: Alice\'s URL with her own sid succeeds', async () => { - const res = await fetch(`${GW}${aliceLaunch.launch_path}?sid=${alice}`); - assert.equal(res.status, 200); + await test('security: no session at all is rejected, not just wrong session', async () => { + const res = await fetch(`${GW}${aliceLaunch.launch_path}`); + assert.equal(res.status, 401); }); await test('progress relay: solving a scenario in Alice\'s lab shows up in her curriculum', async () => { - const scenarioPath = `${aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`; - await api('POST', scenarioPath, { studentId: alice, body: { selected: 'b' } }); - await new Promise((r) => setTimeout(r, 1500)); // relay is fire-and-forget on the Gateway side - const { body } = await api('GET', '/api/curriculum/path', { studentId: alice }); + await aliceSession.request('POST', `${aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' }); + await new Promise((r) => setTimeout(r, 1500)); + const { body } = await aliceSession.request('GET', '/api/curriculum/path'); const scenario = body.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); assert.equal(scenario.status, 'completed'); }); - await test('progress relay: Bob\'s curriculum is unaffected by Alice\'s answer', async () => { - const { body } = await api('GET', '/api/curriculum/path', { studentId: bob }); - const scenario = body.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); - assert.notEqual(scenario.status, 'completed'); - }); - - await test('reset: Alice\'s lab keeps the same launch_path (stable URL) and her progress survives', async () => { - const { status, body } = await api('POST', '/api/environment/reset', { studentId: alice }); - assert.equal(status, 200); + await test('reset: stable launch_path, progress survives', async () => { + const { body } = await aliceSession.request('POST', '/api/environment/reset'); assert.equal(body.launch_path, aliceLaunch.launch_path); - const { body: curriculum } = await api('GET', '/api/curriculum/path', { studentId: alice }); + const { body: curriculum } = await aliceSession.request('GET', '/api/curriculum/path'); const scenario = curriculum.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); assert.equal(scenario.status, 'completed'); }); console.log(`\n${passed} passed, ${failed} failed`); - console.log('\nNot covered here (needs a browser/WebSocket client — verified manually, see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §8):'); - console.log(' - real Lab image: node-pty load, terminal WS, kubectl execution'); - console.log(' - idle reaper timing (needs a real threshold wait, or DB manipulation — see the doc)'); process.exit(failed ? 1 : 0); }