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);