fix(552): mint a short-lived JWT so the SPA reaches /iptv/* under JWT auth #560

Merged
timothy merged 3 commits from feat/552-spa-iptv-jwt into main 2026-07-22 18:52:05 +02:00
Owner

fixes #552

Under a JWT-enabled deployment (JWT:IssuerSigningKey set), /iptv/* is gated by ConditionalIptvAuthorizeFilter and the "jwt" scheme accepts only a bearer token / ?access_token=not the SPA's ctv-session cookie — and nothing minted a JWT for the browser. So the #60 channel preview declared itself Unavailable and couldn't run at all. This mints one.

Shape

  • GET /api/v1/auth/iptv-token on AuthController (session-gated, self-checks the principal like machine-key; the controller is [SkipApiAuthorization] + [IgnoreApi]). Returns { token, expiresAt } when JWT is enabled, 204 when disabled (/iptv/* is open then — nothing to mint). Cache-Control: no-store. A GET is correct: minting a stateless JWT writes no server state, and SOP blocks a cross-site page from reading the credentialed body.
  • JwtHelper.GenerateBrowserToken() — short-lived global token. Lifetime 60 min default, JWT:BrowserTokenLifetimeMinutes override, clamped to 24h (a seconds-vs-minutes typo can't mint a multi-year token; non-positive/unparseable → 60 min).
  • SPA withIptvToken(url) (web/src/media/iptvToken.ts) — fetches on demand, caches until near expiry, appends ?access_token=/&access_token=; a no-op when JWT is off. Used by the channel-preview panel and the troubleshooting screen. resetIptvTokenCache() on Retry/Play so a stale token or stale "JWT disabled" latch can't wedge a user-initiated retry.
  • Mapper.GetPreview drops its iptvJwtEnabled → Unavailable guard; preview is now JWT-agnostic.

Scope: global (the "jwt" scheme validates only signature+lifetime; the token is minted to the already-authenticated admin who can reach every channel). Revocation: short lifetime only (stateless JWT; rotating the signing key is the existing lever). Design settled on the issue before implementing.

Honest finding — the issue's point 2 was a misdiagnosis

The issue claimed the troubleshooting screen was broken under JWT. Live-E2E disproved it: /iptv/session/.troubleshooting/live.m3u8 returns 404 without a token (not 401), because it's served by UseStaticFiles at /iptv/sessionoutside ConditionalIptvAuthorizeFilter (a [ServiceFilter] on IptvController only). So JWT never broke that screen; withIptvToken there is a harmless defensive no-op. The real fix is the channel preview.

Verification

  • .NET: 1819/... green (added JwtHelperTests, AuthController iptv-token tests).
  • Web: lint clean, tsc --noEmit clean, 933/933, build succeeds.
  • Live-E2E under JWT:IssuerSigningKey: iptv-token 401s anonymous, mints a 60-min token with a session; /iptv/channel/1.m3u8 401s without a token, passes with a valid one (302 carrying the token forward), 401s with a garbage token; /iptv/logos stays exempt.
  • BOM + dotnet format whitespace clean; decisions_validate.py exit 0; no OpenAPI drift (AuthController is IgnoreApi, Preview schema unchanged).
  • Independent security review (cold, cross-model): no Critical/High. Two Lows fixed (lifetime clamp; cache-recovery on Retry). A re-review of the fix commit caught a real regression (Retry reloaded the stale-token URL) — fixed with a negative-control-validated test. The Medium (access_token in request logs) and one Low (no-store on tokened manifests) are pre-existing ?access_token=-transport properties (Jellyfin/M3U already use it), now bounded by the 60-min lifetime, deferred to #559.

Docs

security.iptv-browser-token (api-auth-security.md), amended api.channel-preview-capability, spa-conventions §5b. Catalog regenerated.

���� Generated with Claude Code

fixes #552 Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), `/iptv/*` is gated by `ConditionalIptvAuthorizeFilter` and the `"jwt"` scheme accepts only a bearer token / `?access_token=` — **not** the SPA's `ctv-session` cookie — and nothing minted a JWT for the browser. So the #60 channel preview declared itself `Unavailable` and couldn't run at all. This mints one. ## Shape - **`GET /api/v1/auth/iptv-token`** on `AuthController` (session-gated, self-checks the principal like `machine-key`; the controller is `[SkipApiAuthorization]` + `[IgnoreApi]`). Returns `{ token, expiresAt }` when JWT is enabled, **204** when disabled (`/iptv/*` is open then — nothing to mint). `Cache-Control: no-store`. A GET is correct: minting a stateless JWT writes no server state, and SOP blocks a cross-site page from reading the credentialed body. - **`JwtHelper.GenerateBrowserToken()`** — short-lived global token. Lifetime 60 min default, `JWT:BrowserTokenLifetimeMinutes` override, clamped to 24h (a seconds-vs-minutes typo can't mint a multi-year token; non-positive/unparseable → 60 min). - **SPA `withIptvToken(url)`** (`web/src/media/iptvToken.ts`) — fetches on demand, caches until near expiry, appends `?access_token=`/`&access_token=`; a no-op when JWT is off. Used by the channel-preview panel and the troubleshooting screen. `resetIptvTokenCache()` on Retry/Play so a stale token or stale "JWT disabled" latch can't wedge a user-initiated retry. - **`Mapper.GetPreview`** drops its `iptvJwtEnabled → Unavailable` guard; preview is now JWT-agnostic. **Scope: global** (the `"jwt"` scheme validates only signature+lifetime; the token is minted to the already-authenticated admin who can reach every channel). **Revocation: short lifetime only** (stateless JWT; rotating the signing key is the existing lever). Design settled on the issue before implementing. ## Honest finding — the issue's point 2 was a misdiagnosis The issue claimed the troubleshooting screen was broken under JWT. Live-E2E disproved it: `/iptv/session/.troubleshooting/live.m3u8` returns **404 without a token** (not 401), because it's served by `UseStaticFiles` at `/iptv/session` — *outside* `ConditionalIptvAuthorizeFilter` (a `[ServiceFilter]` on `IptvController` only). So JWT never broke that screen; `withIptvToken` there is a harmless defensive no-op. The real fix is the channel preview. ## Verification - **.NET**: 1819/... green (added `JwtHelperTests`, `AuthController` iptv-token tests). - **Web**: lint clean, `tsc --noEmit` clean, 933/933, build succeeds. - **Live-E2E under `JWT:IssuerSigningKey`**: `iptv-token` 401s anonymous, mints a 60-min token with a session; `/iptv/channel/1.m3u8` **401s without a token, passes with a valid one (302 carrying the token forward), 401s with a garbage token**; `/iptv/logos` stays exempt. - BOM + `dotnet format whitespace` clean; `decisions_validate.py` exit 0; no OpenAPI drift (`AuthController` is `IgnoreApi`, `Preview` schema unchanged). - **Independent security review** (cold, cross-model): no Critical/High. Two Lows fixed (lifetime clamp; cache-recovery on Retry). A re-review of the fix commit caught a real regression (Retry reloaded the stale-token URL) — fixed with a negative-control-validated test. The Medium (access_token in request logs) and one Low (no-store on tokened manifests) are pre-existing `?access_token=`-transport properties (Jellyfin/M3U already use it), now bounded by the 60-min lifetime, deferred to **#559**. ## Docs `security.iptv-browser-token` (api-auth-security.md), amended `api.channel-preview-capability`, `spa-conventions §5b`. Catalog regenerated. ���� Generated with [Claude Code](https://claude.com/claude-code)
timothy added 3 commits 2026-07-22 17:45:11 +02:00
Under a JWT-enabled deployment (JWT:IssuerSigningKey set), /iptv/* is gated by
ConditionalIptvAuthorizeFilter and the "jwt" scheme does not accept the SPA's
ctv-session cookie, and nothing minted a JWT for the browser. So the #60 channel
preview was declared Unavailable and could not run at all.

Add GET /api/v1/auth/iptv-token (session-gated, on the [IgnoreApi] AuthController):
mints a short-lived global token via JwtHelper.GenerateBrowserToken (60 min default,
JWT:BrowserTokenLifetimeMinutes override), 204 when JWT is disabled. The SPA's new
withIptvToken(url) helper appends it as ?access_token= to the manifest URL (a no-op
when JWT is off), used by the channel-preview panel and the troubleshooting screen.
Mapper.GetPreview drops its iptvJwtEnabled -> Unavailable guard; preview is now
JWT-agnostic.

Live-E2E under JWT: /iptv manifest 401s without a token and passes with a valid one
(garbage token -> 401); token endpoint 401s anonymous, mints with a session.

Honest finding: the issue's point 2 (troubleshooting screen broken under JWT) does
not reproduce -- its live.m3u8 is static-served (UseStaticFiles at /iptv/session),
outside the JWT filter, so it was never gated. The withIptvToken call there is a
harmless defensive no-op.

Docs: security.iptv-browser-token (api-auth-security.md), amended
api.channel-preview-capability, spa-conventions §5b. No OpenAPI change (IgnoreApi +
unchanged Preview schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold review (no Critical/High). Folded:
- Low: clamp JWT:BrowserTokenLifetimeMinutes to a 24h max so a seconds-vs-minutes
  typo can't mint a multi-year bearer token (non-positive/unparseable still falls
  back to 60 min).
- Low: reset the SPA iptv-token cache on the preview panel's Retry and on each
  troubleshooting Play, so a stale token (key rotated) or a stale "JWT disabled"
  latch (backend reconfigured since page load) can't wedge a user-initiated retry.

Deferred to #559 (tracked): redact access_token from Serilog request logs and set
no-store on token-bearing /iptv manifests — pre-existing properties of the shared
?access_token= transport (Jellyfin/M3U already use it), now bounded by the 60-min
lifetime; cross-cutting fixes beyond this feature's scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(552): don't reload the stale-token URL on preview Retry (review regression)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m41s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
ce30bbbb32
Re-review of 60a0c505 caught a regression the prior fix introduced: onRetry cleared
the token cache and bumped playToken but kept the old resolvedSrc, so HlsPlayer
reloaded the stale-token URL before the remint resolved — a duplicate manifest
session and a stale 401 that could stick the panel as failed even after the fresh
stream succeeded.

Null resolvedSrc in onRetry before bumping playToken so the player unmounts until the
async effect resolves the freshly-minted URL. Added a controlled-async test proving
the stale-token URL is never reloaded and the retry loads the new token (validated by
negative control: the test fails with the fix removed, and only that test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Review-verdict: MERGEABLE @ ce30bbbb

Review trail

Independent cold-context security review (cross-model, no implementation context), three passes:

  1. Initial review of the implementation — no Critical/High. 1 Medium + 3 Low. Verified-correct list included: anonymous can't mint; machine-key/JWT don't satisfy the session-only check; no CSRF gate needed (stateless GET, SOP blocks cross-site body read); global scope is not an escalation for the authenticated admin; 204→undefined handled; separator/encoding correct; panel shows the untokened URL; all GetPreview/ProjectToResponseModel/GetAllChannelsForApi callers updated.
  2. Fix commit re-review — caught a real regression the review-fixes introduced: onRetry cleared the token cache and bumped playToken but kept the stale resolvedSrc, so HlsPlayer reloaded the old-token URL before the remint (duplicate session / a stale 401 sticking the panel as failed). BLOCKED.
  3. Regression-fix re-reviewonRetry now nulls resolvedSrc before the bump so the player unmounts until the fresh URL resolves; the new controlled-async test is non-vacuous (validated by negative control). MERGEABLE @ ce30bbbb.

Findings folded: lifetime upper-bound clamp (24h); token-cache recovery on Retry/Play; the Retry stale-reload regression. Accepted defer (reviewer concurs): Medium (access_token in Serilog request logs) + one Low (no-store on tokened manifests) — pre-existing properties of the shared ?access_token= transport (Jellyfin/M3U already use it), now bounded by the 60-min lifetime, tracked in #559.

Verification on this head (ce30bbbb)

  • .NET 1819/... green; Web lint/tsc clean, 933/933, build succeeds.
  • Live-E2E under JWT:IssuerSigningKey: iptv-token 401s anonymous / mints a 60-min token with a session; /iptv/channel/1.m3u8 401s without a token, passes with a valid one (302, token carried), 401s with a garbage token; /iptv/logos exempt. Troubleshooting live.m3u8 → 404 (static-served, ungated — point-2 misdiagnosis proven).
  • BOM + dotnet format whitespace clean; decisions_validate.py exit 0; no OpenAPI drift.
Review-verdict: MERGEABLE @ ce30bbbb ## Review trail Independent cold-context security review (cross-model, no implementation context), three passes: 1. **Initial review of the implementation** — no Critical/High. 1 Medium + 3 Low. Verified-correct list included: anonymous can't mint; machine-key/JWT don't satisfy the session-only check; no CSRF gate needed (stateless GET, SOP blocks cross-site body read); global scope is not an escalation for the authenticated admin; 204→undefined handled; separator/encoding correct; panel shows the untokened URL; all `GetPreview`/`ProjectToResponseModel`/`GetAllChannelsForApi` callers updated. 2. **Fix commit re-review** — caught a **real regression** the review-fixes introduced: `onRetry` cleared the token cache and bumped `playToken` but kept the stale `resolvedSrc`, so `HlsPlayer` reloaded the old-token URL before the remint (duplicate session / a stale 401 sticking the panel as failed). **BLOCKED.** 3. **Regression-fix re-review** — `onRetry` now nulls `resolvedSrc` before the bump so the player unmounts until the fresh URL resolves; the new controlled-async test is non-vacuous (validated by negative control). **MERGEABLE @ ce30bbbb.** Findings folded: lifetime upper-bound clamp (24h); token-cache recovery on Retry/Play; the Retry stale-reload regression. **Accepted defer** (reviewer concurs): Medium (access_token in Serilog request logs) + one Low (no-store on tokened manifests) — pre-existing properties of the shared `?access_token=` transport (Jellyfin/M3U already use it), now bounded by the 60-min lifetime, tracked in **#559**. ## Verification on this head (ce30bbbb) - **.NET** 1819/... green; **Web** lint/tsc clean, 933/933, build succeeds. - **Live-E2E under `JWT:IssuerSigningKey`**: `iptv-token` 401s anonymous / mints a 60-min token with a session; `/iptv/channel/1.m3u8` 401s without a token, passes with a valid one (302, token carried), 401s with a garbage token; `/iptv/logos` exempt. Troubleshooting `live.m3u8` → 404 (static-served, ungated — point-2 misdiagnosis proven). - BOM + `dotnet format whitespace` clean; `decisions_validate.py` exit 0; no OpenAPI drift.
timothy merged commit 1681ae4e60 into main 2026-07-22 18:52:05 +02:00
timothy deleted branch feat/552-spa-iptv-jwt 2026-07-22 18:52:05 +02:00
Sign in to join this conversation.