feat(ui/auth): redirect to /login when the session times out

Previously a user idling past the 30-minute cookie expiry stayed parked
on a stale page until they tried to navigate. The auth cookie's UTC
expiry is now also stamped onto an expires_at claim at sign-in, and a
SessionExpiry component mounted in MainLayout schedules a delay until
expiry + 2s grace, then force-loads /login — at which point the standard
cookie middleware confirms the session is gone and serves the login page.
This commit is contained in:
Joseph Doherty
2026-05-13 16:13:53 -04:00
parent 3f37584728
commit 80ec16a6d0
3 changed files with 45 additions and 1 deletions

View File

@@ -44,12 +44,15 @@ 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
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,7 +77,7 @@ public static class AuthEndpoints
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(30)
ExpiresUtc = expiresAt
});
context.Response.Redirect("/");

View File

@@ -25,3 +25,5 @@
@* Global host for IDialogService. One instance per layout renders all confirm/prompt
dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@
<DialogHost />
<SessionExpiry />

View File

@@ -0,0 +1,39 @@
@implements IDisposable
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@code {
private CancellationTokenSource? _cts;
protected override async Task OnInitializedAsync()
{
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);
}
private async Task ScheduleRedirectAsync(TimeSpan delay, CancellationToken token)
{
try { await Task.Delay(delay, token); }
catch (TaskCanceledException) { return; }
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
}
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
}