Files
ersatztv/ErsatzTV/JwtHelper.cs
timothyandClaude Opus 4.8 60a0c50578 fix(552): fold #552 security-review findings
Cold review (no Critical/High). Folded:
- Low: clamp JWT:BrowserTokenLifetimeMinutes to a 24h max so a seconds-vs-minutes
  typo can't mint a multi-year bearer token (non-positive/unparseable still falls
  back to 60 min).
- Low: reset the SPA iptv-token cache on the preview panel's Retry and on each
  troubleshooting Play, so a stale token (key rotated) or a stale "JWT disabled"
  latch (backend reconfigured since page load) can't wedge a user-initiated retry.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:34:01 +02:00

65 lines
3.1 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.IdentityModel.Tokens;
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"];
IsEnabled = !string.IsNullOrWhiteSpace(issuerSigningKey);
if (IsEnabled)
{
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;
}
}
/// <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 = expires,
SigningCredentials = new SigningCredentials(IssuerSigningKey, SecurityAlgorithms.HmacSha256Signature)
};
SecurityToken token = tokenHandler.CreateToken(tokenDescriptor);
return (tokenHandler.WriteToken(token), new DateTimeOffset(expires, TimeSpan.Zero));
}
}