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:
@@ -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)
|
||||
|
||||
@@ -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/<token>/ Lab experience (which stays the existing, unmodified
|
||||
// <App/> — 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 <EntryScreen onLogin={setStudent} />
|
||||
return <StudentDashboard student={student} onLogout={() => setStudent(null)} />
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user