Align session codec max-age with the 7-day cap (closes #108)
Some checks failed
check / check (push) Has been cancelled

sessions.NewCookieStore gives its securecookie codecs a 30-day max
age, and assigning store.Options never touches Codecs. The store
therefore decoded a cookie up to 30 days old while the cookie
attribute and the server-side expiry check both said 7 days, so a
refactor of that check -- or a second decode path that skips
IsAuthenticated -- would silently reopen a 30-day window.

Build the store through store.MaxAge, which sets Options.MaxAge and
propagates to every codec. The store is now built by newStore, the
one place a store is constructed; Regenerate shares its cookie
attributes via cookieOptions.

Tests:

- Two codec tests decode a re-stamped cookie a hour inside and an
  hour outside the cap. They only exercise Session.Get, so they pin
  the codec: reverting the fix fails the rejection test whether or
  not the server-side expiry check is present.
- The lazy-refresh bound is pinned two-sidedly, so the documented
  "expires up to 10% early, never late" guarantee now fails the
  suite if idleRefreshDivisor drifts in either direction.

Also fixes the RequireAuth save error, which was assigned to the
outer err and is now scoped to the branch that produces it, and two
README claims: the idle timeout is disabled by any non-positive
value, not only 0, and deploying the two-clock expiry logs existing
sessions out once because they carry no timestamps.

#108
This commit is contained in:
2026-08-12 09:46:00 +00:00
parent d19e33671c
commit 37b665a22f
6 changed files with 247 additions and 39 deletions

View File

@@ -100,6 +100,35 @@ type Session struct {
now func() time.Time
}
// cookieOptions returns the cookie attributes used for every session
// cookie. MaxAge is deliberately left at its zero value: for a store
// it is set through CookieStore.MaxAge (see newStore), and for a
// single session it is copied from the store's options.
func cookieOptions(secure bool) *sessions.Options {
return &sessions.Options{
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}
}
// newStore builds the session cookie store.
//
// The absolute cap MUST be applied with store.MaxAge and not by
// assigning store.Options.MaxAge. NewCookieStore gives the underlying
// securecookie codecs a 30-day max age of their own, and assigning
// Options never touches Codecs -- so a store configured that way still
// decodes a 30-day-old cookie, leaving the cookie attribute and the
// codec disagreeing about the same policy. store.MaxAge sets both.
func newStore(key []byte, secure bool) *sessions.CookieStore {
store := sessions.NewCookieStore(key)
store.Options = cookieOptions(secure)
store.MaxAge(secondsPerDay * sessionMaxAgeDays)
return store
}
// New creates a new session manager. The cookie store is
// initialized during the fx OnStart phase after the database is
// connected, using a session key that is auto-generated and stored
@@ -142,19 +171,8 @@ func New(
)
}
store := sessions.NewCookieStore(keyBytes)
// Configure cookie options for security
store.Options = &sessions.Options{
Path: "/",
MaxAge: secondsPerDay * sessionMaxAgeDays,
HttpOnly: true,
Secure: !params.Config.IsDev(),
SameSite: http.SameSiteLaxMode,
}
s.key = keyBytes
s.store = store
s.store = newStore(keyBytes, !params.Config.IsDev())
s.log.Info("session manager initialized")
return nil
@@ -350,13 +368,8 @@ func (s *Session) Regenerate(
// Apply the standard session options (the destroyed old
// session had MaxAge = -1, which store.New might inherit
// from the cookie).
newSess.Options = &sessions.Options{
Path: "/",
MaxAge: secondsPerDay * sessionMaxAgeDays,
HttpOnly: true,
Secure: !s.config.IsDev(),
SameSite: http.SameSiteLaxMode,
}
newSess.Options = cookieOptions(!s.config.IsDev())
newSess.Options.MaxAge = secondsPerDay * sessionMaxAgeDays
return newSess, nil
}