Child book read-context never built on real tokens: bare FindFirst("sub") nulled by JWT inbound claim mapping (breaks #729 household grants; #618 child gate fail-opens) #734

Closed
opened 2026-07-20 15:26:28 +00:00 by spikerj · 2 comments
Owner

Symptom

toddcan (child of spikerj) opens the reading-journey and sees "No Books Yet" even though spikerj granted two of his uploaded books via the parent-dashboard (#729 shipped in backend PR #432 + angular PR #454, deployed 2026-07-20 03:15Z).

Verified NOT the cause (all confirmed good in the live cluster)

  • Grants persisted: PUT /api/profile/parental/children/8bf8b57d-…/approved-books/{bookId} → 200 (Seq, 05:08Z) and user-profiles shows ApprovedBookIds = ['6a5bf73f…', '6a55ad64…']
  • Both books Status='Available', Visibility='Private', UploadedBy='spikerj', not deleted
  • Child profile IsChildAccount=true, linked ParentKeycloakUserId correct; parent profile Username='spikerj' == Book.UploadedBy
  • BookAccess.IsInLibrary household branch is correct and would match — if the child context were ever built

Root cause

BookController.BuildReadContextAsync (BookController.cs:74) reads User.FindFirst("sub"). The API's JWT bearer setup (Infrastructure/Authentication.cs) never disables inbound claim mapping, so on real Keycloak tokens ASP.NET's JsonWebTokenHandler renames subClaimTypes.NameIdentifier. FindFirst("sub") returns null → profile lookup skipped → BookReadContexts.BuildAsync returns an Adult context for every caller → the household grant branch never runs and the child sees only own-uploads + Public books (catalog is all-Private ⇒ empty shelf).

Why every test passes: TestAuthenticationHandler issues a literal "sub" claim (no mapping), so test-auth/e2e never see the prod claim shape. GameServerTokenValidator.cs:60 even sets MapInboundClaims=false explicitly before reading "sub" — the pitfall was known in that corner.

Blast radius (bare FindFirst("sub"), no NameIdentifier fallback)

  • BookController.cs:74 — this bug, plus the #618 child gate on the Public catalog fail-opens (a child with a real token is treated as an adult; currently masked only because the catalog is all-Private)
  • BlogController.cs:77#719 child linked-book validation broken the same way
  • ReaderController.cs:219 — social reading-list / bookmark-upvote identity is null
  • SignalR: GameHub.cs:112, ChessHub.cs:367, VideoCallHub.cs:381,402, HexTowerDefenceHub.cs:341 (NotificationsHub already has the fallback chain)

Fix

Shared ClaimsPrincipal extension (NameIdentifier-first, sub fallback — the codebase's dominant pattern, cf. ProfileController.GetUserId), applied at all bare-sub sites; TestAuthenticationHandler switched to emit the mapped claim shape so tests exercise what prod actually sees; regression coverage.

## Symptom `toddcan` (child of `spikerj`) opens the reading-journey and sees **"No Books Yet"** even though spikerj granted two of his uploaded books via the parent-dashboard (#729 shipped in backend PR #432 + angular PR #454, deployed 2026-07-20 03:15Z). ## Verified NOT the cause (all confirmed good in the live cluster) - Grants persisted: `PUT /api/profile/parental/children/8bf8b57d-…/approved-books/{bookId}` → 200 (Seq, 05:08Z) and `user-profiles` shows `ApprovedBookIds = ['6a5bf73f…', '6a55ad64…']` - Both books `Status='Available'`, `Visibility='Private'`, `UploadedBy='spikerj'`, not deleted - Child profile `IsChildAccount=true`, linked `ParentKeycloakUserId` correct; parent profile `Username='spikerj'` == `Book.UploadedBy` - `BookAccess.IsInLibrary` household branch is correct and would match — **if the child context were ever built** ## Root cause `BookController.BuildReadContextAsync` (BookController.cs:74) reads `User.FindFirst("sub")`. The API's JWT bearer setup (`Infrastructure/Authentication.cs`) never disables inbound claim mapping, so on **real Keycloak tokens** ASP.NET's `JsonWebTokenHandler` renames `sub` → `ClaimTypes.NameIdentifier`. `FindFirst("sub")` returns **null** → profile lookup skipped → `BookReadContexts.BuildAsync` returns an **Adult** context for every caller → the household grant branch never runs and the child sees only own-uploads + Public books (catalog is all-Private ⇒ empty shelf). Why every test passes: `TestAuthenticationHandler` issues a literal `"sub"` claim (no mapping), so test-auth/e2e never see the prod claim shape. `GameServerTokenValidator.cs:60` even sets `MapInboundClaims=false` explicitly before reading `"sub"` — the pitfall was known in that corner. ## Blast radius (bare `FindFirst("sub")`, no `NameIdentifier` fallback) - `BookController.cs:74` — this bug, **plus the #618 child gate on the Public catalog fail-opens** (a child with a real token is treated as an adult; currently masked only because the catalog is all-Private) - `BlogController.cs:77` — #719 child linked-book validation broken the same way - `ReaderController.cs:219` — social reading-list / bookmark-upvote identity is null - SignalR: `GameHub.cs:112`, `ChessHub.cs:367`, `VideoCallHub.cs:381,402`, `HexTowerDefenceHub.cs:341` (NotificationsHub already has the fallback chain) ## Fix Shared `ClaimsPrincipal` extension (NameIdentifier-first, `sub` fallback — the codebase's dominant pattern, cf. `ProfileController.GetUserId`), applied at all bare-`sub` sites; `TestAuthenticationHandler` switched to emit the **mapped** claim shape so tests exercise what prod actually sees; regression coverage.
Author
Owner

Correction to the blast-radius list: the SignalR hubs (GameHub, ChessHub, VideoCallHub, HexTowerDefenceHub) are not affected — their sub-first chains all fall back to the mapped NameIdentifier URI on the next line, so they resolve correctly on real tokens. The genuinely broken sites were only the three controllers (BookController, BlogController, ReaderController).

Fix is up: spikersoft-backend PR #434 (branch fix/734-sub-claim-mapping). Regression net verified by reverting the controller line — 4 existing child tests fail on the old code now that test principals carry the production claim shape.

Correction to the blast-radius list: the SignalR hubs (`GameHub`, `ChessHub`, `VideoCallHub`, `HexTowerDefenceHub`) are **not** affected — their `sub`-first chains all fall back to the mapped `NameIdentifier` URI on the next line, so they resolve correctly on real tokens. The genuinely broken sites were only the three controllers (`BookController`, `BlogController`, `ReaderController`). Fix is up: spikersoft-backend PR #434 (branch `fix/734-sub-claim-mapping`). Regression net verified by reverting the controller line — 4 existing child tests fail on the old code now that test principals carry the production claim shape.
Author
Owner

Resolved in spikersoft-backend PR #434 (merged to master 2026-07-20 15:38Z). Shared ClaimsPrincipalExtensions.GetKeycloakUserId() (NameIdentifier ?? raw sub) applied at the three broken controller sites; test principals now carry the production claim shape so the child test matrix guards against regressions (old code fails 4 tests). Deploy workflows for the merge are queued at time of closing — once the API rolls, toddcan's my-books returns the two granted books with no data changes. Closing.

Resolved in spikersoft-backend PR #434 (merged to `master` 2026-07-20 15:38Z). Shared `ClaimsPrincipalExtensions.GetKeycloakUserId()` (NameIdentifier ?? raw `sub`) applied at the three broken controller sites; test principals now carry the production claim shape so the child test matrix guards against regressions (old code fails 4 tests). Deploy workflows for the merge are queued at time of closing — once the API rolls, toddcan's my-books returns the two granted books with no data changes. Closing.
Sign in to join this conversation.