@implements IDisposable @inject NavigationManager Navigation @inject IJSRuntime JS @code { // CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by // the cookie authentication middleware (ZB.MOM.WW.ScadaBridge.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 no fixed login-time deadline to redirect at. // // This component must NOT poll the Blazor AuthenticationStateProvider: // CookieAuthenticationStateProvider serves a frozen constructor-time // principal for the whole circuit (CentralUI-004), so the polled auth state // can never transition to "expired" and the redirect would never fire // (CentralUI-020). // // Instead it polls the server endpoint GET /auth/ping via fetch(). Being a // normal HTTP request, the cookie middleware re-validates — and slides — the // cookie on every hit, and answers 200 while the session is live or 401 once // it has lapsed. A genuine idle user's circuit produces no other HTTP // traffic, so once the cookie lapses the next ping returns 401 and this // component redirects to /login. (The ping itself slides the cookie, but the // poll interval is well under the idle timeout, so an idle session still // lapses on schedule once the poll catches the lapsed state — the ping only // ever observes expiry, it does not keep a dead session alive.) /// Server endpoint that reports live session validity. internal const string PingUrl = "/auth/ping"; /// HTTP status returned by once the cookie has lapsed. private const int Unauthorized = 401; private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js"; /// How often the session validity is re-checked. internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1); private CancellationTokenSource? _cts; private IJSObjectReference? _module; protected override void OnInitialized() { // The login page uses the same layout, so this component renders there // too. Polling/redirecting on /login → /login would loop. if (IsOnLoginPage) return; _cts = new CancellationTokenSource(); _ = PollSessionAsync(_cts.Token); } private bool IsOnLoginPage => Navigation.ToBaseRelativePath(Navigation.Uri) .StartsWith("login", StringComparison.OrdinalIgnoreCase); private async Task PollSessionAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { await Task.Delay(PollInterval, token); } catch (TaskCanceledException) { return; } if (token.IsCancellationRequested) return; await CheckSessionAsync(); } } /// /// Runs one liveness check: pings the server and, if the session has lapsed /// server-side (HTTP 401), redirects to the login page. Exposed for tests /// (CentralUI-025) so the redirect path can be exercised without waiting on /// the poll interval. /// internal async Task CheckSessionAsync() { if (IsOnLoginPage) return; int status; try { _module ??= await JS.InvokeAsync("import", ModulePath); status = await _module.InvokeAsync("ping", PingUrl); } catch (JSDisconnectedException) { // Circuit gone — nothing to redirect. return; } catch (JSException) { // Network blip or fetch failure: treat as inconclusive and retry on // the next poll rather than logging an authenticated user out on a // transient error. return; } if (status == Unauthorized) { await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true)); } } public void Dispose() { _cts?.Cancel(); _cts?.Dispose(); // The module reference is owned by the circuit's JS runtime; once the // circuit is disposed disposing it would throw — fire-and-forget and // swallow the expected disconnect. if (_module is not null) { _ = DisposeModuleAsync(_module); } } private static async Task DisposeModuleAsync(IJSObjectReference module) { try { await module.DisposeAsync(); } catch (JSDisconnectedException) { /* circuit already gone */ } } }