#59 — Remember Me — Persistent Login #59
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#59
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Story: Remember Me — Persistent Login
As a returning user,
I want to stay logged in across browser restarts for an extended period,
so that I don't have to re-enter my credentials every time I open the app.
Acceptance criteria:
docs/SECURITY_NOTES.md.LogoutUserCommandcurrently only clears theUserIdkey, leaving a live empty session in Redis until idle-timeout)Out of scope for this story:
Decisions (2026-07-20): Product Owner confirmed 3 months, sliding/rolling window (not truly unbounded) is
an acceptable interpretation of "remember forever" in daily use, given automatic rotation. Persistent login is
the default behavior, not opt-in.
Blockers: None
Priority: Should — directly requested by the Product Owner as recurring daily-use friction (frequent
re-logins).
design (
59_remember_me_persistent_login_design.md)Design:
#59— Remember Me / Persistent LoginCurrent state (confirmed by reading the code)
AddSession()(ISession/ISessionStore) backed byAddStackExchangeRedisCache— no custom session abstraction.app.UseSession(...)setsIdleTimeout = 30 days(server-side, sliding) but the cookie itself hasno
MaxAge/Expires— it's a browser session cookie, wiped on browser close. That's the literal bugthis story fixes: the server would keep the Redis record alive for 30 days, but the cookie doesn't survive
a restart.
LogoutUserCommandHandleronly callsSetCurrentUserIdCommand(null), which doescontext.Session.Remove("UserId")— the Redis record and cookie both live on until 30-day idle timeout.Confirmed gap, matches the story's premise.
ChangePasswordCommandalready requires the current password (own inline check) — no changes needed there.DeleteCurrentUserAccountCommand(username-typed confirmation only) andTransferTodoListOwnershipCommand(no confirmation at all) have no password re-entry — net new for both.
Decisions
0.
ISession.Idcannot be used as a Redis key or as a session identifier — this invalidated anearlier draft of this design. Decompiled
Microsoft.AspNetCore.Session.DistributedSession(10.0.10) toconfirm:
.Idreturns a random 16-byte GUID generated independently of the session's actual cache key(
_sessionKey, a private field never exposed via any public API), persisted as part of the session's ownserialized payload purely for logging/debug purposes. It has no relationship to the cache row's address and
no relationship to the cookie value. An earlier version of this design tried to use
context.Session.IdtoIDistributedCache.Remove(...)a specific session's Redis row directly — that removes a key that was neveractually used for storage, silently no-ops, and leaves the real row untouched. Caught by an integration test
against the real
DistributedSessionStore(not mocks) before it shipped — see the frontend/backend engineerteam memory entry for this cycle. Decompiled
SessionMiddleware.Invokeas well: it mints its own session keybefore calling
_next, and only writesSet-Cookiewhen the incoming cookie was missing/invalid — reassigningHttpContext.Sessionto a differentISessioninstance later in the pipeline does not cause a new cookieto be written, because the cookie-writing callback captured its value before
_nextran. Net effect: there isno supported way, from application code, to (a) address/delete a specific other session's store row, or
(b) force the framework to mint a new cookie value mid-pipeline. Both of the following decisions route around
this rather than fight it.
1. Persistent + rolling cookie, refreshed by re-appending the same opaque value.
SessionOptions.Cookie.MaxAge = 90 days(SessionPolicy.MaxLifetime) makes the cookie persistent, but perDecision 0, the framework never re-sends
Set-Cookiefor an already-valid incoming cookie — so on its own,MaxAgewould count down from first login only, not roll forward with use. Fix:SessionMaintenanceMiddleware(runs after
UseSession()) periodically (SessionPolicy.CookieRefreshInterval, 24h, tracked via anIssuedAtUtcsession value) re-appendscontext.Request.Cookies[name]— the same opaque string thebrowser already sent, never decoded or reinterpreted — via
Response.Cookies.Append(...)with a freshMaxAge. This achieves a true rolling window using onlyRequest.Cookies/Response.Cookies, nounderstanding of the cookie's internal format required.
2. No literal session-ID/token rotation — the AC's underlying goal (bound a leaked cookie's exposure
window) is met by instant full revocation instead. Per Decision 0, minting a genuinely new session ID mid-
pipeline isn't achievable without either reflecting into
DistributedSession's private fields orreimplementing ASP.NET Core's internal
CookieProtection(base64 +IDataProtector, purpose string"SessionMiddleware") by hand to self-issue a compatible cookie. Both were prototyped; both were rejected astoo fragile for what they buy (a hand-rolled reimplementation of framework-internal, version-coupled crypto
plumbing, to satisfy a defense-in-depth AC that a different, more standard mechanism already covers — see
Decision 3). Documented as an explicit accepted deviation from the story's literal AC text, not a silent gap —
see
SECURITY_NOTES.md.3. "Log out of all devices" via a per-user revocation timestamp (the standard "security stamp" pattern —
the same one ASP.NET Core Identity itself uses), not session enumeration. Per Decision 0, there's no way to
list or address a user's other sessions from application code, so a session-ID registry (an earlier draft
of this design) can't actually delete anything it tracks. Instead:
UserEntity.SessionsRevokedBeforeUtc(new nullable
DateTimeOffsetcolumn, migrationAddSessionsRevokedBeforeUtcToUser). Every session alreadycarries its own
IssuedAtUtc(Decision 1).SessionMaintenanceMiddlewarechecksGetSessionsRevokedBeforeUtcQueryfor the current user on every authenticated request; ifIssuedAtUtcisolder than the stored revocation timestamp, it
Clear()s the session (empties it — it authenticates asnobody from then on) and continues the pipeline as logged-out, no separate deletion needed. This is also
what actually delivers on the leaked-cookie-exposure-window goal AC
#3was reaching for: a suspected-leakedcookie can be fully invalidated on demand, without waiting for or depending on periodic rotation.
LogoutAllDevicesCommand(WebApi) =RevokeAllSessionsForCurrentUserCommand(core project, DB write, setsthe timestamp to now, already requires an authenticated session via its own
AuthorizeIsCurrentUserAuthenticatedQuery—
LogoutAllDevicesCommandHandlerneeds no separate auth wiring) followed byDestroyCurrentSessionCommandso the calling device is also logged out immediately in the same response, not left to notice on its next
request.
2b. Fix the logout gap with a new
DestroyCurrentSessionCommand(WebApi-only, same split asSetCurrentUserIdCommand).Session.Clear()(not justRemove("UserId"), the pre-#59bug) +CommitAsync()empties the session's entire record, andResponse.Cookies.Delete(...)drops the browsercookie.
LogoutUserCommandHandlerandDeleteCurrentUserAccountCommandHandlerboth switch fromSetCurrentUserIdCommand(null)to this. Per Decision 0 there's still no way to force-delete the underlyingstore row, but an emptied, cookie-less session is functionally dead — it idles out of the store on the
existing TTL with zero exposure (nothing sensitive left in it).
4. Step-up re-auth for sensitive actions. New
AuthorizeCurrentPasswordQuery(Password ConfirmPassword)in
Features/Authorization/(same shape as the existingAuthorizeTodoListOwnerAccessForCurrentUserQuery—resolves current user, loads the entity, hashes+compares) — throws
AuthenticationException(401) onmismatch, matching
ChangePasswordCommandHandler's existing inline check. Wired viaWithAuthorization(chainable — confirmed
IHandlerRegistrationConfig.WithAuthorizationreturns itself, so it composes with anexisting authorization query) on:
DeleteCurrentUserAccountCommand— addsPassword ConfirmPasswordalongside the existingConfirmedUserName(the username-typed confirmation stays as the "are you sure" UX guard;the password is the actual security control).
TransferTodoListOwnershipCommand— addsPassword ConfirmPassword, chained after the existingAuthorizeTodoListOwnerAccessForCurrentUserQuery.ChangePasswordCommandneeds no change — already has its own current-password check.5. Rate limiting on login — closes the
[OPEN]item inSECURITY_NOTES.md. ASP.NET Core's built-inAddRateLimiter, a fixed-window policy named"login"(5 attempts / minute, partitioned by remote IP,QueueLimit = 0, 429 on rejection). Applied only to theLoginUserCommandendpoint insideMapRequest<TRequest,TResult>()(same spot that already special-cases individual request types forendpoint mapping), not globally.
Out of scope (per story)
Per-device session naming/listing, opt-in toggle (always-on), MFA.
Testing approach
No Docker in this sandbox, so DB-backed (
DbTestContext) tests can't run locally — pre-existing, documentedlimitation; the new
GetSessionsRevokedBeforeUtcQueryHandler/RevokeAllSessionsForCurrentUserCommandHandler/AuthorizeCurrentPasswordQueryHandlertests follow that project's existingDbTestContextpattern and wereverified to compile and to fail only with
DockerUnavailableException, same as the rest of that suite.Everything session/cookie-related in the WebApi project runs locally (no DB needed):
TestContext-basedmocked-
ISessiontests for the simple handlers, and a small hand-writtenFakeSession : ISession(adictionary-backed real implementation, not a mock) for
SessionMaintenanceMiddlewareTests— needed becauseDecision 0's investigation showed real session-store behavior (not just mocked call verification) is what
actually matters here, and an earlier attempt using the real
DistributedSessionStoreagainst a realMemoryDistributedCacheis what surfaced the.Id-is-not-a-key bug in the first place.