diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index 1dfdfd51e..e22e334ed 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -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( diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApi.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApi.cs index 2b1b705e9..83a42c386 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApi.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApi.cs @@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Channels; namespace ErsatzTV.Application.Channels; -public record GetAllChannelsForApi(bool IptvJwtEnabled) : IRequest>; +public record GetAllChannelsForApi : IRequest>; diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs index 7b539acb8..08c5148c3 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs @@ -13,6 +13,6 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository) CancellationToken cancellationToken) { IEnumerable 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(); } } diff --git a/ErsatzTV.Tests/Application/Channels/ChannelPreviewTests.cs b/ErsatzTV.Tests/Application/Channels/ChannelPreviewTests.cs index 2109dcdc0..268fcb3a0 100644 --- a/ErsatzTV.Tests/Application/Channels/ChannelPreviewTests.cs +++ b/ErsatzTV.Tests/Application/Channels/ChannelPreviewTests.cs @@ -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); diff --git a/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.cs index 4eb3f1d57..f6646f0f1 100644 --- a/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.cs @@ -36,7 +36,7 @@ public class GetAllChannelsForApiHandlerTests var handler = new GetAllChannelsForApiHandler(repository); List 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 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 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). diff --git a/ErsatzTV.Tests/Controllers/AuthControllerTests.cs b/ErsatzTV.Tests/Controllers/AuthControllerTests.cs index a669015ea..a3c873ccc 100644 --- a/ErsatzTV.Tests/Controllers/AuthControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/AuthControllerTests.cs @@ -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(), Config(envSeed: false), ApiKeyProvider()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + IActionResult result = controller.IptvToken(); + + result.ShouldBeOfType().Value.ShouldBeOfType().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()); + + IActionResult result = controller.IptvToken(); + + result.ShouldBeOfType(); + } + + [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()); + + var ok = controller.IptvToken().ShouldBeOfType(); + var body = ok.Value.ShouldBeOfType(); + + 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()); + + 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 + { + ["JWT:IssuerSigningKey"] = signingKey, + ["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes + }) + .Build(); } diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index c28e27d54..422c7506a 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -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() { diff --git a/ErsatzTV.Tests/JwtHelperTests.cs b/ErsatzTV.Tests/JwtHelperTests.cs new file mode 100644 index 000000000..8539c5c38 --- /dev/null +++ b/ErsatzTV.Tests/JwtHelperTests.cs @@ -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 + { + ["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)); + } +} diff --git a/ErsatzTV/Controllers/Api/AuthController.cs b/ErsatzTV/Controllers/Api/AuthController.cs index 7279585ae..b9b2cd6e6 100644 --- a/ErsatzTV/Controllers/Api/AuthController.cs +++ b/ErsatzTV/Controllers/Api/AuthController.cs @@ -74,6 +74,40 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA return Ok(new MachineKeyResponse(apiKeyProvider.ApiKey)); } + /// + /// Mint a short-lived, globally-scoped /iptv access token for the browser (issue #552), so the + /// channel-preview and playback-troubleshooting screens can reach the JWT-gated /iptv/* manifest + /// endpoints (which do not accept the ctv-session cookie) by appending ?access_token=. + /// Requires any authenticated session (401 otherwise). Returns 204 No Content when IPTV JWT auth + /// is not configured — /iptv/* 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 + /// ). + /// + [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)); + } + /// First-run setup-claim: create the local admin. Fails 409 if one already exists. [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); diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index d4ffc6436..496e3c244 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -27,7 +27,7 @@ public class ChannelController( [HttpGet("/api/v1/channels")] [EndpointGroupName("general")] public async Task> GetAll() => - await mediator.Send(new GetAllChannelsForApi(JwtHelper.IsEnabled)); + await mediator.Send(new GetAllChannelsForApi()); [HttpGet("/api/v1/channels/state")] [Tags("Channels")] diff --git a/ErsatzTV/JwtHelper.cs b/ErsatzTV/JwtHelper.cs index dae31a982..9202508ae 100644 --- a/ErsatzTV/JwtHelper.cs +++ b/ErsatzTV/JwtHelper.cs @@ -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() + /// + /// 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 ?access_token=. Returns the token and its absolute expiry so + /// the caller can report it without re-parsing the JWT. + /// + 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)); } } diff --git a/docs/decisions.md b/docs/decisions.md index f534c6c99..cb210cdbe 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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`. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 60e28b0ae..3845ea94a 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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) | diff --git a/docs/decisions/api-auth-security.md b/docs/decisions/api-auth-security.md index 0c1ca0707..918036e24 100644 --- a/docs/decisions/api-auth-security.md +++ b/docs/decisions/api-auth-security.md @@ -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. diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 32f537a44..7940bf053 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -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 diff --git a/web/src/media/iptvToken.test.ts b/web/src/media/iptvToken.test.ts new file mode 100644 index 000000000..671477ce4 --- /dev/null +++ b/web/src/media/iptvToken.test.ts @@ -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' + ); + }); +}); diff --git a/web/src/media/iptvToken.ts b/web/src/media/iptvToken.ts new file mode 100644 index 000000000..3462a79c4 --- /dev/null +++ b/web/src/media/iptvToken.ts @@ -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 { + if (jwtDisabled) { + return null; + } + + if (cached && cached.expiresAtMs - Date.now() > REFRESH_SKEW_MS) { + return cached.token; + } + + const result = await request('/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 { + const token = await getIptvToken(); + if (!token) { + return url; + } + + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}access_token=${encodeURIComponent(token)}`; +} diff --git a/web/src/screens/ChannelsScreen.test.tsx b/web/src/screens/ChannelsScreen.test.tsx index ab6b23c2e..e5e46e74a 100644 --- a/web/src/screens/ChannelsScreen.test.tsx +++ b/web/src/screens/ChannelsScreen.test.tsx @@ -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(); - 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(); }); diff --git a/web/src/screens/PlaybackTroubleshootingScreen.test.tsx b/web/src/screens/PlaybackTroubleshootingScreen.test.tsx index df73b3c96..73795bef7 100644 --- a/web/src/screens/PlaybackTroubleshootingScreen.test.tsx +++ b/web/src/screens/PlaybackTroubleshootingScreen.test.tsx @@ -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; diff --git a/web/src/screens/PlaybackTroubleshootingScreen.tsx b/web/src/screens/PlaybackTroubleshootingScreen.tsx index c6dc74e1c..8e46a275d 100644 --- a/web/src/screens/PlaybackTroubleshootingScreen.tsx +++ b/web/src/screens/PlaybackTroubleshootingScreen.tsx @@ -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 diff --git a/web/src/screens/channels/ChannelPreviewPanel.test.tsx b/web/src/screens/channels/ChannelPreviewPanel.test.tsx index f05d5a903..ce46e11e3 100644 --- a/web/src/screens/channels/ChannelPreviewPanel.test.tsx +++ b/web/src/screens/channels/ChannelPreviewPanel.test.tsx @@ -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( ); 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( + + ); + + 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( + + ); + + 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( { 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( ); 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( { 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( ); 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( ); - 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( ); @@ -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( ); 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( ); 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( + + ); + + 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( ); @@ -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( ); @@ -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( ); @@ -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