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
+32 -26
View File
@@ -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),
}