Add inactivity-based session timeout #66
Reference in New Issue
Block a user
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?
Post-1.0 hardening (surfaced during the 1.0 review, see #33 — not a 1.0 blocker).
Sessions have only a 7-day absolute
MaxAge(internal/session/session.go,sessionMaxAgeDays). An idle session stays valid for the full 7 days regardless of activity.Definition of done:
Implementation requirements
Baseline:
main@4f5ecb1. This is post-1.0 hardening per #33, not a 1.0 blocker — keep it proportionate and resist redesigning session handling.1. Sliding idle expiry alongside the absolute cap
Sessions currently have only a 7-day absolute
MaxAge(internal/session/session.go,sessionMaxAgeDays). Add an idle timeout that is refreshed on activity, and keep the absolute cap as a backstop — the two are independent and both must be able to end a session. A session must expire when either the idle window lapses or the absolute age is reached, whichever comes first.Be explicit in the code about which clock is which. The common bug here is refreshing the absolute deadline along with the idle one, which quietly turns a 7-day hard cap into an unbounded session for an active user. Guard that with a test.
2. Configuration
Add one env-configured duration for the idle window, parsed with the existing
envDurationhelper ininternal/config— it is already fail-loud on a set-but-unparseable value (#78), which is the repo policy. Pick a sane default and document it.Conflict warning: PR #92 (#80) is merge-ready but unmerged and rewrites
internal/config, moving env loading into a newloadFromEnv. Adding a key here will conflict there. Keep the addition minimal — one constant, oneConfigfield, one parse call — so the rebase is mechanical. Do not restructure anything else in that package. NoteenvDurationitself is unchanged by #92, so the parsing call site is the only overlap.3. Refresh on activity, deliberately
Decide and document what counts as "activity". The obvious answer is any authenticated request, refreshed in the auth middleware. Two things to get right:
4. Tests
Use fake or injected time rather than real sleeps —
make testmust stay under 20 seconds perREPO_POLICIES.md.5. Docs
README: the new variable, its default, and the interaction between idle and absolute expiry.
TODO.mdin the same commit.Definition of done
The issue's own DoD, plus items 1-5,
make checkgreen via the repo's own entrypoints,.golangci.ymluntouched, single commit whose title ends with(closes #66), no attribution trailers.Implementation plan
Branch
issue-66-session-idle-timeoutoffmain@4f5ecb1. Single commit, title ends with(closes #66).Two independent clocks, both stored in the session
Sessions are
gorilla/sessionscookie sessions, so both anchors live in the session values as Unix seconds:created_at(CreatedAtKey) — anchors the absolute cap. Written once bySetUserat login and never rewritten. Absolute deadline =created_at + 7d(the existingsessionMaxAgeDays, expressed assessionAbsoluteMaxAge).last_seen(LastSeenKey) — anchors the idle window. Written bySetUserand refreshed by the newTouch. Idle deadline =last_seen + SessionIdleTimeout.A new unexported
Session.expired(sess)checks the absolute deadline first, then the idle deadline, and returns true if either has passed.IsAuthenticatedgains that check, which is the single choke point every caller already goes through (auth middleware,handlers/index.go,handlers/auth.go,handlers/profile.go,source_management.go), so there is no path that honours one clock but not the other.Fail-closed detail: an authenticated session with no
created_at(a cookie minted before this change) is treated as expired. Existing logins are invalidated once on deploy.Also worth noting: the absolute cap was previously enforced only by the cookie's
MaxAge, i.e. only by the browser.created_atmakes it server-side.Config
Exactly three lines in
internal/config, to keep the #92 rebase mechanical:defaultSessionIdleTimeout = 24 * time.HourConfig.SessionIdleTimeout time.DurationenvDuration("SESSION_IDLE_TIMEOUT", defaultSessionIdleTimeout)call, error returned so fx aborts startup on a set-but-unparseable valueNothing else in that package is touched. A non-positive value disables idle expiry (absolute cap still applies); documented in the README.
Activity, and where it is refreshed
Activity = a request that passes
RequireAuth.Touchis called there and only there, afterIsAuthenticatedhas succeeded, and the session is saved before the wrapped handler runs. Unauthenticated requests take the redirect branch and never reach it, so a public endpoint hit with someone else's cookie cannot extend that session.Touchalso re-checksIsAuthenticateditself and returns false otherwise, so the guarantee does not depend on the call site.Touchnever writescreated_at.Write amplification
Saving on literally every authenticated request means re-encrypting and re-emitting a
Set-Cookieon every response.Touchtherefore only rewriteslast_seenonce it is older thanidleTimeout/10, and returns a bool so the middleware saves only when something changed. Consequence, chosen deliberately:last_seenlags real activity by up toidleTimeout/10, so a session can expire up to 10% early relative to true last activity — never late. This will be stated in the PR body.Tests
Injected clock (
now func() time.TimeonSession, threaded throughNewForTest) — no real sleeps.Touchrefreshes the idle deadline, and the refreshed session outlives the original deadlineTouchis a no-op below the lazy-refresh threshold, and on an unauthenticated sessionSessionIdleTimeout <= 0-> idle disabled, absolute cap still enforcedSESSION_IDLE_TIMEOUTaborts startup (mirrors theRETENTION_SWEEP_INTERVALtable test)Mutation check: delete the idle branch from
expired, confirm the idle-expiry test fails, restore. Evidence in the PR body.Docs
README:
SESSION_IDLE_TIMEOUTrow (default24h) plus a short paragraph on how the idle and absolute clocks interact.TODO.mdupdated in the same commit.Verification via
make fmt,make check,script/cibuildonly.