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>
65 lines
3.1 KiB
C#
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));
|
|
}
|
|
}
|