using Microsoft.AspNetCore.Authorization; namespace JobTrackerApi.Services; // A precondition, never an authority source: all resource owners still come from User. // Bind retained browser requests to the existing, validated server-side session. No new token // store or schema is needed; knowing a session id alone cannot authenticate a request. public sealed class SessionContextMiddleware(RequestDelegate next) { public const string HeaderName = "X-Session-Context"; public async Task InvokeAsync(HttpContext context) { if (context.User.Identity?.IsAuthenticated != true || context.GetEndpoint()?.Metadata.GetMetadata() is not null) { await next(context); return; } var supplied = context.Request.Headers[HeaderName]; var safeMethod = HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method) || HttpMethods.IsOptions(context.Request.Method) || HttpMethods.IsTrace(context.Request.Method); // Unbound reads support bootstrap, direct document links and API clients. When a browser // supplies context on a read, enforce it too so a stale tab cannot adopt another owner's data. if (safeMethod && supplied.Count == 0) { await next(context); return; } var sessionId = context.User.FindFirst("sid")?.Value; if (supplied.Count != 1 || string.IsNullOrWhiteSpace(supplied[0]) || string.IsNullOrWhiteSpace(sessionId) || !string.Equals(supplied[0], sessionId, StringComparison.Ordinal)) { var missing = supplied.Count == 0 || (supplied.Count == 1 && string.IsNullOrWhiteSpace(supplied[0])); context.Response.StatusCode = missing ? StatusCodes.Status428PreconditionRequired : StatusCodes.Status409Conflict; context.Response.Headers.CacheControl = "no-store"; await context.Response.WriteAsJsonAsync(new { code = missing ? "session_context_required" : "session_context_mismatch", message = "Reload this page before continuing. Your session may have changed." }, context.RequestAborted); return; } await next(context); } }