5.3 KiB
ADR: Session ownership check and AuthenticatedRequest type
Status
Accepted
Date
2026-04-28
Context
evaluateAnswer accepted any sessionId without verifying it belonged to the requesting user. The only protection was the unguessability of a UUID — security through obscurity. If a user intercepted or guessed another user's sessionId, they could submit answers on their behalf.
Additionally, protected controller handlers typed their req parameter as Request, making session optional even though requireAuth middleware guarantees it is present. This required non-null assertions (req.session!) in business logic — a type assertion that could cause a runtime crash if middleware ordering ever changed.
Decision
Store userId in GameSessionData. Pass userId from the controller into both createGameSession and evaluateAnswer. Assert ownership on evaluation — if the session's userId doesn't match the requesting user's ID, throw NotFoundError. Introduce AuthenticatedRequest to eliminate non-null assertions in protected handlers.
Options considered
Option A — AuthenticatedRequest type ✅
Define AuthenticatedRequest = Request & { session: { session: Session; user: User } } in types/express.d.ts. Use it in protected controller handlers instead of Request. Requires a single as express.RequestHandler cast at route registration due to Express's type limitations.
Chosen because: eliminates dangerous non-null assertions in business logic. The cast at route registration is a necessary cast caused by a third-party library limitation, not uncertain logic.
Option B — Non-null assertion (req.session!)
Keep Request on all handlers. Assert req.session! at every usage.
Rejected because: non-null assertions in business logic are dangerous — if middleware ordering ever changes, the assertion silently passes and crashes at runtime.
Option C — NotFoundError (404) on ownership failure ✅
When a session exists but belongs to a different user, throw NotFoundError with the same message as a missing session.
Chosen because: session IDs are opaque secrets. Returning 403 would confirm to the caller that the session ID is valid and belongs to someone else — information they shouldn't have. This pattern is used by GitHub, AWS, and most security-conscious APIs.
Option D — ForbiddenError (403) on ownership failure
Explicit error that distinguishes "not found" from "not allowed".
Rejected because: for user-owned resources identified by opaque IDs, confirming existence to an unauthorised caller is an information leak. 404 is the industry standard for this case.
Consequences
- Alice cannot submit answers for Bob's session — ownership is verified at the service layer
req.session.user.idis accessible without non-null assertions in protected handlersGameSessionDatanow carriesuserId— any futureGameSessionStoreimplementation must store and return it- Route registration requires
as express.RequestHandlercast for protected handlers — one cast per route, in wiring code only ValKeyGameSessionStoremust serialise and deserialiseuserIdalongsideanswers
Affected files
apps/api/src/types/express.d.ts—AuthenticatedRequesttype addedapps/api/src/gameSessionStore/GameSessionStore.ts—userIdadded toGameSessionDataapps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts— updated data fixturesapps/api/src/services/gameService.ts—userIdparameter added to both functions, ownership assertion inevaluateAnswerapps/api/src/services/gameService.test.ts— updated all calls, ownership test addedapps/api/src/controllers/gameController.ts— extractsuserIdfromreq.session.user.id, passes to service callsapps/api/src/routes/gameRouter.ts—as express.RequestHandlercast at route registration
References
Setup guide / implementation notes
-
types/express.d.ts— add:export type AuthenticatedRequest = Request & { session: { session: Session; user: User }; }; -
GameSessionStore.ts— adduserIdtoGameSessionData:export type GameSessionData = { answers: Map<string, number>; userId: string; }; -
gameService.ts— adduserIdto both function signatures:export const createGameSession = async ( request: GameRequest, store: GameSessionStore, userId: string, ): Promise<GameSession>Store it on create:
await store.create( sessionId, { answers: answerKey, userId }, 30 * 60 * 1000, );Assert on evaluate:
if (!session || session.userId !== userId) { throw new NotFoundError(`Game session not found: ${submission.sessionId}`); } -
gameController.ts— extract from authenticated request:req.session.user.id; -
gameRouter.ts— cast at registration:router.post("/start", controller.createGame as express.RequestHandler); router.post("/answer", controller.submitAnswer as express.RequestHandler);