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; /// /// Browser SPA session authentication (issue #295). Excluded from the OpenAPI document — the spec's /// audience is machine X-Api-Key clients, and a browser-interactive cookie login is not something a /// generated client drives — and exempt from the global (this surface /// must be reachable before a caller has a session). Sensitive operations self-check the principal. /// [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"]); /// Public: what auth options exist + whether first-run setup is still needed (drives the SPA boot gate). [HttpGet("/api/v1/auth/config")] public async Task Config(CancellationToken cancellationToken) { bool configured = await mediator.Send(new IsLocalAdminConfigured(), cancellationToken); return Ok(new AuthConfigResponse(OidcHelper.IsEnabled, true, !configured && !EnvSeedConfigured)); } /// The current session (anonymous is a 200 with authenticated=false, never a 401). [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)); } /// /// The effective machine X-Api-Key (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. /// [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)); } /// /// 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")] public async Task 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 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)); }); } /// Local username/password login. A generic 401 on any failure (no username enumeration). [HttpPost("/api/v1/auth/login")] [EnableRateLimiting("auth")] public async Task Login([FromBody] LoginRequest request, CancellationToken cancellationToken) { Either 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)); }); } /// /// 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 server-side (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. /// [HttpPost("/api/v1/auth/logout")] public async Task 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(); } /// Change the local admin password (requires a local-login session); rotates the stamp, revoking other sessions. [HttpPost("/api/v1/auth/password")] [EnableRateLimiting("auth")] public async Task 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 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(); }); } /// Browser-navigation OIDC challenge. Outside /api (a top-level GET redirect). 404 when OIDC is off. [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 { 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);