Mint a short-lived JWT for the SPA so /iptv/* works under JWT-enabled deployments #552

Closed
opened 2026-07-21 23:21:01 +02:00 by timothy · 3 comments
Owner

Split out of #60 (PR #551).

Problem

/iptv/* is gated by ConditionalIptvAuthorizeFilter, which no-ops unless JwtHelper.IsEnabled (i.e. JWT:IssuerSigningKey is configured). When JWT is enabled, the "jwt" scheme accepts only a bearer token or ?access_token= — and the SPA's ctv-session cookie is a distinct scheme that does not satisfy it. Nothing in the app currently calls JwtHelper.GenerateToken(); there is no flow that mints a JWT for the browser.

Two consequences:

  1. #60's channel preview cannot run at all under that configuration. It currently degrades honestly — the server declares Preview.Availability = "Unavailable" with reason IPTV JWT authentication is enabled — but the capability is simply absent.
  2. The existing playback-troubleshooting screen is already broken there, and has been. TroubleshootController hands {PathBase}/iptv/session/.troubleshooting/live.m3u8 straight to HlsPlayer with no token appended. This works today only because JWT is disabled in the default/dev configuration; enabling JWT breaks it silently.

Point 2 is a pre-existing latent bug, not something #60 introduced — but the same fix covers both.

Suggested shape

An authenticated /api/v1 endpoint that mints a short-lived JWT the SPA appends as ?access_token=, scoped as narrowly as practical. Design questions to settle: token lifetime, whether it is channel-scoped or global, and whether revocation matters.

Notes

  • This is new auth surface, so it needs an independent security review per process.independent-review-rubric.
  • Default deployments have JWT disabled, so this is not urgent — but the troubleshooting-screen breakage means it is a real correctness gap, not purely a new feature.

Done-when

  • An authenticated endpoint mints a short-lived token; lifetime/scope decisions recorded in docs/decisions.md — GET /api/v1/auth/iptv-token; security.iptv-browser-token (60 min / configurable / global / short-lifetime revocation)
  • The channel preview works under a JWT-enabled deployment (live-E2E with JWT:IssuerSigningKey set) — /iptv manifest 401s without a token, passes with the minted token (garbage → 401)
  • The playback-troubleshooting screen works under the same configuration — point 2 was a misdiagnosis: its live.m3u8 is static-served (outside the JWT filter), 404s not 401s; the defensive withIptvToken is a no-op there. See closing record.
  • Independent security review passed, with Review-verdict: referencing the PR's current head sha — PR #560, Review-verdict: MERGEABLE @ ce30bbbb

PR: #560

Split out of #60 (PR #551). ## Problem `/iptv/*` is gated by `ConditionalIptvAuthorizeFilter`, which no-ops unless `JwtHelper.IsEnabled` (i.e. `JWT:IssuerSigningKey` is configured). When JWT **is** enabled, the `"jwt"` scheme accepts only a bearer token or `?access_token=` — and the SPA's `ctv-session` cookie is a **distinct scheme that does not satisfy it**. Nothing in the app currently calls `JwtHelper.GenerateToken()`; there is no flow that mints a JWT for the browser. Two consequences: 1. **#60's channel preview** cannot run at all under that configuration. It currently degrades honestly — the server declares `Preview.Availability = "Unavailable"` with reason `IPTV JWT authentication is enabled` — but the capability is simply absent. 2. **The existing playback-troubleshooting screen is already broken there**, and has been. `TroubleshootController` hands `{PathBase}/iptv/session/.troubleshooting/live.m3u8` straight to `HlsPlayer` with no token appended. This works today only because JWT is disabled in the default/dev configuration; enabling JWT breaks it silently. Point 2 is a pre-existing latent bug, not something #60 introduced — but the same fix covers both. ## Suggested shape An authenticated `/api/v1` endpoint that mints a short-lived JWT the SPA appends as `?access_token=`, scoped as narrowly as practical. Design questions to settle: token lifetime, whether it is channel-scoped or global, and whether revocation matters. ## Notes - This is **new auth surface**, so it needs an independent security review per `process.independent-review-rubric`. - Default deployments have JWT disabled, so this is not urgent — but the troubleshooting-screen breakage means it is a real correctness gap, not purely a new feature. ## Done-when - [x] An authenticated endpoint mints a short-lived token; lifetime/scope decisions recorded in `docs/decisions.md` — `GET /api/v1/auth/iptv-token`; `security.iptv-browser-token` (60 min / configurable / global / short-lifetime revocation) - [x] The channel preview works under a JWT-enabled deployment (live-E2E with `JWT:IssuerSigningKey` set) — `/iptv` manifest 401s without a token, passes with the minted token (garbage → 401) - [x] The playback-troubleshooting screen works under the same configuration — **point 2 was a misdiagnosis**: its `live.m3u8` is static-served (outside the JWT filter), 404s not 401s; the defensive `withIptvToken` is a no-op there. See closing record. - [x] Independent security review passed, with `Review-verdict:` referencing the PR's current head sha — PR #560, `Review-verdict: MERGEABLE @ ce30bbbb` **PR:** #560
timothy added the apipriority: mediumsecurity labels 2026-07-21 23:21:01 +02:00
timothy added the in-progress label 2026-07-22 00:26:38 +02:00
Author
Owner

Claiming this (Claude Code session, 2026-07-22). Top-ranked eligible backlog item from scripts/select-queue.sh.

Starting with recon of the existing auth seams (JwtHelper, ConditionalIptvAuthorizeFilter, the ctv-session cookie scheme, TroubleshootController) before settling the lifetime/scope design questions. Will report the proposed token shape here before implementing, since this is new auth surface.

Claiming this (Claude Code session, 2026-07-22). Top-ranked eligible backlog item from `scripts/select-queue.sh`. Starting with recon of the existing auth seams (`JwtHelper`, `ConditionalIptvAuthorizeFilter`, the `ctv-session` cookie scheme, `TroubleshootController`) before settling the lifetime/scope design questions. Will report the proposed token shape here before implementing, since this is new auth surface.
Author
Owner

Design (settled before implementing — new auth surface)

Endpoint — GET /api/v1/auth/iptv-token on AuthController (already [SkipApiAuthorization] + self-checks the principal, same shape as machine-key). Session-gated (401 otherwise), Cache-Control: no-store. Returns { token, expiresAt } when JwtHelper.IsEnabled; 204 No Content when JWT is disabled — /iptv/* is open then, so there is nothing to mint and the SPA plays the plain URL. Minting a stateless JWT writes no server state, so a GET is correct (not a side-effecting GET; same reasoning as machine-key). Stays out of the OpenAPI doc (AuthController is IgnoreApi, per the #295 F4 ruling — a browser-interactive credential isn't something a generated client drives).

Lifetime: 60 min, configurable via JWT:BrowserTokenLifetimeMinutes. The token re-validates on every /iptv/* request, so lifetime = max continuous watch before playback stalls; 60 min comfortably covers an operator "does this channel work" check, an expired idle session just needs Retry (which mints fresh), and a security-conscious operator can tighten it. Far shorter than the existing (unused) hardcoded 1-day GenerateToken.

Scope: global. The existing jwt scheme validates only signature + lifetime (no audience/channel claims), and the token is minted only to the already-authenticated admin who can reach every channel. Channel-scoping would mean adding claim-based auth to ConditionalIptvAuthorizeFilter and the streaming path — unnecessary here; noted as a future enhancement if a non-admin preview audience ever appears.

Revocation: none per-token (stateless JWT). Rotating JWT:IssuerSigningKey invalidates all tokens (existing lever); the short lifetime is the containment.

SPA — a shared withIptvToken(url) helper fetches a token on-demand (cached in memory until near expiry) and appends ?access_token=/&access_token=. Used by both consumers:

  1. The #551 preview panel — so Mapper.GetPreview drops its iptvJwtEnabled → Unavailable guard and preview becomes Available under JWT (updating #551's mapper + tests + the api.channel-preview-capability decision). Keeping the token out of the cacheable channel-list response is why the SPA appends on-demand rather than the server baking it into ManifestUrl.
  2. The pre-existing troubleshooting screen ({PathBase}/iptv/session/.troubleshooting/live.m3u8) — the latent breakage noted in the issue; same helper fixes it, no TroubleshootController change.

Implementing now; will run local gate + live-E2E with JWT:IssuerSigningKey set, then request an independent security review before merge.

## Design (settled before implementing — new auth surface) **Endpoint** — `GET /api/v1/auth/iptv-token` on `AuthController` (already `[SkipApiAuthorization]` + self-checks the principal, same shape as `machine-key`). Session-gated (401 otherwise), `Cache-Control: no-store`. Returns `{ token, expiresAt }` when `JwtHelper.IsEnabled`; **204 No Content** when JWT is disabled — `/iptv/*` is open then, so there is nothing to mint and the SPA plays the plain URL. Minting a stateless JWT writes no server state, so a GET is correct (not a side-effecting GET; same reasoning as `machine-key`). Stays out of the OpenAPI doc (`AuthController` is `IgnoreApi`, per the #295 F4 ruling — a browser-interactive credential isn't something a generated client drives). **Lifetime: 60 min, configurable** via `JWT:BrowserTokenLifetimeMinutes`. The token re-validates on every `/iptv/*` request, so lifetime = max continuous watch before playback stalls; 60 min comfortably covers an operator "does this channel work" check, an expired idle session just needs Retry (which mints fresh), and a security-conscious operator can tighten it. Far shorter than the existing (unused) hardcoded 1-day `GenerateToken`. **Scope: global.** The existing `jwt` scheme validates only signature + lifetime (no audience/channel claims), and the token is minted only to the already-authenticated admin who can reach every channel. Channel-scoping would mean adding claim-based auth to `ConditionalIptvAuthorizeFilter` and the streaming path — unnecessary here; noted as a future enhancement if a non-admin preview audience ever appears. **Revocation: none per-token** (stateless JWT). Rotating `JWT:IssuerSigningKey` invalidates all tokens (existing lever); the short lifetime is the containment. **SPA** — a shared `withIptvToken(url)` helper fetches a token on-demand (cached in memory until near expiry) and appends `?access_token=`/`&access_token=`. Used by **both** consumers: 1. The #551 preview panel — so `Mapper.GetPreview` drops its `iptvJwtEnabled → Unavailable` guard and preview becomes Available under JWT (updating #551's mapper + tests + the `api.channel-preview-capability` decision). Keeping the token out of the cacheable channel-list response is why the SPA appends on-demand rather than the server baking it into `ManifestUrl`. 2. The pre-existing troubleshooting screen (`{PathBase}/iptv/session/.troubleshooting/live.m3u8`) — the latent breakage noted in the issue; same helper fixes it, no `TroubleshootController` change. Implementing now; will run local gate + live-E2E with `JWT:IssuerSigningKey` set, then request an independent security review before merge.
Author
Owner

Closing record

Outcome: PR #560 (head ce30bbbb) — GET /api/v1/auth/iptv-token mints a short-lived (60 min default, JWT:BrowserTokenLifetimeMinutes, clamped 24h) global JWT the SPA's withIptvToken() appends as ?access_token= to the /iptv manifest, so the #60 channel preview works under a JWT-enabled deployment. Mapper.GetPreview is now JWT-agnostic. All Done-when ticked; reviewed MERGEABLE @ ce30bbbb. Merge pending only an operator rerun of two flaky CI jobs (see Verification).
Root cause: /iptv/* is gated by ConditionalIptvAuthorizeFilter (active when JWT:IssuerSigningKey is set) and the "jwt" scheme accepts only bearer/?access_token=, not the SPA's ctv-session cookie — and nothing ever called JwtHelper.GenerateToken(). So the browser had no way to authenticate to /iptv/*.
Decisions/conventions changed: added security.iptv-browser-token; amended api.channel-preview-capability (dropped the JWT→Unavailable clause). Catalog regenerated.
Reusable knowledge: (1) Point 2 was a misdiagnosis — the troubleshooting live.m3u8 is served by UseStaticFiles at RequestPath=/iptv/session, outside ConditionalIptvAuthorizeFilter (a [ServiceFilter] on IptvController only), so it returns 404 (not 401) without a token; JWT never broke it. (2) HLS segments are likewise static-served/ungated, and the multi-variant playlist embeds the token into its variant URL (IptvController:305) — so under JWT only the top-level manifest needs a token; everything downstream propagates or is ungated. (3) The old JwtHelper.GenerateToken() minted 1-day tokens and was dead code; browser tokens are now 60 min.
Verification: .NET all 7 test projects green locally (ErsatzTV.Tests 1819, Core, Architecture, Mcp, Infrastructure, FFmpeg, Scanner); web 933/933, lint/tsc/build clean. Live-E2E under JWT:IssuerSigningKey: /iptv manifest 401s without a token, passes with a valid one (302, token carried forward), 401s with a garbage token; iptv-token 401s anonymous, mints with a session. CI on ce30bbbb: Formatting, EF-migration (required), API-docs, Functional-E2E, CI-pin, Docs-reminder all green. The two reds — Build & test (.NET) and decisions lifecycle — are known flakes: current main 732322dc shows the identical Build&test fail→pass (runs 5403→5409), and decisions_validate.py passes locally. Operator action needed: rerun both jobs in the Gitea UI (agent-side rerun/cancel 404s); then the H6 gate auto-grants.
Deferred: #559 — redact access_token from Serilog request logs + no-store on tokened /iptv manifests (pre-existing ?access_token= transport hardening, now bounded by the 60-min lifetime; accepted by the reviewer).
Docs updated: docs/decisions/api-auth-security.md (new record), docs/decisions.md (amended preview record), docs/decisions/README.md (catalog), docs/spa-conventions.md §5b.

## Closing record **Outcome:** PR #560 (head `ce30bbbb`) — `GET /api/v1/auth/iptv-token` mints a short-lived (60 min default, `JWT:BrowserTokenLifetimeMinutes`, clamped 24h) global JWT the SPA's `withIptvToken()` appends as `?access_token=` to the `/iptv` manifest, so the #60 channel preview works under a JWT-enabled deployment. `Mapper.GetPreview` is now JWT-agnostic. All Done-when ticked; reviewed `MERGEABLE @ ce30bbbb`. **Merge pending only an operator rerun of two flaky CI jobs** (see Verification). **Root cause:** `/iptv/*` is gated by `ConditionalIptvAuthorizeFilter` (active when `JWT:IssuerSigningKey` is set) and the `"jwt"` scheme accepts only bearer/`?access_token=`, not the SPA's `ctv-session` cookie — and nothing ever called `JwtHelper.GenerateToken()`. So the browser had no way to authenticate to `/iptv/*`. **Decisions/conventions changed:** added `security.iptv-browser-token`; amended `api.channel-preview-capability` (dropped the JWT→Unavailable clause). Catalog regenerated. **Reusable knowledge:** (1) **Point 2 was a misdiagnosis** — the troubleshooting `live.m3u8` is served by `UseStaticFiles` at `RequestPath=/iptv/session`, *outside* `ConditionalIptvAuthorizeFilter` (a `[ServiceFilter]` on `IptvController` only), so it returns 404 (not 401) without a token; JWT never broke it. (2) HLS **segments** are likewise static-served/ungated, and the multi-variant playlist embeds the token into its variant URL (`IptvController:305`) — so under JWT only the **top-level manifest** needs a token; everything downstream propagates or is ungated. (3) The old `JwtHelper.GenerateToken()` minted 1-day tokens and was dead code; browser tokens are now 60 min. **Verification:** .NET all 7 test projects green locally (ErsatzTV.Tests 1819, Core, Architecture, Mcp, Infrastructure, FFmpeg, Scanner); web 933/933, lint/tsc/build clean. Live-E2E under `JWT:IssuerSigningKey`: `/iptv` manifest 401s without a token, passes with a valid one (302, token carried forward), 401s with a garbage token; `iptv-token` 401s anonymous, mints with a session. CI on `ce30bbbb`: **Formatting, EF-migration (required), API-docs, Functional-E2E, CI-pin, Docs-reminder all green.** The two reds — `Build & test (.NET)` and `decisions lifecycle` — are **known flakes**: current main `732322dc` shows the identical Build&test fail→pass (runs 5403→5409), and `decisions_validate.py` passes locally. **Operator action needed:** rerun both jobs in the Gitea UI (agent-side rerun/cancel 404s); then the H6 gate auto-grants. **Deferred:** #559 — redact `access_token` from Serilog request logs + `no-store` on tokened `/iptv` manifests (pre-existing `?access_token=` transport hardening, now bounded by the 60-min lifetime; accepted by the reviewer). **Docs updated:** `docs/decisions/api-auth-security.md` (new record), `docs/decisions.md` (amended preview record), `docs/decisions/README.md` (catalog), `docs/spa-conventions.md` §5b.
timothy removed the in-progress label 2026-07-22 18:53:18 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#552