Keep a sign-in until it expires, not until the browser feels like forgetting it - #85
Merged
Merged
Conversation
…getting it The auth cookie was signed in with no AuthenticationProperties, so it went out with no Expires: a session cookie the browser could drop whenever it decided the session had ended. The eight-hour ExpireTimeSpan sitting above it looked like the answer but bounds the ticket inside the cookie, not the browser's willingness to keep the container. On a desktop tab that difference rarely showed; on a phone it showed every time the OS reclaimed the backgrounded tab, which is a coach putting their phone away at half time. Both sign-in routes now pass a persistent session, and the window goes to fourteen days so one match day reaches the next without a password. SameSite drops from Strict to Lax for a second symptom that read the same way. Strict withholds the cookie on every cross-site navigation, an ordinary link click included, so arriving from WhatsApp, an email or a search result rendered the page signed out — and a reload put it right, which is what made it look intermittent rather than wrong. Lax still withholds it on the cross-site POST that CSRF needs, and nothing here is reached by one. Data protection now names itself. The keys were already on the volume, but the purpose they are derived for defaulted to the content root path, which is /app only because the Dockerfile says WORKDIR /app. Keys on disk derived for another string open nothing, silently, and a deploy that changed nothing about authentication would sign everyone out. All three are decisions the browser makes from the Set-Cookie header, so none of them is visible to a C# test; session.spec.js pins them by reading the cookie's attributes after a real form sign-in and by following a link in from another site. Verified red against the previous behaviour first — the cross-site test lands on /login?ReturnUrl=%2Fsettings without the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134Dopv6gxVa6vk4AQfu84a
OnValidatePrincipal re-checks the security stamp on every HTTP request, and a Blazor Server tab makes almost none: after the first page load the session is SignalR. The stock ServerAuthenticationStateProvider reads the principal once when the circuit is created and never asks again, so deleting an account or resetting its password left the owner's open tab working until somebody reloaded it. That was not only a markup problem. CircuitCurrentUser reads the same provider, so RunAdminAsync — the guard that is supposed to hold when the hidden controls do not — was consulting the stale principal too. RevalidatingUserAuthenticationStateProvider re-asks the same question on a timer and signs the circuit out when the answer changes. Five minutes by default; Auth:RevalidationIntervalSeconds sets it, and 0 leaves the stock provider in place so the test for this can be run against the old behaviour and actually go red. It takes a scope factory rather than a UserService because it *is* the circuit's AuthenticationStateProvider, and UserService depends on ICurrentUser, which depends on that — injecting it directly refuses to build. Both halves now read the session out of a principal through one shared overload, so the request path and the circuit path cannot drift on what a valid session is. Measured rather than assumed, and two findings are worth keeping: With revalidation off, an account deleted while its owner sat idle on /users still rendered the Add User button — the gap, seen directly. A rejoin does not carry stale authority through, so the ten-minute retained circuit window does not widen the gap. With a revoked cookie a dropped circuit does not come back at all: the reconnect fails and Blazor's client falls back to a full page reload, which is an HTTP request, which lands on /login. Probed both ways round — blocking _blazor/negotiate to force the give-up path, and leaving it open for a clean rejoin — while reconnect.spec.js shows a valid cookie rejoining cleanly and staying live. The stale window is the revalidation interval on a connected idle circuit, and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134Dopv6gxVa6vk4AQfu84a
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The auth cookie was signed in with no AuthenticationProperties, so it went out
with no Expires: a session cookie the browser could drop whenever it decided the
session had ended. The eight-hour ExpireTimeSpan sitting above it looked like the
answer but bounds the ticket inside the cookie, not the browser's willingness to
keep the container. On a desktop tab that difference rarely showed; on a phone it
showed every time the OS reclaimed the backgrounded tab, which is a coach putting
their phone away at half time.
Both sign-in routes now pass a persistent session, and the window goes to
fourteen days so one match day reaches the next without a password.
SameSite drops from Strict to Lax for a second symptom that read the same way.
Strict withholds the cookie on every cross-site navigation, an ordinary link
click included, so arriving from WhatsApp, an email or a search result rendered
the page signed out — and a reload put it right, which is what made it look
intermittent rather than wrong. Lax still withholds it on the cross-site POST
that CSRF needs, and nothing here is reached by one.
Data protection now names itself. The keys were already on the volume, but the
purpose they are derived for defaulted to the content root path, which is /app
only because the Dockerfile says WORKDIR /app. Keys on disk derived for another
string open nothing, silently, and a deploy that changed nothing about
authentication would sign everyone out.
All three are decisions the browser makes from the Set-Cookie header, so none of
them is visible to a C# test; session.spec.js pins them by reading the cookie's
attributes after a real form sign-in and by following a link in from another
site. Verified red against the previous behaviour first — the cross-site test
lands on /login?ReturnUrl=%2Fsettings without the change.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_0134Dopv6gxVa6vk4AQfu84a