Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/SessionExpiry.razor
T
2026-09-09 16:13:58 -04:00

113 lines
3.7 KiB
Plaintext

@implements IDisposable
@inject NavigationManager Navigation
@inject IJSRuntime JS
<AntiforgeryToken />
@code {
// Passive HTTP probes observe expiry but never advance activity. The JS module
// separately reports trusted browser input through an antiforgery-protected POST.
/// <summary>Server endpoint that reports live session validity.</summary>
internal const string PingUrl = "/auth/ping";
/// <summary>HTTP status returned by <see cref="PingUrl"/> once the cookie has lapsed.</summary>
private const int Unauthorized = 401;
private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js";
/// <summary>How often the session validity is re-checked.</summary>
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);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || IsOnLoginPage) return;
try { _module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath); }
catch (JSDisconnectedException) { }
catch (JSException) { }
}
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();
}
}
/// <summary>
/// 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.
/// </summary>
internal async Task CheckSessionAsync()
{
if (IsOnLoginPage) return;
int status;
try
{
_module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath);
status = await _module.InvokeAsync<int>("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 */ }
}
}