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
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
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.
## 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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Symptom
toddcan(child ofspikerj) 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)
PUT /api/profile/parental/children/8bf8b57d-…/approved-books/{bookId}→ 200 (Seq, 05:08Z) anduser-profilesshowsApprovedBookIds = ['6a5bf73f…', '6a55ad64…']Status='Available',Visibility='Private',UploadedBy='spikerj', not deletedIsChildAccount=true, linkedParentKeycloakUserIdcorrect; parent profileUsername='spikerj'==Book.UploadedByBookAccess.IsInLibraryhousehold branch is correct and would match — if the child context were ever builtRoot cause
BookController.BuildReadContextAsync(BookController.cs:74) readsUser.FindFirst("sub"). The API's JWT bearer setup (Infrastructure/Authentication.cs) never disables inbound claim mapping, so on real Keycloak tokens ASP.NET'sJsonWebTokenHandlerrenamessub→ClaimTypes.NameIdentifier.FindFirst("sub")returns null → profile lookup skipped →BookReadContexts.BuildAsyncreturns 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:
TestAuthenticationHandlerissues a literal"sub"claim (no mapping), so test-auth/e2e never see the prod claim shape.GameServerTokenValidator.cs:60even setsMapInboundClaims=falseexplicitly before reading"sub"— the pitfall was known in that corner.Blast radius (bare
FindFirst("sub"), noNameIdentifierfallback)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 wayReaderController.cs:219— social reading-list / bookmark-upvote identity is nullGameHub.cs:112,ChessHub.cs:367,VideoCallHub.cs:381,402,HexTowerDefenceHub.cs:341(NotificationsHub already has the fallback chain)Fix
Shared
ClaimsPrincipalextension (NameIdentifier-first,subfallback — the codebase's dominant pattern, cf.ProfileController.GetUserId), applied at all bare-subsites;TestAuthenticationHandlerswitched to emit the mapped claim shape so tests exercise what prod actually sees; regression coverage.Correction to the blast-radius list: the SignalR hubs (
GameHub,ChessHub,VideoCallHub,HexTowerDefenceHub) are not affected — theirsub-first chains all fall back to the mappedNameIdentifierURI 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.Resolved in spikersoft-backend PR #434 (merged to
master2026-07-20 15:38Z). SharedClaimsPrincipalExtensions.GetKeycloakUserId()(NameIdentifier ?? rawsub) 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.