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
No Branch/Tag Specified
main
renovate/lucene.net
renovate/dotnet-monorepo
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
renovate/meziantou.analyzer-3.x
renovate/cliwrap-3.x
913-scripted-playout-401
release/v26.15.0-notes
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#552
Reference in New Issue
Block a user
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.
Split out of #60 (PR #551).
Problem
/iptv/*is gated byConditionalIptvAuthorizeFilter, which no-ops unlessJwtHelper.IsEnabled(i.e.JWT:IssuerSigningKeyis configured). When JWT is enabled, the"jwt"scheme accepts only a bearer token or?access_token=— and the SPA'sctv-sessioncookie is a distinct scheme that does not satisfy it. Nothing in the app currently callsJwtHelper.GenerateToken(); there is no flow that mints a JWT for the browser.Two consequences:
Preview.Availability = "Unavailable"with reasonIPTV JWT authentication is enabled— but the capability is simply absent.TroubleshootControllerhands{PathBase}/iptv/session/.troubleshooting/live.m3u8straight toHlsPlayerwith 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/v1endpoint 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
process.independent-review-rubric.Done-when
docs/decisions.md—GET /api/v1/auth/iptv-token;security.iptv-browser-token(60 min / configurable / global / short-lifetime revocation)JWT:IssuerSigningKeyset) —/iptvmanifest 401s without a token, passes with the minted token (garbage → 401)live.m3u8is static-served (outside the JWT filter), 404s not 401s; the defensivewithIptvTokenis a no-op there. See closing record.Review-verdict:referencing the PR's current head sha — PR #560,Review-verdict: MERGEABLE @ ce30bbbbPR: #560
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, thectv-sessioncookie scheme,TroubleshootController) before settling the lifetime/scope design questions. Will report the proposed token shape here before implementing, since this is new auth surface.Design (settled before implementing — new auth surface)
Endpoint —
GET /api/v1/auth/iptv-tokenonAuthController(already[SkipApiAuthorization]+ self-checks the principal, same shape asmachine-key). Session-gated (401 otherwise),Cache-Control: no-store. Returns{ token, expiresAt }whenJwtHelper.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 asmachine-key). Stays out of the OpenAPI doc (AuthControllerisIgnoreApi, 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-dayGenerateToken.Scope: global. The existing
jwtscheme 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 toConditionalIptvAuthorizeFilterand 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:IssuerSigningKeyinvalidates 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:Mapper.GetPreviewdrops itsiptvJwtEnabled → Unavailableguard and preview becomes Available under JWT (updating #551's mapper + tests + theapi.channel-preview-capabilitydecision). Keeping the token out of the cacheable channel-list response is why the SPA appends on-demand rather than the server baking it intoManifestUrl.{PathBase}/iptv/session/.troubleshooting/live.m3u8) — the latent breakage noted in the issue; same helper fixes it, noTroubleshootControllerchange.Implementing now; will run local gate + live-E2E with
JWT:IssuerSigningKeyset, then request an independent security review before merge.Closing record
Outcome: PR #560 (head
ce30bbbb) —GET /api/v1/auth/iptv-tokenmints a short-lived (60 min default,JWT:BrowserTokenLifetimeMinutes, clamped 24h) global JWT the SPA'swithIptvToken()appends as?access_token=to the/iptvmanifest, so the #60 channel preview works under a JWT-enabled deployment.Mapper.GetPreviewis now JWT-agnostic. All Done-when ticked; reviewedMERGEABLE @ ce30bbbb. Merge pending only an operator rerun of two flaky CI jobs (see Verification).Root cause:
/iptv/*is gated byConditionalIptvAuthorizeFilter(active whenJWT:IssuerSigningKeyis set) and the"jwt"scheme accepts only bearer/?access_token=, not the SPA'sctv-sessioncookie — and nothing ever calledJwtHelper.GenerateToken(). So the browser had no way to authenticate to/iptv/*.Decisions/conventions changed: added
security.iptv-browser-token; amendedapi.channel-preview-capability(dropped the JWT→Unavailable clause). Catalog regenerated.Reusable knowledge: (1) Point 2 was a misdiagnosis — the troubleshooting
live.m3u8is served byUseStaticFilesatRequestPath=/iptv/session, outsideConditionalIptvAuthorizeFilter(a[ServiceFilter]onIptvControlleronly), 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 oldJwtHelper.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:/iptvmanifest 401s without a token, passes with a valid one (302, token carried forward), 401s with a garbage token;iptv-token401s anonymous, mints with a session. CI once30bbbb: Formatting, EF-migration (required), API-docs, Functional-E2E, CI-pin, Docs-reminder all green. The two reds —Build & test (.NET)anddecisions lifecycle— are known flakes: current main732322dcshows the identical Build&test fail→pass (runs 5403→5409), anddecisions_validate.pypasses 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_tokenfrom Serilog request logs +no-storeon tokened/iptvmanifests (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.