Coverage for server / routes / student / student_dashboard_ws.py: 87%
63 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2EI-2966 — Student Dashboard WebSocket route.
4Endpoint: `GET /v1/student/dashboard/ws`
6Hand-rolled token validation because FastAPI's HTTP `Depends` chain
7doesn't run on WebSocket lifecycles cleanly. The token is read from the
8**HttpOnly `access_token` cookie** the BFF already sets, validated the same
9way `Auth0Bearer` would, and bound to the resolved user_id.
11SEC-3 — the token used to arrive as a `?token=` query parameter. That put a
12live Auth0 bearer token into browser history, Referer headers, nginx and proxy
13access logs, and any network appliance on the path, defeating the whole point
14of the HttpOnly-cookie BFF design used by every REST route. The query
15parameter is gone; it is not accepted as a fallback, because leaving it
16accepted would let the leak be reintroduced by any future caller.
18**Origin is validated explicitly, and that is not optional here.**
19`CORSMiddleware` does *not* apply to WebSocket handshakes — WS is exempt from
20the same-origin policy — and the auth cookie is issued `SameSite=none`, so a
21browser will happily attach it to a socket opened by *any* site. Without the
22Origin allowlist below, moving from query-param auth to cookie auth would
23trade a token-in-URL leak for cross-site WebSocket hijacking (CSWSH), where a
24malicious page opens an authenticated socket and reads the student's dashboard
25stream. Origin is set by the browser and cannot be forged from JavaScript.
27Push contract:
28 Server -> client messages are JSON objects:
29 { "type": "heartbeat", "ts": ISO-8601 } — sent every 25 s
30 { "type": "submission_submitted", "assignment_id": ..., "ts": ... }
31 { "type": "submission_graded", "assignment_id": ..., "grade": ..., "ts": ... }
32 { "type": "assignment_created", "assignment_id": ..., "ts": ... }
34 Client -> server messages: any text payload is acknowledged but
35 otherwise ignored (no client-driven commands today).
36"""
38from __future__ import annotations
40import asyncio
41import json
42import logging
43from datetime import datetime, timezone
45import os
47from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
49from server.authentication.auth0_bearer import auth0_handler
50from server.utilities.websocket_manager import student_dashboard_ws_manager
53_LOG = logging.getLogger(__name__)
55router = APIRouter()
57_HEARTBEAT_INTERVAL_SEC = 25
59#: Cookie the BFF writes the Auth0 access token into (see cookie_session.py).
60_ACCESS_TOKEN_COOKIE = "access_token"
63def _allowed_origins() -> set[str]:
64 """Origins permitted to open a dashboard socket.
66 Read at call time (not import time) so tests and redeploys can change
67 ALLOWED_ORIGINS without reimporting. Shares the CORS allowlist so there is
68 one place to add an environment, but note CORSMiddleware itself never sees
69 a WebSocket handshake — this check is what actually enforces it.
70 """
71 raw = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000")
72 return {o.strip().rstrip("/") for o in raw.split(",") if o.strip()}
75def _origin_allowed(origin: str | None) -> bool:
76 """True when *origin* is on the allowlist.
78 A missing Origin is REJECTED. Browsers always send it on a WS handshake,
79 so absence means a non-browser client — which has no business using a
80 cookie-authenticated socket, and for which cookie auth grants nothing
81 anyway.
82 """
83 if not origin:
84 return False
85 return origin.rstrip("/") in _allowed_origins()
88async def _validate_ws_token(token: str) -> str | None:
89 """Resolve a bearer token to a user_id (stringified Auth0 sub) or None.
91 We deliberately don't run the full role-mapping + DB-lookup path here —
92 the WS connection just needs a stable per-user identifier to key the
93 connection pool. Auth0's `sub` claim is that identifier.
94 """
95 if not token:
96 return None
97 try:
98 payload = await auth0_handler.verify_token(token)
99 if not payload:
100 return None
101 sub = payload.get("sub")
102 return str(sub) if sub else None
103 except Exception: # noqa: BLE001 — any failure → unauthenticated
104 return None
107@router.websocket("/ws")
108async def student_dashboard_ws(websocket: WebSocket):
109 """Streaming dashboard updates for the authenticated student.
111 Auth is the HttpOnly `access_token` cookie; there is no query parameter.
112 """
113 # Origin first: reject a cross-site socket before the cookie is even read,
114 # so a hijack attempt never reaches token validation.
115 if not _origin_allowed(websocket.headers.get("origin")):
116 _LOG.warning(
117 "student_dashboard_ws rejected: origin %r not allowed",
118 websocket.headers.get("origin"),
119 )
120 await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
121 return
123 user_id = await _validate_ws_token(websocket.cookies.get(_ACCESS_TOKEN_COOKIE, ""))
124 if not user_id:
125 await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
126 return
128 await websocket.accept()
129 await student_dashboard_ws_manager.connect(user_id, websocket)
131 async def _heartbeat_loop() -> None:
132 try:
133 while True:
134 await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
135 await websocket.send_text(
136 json.dumps({
137 "type": "heartbeat",
138 "ts": datetime.now(timezone.utc).isoformat(),
139 })
140 )
141 except Exception: # noqa: BLE001 — receive_text loop handles cleanup
142 return
144 heartbeat_task = asyncio.create_task(_heartbeat_loop())
145 try:
146 while True:
147 # Client pings / pongs are accepted but ignored — server is push-only.
148 await websocket.receive_text()
149 except WebSocketDisconnect:
150 pass
151 except Exception as e: # noqa: BLE001
152 _LOG.warning("student_dashboard_ws closed with error: %s", e)
153 finally:
154 heartbeat_task.cancel()
155 await student_dashboard_ws_manager.disconnect(user_id, websocket)
158async def publish_dashboard_event(user_id: str, event_type: str, **payload) -> int:
159 """Public helper service callers use to push an event to a student.
161 Example:
162 from server.routes.student.student_dashboard_ws import publish_dashboard_event
163 await publish_dashboard_event(
164 student_id,
165 "submission_submitted",
166 assignment_id=str(assignment_id),
167 )
169 Returns the number of sockets the event reached (0 if the student
170 isn't currently connected — that's fine, the dashboard's 30s
171 fallback polling will pick up the change on the next tick).
172 """
173 body = {
174 "type": event_type,
175 "ts": datetime.now(timezone.utc).isoformat(),
176 **payload,
177 }
178 return await student_dashboard_ws_manager.broadcast_to_user(str(user_id), body)