Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Live E2E found that replaying a pre-logout cookie still authenticated (200, not 401): SignOutAsync only clears the CLIENT cookie, but the stateless encrypted cookie ticket stays valid server-side because its security stamp is unchanged — a captured cookie was replayable after logout until ticket expiry. Fix: logout now rotates the local-admin security stamp (RotateLocalAdminSecurityStamp), so every outstanding local session (old stamp) fails OnValidatePrincipal on its next request. For the single admin this is "log out everywhere". Gated on an authenticated local session so an unauthenticated caller can't force-revoke the admin. OIDC sessions (no stamp) are unaffected; SignOutAsync still clears the client cookie for UX. +2 handler tests (rotate-when-configured / no-op-when-unconfigured). Auth suite green (21). Docs: decisions.md note updated. Refs #295 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
66 lines
2.5 KiB
C#
66 lines
2.5 KiB
C#
using ErsatzTV.Application.Auth;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Infrastructure.Data.Repositories;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Application.Auth;
|
|
|
|
[TestFixture]
|
|
public class RotateLocalAdminSecurityStampHandlerTests
|
|
{
|
|
private InMemoryTvContext _db = null!;
|
|
private IConfigElementRepository _configElementRepository = null!;
|
|
private ILocalPasswordHasher _passwordHasher = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp()
|
|
{
|
|
_db = await InMemoryTvContext.CreateAsync();
|
|
_configElementRepository = new ConfigElementRepository(_db.Factory);
|
|
_passwordHasher = new LocalPasswordHasher();
|
|
}
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
private RotateLocalAdminSecurityStampHandler MakeHandler() => new(_db.Factory);
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Rotate_The_Stamp_When_Configured()
|
|
{
|
|
// Arrange: claim an admin so a stamp exists.
|
|
Either<BaseError, LocalAdminPrincipal> claim = await new ClaimLocalAdminHandler(_db.Factory, _passwordHasher)
|
|
.Handle(new ClaimLocalAdmin("admin", "supersecret"), CancellationToken.None);
|
|
claim.IsRight.ShouldBeTrue();
|
|
|
|
Option<string> before =
|
|
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
|
|
string originalStamp = before.Match(s => s, () => throw new ShouldAssertException("expected a stamp"));
|
|
|
|
// Act
|
|
await MakeHandler().Handle(new RotateLocalAdminSecurityStamp(), CancellationToken.None);
|
|
|
|
// Assert: the stamp changed (all outstanding sessions carrying the old stamp are now stale).
|
|
Option<string> after =
|
|
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
|
|
string rotatedStamp = after.Match(s => s, () => throw new ShouldAssertException("expected a stamp"));
|
|
rotatedStamp.ShouldNotBe(originalStamp);
|
|
rotatedStamp.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Be_A_No_Op_When_Unconfigured()
|
|
{
|
|
await MakeHandler().Handle(new RotateLocalAdminSecurityStamp(), CancellationToken.None);
|
|
|
|
Option<string> stamp =
|
|
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
|
|
stamp.IsNone.ShouldBeTrue();
|
|
}
|
|
}
|