From 80ec16a6d09ecd3ca33f8b3d52f179ae4c01550e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 13 May 2026 16:13:53 -0400 Subject: [PATCH] feat(ui/auth): redirect to /login when the session times out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs | 5 ++- .../Components/Layout/MainLayout.razor | 2 + .../Components/Shared/SessionExpiry.razor | 39 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor diff --git a/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs b/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs index f989fb9..47f0451 100644 --- a/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs +++ b/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs @@ -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 { 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("/"); diff --git a/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor b/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor index 1e87d7d..011b4cf 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor @@ -25,3 +25,5 @@ @* Global host for IDialogService. One instance per layout renders all confirm/prompt dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@ + + diff --git a/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor b/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor new file mode 100644 index 0000000..d8d2ed6 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor @@ -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(); + } +}