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

1""" 

2EI-2966 — Student Dashboard WebSocket route. 

3 

4Endpoint: `GET /v1/student/dashboard/ws` 

5 

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. 

10 

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. 

17 

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. 

26 

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": ... } 

33 

34 Client -> server messages: any text payload is acknowledged but 

35 otherwise ignored (no client-driven commands today). 

36""" 

37 

38from __future__ import annotations 

39 

40import asyncio 

41import json 

42import logging 

43from datetime import datetime, timezone 

44 

45import os 

46 

47from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status 

48 

49from server.authentication.auth0_bearer import auth0_handler 

50from server.utilities.websocket_manager import student_dashboard_ws_manager 

51 

52 

53_LOG = logging.getLogger(__name__) 

54 

55router = APIRouter() 

56 

57_HEARTBEAT_INTERVAL_SEC = 25 

58 

59#: Cookie the BFF writes the Auth0 access token into (see cookie_session.py). 

60_ACCESS_TOKEN_COOKIE = "access_token" 

61 

62 

63def _allowed_origins() -> set[str]: 

64 """Origins permitted to open a dashboard socket. 

65 

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()} 

73 

74 

75def _origin_allowed(origin: str | None) -> bool: 

76 """True when *origin* is on the allowlist. 

77 

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() 

86 

87 

88async def _validate_ws_token(token: str) -> str | None: 

89 """Resolve a bearer token to a user_id (stringified Auth0 sub) or None. 

90 

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 

105 

106 

107@router.websocket("/ws") 

108async def student_dashboard_ws(websocket: WebSocket): 

109 """Streaming dashboard updates for the authenticated student. 

110 

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 

122 

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 

127 

128 await websocket.accept() 

129 await student_dashboard_ws_manager.connect(user_id, websocket) 

130 

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 

143 

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) 

156 

157 

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. 

160 

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 ) 

168 

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)