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
22 changed files with 723 additions and 128 deletions
+9 -19
View File
@@ -98,8 +98,7 @@ internal static class Mapper
internal static ChannelResponseModel ProjectToResponseModel(
Channel channel,
int playoutCount,
bool iptvJwtEnabled) =>
int playoutCount) =>
new(
channel.Id,
channel.Number,
@@ -114,7 +113,7 @@ internal static class Mapper
channel.ShowInEpg,
playoutCount,
GetLogoUrl(channel),
GetPreview(channel.StreamingMode, channel.Number, iptvJwtEnabled, channel.IsEnabled, playoutCount));
GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount));
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -183,28 +182,19 @@ internal static class Mapper
internal static ChannelPreviewResponseModel GetPreview(
StreamingMode streamingMode,
string channelNumber,
bool iptvJwtEnabled,
bool isEnabled,
int playoutCount)
{
// Precedence among the three Unavailable causes (checked in this order; the first match wins):
// 1. JWT enabled — a host-wide auth configuration; nothing the operator can fix per-channel.
// 2. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
// Precedence among the two Unavailable causes (checked in this order; the first match wins):
// 1. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
// preview must not even try.
// 3. no playout — the channel could theoretically play once scheduled, but a manifest
// 2. no playout — the channel could theoretically play once scheduled, but a manifest
// request against it blocks indefinitely today; catch it before that happens.
//
// /iptv/* is gated by ConditionalIptvAuthorizeFilter only when JWT is configured, and the
// "jwt" scheme does not accept the SPA's ctv-session cookie. Nothing mints a JWT for the
// SPA today, so preview cannot run at all in that configuration.
if (iptvJwtEnabled)
{
return new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Unavailable,
null,
"IPTV JWT authentication is enabled");
}
// IPTV JWT auth (ConditionalIptvAuthorizeFilter, active only when JWT:IssuerSigningKey is set) is no
// longer an Unavailable cause: the SPA mints a short-lived token via GET /api/v1/auth/iptv-token and
// appends it as ?access_token= to the manifest URL below (issue #552). The token is global and the
// ManifestUrl is identical with or without JWT, so this projection is JWT-agnostic.
if (!isEnabled)
{
return new ChannelPreviewResponseModel(
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetAllChannelsForApi(bool IptvJwtEnabled) : IRequest<List<ChannelResponseModel>>;
public record GetAllChannelsForApi : IRequest<List<ChannelResponseModel>>;
@@ -13,6 +13,6 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), request.IptvJwtEnabled)).ToList();
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
}
}
@@ -14,7 +14,7 @@ public class ChannelPreviewTests
public void HlsModes_Are_Available_With_Plain_Manifest_Url(StreamingMode mode)
{
ChannelPreviewResponseModel result =
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false, isEnabled: true, playoutCount: 1);
Mapper.GetPreview(mode, "12.1", isEnabled: true, playoutCount: 1);
result.Availability.ShouldBe("Available");
result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8");
@@ -26,34 +26,19 @@ public class ChannelPreviewTests
public void TransportStream_Modes_Are_ForcedHlsOnly(StreamingMode mode)
{
ChannelPreviewResponseModel result =
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false, isEnabled: true, playoutCount: 1);
Mapper.GetPreview(mode, "12.1", isEnabled: true, playoutCount: 1);
result.Availability.ShouldBe("ForcedHlsOnly");
result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8?mode=segmenter");
result.UnavailableReason.ShouldBeNull();
}
[TestCase(StreamingMode.HttpLiveStreamingSegmenter)]
[TestCase(StreamingMode.HttpLiveStreamingDirect)]
[TestCase(StreamingMode.TransportStream)]
[TestCase(StreamingMode.TransportStreamHybrid)]
public void Jwt_Enabled_Makes_Every_Mode_Unavailable(StreamingMode mode)
{
ChannelPreviewResponseModel result =
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: true, isEnabled: true, playoutCount: 1);
result.Availability.ShouldBe("Unavailable");
result.ManifestUrl.ShouldBeNull();
result.UnavailableReason.ShouldBe("IPTV JWT authentication is enabled");
}
[Test]
public void Disabled_Channel_Is_Unavailable()
{
ChannelPreviewResponseModel result = Mapper.GetPreview(
StreamingMode.HttpLiveStreamingSegmenter,
"12.1",
iptvJwtEnabled: false,
isEnabled: false,
playoutCount: 1);
@@ -68,7 +53,6 @@ public class ChannelPreviewTests
ChannelPreviewResponseModel result = Mapper.GetPreview(
StreamingMode.HttpLiveStreamingSegmenter,
"12.1",
iptvJwtEnabled: false,
isEnabled: true,
playoutCount: 0);
@@ -77,32 +61,14 @@ public class ChannelPreviewTests
result.UnavailableReason.ShouldBe("Channel has no playout");
}
[Test]
public void Jwt_Enabled_Takes_Precedence_Over_Disabled_Channel()
{
// Both causes are true at once: JWT enabled (checked first) must win over the channel being
// disabled (checked second), per the precedence order documented on GetPreview.
ChannelPreviewResponseModel result = Mapper.GetPreview(
StreamingMode.HttpLiveStreamingSegmenter,
"12.1",
iptvJwtEnabled: true,
isEnabled: false,
playoutCount: 0);
result.Availability.ShouldBe("Unavailable");
result.ManifestUrl.ShouldBeNull();
result.UnavailableReason.ShouldBe("IPTV JWT authentication is enabled");
}
[Test]
public void Disabled_Channel_Takes_Precedence_Over_Zero_Playout_Count()
{
// Both causes are true at once: channel disabled (checked second) must win over zero
// playouts (checked third), per the precedence order documented on GetPreview.
// Both causes are true at once: channel disabled (checked first) must win over zero
// playouts (checked second), per the precedence order documented on GetPreview.
ChannelPreviewResponseModel result = Mapper.GetPreview(
StreamingMode.HttpLiveStreamingSegmenter,
"12.1",
iptvJwtEnabled: false,
isEnabled: false,
playoutCount: 0);
@@ -117,7 +83,6 @@ public class ChannelPreviewTests
ChannelPreviewResponseModel result = Mapper.GetPreview(
StreamingMode.HttpLiveStreamingSegmenter,
"7",
iptvJwtEnabled: false,
isEnabled: true,
playoutCount: 1);
@@ -36,7 +36,7 @@ public class GetAllChannelsForApiHandlerTests
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> result = await handler.Handle(
new GetAllChannelsForApi(IptvJwtEnabled: false),
new GetAllChannelsForApi(),
CancellationToken.None);
ChannelResponseModel channel = result.ShouldHaveSingleItem();
@@ -64,7 +64,7 @@ public class GetAllChannelsForApiHandlerTests
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> result = await handler.Handle(
new GetAllChannelsForApi(IptvJwtEnabled: false),
new GetAllChannelsForApi(),
CancellationToken.None);
// Uploaded logos are addressed as "iptv/logos/{file}"; the browse DTO roots it (leading slash) so
@@ -81,7 +81,7 @@ public class GetAllChannelsForApiHandlerTests
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> result = await handler.Handle(
new GetAllChannelsForApi(IptvJwtEnabled: false),
new GetAllChannelsForApi(),
CancellationToken.None);
// An absolute external URL is directly usable and must pass through unchanged (no leading slash added).
@@ -134,4 +134,99 @@ public class AuthControllerTests
httpContext.Response.Headers.CacheControl.ToString().ShouldBe("no-store");
httpContext.Response.Headers.Pragma.ToString().ShouldBe("no-cache");
}
[TearDown]
public void ResetJwt() =>
// JwtHelper is static global state; leave it disabled so tests don't leak JWT config into each other.
JwtHelper.Init(new ConfigurationBuilder().Build());
private static AuthController AuthenticatedController(IMediator mediator) =>
new(mediator, Config(envSeed: false), ApiKeyProvider())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], AuthConstants.CookieScheme))
}
}
};
[Test]
public void IptvToken_Returns_401_When_Anonymous()
{
JwtHelper.Init(JwtConfig("this-is-a-sufficiently-long-signing-key-123456"));
var controller = new AuthController(Substitute.For<IMediator>(), Config(envSeed: false), ApiKeyProvider())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
IActionResult result = controller.IptvToken();
result.ShouldBeOfType<UnauthorizedObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(401);
}
[Test]
public void IptvToken_Returns_204_When_Jwt_Disabled()
{
// JWT off (TearDown default): /iptv/* is open, so there is nothing to mint — the SPA plays the plain URL.
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
IActionResult result = controller.IptvToken();
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public void IptvToken_Mints_A_Verifiable_Token_When_Jwt_Enabled()
{
const string SigningKey = "this-is-a-sufficiently-long-signing-key-123456";
JwtHelper.Init(JwtConfig(SigningKey));
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
var ok = controller.IptvToken().ShouldBeOfType<OkObjectResult>();
var body = ok.Value.ShouldBeOfType<IptvTokenResponse>();
body.Token.ShouldNotBeNullOrWhiteSpace();
body.ExpiresAt.ShouldBeGreaterThan(DateTimeOffset.UtcNow);
// The minted token must validate against the same key/params the "jwt" scheme enforces at runtime
// (signature + lifetime; issuer/audience are not validated).
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
Should.NotThrow(() => handler.ValidateToken(
body.Token,
new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
System.Text.Encoding.ASCII.GetBytes(SigningKey)),
ValidateLifetime = true
},
out _));
}
[Test]
public void IptvToken_Sets_CacheControl_NoStore_When_Jwt_Enabled()
{
JwtHelper.Init(JwtConfig("this-is-a-sufficiently-long-signing-key-123456"));
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
controller.IptvToken();
HttpResponse response = controller.ControllerContext.HttpContext.Response;
response.Headers.CacheControl.ToString().ShouldBe("no-store");
response.Headers.Pragma.ToString().ShouldBe("no-cache");
}
private static IConfiguration JwtConfig(string signingKey, string? lifetimeMinutes = null) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT:IssuerSigningKey"] = signingKey,
["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes
})
.Build();
}
@@ -156,7 +156,7 @@ public class OpenApiSerializerContractTests
true,
2,
"/iptv/logos/logo.png",
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", false, true, 2));
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", true, 2));
private static string FindOpenApiDocument()
{
+80
View File
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using ErsatzTV;
using Microsoft.Extensions.Configuration;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests;
[TestFixture]
public class JwtHelperTests
{
private const string SigningKey = "this-is-a-sufficiently-long-signing-key-123456";
[TearDown]
public void ResetJwt() => JwtHelper.Init(new ConfigurationBuilder().Build());
private static IConfiguration Config(string? lifetimeMinutes) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT:IssuerSigningKey"] = SigningKey,
["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes
})
.Build();
[Test]
public void Init_Enables_When_Signing_Key_Present()
{
JwtHelper.Init(Config(null));
JwtHelper.IsEnabled.ShouldBeTrue();
}
[Test]
public void Browser_Token_Lifetime_Defaults_To_60_Minutes()
{
JwtHelper.Init(Config(null));
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(60));
}
[Test]
public void Browser_Token_Lifetime_Honors_Configured_Minutes()
{
JwtHelper.Init(Config("15"));
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(15));
}
[TestCase("0")]
[TestCase("-5")]
[TestCase("not-a-number")]
public void Browser_Token_Lifetime_Falls_Back_To_Default_On_Invalid(string value)
{
// A non-positive / unparseable value must not mint an already-expired token.
JwtHelper.Init(Config(value));
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(60));
}
[TestCase("1441")]
[TestCase("600000")]
[TestCase("2147483647")]
public void Browser_Token_Lifetime_Is_Clamped_To_The_Maximum(string value)
{
// A value above the documented max (24h) — including a seconds-vs-minutes typo — is clamped, never
// honored literally into a multi-year bearer token.
JwtHelper.Init(Config(value));
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(1440));
}
[Test]
public void GenerateBrowserToken_Expiry_Reflects_Configured_Lifetime()
{
JwtHelper.Init(Config("30"));
(string token, DateTimeOffset expiresAt) = JwtHelper.GenerateBrowserToken();
token.ShouldNotBeNullOrWhiteSpace();
// ~30 minutes out (allow a wide slack for slow CI without asserting an exact instant).
expiresAt.ShouldBeGreaterThan(DateTimeOffset.UtcNow.AddMinutes(29));
expiresAt.ShouldBeLessThan(DateTimeOffset.UtcNow.AddMinutes(31));
}
}
@@ -74,6 +74,40 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
return Ok(new MachineKeyResponse(apiKeyProvider.ApiKey));
}
/// <summary>
/// Mint a short-lived, globally-scoped <c>/iptv</c> access token for the browser (issue #552), so the
/// channel-preview and playback-troubleshooting screens can reach the JWT-gated <c>/iptv/*</c> manifest
/// endpoints (which do not accept the <c>ctv-session</c> cookie) by appending <c>?access_token=</c>.
/// Requires any authenticated session (401 otherwise). Returns <b>204 No Content</b> when IPTV JWT auth
/// is not configured — <c>/iptv/*</c> is open then, so there is nothing to mint and the SPA plays the
/// plain URL. Never CSRF-gated: it's a GET that writes no server state (the JWT is stateless), and SOP
/// already blocks a cross-site page from reading the credentialed response body (same reasoning as
/// <see cref="MachineKey" />).
/// </summary>
[HttpGet("/api/v1/auth/iptv-token")]
public IActionResult IptvToken()
{
if (User.Identity?.IsAuthenticated != true)
{
return Unauthorized(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "An authenticated session is required."
});
}
if (!JwtHelper.IsEnabled)
{
return NoContent();
}
Response.Headers.CacheControl = "no-store";
Response.Headers.Pragma = "no-cache";
(string token, DateTimeOffset expiresAt) = JwtHelper.GenerateBrowserToken();
return Ok(new IptvTokenResponse(token, expiresAt));
}
/// <summary>First-run setup-claim: create the local admin. Fails 409 if one already exists.</summary>
[HttpPost("/api/v1/auth/setup")]
[EnableRateLimiting("auth")]
@@ -260,3 +294,5 @@ public record AuthConfigResponse(bool OidcEnabled, bool LocalLoginEnabled, bool
public record AuthSessionResponse(bool Authenticated, string Username, string Method);
public record MachineKeyResponse(string ApiKey);
public record IptvTokenResponse(string Token, DateTimeOffset ExpiresAt);
@@ -27,7 +27,7 @@ public class ChannelController(
[HttpGet("/api/v1/channels")]
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() =>
await mediator.Send(new GetAllChannelsForApi(JwtHelper.IsEnabled));
await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/v1/channels/state")]
[Tags("Channels")]
+34 -3
View File
@@ -6,9 +6,21 @@ namespace ErsatzTV;
public static class JwtHelper
{
// Default lifetime for a browser-minted /iptv access token. Kept short: the token re-validates on every
// /iptv/* request, so this is the maximum continuous watch before playback stalls and the operator must
// Retry (which mints a fresh token). Operators can override via JWT:BrowserTokenLifetimeMinutes (#552).
private static readonly TimeSpan DefaultBrowserTokenLifetime = TimeSpan.FromMinutes(60);
// Upper bound on the configurable lifetime (24h). A global bearer token that lives longer magnifies the
// exposure of any log/cache leak, and a typo (e.g. a value meant as seconds) shouldn't mint a
// multi-year token; a configured value above this is clamped down rather than honored literally.
private const int MaxBrowserTokenLifetimeMinutes = 1440;
public static SymmetricSecurityKey IssuerSigningKey { get; private set; }
public static bool IsEnabled { get; private set; }
public static TimeSpan BrowserTokenLifetime { get; private set; } = DefaultBrowserTokenLifetime;
public static void Init(IConfiguration configuration)
{
string issuerSigningKey = configuration["JWT:IssuerSigningKey"];
@@ -17,17 +29,36 @@ public static class JwtHelper
{
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(issuerSigningKey!));
}
// A non-positive or unparseable value falls back to the default rather than minting a token that is
// already expired (which would break preview/troubleshooting entirely under JWT); a value above the
// documented maximum is clamped down (see MaxBrowserTokenLifetimeMinutes).
if (int.TryParse(configuration["JWT:BrowserTokenLifetimeMinutes"], out int minutes) && minutes > 0)
{
BrowserTokenLifetime = TimeSpan.FromMinutes(Math.Min(minutes, MaxBrowserTokenLifetimeMinutes));
}
else
{
BrowserTokenLifetime = DefaultBrowserTokenLifetime;
}
}
public static string GenerateToken()
/// <summary>
/// Mint a short-lived, globally-scoped /iptv access token for the browser SPA (#552). The token carries
/// no claims beyond expiry; the "jwt" scheme validates only signature + lifetime, matching the token
/// Jellyfin/external clients pass as <c>?access_token=</c>. Returns the token and its absolute expiry so
/// the caller can report it without re-parsing the JWT.
/// </summary>
public static (string Token, DateTimeOffset ExpiresAt) GenerateBrowserToken()
{
DateTime expires = DateTime.UtcNow.Add(BrowserTokenLifetime);
var tokenHandler = new JwtSecurityTokenHandler();
var tokenDescriptor = new SecurityTokenDescriptor
{
Expires = DateTime.UtcNow.AddDays(1),
Expires = expires,
SigningCredentials = new SigningCredentials(IssuerSigningKey, SecurityAlgorithms.HmacSha256Signature)
};
SecurityToken token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
return (tokenHandler.WriteToken(token), new DateTimeOffset(expires, TimeSpan.Zero));
}
}
+16 -13
View File
@@ -3445,19 +3445,20 @@ leaving the shared tree dirty is the one outcome that would make this script a n
`key: api.channel-preview-capability` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`.
**Signals:** a Play button that does nothing; preview eligibility inferred from a display string; a green preview on a Transport Stream channel being read as validating its configured pipeline · paths: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`, `ErsatzTV.Application/Channels/Mapper.cs`, `web/src/screens/channels/ChannelPreviewPanel.tsx` · issues: #60
**Mechanics:** `Mapper.GetPreview(StreamingMode, channelNumber, iptvJwtEnabled, isEnabled, playoutCount)` is pure; `JwtHelper.IsEnabled` is read only at `ChannelController.GetAll` and passed in
**Signals:** a Play button that does nothing; preview eligibility inferred from a display string; a green preview on a Transport Stream channel being read as validating its configured pipeline · paths: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`, `ErsatzTV.Application/Channels/Mapper.cs`, `web/src/screens/channels/ChannelPreviewPanel.tsx` · issues: #60, #552
**Mechanics:** `Mapper.GetPreview(StreamingMode, channelNumber, isEnabled, playoutCount)` is pure and JWT-agnostic
`ChannelPreviewAvailability` is one of `Available`, `ForcedHlsOnly`, or `Unavailable`, computed in one
place from the real `StreamingMode` enum plus JWT status. The SPA renders and acts on it and derives
nothing — deriving it client-side would mean keying behavior off `Mapper.GetStreamingMode`'s
human-readable display label, where a copy tweak would silently break playback.
place from the real `StreamingMode` enum plus the channel's enabled/playout state. The SPA renders and
acts on it and derives nothing — deriving it client-side would mean keying behavior off
`Mapper.GetStreamingMode`'s human-readable display label, where a copy tweak would silently break
playback.
`Unavailable` now covers three causes, checked in this order (first match wins): JWT enabled, the
channel is disabled (`IptvController` 404s a disabled channel, so preview must not even try), and
the channel has zero playouts (a manifest request against one blocks indefinitely). Only the JWT
case was in scope at first pass; the other two were keying preview on `StreamingMode` + JWT alone,
so a disabled or playout-less channel was declared `Available` and then failed confusingly.
`Unavailable` covers two causes, checked in this order (first match wins): the channel is disabled
(`IptvController` 404s a disabled channel, so preview must not even try), and the channel has zero
playouts (a manifest request against one blocks indefinitely). At first pass these two were keying
preview on `StreamingMode` alone, so a disabled or playout-less channel was declared `Available` and
then failed confusingly.
Only the two HLS modes are browser-playable; a browser cannot play the `video/mp2t` that the
Transport Stream modes serve over `/iptv/*`. Those are declared `ForcedHlsOnly`: preview is offered
@@ -3467,6 +3468,8 @@ are reported, never auto-recovered — a diagnostic surface must show the fault
it; a user-initiated Retry re-issues the manifest request via a real `playToken` because the manifest
GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op.
`/iptv/*` does not accept the SPA's `ctv-session` cookie, and nothing mints a JWT for the SPA, so
under a JWT-enabled deployment preview is declared `Unavailable` with the reason `IPTV JWT
authentication is enabled` rather than failing silently against a 401.
Originally, a JWT-enabled deployment made preview `Unavailable` (reason `IPTV JWT authentication is
enabled`) because `/iptv/*` does not accept the SPA's `ctv-session` cookie and nothing minted a JWT
for the browser. #552 closed that: the SPA now mints a short-lived token and appends it as
`?access_token=`, so this projection no longer inspects JWT status at all. See
`security.iptv-browser-token`.
+1
View File
@@ -127,6 +127,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `security.corp-same-origin` | `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests. | 2026-07-13 | [link](api-auth-security.md#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330) |
| `security.csp-permissions-policy` | `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline) |
| `security.fail-closed-api-auth` | Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292) |
| `security.iptv-browser-token` | Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. | 2026-07-22 | [link](api-auth-security.md#2026-07-22--short-lived-browser-iptv-token-so-the-spa-reaches-iptv-under-jwt-auth-552) |
| `security.session-auth-dual-credential` | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only) |
| `security.session-cutover-postify` | The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`). | 2026-07-12 | [link](api-auth-security.md#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification) |
| `session.shared-checkout-refresh` | Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`. | 2026-07-21 | [link](../decisions.md#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541) |
+51
View File
@@ -22,6 +22,7 @@ contract-freeze), #206 (Blazor-removal auth posture), #283 (artwork content-type
- [2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification](#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification)
- [2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)](#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline)
- [2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)](#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330)
- [2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552)](#2026-07-22--short-lived-browser-iptv-token-so-the-spa-reaches-iptv-under-jwt-auth-552)
---
@@ -426,3 +427,53 @@ explicit `Api:CorsAllowedOrigins` machine-client path continues to work. It is a
server-side HTTP clients, so Jellyfin's `/iptv/*` requests are unaffected; same-origin SPA artwork and
IPTV requests remain allowed. This is defense in depth for browser embedding and does not replace CORS
or authentication. Refs #330 #319 #314.
## 2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552)
`key: security.iptv-browser-token` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
**Rule:** Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`.
**Signals:** channel preview / playback-troubleshooting 401ing under JWT; `/iptv/*` not accepting `ctv-session`; minting a JWT for the browser · paths: `ErsatzTV/JwtHelper.cs`, `ErsatzTV/Controllers/Api/AuthController.cs`, `web/src/media/iptvToken.ts` · issues: #552, #60
**Mechanics:** `JwtHelper.GenerateBrowserToken()`; `AuthController.IptvToken`; `withIptvToken(url)` (SPA)
`/iptv/*` is gated by `ConditionalIptvAuthorizeFilter` only when `JWT:IssuerSigningKey` is configured,
and the `"jwt"` scheme accepts only a bearer token or `?access_token=`**not** the SPA's `ctv-session`
cookie (a distinct scheme). Nothing minted a JWT for the browser, so under JWT the #60 channel preview
was declared `Unavailable` and the pre-existing playback-troubleshooting screen was latently broken. This
closes both with one seam.
- **Endpoint.** `GET /api/v1/auth/iptv-token` on `AuthController` (already `[SkipApiAuthorization]` +
self-checks the principal, like `machine-key`). Requires any authenticated session (401 otherwise);
returns `{ token, expiresAt }` when `JwtHelper.IsEnabled`, else **204 No Content**`/iptv/*` is open
then, so there is nothing to append and the SPA plays the plain URL. `Cache-Control: no-store`. Excluded
from the OpenAPI document (`AuthController` is `[IgnoreApi]`, per the #295 F4 ruling — a
browser-interactive credential is not something a generated client drives).
- **A GET is correct here** despite the "no side-effecting GET under `/api`" rule (`api-conventions.md §9`):
minting a JWT writes **no server state** (stateless token, no DB row, no revocation list), so it is not a
CSRF-relevant side effect, and same-origin policy already blocks a cross-site page from reading the
credentialed response body — identical reasoning to the `machine-key` GET.
- **Scope: global.** The `"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 — deferred until a non-admin preview audience exists.
- **Lifetime: 60 min default, `JWT:BrowserTokenLifetimeMinutes` override, clamped to 24h.** The token
re-validates on every `/iptv/*` request, so lifetime is the max continuous watch before playback stalls;
60 min comfortably covers an operator verification session, an expired idle session just needs Retry
(mints fresh), and a security-conscious operator can tighten it. A non-positive/unparseable value falls
back to the default rather than minting an already-expired token; a value above 24h (a seconds-vs-minutes
typo would otherwise mint a multi-year bearer token) is clamped down. **Revocation is by short lifetime
only** — a stateless JWT has no per-token revocation; rotating `JWT:IssuerSigningKey` invalidates all
tokens (the existing lever). The SPA's `resetIptvTokenCache()` (called on the preview panel's Retry and on
each troubleshooting Play) makes a user-initiated retry re-mint, so a stale token or a stale "JWT disabled"
latch from a since-reconfigured backend can't wedge a recovery attempt.
- **Deferred hardening (broader than #552).** The `?access_token=` transport itself has two pre-existing
weaknesses this feature inherits, now bounded by the short lifetime: Serilog's request log includes the
full query (so a token can reach logs on an `/iptv` 5xx), and the token-bearing dynamic manifests carry no
`Cache-Control: no-store`. Both predate this feature (Jellyfin and the M3U playlist already pass
`access_token` in `/iptv` URLs) and their fixes are cross-cutting changes to shared request-logging /
manifest behavior — tracked as a follow-up in #559, not folded here.
- **Only the top-level manifest needs the token.** The multi-variant playlist embeds `access_token` into
its variant URL (`IptvController.GetMultiVariantPlaylist`), and HLS segments are served by
`UseStaticFiles` at `RequestPath=/iptv/session`**outside** `ConditionalIptvAuthorizeFilter` (which is
a `[ServiceFilter]` on `IptvController` only) — so segment GETs are ungated regardless. The SPA's
`withIptvToken(url)` appends the token to the one manifest URL (a no-op when JWT is off; caches the token
in memory until shortly before expiry) and is used by both the channel-preview panel and the
playback-troubleshooting screen.
+11
View File
@@ -225,6 +225,17 @@ safe) and mirrors `onError`'s requirements exactly: pass a **stable** `onPlaying
since it too sits in the attach effect's dependency array and an unstable identity would restart the
stream every render.
**Resolving a `/iptv/*` src under JWT auth (#552).** Before feeding an `/iptv/*` manifest URL to
`HlsPlayer`, pass it through `withIptvToken(url)` (`web/src/media/iptvToken.ts`): under a JWT-enabled
deployment it appends the short-lived `?access_token=` the `/iptv/*` scheme requires (the `ctv-session`
cookie does not satisfy it), and it is a **no-op** when JWT is off (the endpoint answers 204, cached).
It is async, so resolve it into state and gate the player on the resolved src — the channel-preview
panel does this in an effect keyed on `playToken`; the troubleshooting screen awaits it inline in its
`onPlay` handler. Only the top-level manifest needs it (segments and the multi-variant→variant hop
carry or bypass the token server-side). **In tests, mock `../media/iptvToken` to identity**
(`withIptvToken: (url) => Promise.resolve(url)`) so opening a preview fires no real `iptv-token` fetch;
the append logic itself is unit-tested in `media/iptvToken.test.ts`.
## 5c. Media "Add to…" affordances
Screens that let the user add media items to a collection/playlist/schedule use the shared layer in
+109
View File
@@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getIptvToken, resetIptvTokenCache, withIptvToken } from './iptvToken';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function noContentResponse(): Response {
return new Response(null, { status: 204 });
}
function tokenBody(expiresInMinutes = 60): { token: string; expiresAt: string } {
return {
token: 'minted-jwt',
expiresAt: new Date(Date.now() + expiresInMinutes * 60_000).toISOString()
};
}
describe('getIptvToken', () => {
beforeEach(() => {
resetIptvTokenCache();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns the minted token when the endpoint answers 200', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(tokenBody()));
await expect(getIptvToken()).resolves.toBe('minted-jwt');
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/iptv-token', expect.objectContaining({ method: 'GET' }));
});
it('returns null on 204 (IPTV JWT auth disabled)', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await expect(getIptvToken()).resolves.toBeNull();
});
it('caches a minted token across calls', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(tokenBody()));
await getIptvToken();
await getIptvToken();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not re-hit the endpoint once it has seen a 204', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await getIptvToken();
await getIptvToken();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('re-mints when the cached token is near expiry', async () => {
// 1-minute lifetime is inside the 2-minute refresh skew, so the second call must re-fetch. A fresh
// Response per call — a single Response body can only be read once.
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(tokenBody(1))));
await getIptvToken();
await getIptvToken();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('withIptvToken', () => {
beforeEach(() => {
resetIptvTokenCache();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('appends ?access_token= to a query-less URL', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(tokenBody()));
await expect(withIptvToken('/iptv/channel/12.1.m3u8')).resolves.toBe(
'/iptv/channel/12.1.m3u8?access_token=minted-jwt'
);
});
it('appends &access_token= to a URL that already has a query', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(tokenBody()));
await expect(withIptvToken('/iptv/channel/12.1.m3u8?mode=segmenter')).resolves.toBe(
'/iptv/channel/12.1.m3u8?mode=segmenter&access_token=minted-jwt'
);
});
it('returns the URL unchanged when JWT auth is disabled (204)', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await expect(withIptvToken('/iptv/channel/12.1.m3u8')).resolves.toBe('/iptv/channel/12.1.m3u8');
});
it('url-encodes the token', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({ token: 'a b+c', expiresAt: new Date(Date.now() + 3_600_000).toISOString() })
);
await expect(withIptvToken('/iptv/channel/12.1.m3u8')).resolves.toBe(
'/iptv/channel/12.1.m3u8?access_token=a%20b%2Bc'
);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { request } from '../api/client';
// The IPTV access-token endpoint (`GET /api/v1/auth/iptv-token`) lives on the [IgnoreApi] auth surface, so
// like the rest of `auth.ts` its DTO is hand-written here rather than sourced from `./generated/v1`.
// Wire fields are camelCase to match the server record `IptvTokenResponse(string Token, DateTimeOffset
// ExpiresAt)`.
export interface IptvToken {
token: string;
expiresAt: string;
}
interface CachedToken {
token: string;
expiresAtMs: number;
}
// Refresh a little before expiry so an in-flight play doesn't begin with an about-to-expire token (which
// would stall mid-stream). Well under the 60-minute default lifetime.
const REFRESH_SKEW_MS = 120_000;
let cached: CachedToken | null = null;
// Once the server answers 204 (IPTV JWT auth is off), it cannot turn on without a server restart — remember
// it so we don't re-hit the endpoint on every play. Reset via resetIptvTokenCache (used by tests).
let jwtDisabled = false;
export function resetIptvTokenCache(): void {
cached = null;
jwtDisabled = false;
}
/**
* Fetch a short-lived `/iptv/*` access token for the browser (#552), or `null` when IPTV JWT auth is
* disabled (the endpoint answers 204 and `/iptv/*` is open — nothing to append). Cached in memory until
* shortly before expiry so repeated plays reuse one token.
*/
export async function getIptvToken(): Promise<string | null> {
if (jwtDisabled) {
return null;
}
if (cached && cached.expiresAtMs - Date.now() > REFRESH_SKEW_MS) {
return cached.token;
}
const result = await request<IptvToken | undefined>('/api/v1/auth/iptv-token');
if (!result) {
// 204 No Content: IPTV JWT auth is off, so `/iptv/*` needs no token.
jwtDisabled = true;
cached = null;
return null;
}
cached = { token: result.token, expiresAtMs: Date.parse(result.expiresAt) };
return cached.token;
}
/**
* Resolve a `/iptv/*` URL to one the browser can actually fetch under a JWT-enabled deployment, appending
* `?access_token=`/`&access_token=` when a token is required. A no-op when JWT auth is disabled. Only the
* top-level manifest URL needs this: the multi-variant playlist propagates the token to its variant, and
* HLS segments are served by static-file middleware outside the JWT filter.
*/
export async function withIptvToken(url: string): Promise<string> {
const token = await getIptvToken();
if (!token) {
return url;
}
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}access_token=${encodeURIComponent(token)}`;
}
+9 -2
View File
@@ -27,6 +27,13 @@ vi.mock('hls.js', () => {
return { default: MockHls };
});
// The preview panel resolves a tokened src via withIptvToken (#552); mock it to identity so opening the
// panel doesn't fire a real /api/v1/auth/iptv-token fetch. Behavior is covered in media/iptvToken.test.ts.
vi.mock('../media/iptvToken', () => ({
withIptvToken: (url: string) => Promise.resolve(url),
resetIptvTokenCache: () => {}
}));
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
@@ -324,7 +331,7 @@ describe('ChannelsScreen — load + render', () => {
preview: {
availability: 'Unavailable',
manifestUrl: null,
unavailableReason: 'IPTV JWT authentication is enabled'
unavailableReason: 'Channel has no playout'
}
})
]
@@ -332,7 +339,7 @@ describe('ChannelsScreen — load + render', () => {
render(<ChannelsScreen />);
const play = await screen.findByRole('button', { name: /IPTV JWT authentication is enabled/ });
const play = await screen.findByRole('button', { name: /Channel has no playout/ });
expect(play).toBeDisabled();
});
@@ -26,6 +26,14 @@ vi.mock('hls.js', () => {
return { default: MockHls };
});
// onPlay resolves the manifest through withIptvToken (#552, appends a token under a JWT-enabled
// deployment); identity-mock it so the assertions below see the plain LIVE_MANIFEST and no real
// /api/v1/auth/iptv-token fetch fires. Append behavior is covered in media/iptvToken.test.ts.
vi.mock('../media/iptvToken', () => ({
withIptvToken: (url: string) => Promise.resolve(url),
resetIptvTokenCache: () => {}
}));
interface Status {
state: string;
exitCode: null | number;
@@ -24,6 +24,7 @@ import {
type Watermark
} from '../api';
import { HlsPlayer } from '../media/HlsPlayer';
import { resetIptvTokenCache, withIptvToken } from '../media/iptvToken';
import { parseDurationSeconds } from '../media/mediaKinds';
// StreamingMode.HttpLiveStreamingSegmenter — mirrors PlaybackTroubleshooting.razor, which always
@@ -448,6 +449,9 @@ export function PlaybackTroubleshootingScreen() {
return;
}
setNotice(null);
// Each explicit Play re-evaluates the IPTV token from scratch (drops any stale token / "JWT disabled"
// latch cached from a prior config), so a manual retry can't be wedged by a stale credential (#552).
resetIptvTokenCache();
setPlaybackStatus((current) => (current ? { ...current, logs: null, speed: null } : current));
setPhase('starting');
startingSinceRef.current = Date.now();
@@ -459,9 +463,15 @@ export function PlaybackTroubleshootingScreen() {
if (!activeRef.current) {
return;
}
// The start endpoint returns the open `/iptv` live manifest — feed it to HlsPlayer. The URL is
// constant across plays, so bump the token to force a re-attach even on a repeat play.
setPlayerSrc(result.url);
// The start endpoint returns the `/iptv` live manifest — feed it to HlsPlayer. Under a JWT-enabled
// deployment /iptv/* needs a short-lived access token appended (#552); withIptvToken is a no-op when
// JWT is off. The URL is constant across plays, so bump the token to force a re-attach even on a
// repeat play.
const src = await withIptvToken(result.url);
if (!activeRef.current) {
return;
}
setPlayerSrc(src);
setPlayToken((current) => current + 1);
// Fast-completion race (Codex MAJOR): the POST returns 200 only after segments exist, so a short
// clip can settle between polls; applyStatus discards settled states while 'starting', turning a
@@ -1,9 +1,22 @@
import { act } from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChannelPreviewAvailability } from '../../api/channels';
import { resetIptvTokenCache, withIptvToken } from '../../media/iptvToken';
import { ChannelPreviewPanel } from './ChannelPreviewPanel';
// The panel resolves the tokened src via withIptvToken (#552) before mounting the player. Mock it to
// identity by default so the assertions below see the plain manifest URLs; individual tests override the
// implementation to exercise token-append / failure paths. The real append logic is unit-tested in
// media/iptvToken.test.ts.
vi.mock('../../media/iptvToken', () => ({
withIptvToken: vi.fn((url: string) => Promise.resolve(url)),
resetIptvTokenCache: vi.fn()
}));
const withIptvTokenMock = vi.mocked(withIptvToken);
const resetIptvTokenCacheMock = vi.mocked(resetIptvTokenCache);
const hlsMock = {
isSupported: true,
loadSource: vi.fn(),
@@ -26,6 +39,19 @@ vi.mock('hls.js', () => {
return { default: MockHls };
});
// Flush the microtask the token-resolution effect awaits before mounting the player.
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
async function renderPanel(ui: React.ReactElement) {
const result = render(ui);
await flush();
return result;
}
function channel(preview: ChannelPreview, streamingMode = 'HLS Segmenter', id = 1) {
return { id, name: 'Vaporwave', number: '12.1', preview, streamingMode };
}
@@ -57,7 +83,7 @@ const available2: ChannelPreview = {
const unavailable: ChannelPreview = {
availability: 'Unavailable',
manifestUrl: null,
unavailableReason: 'IPTV JWT authentication is enabled'
unavailableReason: 'Channel is disabled'
};
function getErrorHandler() {
@@ -72,22 +98,47 @@ describe('ChannelPreviewPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
hlsMock.isSupported = true;
withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(url));
});
afterEach(() => {
cleanup();
});
it('loads the declared manifest url for an available channel', () => {
render(
it('loads the declared manifest url for an available channel', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8');
});
it('shows the now-playing title it was given', () => {
render(
it('appends the iptv access token to the manifest url before playing (JWT-enabled deployment)', async () => {
withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(`${url}?access_token=minted`));
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(withIptvTokenMock).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8');
expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8?access_token=minted');
// The token must not leak into the visible URL fact (kept token-free for curl reproducibility).
expect(screen.getByText('/iptv/channel/12.1.m3u8')).toBeInTheDocument();
});
it('surfaces a fatal error when the access token cannot be obtained', async () => {
withIptvTokenMock.mockRejectedValue(new Error('no session'));
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.getByText(/could not obtain an iptv access token/i)).toBeInTheDocument();
expect(hlsMock.loadSource).not.toHaveBeenCalled();
});
it('shows the now-playing title it was given', async () => {
await renderPanel(
<ChannelPreviewPanel
channel={channel(available)}
nowPlaying={{ finishUtc: '2026-07-21T21:00:00Z', startUtc: '2026-07-21T20:00:00Z', title: 'Neon Nights' }}
@@ -99,16 +150,16 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText('Neon Nights')).toBeInTheDocument();
});
it('shows nothing-scheduled when there is no now-playing', () => {
render(
it('shows nothing-scheduled when there is no now-playing', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.getByText('Nothing scheduled')).toBeInTheDocument();
});
it('does not autoplay a forced-hls channel and shows the caveat both before and after opting in', () => {
render(
it('does not autoplay a forced-hls channel and shows the caveat both before and after opting in', async () => {
await renderPanel(
<ChannelPreviewPanel
channel={channel(forced, 'MPEG-TS')}
nowPlaying={null}
@@ -123,31 +174,32 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText(/not the channel's configured pipeline/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /preview via hls anyway/i }));
await flush();
// After opting in: the stream loads, and the caveat persists as a banner beside the result.
expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8?mode=segmenter');
expect(screen.getByText(/not the channel's configured pipeline/i)).toBeInTheDocument();
});
it('never shows the caveat for an available channel', () => {
render(
it('never shows the caveat for an available channel', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.queryByText(/not the channel's configured pipeline/i)).not.toBeInTheDocument();
});
it('renders the server reason and no player when unavailable', () => {
render(
it('renders the server reason and no player when unavailable', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(unavailable)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.getByText('IPTV JWT authentication is enabled')).toBeInTheDocument();
expect(screen.getByText('Channel is disabled')).toBeInTheDocument();
expect(hlsMock.loadSource).not.toHaveBeenCalled();
});
it('surfaces a fatal playback error', () => {
render(
it('surfaces a fatal playback error', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
@@ -159,29 +211,55 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText(/networkError: manifestLoadError/)).toBeInTheDocument();
});
it('shows the manifest url so a failure can be reproduced with curl', () => {
render(
it('shows the manifest url so a failure can be reproduced with curl', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.getByText('/iptv/channel/12.1.m3u8')).toBeInTheDocument();
});
it('clicking Retry re-issues the manifest request (a fresh play token)', () => {
render(
it('clicking Retry re-issues the manifest request (a fresh play token)', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await flush();
expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8');
// Retry drops the token cache so a stale/expired token can't wedge the recovery attempt.
expect(resetIptvTokenCacheMock).toHaveBeenCalled();
});
it('clicking Retry clears a prior error', () => {
render(
it('on Retry re-mints the token and never reloads the stale-token URL', async () => {
// withIptvToken returns a DIFFERENT token per call (simulating a remint after resetIptvTokenCache).
let n = 0;
withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(`${url}?access_token=t${++n}`));
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(1, '/iptv/channel/12.1.m3u8?access_token=t1');
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await flush();
// Exactly two loads total: the initial (t1) and the retry (t2). The stale-token URL (t1) must NOT be
// reloaded when playToken bumps — the player unmounts until the fresh URL resolves. A regression that
// kept resolvedSrc across the bump would produce a third loadSource with the stale t1 URL.
expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8?access_token=t2');
expect(hlsMock.loadSource.mock.calls.filter((c) => c[0] === '/iptv/channel/12.1.m3u8?access_token=t1')).toHaveLength(1);
});
it('clicking Retry clears a prior error', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
@@ -192,13 +270,14 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText(/networkError: manifestLoadError/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await flush();
expect(screen.queryByText(/networkError: manifestLoadError/)).not.toBeInTheDocument();
});
it('stays in starting once hls.js merely reports MANIFEST_PARSED', () => {
it('stays in starting once hls.js merely reports MANIFEST_PARSED', async () => {
// MANIFEST_PARSED fires before any media has decoded, so it must not be enough to reach playing.
render(
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
@@ -212,8 +291,8 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText('starting')).toBeInTheDocument();
});
it('reaches the playing state once the video element fires its real playing event', () => {
render(
it('reaches the playing state once the video element fires its real playing event', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
@@ -229,11 +308,11 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText('playing')).toBeInTheDocument();
});
it('does not let a subsequent playing event (e.g. a manual play-button click) erase a fatal error', () => {
it('does not let a subsequent playing event (e.g. a manual play-button click) erase a fatal error', async () => {
// A fatal error must be reported and stay reported: it must never be silently erased by a later
// `playing` event, such as the one fired when the operator manually clicks the visible play
// button on the <video controls> element after the failure banner is already showing.
render(
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
@@ -254,8 +333,8 @@ describe('ChannelPreviewPanel', () => {
});
describe('switching to a different channel while mounted (render-phase reset)', () => {
it('does not auto-start a forced-hls-only channel switched in from an available one', () => {
const { rerender } = render(
it('does not auto-start a forced-hls-only channel switched in from an available one', async () => {
const { rerender } = await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
@@ -264,14 +343,15 @@ describe('ChannelPreviewPanel', () => {
rerender(
<ChannelPreviewPanel channel={channel(forced, 'MPEG-TS', 2)} nowPlaying={null} onClose={vi.fn()} open />
);
await flush();
// No new load for the forced channel: it must stay opt-in, never auto-start.
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button', { name: /preview via hls anyway/i })).toBeInTheDocument();
});
it('clears a prior error and starts the new channel when switching between two available channels', () => {
const { rerender } = render(
it('clears a prior error and starts the new channel when switching between two available channels', async () => {
const { rerender } = await renderPanel(
<ChannelPreviewPanel channel={channel(available, undefined, 1)} nowPlaying={null} onClose={vi.fn()} open />
);
@@ -284,6 +364,7 @@ describe('ChannelPreviewPanel', () => {
rerender(
<ChannelPreviewPanel channel={channel(available2, undefined, 2)} nowPlaying={null} onClose={vi.fn()} open />
);
await flush();
expect(screen.queryByText(/networkError: manifestLoadError/)).not.toBeInTheDocument();
expect(hlsMock.loadSource).toHaveBeenLastCalledWith('/iptv/channel/34.1.m3u8');
@@ -300,7 +381,7 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText('playing')).toBeInTheDocument();
});
it('does not carry the retry play token over to the next channel', () => {
it('does not carry the retry play token over to the next channel', async () => {
// Both channels share a manifest URL so the only thing that could re-trigger the attach
// effect across the switch is playToken. If the reset failed to zero it out, the token
// would be unchanged (2 -> 2) and the effect (and loadSource) would NOT re-fire on switch.
@@ -315,22 +396,21 @@ describe('ChannelPreviewPanel', () => {
unavailableReason: null
};
const { rerender } = render(
const { rerender } = await renderPanel(
<ChannelPreviewPanel channel={channel(sameUrlA, undefined, 1)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await flush();
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await flush();
expect(hlsMock.loadSource).toHaveBeenCalledTimes(3);
rerender(
<ChannelPreviewPanel channel={channel(sameUrlB, undefined, 2)} nowPlaying={null} onClose={vi.fn()} open />
);
// A fresh channel with playToken reset to 0 differs from the prior effective token (2),
// so the attach effect re-fires exactly once more even though the manifest url is unchanged.
expect(hlsMock.loadSource).toHaveBeenCalledTimes(4);
await waitFor(() => expect(hlsMock.loadSource).toHaveBeenCalledTimes(4));
});
});
});
@@ -3,6 +3,7 @@ import type { ChannelPreviewAvailability } from '../../api/channels';
import { Button } from '../../components/forms';
import { SlideOver } from '../../components/overlay';
import { HlsPlayer } from '../../media/HlsPlayer';
import { resetIptvTokenCache, withIptvToken } from '../../media/iptvToken';
export const FORCED_HLS_CAVEAT =
"This channel is configured for Transport Stream, which browsers cannot play. This preview forces an HLS segmenter session — it checks the content, not the channel's configured pipeline.";
@@ -61,6 +62,12 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
// render (React's documented "adjusting state when a prop changes" pattern) rather than in a
// useEffect body, which would trip the react-hooks "no set-state-in-effect" rule (spa-conventions
// §3) and cause an extra commit.
// Under a JWT-enabled deployment, /iptv/* requires a short-lived access token appended as
// ?access_token= (#552); withIptvToken resolves it (a no-op when JWT is off). Async, so it lands in
// state and feeds HlsPlayer — manifestUrl itself stays token-free for the facts grid (curl-reproducible,
// no token leak in the UI).
const [resolvedSrc, setResolvedSrc] = useState<null | string>(null);
const [resetKey, setResetKey] = useState(channel.id);
if (resetKey !== channel.id) {
setResetKey(channel.id);
@@ -68,6 +75,7 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
setState(availability === 'Available' ? 'starting' : 'idle');
setError(null);
setPlayToken(0);
setResolvedSrc(null);
}
// failedRef mutations are confined to event handlers and this effect — never render-phase — per
@@ -106,13 +114,52 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
// retries hides the fault it exists to reveal). Retry re-issues the manifest request by bumping
// playToken and clears the previous error so a stale failure doesn't linger over a fresh attempt.
const onRetry = useCallback(() => {
// Retry is the recovery affordance, so re-evaluate the token from scratch: drop the cache so a stale
// token (key rotated) or a stale "JWT disabled" latch (backend reconfigured since load) can't wedge
// playback. An actually-expired token would refresh on its own, but this also covers those edges (#552).
resetIptvTokenCache();
// Null resolvedSrc FIRST so the player unmounts and does not reload the OLD tokened URL when playToken
// bumps below — the fresh URL comes from the async effect after withIptvToken re-mints. Skipping this
// would load the stale token once (duplicate session / a stale 401 that sticks the panel as failed even
// after the fresh stream succeeds).
setResolvedSrc(null);
failedRef.current = false;
setError(null);
setState('starting');
setPlayToken((current) => current + 1);
}, []);
const src = started ? manifestUrl : null;
// Resolve the tokened src whenever a play begins or is retried. Keyed on playToken so a Retry (which may
// follow a token expiry) re-mints; the cleanup flag drops a stale resolution if the channel/attempt
// changes mid-flight. A token-fetch failure surfaces as a fatal error, same as any manifest failure.
useEffect(() => {
// Not playing yet (forced channel before opt-in, or just-reset): nothing to resolve. resolvedSrc is
// already null here — it starts null and the render-phase reset above nulls it whenever `started`
// flips false — so return without a synchronous setState (react-hooks/set-state-in-effect).
if (!started || !manifestUrl) {
return;
}
let active = true;
void (async () => {
try {
const url = await withIptvToken(manifestUrl);
if (active) {
setResolvedSrc(url);
}
} catch {
if (active) {
failedRef.current = true;
setError('Could not obtain an IPTV access token.');
setState('failed');
}
}
})();
return () => {
active = false;
};
}, [started, manifestUrl, playToken]);
return (
<SlideOver onClose={onClose} open={open} subtitle={`Channel ${channel.number}`} title={`Preview: ${channel.name}`}>
@@ -130,14 +177,14 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
Preview via HLS anyway
</Button>
)}
{src && (
{resolvedSrc && (
<>
<HlsPlayer
className="ctv-preview-video"
onError={onError}
onPlaying={onPlaying}
playToken={playToken}
src={src}
src={resolvedSrc}
style={{ aspectRatio: '16 / 9', background: '#000' }}
/>
<div className="ctv-detail-actions">