Files
ersatztv/ErsatzTV/Controllers/Api/AuthController.cs
T
timothyandClaude Opus 4.8 f8ae4d62ab fix(552): mint a short-lived JWT so the SPA reaches /iptv/* under JWT auth
Under a JWT-enabled deployment (JWT:IssuerSigningKey set), /iptv/* is gated by
ConditionalIptvAuthorizeFilter and the "jwt" scheme does not accept the SPA's
ctv-session cookie, and nothing minted a JWT for the browser. So the #60 channel
preview was declared Unavailable and could not run at all.

Add GET /api/v1/auth/iptv-token (session-gated, on the [IgnoreApi] AuthController):
mints a short-lived global token via JwtHelper.GenerateBrowserToken (60 min default,
JWT:BrowserTokenLifetimeMinutes override), 204 when JWT is disabled. The SPA's new
withIptvToken(url) helper appends it as ?access_token= to the manifest URL (a no-op
when JWT is off), used by the channel-preview panel and the troubleshooting screen.
Mapper.GetPreview drops its iptvJwtEnabled -> Unavailable guard; preview is now
JWT-agnostic.

Live-E2E under JWT: /iptv manifest 401s without a token and passes with a valid one
(garbage token -> 401); token endpoint 401s anonymous, mints with a session.

Honest finding: the issue's point 2 (troubleshooting screen broken under JWT) does
not reproduce -- its live.m3u8 is static-served (UseStaticFiles at /iptv/session),
outside the JWT filter, so it was never gated. The withIptvToken call there is a
harmless defensive no-op.

Docs: security.iptv-browser-token (api-auth-security.md), amended
api.channel-preview-capability, spa-conventions §5b. No OpenAPI change (IgnoreApi +
unchanged Preview schema).

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

299 lines
13 KiB
C#

using System.Security.Claims;
using ErsatzTV.Application.Auth;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using ErsatzTV.Services;
using MediatR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace ErsatzTV.Controllers.Api;
/// <summary>
/// Browser SPA session authentication (issue #295). Excluded from the OpenAPI document — the spec's
/// audience is machine <c>X-Api-Key</c> clients, and a browser-interactive cookie login is not something a
/// generated client drives — and exempt from the global <see cref="ApiAuthorizationFilter" /> (this surface
/// must be reachable before a caller has a session). Sensitive operations self-check the principal.
/// </summary>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[SkipApiAuthorization]
public class AuthController(IMediator mediator, IConfiguration configuration, IApiKeyProvider apiKeyProvider)
: ControllerBase
{
// When the operator has set an env seed, the local admin is managed via configuration — the browser
// setup-claim is closed. This also eliminates the startup race where an attacker could claim admin in the
// window before LocalAdminSeedService runs (the seed would then lose and its insert be swallowed).
private bool EnvSeedConfigured => !string.IsNullOrWhiteSpace(configuration["Auth:LocalAdmin:Password"]);
/// <summary>Public: what auth options exist + whether first-run setup is still needed (drives the SPA boot gate).</summary>
[HttpGet("/api/v1/auth/config")]
public async Task<IActionResult> Config(CancellationToken cancellationToken)
{
bool configured = await mediator.Send(new IsLocalAdminConfigured(), cancellationToken);
return Ok(new AuthConfigResponse(OidcHelper.IsEnabled, true, !configured && !EnvSeedConfigured));
}
/// <summary>The current session (anonymous is a 200 with authenticated=false, never a 401).</summary>
[HttpGet("/api/v1/auth/session")]
public IActionResult Session()
{
if (User.Identity?.IsAuthenticated != true)
{
return Ok(new AuthSessionResponse(false, null, null));
}
return Ok(new AuthSessionResponse(true, User.Identity?.Name, User.FindFirst(AuthConstants.AuthMethodClaim)?.Value));
}
/// <summary>
/// The effective machine <c>X-Api-Key</c> (issue #301), for the ChicoryTV "API Key" settings screen to
/// display once it moves off session-managed auth. Requires any authenticated session (OIDC or local);
/// 401 otherwise. Never CSRF-gated — it's a GET, and SOP already blocks a cross-site page from reading a
/// credentialed response body.
/// </summary>
[HttpGet("/api/v1/auth/machine-key")]
public IActionResult MachineKey()
{
if (User.Identity?.IsAuthenticated != true)
{
return Unauthorized(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "An authenticated session is required."
});
}
Response.Headers.CacheControl = "no-store";
Response.Headers.Pragma = "no-cache";
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")]
public async Task<IActionResult> Setup([FromBody] SetupRequest request, CancellationToken cancellationToken)
{
if (EnvSeedConfigured)
{
return ApiResults.ConflictProblem(
"Managed via configuration",
"The local administrator is provisioned from Auth:LocalAdmin:* configuration; browser setup is disabled.");
}
if (await mediator.Send(new IsLocalAdminConfigured(), cancellationToken))
{
return ApiResults.ConflictProblem("Already configured", "A local administrator already exists.");
}
Either<BaseError, LocalAdminPrincipal> result =
await mediator.Send(new ClaimLocalAdmin(request.Username, request.Password), cancellationToken);
return await result.Match(
Left: async error =>
{
// A concurrent claim that lost the race reports 409 (the record now exists), not the 422 a
// validation error gets.
if (await mediator.Send(new IsLocalAdminConfigured(), cancellationToken))
{
return ApiResults.ConflictProblem("Already configured", "A local administrator already exists.");
}
return error.ToErrorResult();
},
Right: async principal =>
{
await IssueLocalCookieAsync(principal);
return (IActionResult)Ok(new AuthSessionResponse(true, principal.Username, AuthConstants.MethodLocal));
});
}
/// <summary>Local username/password login. A generic 401 on any failure (no username enumeration).</summary>
[HttpPost("/api/v1/auth/login")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
Either<BaseError, LocalAdminPrincipal> result =
await mediator.Send(new VerifyLocalAdminLogin(request.Username, request.Password), cancellationToken);
return await result.Match(
Left: _ => Task.FromResult((IActionResult)Unauthorized(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "Invalid username or password."
})),
Right: async principal =>
{
await IssueLocalCookieAsync(principal);
return (IActionResult)Ok(new AuthSessionResponse(true, principal.Username, AuthConstants.MethodLocal));
});
}
/// <summary>
/// Sign out of the cookie session. Requires the CSRF header (this controller is filter-exempt). For a
/// local session this rotates the security stamp, ending the session <b>server-side</b> (a captured
/// cookie can't be replayed after logout) — which, for the single local admin, revokes all local
/// sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated caller
/// can't force-revoke the admin.
/// </summary>
[HttpPost("/api/v1/auth/logout")]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
// Prevent forced-logout CSRF: a same-site form POST carries the Lax cookie but can't set a custom
// header. The whole controller is [SkipApiAuthorization], so the filter's CSRF check doesn't apply.
if (RequiresCsrf(out IActionResult csrfError))
{
return csrfError;
}
if (User.Identity?.IsAuthenticated == true &&
string.Equals(
User.FindFirst(AuthConstants.AuthMethodClaim)?.Value,
AuthConstants.MethodLocal,
StringComparison.Ordinal))
{
await mediator.Send(new RotateLocalAdminSecurityStamp(), cancellationToken);
}
await HttpContext.SignOutAsync(AuthConstants.CookieScheme);
return NoContent();
}
/// <summary>Change the local admin password (requires a local-login session); rotates the stamp, revoking other sessions.</summary>
[HttpPost("/api/v1/auth/password")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
{
// Filter-exempt controller, so apply the session-mutation CSRF check here explicitly.
if (RequiresCsrf(out IActionResult csrfError))
{
return csrfError;
}
// A machine key must not be able to rotate the local admin's password — require a local session.
if (User.Identity?.IsAuthenticated != true ||
!string.Equals(
User.FindFirst(AuthConstants.AuthMethodClaim)?.Value,
AuthConstants.MethodLocal,
StringComparison.Ordinal))
{
return Unauthorized(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "A local-login session is required to change the password."
});
}
Either<BaseError, LocalAdminPrincipal> result = await mediator.Send(
new ChangeLocalAdminPassword(User.Identity?.Name, request.CurrentPassword, request.NewPassword),
cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async principal =>
{
// Re-issue the cookie with the rotated stamp so THIS session survives while others are revoked.
await IssueLocalCookieAsync(principal);
return (IActionResult)NoContent();
});
}
/// <summary>Browser-navigation OIDC challenge. Outside <c>/api</c> (a top-level GET redirect). 404 when OIDC is off.</summary>
[HttpGet("/auth/oidc/login")]
public IActionResult OidcLogin()
{
if (!OidcHelper.IsEnabled)
{
return NotFound();
}
return Challenge(
new AuthenticationProperties { RedirectUri = $"{Request.PathBase}/app" },
AuthConstants.OidcScheme);
}
// Session-mutation CSRF gate for the [SkipApiAuthorization] auth surface (the global filter doesn't see
// it). Presence-only: a custom header can't be set by a cross-site form/navigation. Returns true (with a
// 403 result) when the header is missing.
private bool RequiresCsrf(out IActionResult error)
{
if (Request.Headers.ContainsKey(ApiAuthorizationFilter.CsrfHeaderName))
{
error = null;
return false;
}
error = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Forbidden",
Detail = $"This request requires the '{ApiAuthorizationFilter.CsrfHeaderName}' header."
})
{
StatusCode = StatusCodes.Status403Forbidden
};
return true;
}
private Task IssueLocalCookieAsync(LocalAdminPrincipal principal)
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, principal.Username),
new(AuthConstants.AuthMethodClaim, AuthConstants.MethodLocal),
new(AuthConstants.SecurityStampClaim, principal.SecurityStamp)
};
var identity = new ClaimsIdentity(claims, AuthConstants.CookieScheme);
return HttpContext.SignInAsync(AuthConstants.CookieScheme, new ClaimsPrincipal(identity));
}
}
public record AuthConfigResponse(bool OidcEnabled, bool LocalLoginEnabled, bool SetupRequired);
public record AuthSessionResponse(bool Authenticated, string Username, string Method);
public record MachineKeyResponse(string ApiKey);
public record IptvTokenResponse(string Token, DateTimeOffset ExpiresAt);