fix(central-ui): resolve CentralUI-005 — sliding cookie session expiry (Security AddCookie + AuthEndpoints + SessionExpiry)

This commit is contained in:
Joseph Doherty
2026-05-16 23:54:31 -04:00
parent b1f4251d75
commit 1e2e7d2e7c
6 changed files with 240 additions and 42 deletions

View File

@@ -44,15 +44,17 @@ public static class AuthEndpoints
// Map LDAP groups to roles
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(30);
// Build claims from LDAP auth + role mapping
// Build claims from LDAP auth + role mapping.
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped
// — session expiry is owned by the cookie middleware's sliding window
// (ScadaLink.Security AddCookie: ExpireTimeSpan = idle timeout,
// SlidingExpiration = true). A frozen absolute claim would contradict
// the documented sliding-refresh policy.
var claims = new List<Claim>
{
new(ClaimTypes.Name, authResult.Username ?? username),
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
new("expires_at", expiresAt.ToUnixTimeSeconds().ToString()),
};
foreach (var role in roleMappingResult.Roles)
@@ -74,11 +76,7 @@ public static class AuthEndpoints
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = expiresAt
});
BuildSignInProperties());
context.Response.Redirect("/");
}).DisableAntiforgery();
@@ -138,4 +136,22 @@ public static class AuthEndpoints
return endpoints;
}
/// <summary>
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
/// CentralUI-005: deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
/// Session expiry is owned by the cookie authentication middleware's sliding
/// window (configured in <c>ScadaLink.Security</c>'s <c>AddCookie</c>:
/// <c>ExpireTimeSpan</c> = the idle timeout, <c>SlidingExpiration = true</c>).
/// Setting a fixed <c>ExpiresUtc</c> here would re-impose a hard absolute cap
/// that overrides the sliding window and contradicts the documented
/// "sliding refresh, 30-minute idle timeout" policy. <see cref="AuthenticationProperties.IsPersistent"/>
/// is true so the cookie survives a browser restart within the idle window;
/// <see cref="AuthenticationProperties.AllowRefresh"/> is left unset (null)
/// so the middleware is free to slide the expiry on activity.
/// </summary>
public static AuthenticationProperties BuildSignInProperties() => new()
{
IsPersistent = true
};
}

View File

@@ -3,37 +3,62 @@
@inject NavigationManager Navigation
@code {
// CentralUI-005: session expiry is a sliding window owned by the cookie
// authentication middleware (ScadaLink.Security AddCookie:
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
// cookie is continually renewed; an idle user's cookie lapses after the idle
// timeout. There is therefore no fixed login-time deadline to redirect at —
// the old code read an "expires_at" claim and scheduled a single hard
// redirect, which both contradicted the sliding policy and logged active
// users out mid-session.
//
// Instead this component polls the authentication state on a recurring
// interval. While the session is still valid it does nothing; once the
// sliding cookie has expired (the server-side idle cutoff has been reached)
// the next poll observes an unauthenticated principal and redirects to the
// login page. Re-checking the state is itself circuit activity, so this poll
// alone never keeps a truly idle session alive — only genuine user activity
// refreshes the cookie before it lapses.
/// <summary>How often the session validity is re-checked.</summary>
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
private CancellationTokenSource? _cts;
protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{
// The login page uses the same layout, so this component renders there
// too. Redirecting /login → /login would loop ("too many redirects").
// too. Polling/redirecting on /login → /login would loop.
var path = Navigation.ToBaseRelativePath(Navigation.Uri);
if (path.StartsWith("login", StringComparison.OrdinalIgnoreCase)) return;
var auth = await AuthStateProvider.GetAuthenticationStateAsync();
if (auth.User.Identity?.IsAuthenticated != true) return;
var claim = auth.User.FindFirst("expires_at")?.Value;
if (!long.TryParse(claim, out var unix)) return;
var remaining = DateTimeOffset.FromUnixTimeSeconds(unix) - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero)
{
Navigation.NavigateTo("/login", forceLoad: true);
return;
}
_cts = new CancellationTokenSource();
_ = ScheduleRedirectAsync(remaining + TimeSpan.FromSeconds(2), _cts.Token);
_ = PollSessionAsync(_cts.Token);
}
private async Task ScheduleRedirectAsync(TimeSpan delay, CancellationToken token)
private async Task PollSessionAsync(CancellationToken token)
{
try { await Task.Delay(delay, token); }
catch (TaskCanceledException) { return; }
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
while (!token.IsCancellationRequested)
{
try { await Task.Delay(PollInterval, token); }
catch (TaskCanceledException) { return; }
AuthenticationState auth;
try
{
auth = await AuthStateProvider.GetAuthenticationStateAsync();
}
catch (ObjectDisposedException)
{
return;
}
if (auth.User.Identity?.IsAuthenticated != true)
{
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
return;
}
}
}
public void Dispose()