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>
233 lines
9.0 KiB
C#
233 lines
9.0 KiB
C#
using System.Collections.Generic;
|
|
using System.Security.Claims;
|
|
using ErsatzTV.Application.Auth;
|
|
using ErsatzTV.Controllers.Api;
|
|
using ErsatzTV.Controllers.Api.Requests;
|
|
using ErsatzTV.Services;
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Controllers;
|
|
|
|
[TestFixture]
|
|
public class AuthControllerTests
|
|
{
|
|
private static IConfiguration Config(bool envSeed) =>
|
|
new ConfigurationBuilder()
|
|
.AddInMemoryCollection(
|
|
envSeed
|
|
? new Dictionary<string, string?> { ["Auth:LocalAdmin:Password"] = "seed-password" }
|
|
: new Dictionary<string, string?>())
|
|
.Build();
|
|
|
|
private static IApiKeyProvider ApiKeyProvider(string key = "the-machine-key")
|
|
{
|
|
var provider = Substitute.For<IApiKeyProvider>();
|
|
provider.ApiKey.Returns(key);
|
|
return provider;
|
|
}
|
|
|
|
[Test]
|
|
public async Task Config_Reports_Setup_Not_Required_When_Env_Seed_Configured()
|
|
{
|
|
var mediator = Substitute.For<IMediator>();
|
|
mediator.Send(Arg.Any<IsLocalAdminConfigured>(), Arg.Any<CancellationToken>()).Returns(false);
|
|
|
|
var controller = new AuthController(mediator, Config(envSeed: true), ApiKeyProvider());
|
|
|
|
var result = await controller.Config(CancellationToken.None) as OkObjectResult;
|
|
var body = result!.Value.ShouldBeOfType<AuthConfigResponse>();
|
|
|
|
// Env seed owns the credential → the SPA must not offer the browser setup-claim.
|
|
body.SetupRequired.ShouldBeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Config_Reports_Setup_Required_When_Unconfigured_And_No_Env_Seed()
|
|
{
|
|
var mediator = Substitute.For<IMediator>();
|
|
mediator.Send(Arg.Any<IsLocalAdminConfigured>(), Arg.Any<CancellationToken>()).Returns(false);
|
|
|
|
var controller = new AuthController(mediator, Config(envSeed: false), ApiKeyProvider());
|
|
|
|
var result = await controller.Config(CancellationToken.None) as OkObjectResult;
|
|
var body = result!.Value.ShouldBeOfType<AuthConfigResponse>();
|
|
|
|
body.SetupRequired.ShouldBeTrue();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Setup_Is_Closed_With_409_When_Env_Seed_Configured()
|
|
{
|
|
var mediator = Substitute.For<IMediator>();
|
|
var controller = new AuthController(mediator, Config(envSeed: true), ApiKeyProvider())
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
|
|
var result = await controller.Setup(new SetupRequest("admin", "hunter2pw"), CancellationToken.None);
|
|
|
|
var problem = result.ShouldBeOfType<ConflictObjectResult>();
|
|
problem.StatusCode.ShouldBe(StatusCodes.Status409Conflict);
|
|
// The claim must never be attempted while the env seed owns the credential.
|
|
await mediator.DidNotReceive().Send(Arg.Any<ClaimLocalAdmin>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public void MachineKey_Returns_401_When_Anonymous()
|
|
{
|
|
var mediator = Substitute.For<IMediator>();
|
|
var controller = new AuthController(mediator, Config(envSeed: false), ApiKeyProvider())
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
|
|
IActionResult result = controller.MachineKey();
|
|
|
|
var unauthorized = result.ShouldBeOfType<UnauthorizedObjectResult>();
|
|
unauthorized.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(401);
|
|
}
|
|
|
|
[Test]
|
|
public void MachineKey_Returns_The_Key_For_An_Authenticated_Session()
|
|
{
|
|
var mediator = Substitute.For<IMediator>();
|
|
var controller = new AuthController(mediator, Config(envSeed: false), ApiKeyProvider("the-machine-key"))
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(
|
|
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], AuthConstants.CookieScheme))
|
|
}
|
|
}
|
|
};
|
|
|
|
IActionResult result = controller.MachineKey();
|
|
|
|
var ok = result.ShouldBeOfType<OkObjectResult>();
|
|
ok.Value.ShouldBeOfType<MachineKeyResponse>().ApiKey.ShouldBe("the-machine-key");
|
|
}
|
|
|
|
[Test]
|
|
public void MachineKey_Sets_CacheControl_NoStore_For_An_Authenticated_Session()
|
|
{
|
|
var mediator = Substitute.For<IMediator>();
|
|
var httpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(
|
|
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], AuthConstants.CookieScheme))
|
|
};
|
|
var controller = new AuthController(mediator, Config(envSeed: false), ApiKeyProvider("the-machine-key"))
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = httpContext }
|
|
};
|
|
|
|
controller.MachineKey();
|
|
|
|
httpContext.Response.Headers.CacheControl.ToString().ShouldBe("no-store");
|
|
httpContext.Response.Headers.Pragma.ToString().ShouldBe("no-cache");
|
|
}
|
|
|
|
[TearDown]
|
|
public void ResetJwt() =>
|
|
// JwtHelper is static global state; leave it disabled so tests don't leak JWT config into each other.
|
|
JwtHelper.Init(new ConfigurationBuilder().Build());
|
|
|
|
private static AuthController AuthenticatedController(IMediator mediator) =>
|
|
new(mediator, Config(envSeed: false), ApiKeyProvider())
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(
|
|
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], AuthConstants.CookieScheme))
|
|
}
|
|
}
|
|
};
|
|
|
|
[Test]
|
|
public void IptvToken_Returns_401_When_Anonymous()
|
|
{
|
|
JwtHelper.Init(JwtConfig("this-is-a-sufficiently-long-signing-key-123456"));
|
|
var controller = new AuthController(Substitute.For<IMediator>(), Config(envSeed: false), ApiKeyProvider())
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
|
|
IActionResult result = controller.IptvToken();
|
|
|
|
result.ShouldBeOfType<UnauthorizedObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(401);
|
|
}
|
|
|
|
[Test]
|
|
public void IptvToken_Returns_204_When_Jwt_Disabled()
|
|
{
|
|
// JWT off (TearDown default): /iptv/* is open, so there is nothing to mint — the SPA plays the plain URL.
|
|
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
|
|
|
|
IActionResult result = controller.IptvToken();
|
|
|
|
result.ShouldBeOfType<NoContentResult>();
|
|
}
|
|
|
|
[Test]
|
|
public void IptvToken_Mints_A_Verifiable_Token_When_Jwt_Enabled()
|
|
{
|
|
const string SigningKey = "this-is-a-sufficiently-long-signing-key-123456";
|
|
JwtHelper.Init(JwtConfig(SigningKey));
|
|
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
|
|
|
|
var ok = controller.IptvToken().ShouldBeOfType<OkObjectResult>();
|
|
var body = ok.Value.ShouldBeOfType<IptvTokenResponse>();
|
|
|
|
body.Token.ShouldNotBeNullOrWhiteSpace();
|
|
body.ExpiresAt.ShouldBeGreaterThan(DateTimeOffset.UtcNow);
|
|
|
|
// The minted token must validate against the same key/params the "jwt" scheme enforces at runtime
|
|
// (signature + lifetime; issuer/audience are not validated).
|
|
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
|
|
Should.NotThrow(() => handler.ValidateToken(
|
|
body.Token,
|
|
new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
|
{
|
|
ValidateIssuer = false,
|
|
ValidateAudience = false,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
|
|
System.Text.Encoding.ASCII.GetBytes(SigningKey)),
|
|
ValidateLifetime = true
|
|
},
|
|
out _));
|
|
}
|
|
|
|
[Test]
|
|
public void IptvToken_Sets_CacheControl_NoStore_When_Jwt_Enabled()
|
|
{
|
|
JwtHelper.Init(JwtConfig("this-is-a-sufficiently-long-signing-key-123456"));
|
|
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
|
|
|
|
controller.IptvToken();
|
|
|
|
HttpResponse response = controller.ControllerContext.HttpContext.Response;
|
|
response.Headers.CacheControl.ToString().ShouldBe("no-store");
|
|
response.Headers.Pragma.ToString().ShouldBe("no-cache");
|
|
}
|
|
|
|
private static IConfiguration JwtConfig(string signingKey, string? lifetimeMinutes = null) =>
|
|
new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["JWT:IssuerSigningKey"] = signingKey,
|
|
["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes
|
|
})
|
|
.Build();
|
|
}
|