All checks were successful
check / check (push) Successful in 3m16s
Two places decided whether a request was TLS, by two different means, and they disagreed. The session cookie's Secure attribute was fixed at startup from !Config.IsDev(). "dev" is the environment when WEBHOOKER_ENVIRONMENT is unset, so a deployment terminating TLS at a proxy without also setting the environment shipped the authentication cookie with no Secure attribute -- on the same response as a CSRF cookie that had one. It failed silently: everything kept working, so nothing prompted anyone to look. The CSRF middleware's per-request check compared X-Forwarded-Proto with == "https" exactly, so "HTTPS", "https, http" and "https,https" all took the plaintext path. Uppercase is legal for a case-insensitive token and the comma forms are what a proxy chained behind another proxy emits by appending rather than replacing. On that path gorilla/csrf stops enforcing the strict Referer check on a site that genuinely is HTTPS. Both now go through internal/reqtls.IsTLS, which folds case and takes the leftmost comma-separated element -- the hop nearest the client, and so the one a cookie's Secure attribute is about. A third package is needed because internal/middleware already imports internal/session, so session cannot import middleware back. Per-request beat a startup warning for the session cookie because it turned out to need no restructuring: gorilla/sessions gives every session its own copy of the store's Options and renders the cookie from that copy, and every session-cookie write here already goes through Session.Save or Session.Regenerate, both of which hold the request. The store's template Secure becomes true so that a write path added later which forgets to track the transport fails visibly instead of silently dropping Secure. The flag tracks the transport in both directions rather than latching on. Secure over plaintext is discarded by the browser without an error, which would make a plain-HTTP local run impossible to log into -- and would also void the deletion cookies in Destroy and Regenerate, leaving a session the user just tried to end still live. A third site that makes this decision, internal/handlers' BaseURL construction, assigns the raw header straight into the URL scheme. It is left alone here and filed separately.
1145 lines
26 KiB
Go
1145 lines
26 KiB
Go
package session_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/sessions"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
const testKeySize = 32
|
|
|
|
// testIdleTimeout is the idle window used by the expiry tests.
|
|
const testIdleTimeout = time.Hour
|
|
|
|
// testAbsoluteMaxAge restates the documented absolute session cap
|
|
// independently of the implementation constant.
|
|
const testAbsoluteMaxAge = 7 * 24 * time.Hour
|
|
|
|
// fakeClock is a manually advanced clock, so expiry can be tested
|
|
// without sleeping.
|
|
type fakeClock struct {
|
|
t time.Time
|
|
}
|
|
|
|
func (c *fakeClock) Now() time.Time {
|
|
return c.t
|
|
}
|
|
|
|
func (c *fakeClock) Advance(d time.Duration) {
|
|
c.t = c.t.Add(d)
|
|
}
|
|
|
|
// testKey returns the fixed session key the tests sign with. The
|
|
// codec tests re-sign cookies with it, so it must be the same key the
|
|
// store was built from.
|
|
func testKey() []byte {
|
|
key := make([]byte, testKeySize)
|
|
|
|
for i := range key {
|
|
key[i] = byte(i + 42)
|
|
}
|
|
|
|
return key
|
|
}
|
|
|
|
// testSession creates a Session with a real cookie store and the
|
|
// real clock.
|
|
func testSession(t *testing.T) *session.Session {
|
|
t.Helper()
|
|
|
|
s, _ := testSessionWithClock(t, testIdleTimeout, nil)
|
|
|
|
return s
|
|
}
|
|
|
|
// testSessionWithClock creates a Session with a real cookie store,
|
|
// the given idle timeout, and a manually advanced clock. Passing a
|
|
// nil clock uses the real one.
|
|
func testSessionWithClock(
|
|
t *testing.T,
|
|
idleTimeout time.Duration,
|
|
clock *fakeClock,
|
|
) (*session.Session, *fakeClock) {
|
|
t.Helper()
|
|
|
|
key := testKey()
|
|
store := session.NewStore(key)
|
|
|
|
cfg := &config.Config{
|
|
Environment: config.EnvironmentDev,
|
|
SessionIdleTimeout: idleTimeout,
|
|
}
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
|
|
var now func() time.Time
|
|
|
|
if clock != nil {
|
|
now = clock.Now
|
|
}
|
|
|
|
return session.NewForTest(store, cfg, log, key, now), clock
|
|
}
|
|
|
|
// newFakeClock returns a clock started at a fixed instant.
|
|
func newFakeClock() *fakeClock {
|
|
return &fakeClock{
|
|
t: time.Date(
|
|
2026, time.January, 2, 3, 4, 5, 0, time.UTC,
|
|
),
|
|
}
|
|
}
|
|
|
|
// authenticatedSession returns a fresh session that has just been
|
|
// logged in, along with its manager and clock.
|
|
func authenticatedSession(
|
|
t *testing.T,
|
|
idleTimeout time.Duration,
|
|
) (*session.Session, *sessions.Session, *fakeClock) {
|
|
t.Helper()
|
|
|
|
s, clock := testSessionWithClock(
|
|
t, idleTimeout, newFakeClock(),
|
|
)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
require.True(t, s.IsAuthenticated(sess))
|
|
|
|
return s, sess, clock
|
|
}
|
|
|
|
// --- Get and Save Tests ---
|
|
|
|
func TestGet_NewSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assert.True(
|
|
t, sess.IsNew,
|
|
"session should be new when no cookie is present",
|
|
)
|
|
}
|
|
|
|
func TestGet_ExistingSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
// Create and save a session
|
|
req1 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w1 := httptest.NewRecorder()
|
|
|
|
sess1, err := s.Get(req1)
|
|
require.NoError(t, err)
|
|
|
|
sess1.Values["test_key"] = "test_value"
|
|
require.NoError(t, s.Save(req1, w1, sess1))
|
|
|
|
// Extract cookies
|
|
cookies := w1.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
// Make a new request with the session cookie
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
for _, c := range cookies {
|
|
req2.AddCookie(c)
|
|
}
|
|
|
|
sess2, err := s.Get(req2)
|
|
require.NoError(t, err)
|
|
assert.False(
|
|
t, sess2.IsNew,
|
|
"session should not be new when cookie is present",
|
|
)
|
|
assert.Equal(t, "test_value", sess2.Values["test_key"])
|
|
}
|
|
|
|
func TestSave_SetsCookie(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
sess.Values["key"] = "value"
|
|
|
|
err = s.Save(req, w, sess)
|
|
require.NoError(t, err)
|
|
|
|
cookies := w.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "Save should set a cookie")
|
|
|
|
// Verify the cookie has the expected name
|
|
var found bool
|
|
|
|
for _, c := range cookies {
|
|
if c.Name == session.SessionName {
|
|
found = true
|
|
|
|
assert.True(
|
|
t, c.HttpOnly,
|
|
"session cookie should be HTTP-only",
|
|
)
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.True(
|
|
t, found,
|
|
"should find a cookie named %s", session.SessionName,
|
|
)
|
|
}
|
|
|
|
// --- SetUser and User Retrieval Tests ---
|
|
|
|
func TestSetUser_SetsAllFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-abc-123", "alice")
|
|
|
|
assert.Equal(
|
|
t, "user-abc-123", sess.Values[session.UserIDKey],
|
|
)
|
|
assert.Equal(
|
|
t, "alice", sess.Values[session.UsernameKey],
|
|
)
|
|
assert.Equal(
|
|
t, true, sess.Values[session.AuthenticatedKey],
|
|
)
|
|
}
|
|
|
|
// testSessionGetter exercises a session string getter before and
|
|
// after SetUser: it must report false with an empty value on a
|
|
// fresh session, then true with the expected value once
|
|
// SetUser(sess, "user-xyz", "bob") has run.
|
|
func testSessionGetter(
|
|
t *testing.T,
|
|
get func(
|
|
*session.Session, *sessions.Session,
|
|
) (string, bool),
|
|
expected string,
|
|
) {
|
|
t.Helper()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
// Before setting user
|
|
val, ok := get(s, sess)
|
|
assert.False(
|
|
t, ok, "should return false before SetUser",
|
|
)
|
|
assert.Empty(t, val)
|
|
|
|
// After setting user
|
|
s.SetUser(sess, "user-xyz", "bob")
|
|
|
|
val, ok = get(s, sess)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, expected, val)
|
|
}
|
|
|
|
func TestGetUserID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testSessionGetter(
|
|
t,
|
|
func(
|
|
s *session.Session, sess *sessions.Session,
|
|
) (string, bool) {
|
|
return s.GetUserID(sess)
|
|
},
|
|
"user-xyz",
|
|
)
|
|
}
|
|
|
|
func TestGetUsername(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testSessionGetter(
|
|
t,
|
|
func(
|
|
s *session.Session, sess *sessions.Session,
|
|
) (string, bool) {
|
|
return s.GetUsername(sess)
|
|
},
|
|
"bob",
|
|
)
|
|
}
|
|
|
|
// --- IsAuthenticated Tests ---
|
|
|
|
func TestIsAuthenticated_NoSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"new session should not be authenticated",
|
|
)
|
|
}
|
|
|
|
func TestIsAuthenticated_AfterSetUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
assert.True(t, s.IsAuthenticated(sess))
|
|
}
|
|
|
|
func TestIsAuthenticated_AfterClearUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
require.True(t, s.IsAuthenticated(sess))
|
|
|
|
s.ClearUser(sess)
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"should not be authenticated after ClearUser",
|
|
)
|
|
}
|
|
|
|
func TestIsAuthenticated_WrongType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
// Set authenticated to a non-bool value
|
|
sess.Values[session.AuthenticatedKey] = "yes"
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"should return false for non-bool authenticated value",
|
|
)
|
|
}
|
|
|
|
// --- ClearUser Tests ---
|
|
|
|
func TestClearUser_RemovesAllKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
s.ClearUser(sess)
|
|
|
|
_, hasUserID := sess.Values[session.UserIDKey]
|
|
assert.False(t, hasUserID, "UserIDKey should be removed")
|
|
|
|
_, hasUsername := sess.Values[session.UsernameKey]
|
|
assert.False(t, hasUsername, "UsernameKey should be removed")
|
|
|
|
_, hasAuth := sess.Values[session.AuthenticatedKey]
|
|
assert.False(
|
|
t, hasAuth, "AuthenticatedKey should be removed",
|
|
)
|
|
}
|
|
|
|
// --- Destroy Tests ---
|
|
|
|
func TestDestroy_InvalidatesSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
|
|
s.Destroy(sess)
|
|
|
|
// After Destroy: MaxAge should be -1 (delete cookie) and
|
|
// user data cleared
|
|
assert.Equal(
|
|
t, -1, sess.Options.MaxAge,
|
|
"Destroy should set MaxAge to -1",
|
|
)
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"should not be authenticated after Destroy",
|
|
)
|
|
|
|
_, hasUserID := sess.Values[session.UserIDKey]
|
|
assert.False(t, hasUserID, "Destroy should clear user ID")
|
|
}
|
|
|
|
// --- Session Persistence Round-Trip ---
|
|
|
|
func TestSessionPersistence_RoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
// Step 1: Create session, set user, save
|
|
req1 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w1 := httptest.NewRecorder()
|
|
|
|
sess1, err := s.Get(req1)
|
|
require.NoError(t, err)
|
|
s.SetUser(sess1, "user-round-trip", "charlie")
|
|
require.NoError(t, s.Save(req1, w1, sess1))
|
|
|
|
cookies := w1.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
// Step 2: New request with cookies -- session data should
|
|
// persist
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/profile", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req2.AddCookie(c)
|
|
}
|
|
|
|
sess2, err := s.Get(req2)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(
|
|
t, s.IsAuthenticated(sess2),
|
|
"session should be authenticated after round-trip",
|
|
)
|
|
|
|
userID, ok := s.GetUserID(sess2)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "user-round-trip", userID)
|
|
|
|
username, ok := s.GetUsername(sess2)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "charlie", username)
|
|
}
|
|
|
|
// --- Constants Tests ---
|
|
|
|
func TestSessionConstants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t, "webhooker_session", session.SessionName)
|
|
assert.Equal(t, "user_id", session.UserIDKey)
|
|
assert.Equal(t, "username", session.UsernameKey)
|
|
assert.Equal(t, "authenticated", session.AuthenticatedKey)
|
|
assert.Equal(t, "created_at", session.CreatedAtKey)
|
|
assert.Equal(t, "last_seen", session.LastSeenKey)
|
|
}
|
|
|
|
// --- Expiry Tests ---
|
|
|
|
func TestSetUser_StartsBothClocks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
assert.Equal(
|
|
t, clock.Now().Unix(), sess.Values[session.CreatedAtKey],
|
|
"SetUser should anchor the absolute clock",
|
|
)
|
|
assert.Equal(
|
|
t, clock.Now().Unix(), sess.Values[session.LastSeenKey],
|
|
"SetUser should anchor the idle clock",
|
|
)
|
|
}
|
|
|
|
func TestIsAuthenticated_WithinIdleWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
clock.Advance(testIdleTimeout - time.Second)
|
|
|
|
assert.True(
|
|
t, s.IsAuthenticated(sess),
|
|
"session should still be valid just inside the idle window",
|
|
)
|
|
}
|
|
|
|
func TestIsAuthenticated_IdleExpired(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
clock.Advance(testIdleTimeout)
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"session should expire once the idle window lapses",
|
|
)
|
|
}
|
|
|
|
// TestTouch_DoesNotExtendAbsoluteCap is the regression test for the
|
|
// refresh-the-wrong-clock bug: a session that is used continuously
|
|
// must survive well past the idle window and still die at the
|
|
// absolute cap.
|
|
func TestTouch_DoesNotExtendAbsoluteCap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
createdAt := sess.Values[session.CreatedAtKey]
|
|
|
|
// Stay active: a request every half idle window, right up to
|
|
// the absolute cap.
|
|
step := testIdleTimeout / 2
|
|
steps := int(testAbsoluteMaxAge/step) - 1
|
|
|
|
for i := range steps {
|
|
clock.Advance(step)
|
|
s.Touch(sess)
|
|
|
|
require.True(
|
|
t, s.IsAuthenticated(sess),
|
|
"active session should survive the idle window "+
|
|
"(step %d of %d)", i+1, steps,
|
|
)
|
|
}
|
|
|
|
// One more step of activity takes the session to exactly the
|
|
// absolute cap, measured from login. Nothing that happened in
|
|
// the loop may have moved that deadline.
|
|
clock.Advance(step)
|
|
s.Touch(sess)
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"activity must not extend the absolute cap",
|
|
)
|
|
assert.Equal(
|
|
t, createdAt, sess.Values[session.CreatedAtKey],
|
|
"Touch must never rewrite the absolute-clock anchor",
|
|
)
|
|
}
|
|
|
|
func TestTouch_RefreshesIdleDeadline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
// Halfway through the window, activity happens.
|
|
clock.Advance(testIdleTimeout / 2)
|
|
assert.True(
|
|
t, s.Touch(sess),
|
|
"Touch should refresh once past the lazy-refresh threshold",
|
|
)
|
|
|
|
// Past the original deadline, but inside the refreshed one.
|
|
clock.Advance(testIdleTimeout - time.Second)
|
|
assert.True(
|
|
t, s.IsAuthenticated(sess),
|
|
"refreshed session should outlive the original deadline",
|
|
)
|
|
|
|
// And it still expires an idle window after that activity.
|
|
clock.Advance(time.Second)
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"refreshed session should expire one window after activity",
|
|
)
|
|
}
|
|
|
|
func TestTouch_LazyBelowRefreshThreshold(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
before := sess.Values[session.LastSeenKey]
|
|
|
|
// A request arriving almost immediately is not worth a cookie
|
|
// rewrite.
|
|
clock.Advance(time.Second)
|
|
|
|
assert.False(
|
|
t, s.Touch(sess),
|
|
"Touch should not rewrite the session below the threshold",
|
|
)
|
|
assert.Equal(
|
|
t, before, sess.Values[session.LastSeenKey],
|
|
"last-seen should be unchanged below the threshold",
|
|
)
|
|
}
|
|
|
|
func TestTouch_RefreshThresholdIsOneTenthOfIdleWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// testRefreshDivisor restates the documented bound independently
|
|
// of the implementation constant: the idle timestamp is rewritten
|
|
// once it is a tenth of the idle window old, which is what makes
|
|
// "expires up to 10% early, never late" true. Both assertions are
|
|
// needed to pin it -- a larger divisor fails the first, a smaller
|
|
// one fails the second.
|
|
const testRefreshDivisor = 10
|
|
|
|
threshold := testIdleTimeout / testRefreshDivisor
|
|
|
|
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
clock.Advance(threshold - time.Second)
|
|
assert.False(
|
|
t, s.Touch(sess),
|
|
"Touch must not rewrite the session below a tenth of the window",
|
|
)
|
|
|
|
clock.Advance(time.Second)
|
|
assert.True(
|
|
t, s.Touch(sess),
|
|
"Touch must rewrite the session at a tenth of the window",
|
|
)
|
|
}
|
|
|
|
func TestTouch_UnauthenticatedSessionIsNotRefreshed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clock := testSessionWithClock(
|
|
t, testIdleTimeout, newFakeClock(),
|
|
)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
clock.Advance(testIdleTimeout / 2)
|
|
|
|
assert.False(
|
|
t, s.Touch(sess),
|
|
"an unauthenticated session must not be refreshed",
|
|
)
|
|
|
|
_, hasLastSeen := sess.Values[session.LastSeenKey]
|
|
assert.False(
|
|
t, hasLastSeen,
|
|
"Touch must not stamp an unauthenticated session",
|
|
)
|
|
}
|
|
|
|
func TestTouch_IdleExpiredSessionIsNotRevived(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
clock.Advance(testIdleTimeout)
|
|
require.False(t, s.IsAuthenticated(sess))
|
|
|
|
assert.False(
|
|
t, s.Touch(sess),
|
|
"an already expired session must not be refreshed",
|
|
)
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"Touch must not revive an expired session",
|
|
)
|
|
}
|
|
|
|
func TestIsAuthenticated_MissingTimestamps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _ := testSessionWithClock(
|
|
t, testIdleTimeout, newFakeClock(),
|
|
)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
// A session from before idle expiry existed: authenticated,
|
|
// but with no timestamps. Fail closed.
|
|
sess.Values[session.AuthenticatedKey] = true
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"a session with no timestamps should be rejected",
|
|
)
|
|
}
|
|
|
|
func TestIsAuthenticated_MissingLastSeen(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, _ := authenticatedSession(t, testIdleTimeout)
|
|
|
|
delete(sess.Values, session.LastSeenKey)
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"a session with no idle anchor should be rejected",
|
|
)
|
|
}
|
|
|
|
func TestIdleTimeoutDisabled_AbsoluteCapStillApplies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, clock := authenticatedSession(t, 0)
|
|
|
|
// Idle expiry is off, so an untouched session survives an
|
|
// arbitrary idle stretch.
|
|
clock.Advance(testAbsoluteMaxAge - time.Second)
|
|
assert.True(
|
|
t, s.IsAuthenticated(sess),
|
|
"idle expiry should be disabled by a non-positive timeout",
|
|
)
|
|
|
|
assert.False(
|
|
t, s.Touch(sess),
|
|
"Touch should be a no-op when idle expiry is disabled",
|
|
)
|
|
|
|
// The absolute cap still ends it.
|
|
clock.Advance(time.Second)
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"the absolute cap must still apply with idle expiry off",
|
|
)
|
|
}
|
|
|
|
func TestClearUser_RemovesTimestamps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, sess, _ := authenticatedSession(t, testIdleTimeout)
|
|
|
|
s.ClearUser(sess)
|
|
|
|
_, hasCreatedAt := sess.Values[session.CreatedAtKey]
|
|
assert.False(t, hasCreatedAt, "CreatedAtKey should be removed")
|
|
|
|
_, hasLastSeen := sess.Values[session.LastSeenKey]
|
|
assert.False(t, hasLastSeen, "LastSeenKey should be removed")
|
|
}
|
|
|
|
// --- Edge Cases ---
|
|
|
|
func TestSetUser_OverwritesPreviousUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-1", "alice")
|
|
assert.True(t, s.IsAuthenticated(sess))
|
|
|
|
// Overwrite with a different user
|
|
s.SetUser(sess, "user-2", "bob")
|
|
|
|
userID, ok := s.GetUserID(sess)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "user-2", userID)
|
|
|
|
username, ok := s.GetUsername(sess)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "bob", username)
|
|
}
|
|
|
|
func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
// Create a session
|
|
req1 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w1 := httptest.NewRecorder()
|
|
|
|
sess, err := s.Get(req1)
|
|
require.NoError(t, err)
|
|
s.SetUser(sess, "user-123", "alice")
|
|
require.NoError(t, s.Save(req1, w1, sess))
|
|
|
|
cookies := w1.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
// Destroy and save
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/logout", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req2.AddCookie(c)
|
|
}
|
|
|
|
w2 := httptest.NewRecorder()
|
|
|
|
sess2, err := s.Get(req2)
|
|
require.NoError(t, err)
|
|
s.Destroy(sess2)
|
|
require.NoError(t, s.Save(req2, w2, sess2))
|
|
|
|
// The cookie should have MaxAge = -1 (browser should delete)
|
|
responseCookies := w2.Result().Cookies()
|
|
|
|
var sessionCookie *http.Cookie
|
|
|
|
for _, c := range responseCookies {
|
|
if c.Name == session.SessionName {
|
|
sessionCookie = c
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
require.NotNil(
|
|
t, sessionCookie,
|
|
"should have a session cookie in response",
|
|
)
|
|
assert.Negative(
|
|
t, sessionCookie.MaxAge,
|
|
"destroyed session cookie should have negative MaxAge",
|
|
)
|
|
}
|
|
|
|
// --- Secure Attribute / Transport Tests ---
|
|
|
|
// transportCase describes one client-facing transport and the Secure
|
|
// attribute the session cookie must carry for it.
|
|
type transportCase struct {
|
|
name string
|
|
tls bool
|
|
header string
|
|
want bool
|
|
why string
|
|
}
|
|
|
|
// transportCases enumerates the transports the session cookie has to
|
|
// get right. Every https spelling here is one a real proxy emits.
|
|
func transportCases() []transportCase {
|
|
return []transportCase{
|
|
{
|
|
name: "direct TLS",
|
|
tls: true,
|
|
want: true,
|
|
why: "this process terminated TLS itself",
|
|
},
|
|
{
|
|
name: "proxy reports https",
|
|
header: "https",
|
|
want: true,
|
|
why: "the ordinary reverse-proxy deployment",
|
|
},
|
|
{
|
|
name: "proxy reports HTTPS",
|
|
header: "HTTPS",
|
|
want: true,
|
|
why: "the header value is a case-insensitive token",
|
|
},
|
|
{
|
|
name: "appended chain https, http",
|
|
header: "https, http",
|
|
want: true,
|
|
why: "the leftmost hop is the browser's connection",
|
|
},
|
|
{
|
|
name: "appended chain https,https",
|
|
header: "https,https",
|
|
want: true,
|
|
why: "two TLS hops, no space after the comma",
|
|
},
|
|
{
|
|
name: "trailing space",
|
|
header: "https ",
|
|
want: true,
|
|
why: "whitespace is not part of the token",
|
|
},
|
|
{
|
|
name: "proxy reports http",
|
|
header: "http",
|
|
want: false,
|
|
why: "the negative control: Secure over plaintext is " +
|
|
"dropped by the browser without a word",
|
|
},
|
|
{
|
|
name: "plaintext, no proxy",
|
|
want: false,
|
|
why: "a plain local run must stay loggable-in",
|
|
},
|
|
}
|
|
}
|
|
|
|
// transportRequest builds a request carrying the case's transport.
|
|
func (tc transportCase) request(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
|
|
r := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet,
|
|
"http://example.com/", nil,
|
|
)
|
|
|
|
if tc.tls {
|
|
r.TLS = &tls.ConnectionState{}
|
|
}
|
|
|
|
if tc.header != "" {
|
|
r.Header.Set("X-Forwarded-Proto", tc.header)
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// sessionCookieFrom returns the session cookie from a response, or
|
|
// fails the test if there is none.
|
|
func sessionCookieFrom(
|
|
t *testing.T,
|
|
w *httptest.ResponseRecorder,
|
|
) *http.Cookie {
|
|
t.Helper()
|
|
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == session.SessionName {
|
|
return c
|
|
}
|
|
}
|
|
|
|
require.FailNow(t, "no session cookie in response")
|
|
|
|
return nil
|
|
}
|
|
|
|
// TestSave_SecureFollowsRequestTransport is the regression test for
|
|
// the defect this replaces: Secure was fixed at startup from the
|
|
// configured environment, and "dev" is the environment when
|
|
// WEBHOOKER_ENVIRONMENT is unset. A deployment behind a TLS proxy in
|
|
// that DEFAULT posture shipped the authentication cookie with no
|
|
// Secure attribute and said nothing about it.
|
|
//
|
|
// testSession builds its config with EnvironmentDev precisely so that
|
|
// the https cases below fail against the old startup-fixed behaviour.
|
|
func TestSave_SecureFollowsRequestTransport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range transportCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
r := tc.request(t)
|
|
w := httptest.NewRecorder()
|
|
|
|
sess, err := s.Get(r)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-1", "alice")
|
|
require.NoError(t, s.Save(r, w, sess))
|
|
|
|
assert.Equal(
|
|
t, tc.want, sessionCookieFrom(t, w).Secure,
|
|
"session cookie Secure for %q: %s",
|
|
tc.name, tc.why,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSave_SecureTracksTransportBothWays pins that the flag is not
|
|
// latched. One store serves every request, so a Secure cookie set for
|
|
// a proxied request must not leak into a later plaintext response --
|
|
// the browser would silently discard that one, and a local run would
|
|
// become impossible to log into.
|
|
func TestSave_SecureTracksTransportBothWays(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
secureReq := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet,
|
|
"http://example.com/", nil,
|
|
)
|
|
secureReq.Header.Set("X-Forwarded-Proto", "https")
|
|
|
|
secureW := httptest.NewRecorder()
|
|
|
|
secureSess, err := s.Get(secureReq)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Save(secureReq, secureW, secureSess))
|
|
require.True(
|
|
t, sessionCookieFrom(t, secureW).Secure,
|
|
"proxied request should produce a Secure cookie",
|
|
)
|
|
|
|
plainReq := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet,
|
|
"http://example.com/", nil,
|
|
)
|
|
plainW := httptest.NewRecorder()
|
|
|
|
plainSess, err := s.Get(plainReq)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Save(plainReq, plainW, plainSess))
|
|
|
|
assert.False(
|
|
t, sessionCookieFrom(t, plainW).Secure,
|
|
"a later plaintext request must not inherit Secure from "+
|
|
"the earlier proxied one",
|
|
)
|
|
}
|
|
|
|
// TestDestroy_DeletionCookieFollowsTransport covers the trap in the
|
|
// deletion path. The store's template Secure is true, so a logout over
|
|
// plaintext that failed to track the transport would emit a Secure
|
|
// deletion cookie -- which the browser drops, leaving the session the
|
|
// user just tried to end still sitting in the jar.
|
|
func TestDestroy_DeletionCookieFollowsTransport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
r := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet,
|
|
"http://example.com/", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
sess, err := s.Get(r)
|
|
require.NoError(t, err)
|
|
|
|
s.Destroy(sess)
|
|
require.NoError(t, s.Save(r, w, sess))
|
|
|
|
cookie := sessionCookieFrom(t, w)
|
|
|
|
require.Negative(
|
|
t, cookie.MaxAge,
|
|
"Destroy then Save should emit a deletion cookie",
|
|
)
|
|
assert.False(
|
|
t, cookie.Secure,
|
|
"a deletion cookie sent over plaintext must not be Secure, "+
|
|
"or the browser discards it and the session survives",
|
|
)
|
|
}
|
|
|
|
// TestRegenerate_BothCookiesFollowTransport covers the login path.
|
|
// Regenerate writes two cookies -- a deletion for the pre-login
|
|
// session and the new authenticated one -- and both have to match the
|
|
// transport or one of them is silently dropped.
|
|
func TestRegenerate_BothCookiesFollowTransport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range transportCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
r := tc.request(t)
|
|
w := httptest.NewRecorder()
|
|
|
|
oldSess, err := s.Get(r)
|
|
require.NoError(t, err)
|
|
|
|
newSess, err := s.Regenerate(r, w, oldSess)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(newSess, "user-1", "alice")
|
|
require.NoError(t, s.Save(r, w, newSess))
|
|
|
|
cookies := w.Result().Cookies()
|
|
require.Len(
|
|
t, cookies, 2,
|
|
"Regenerate then Save writes a deletion cookie "+
|
|
"and a replacement",
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
assert.Equal(
|
|
t, tc.want, c.Secure,
|
|
"cookie %d Secure for %q: %s",
|
|
c.MaxAge, tc.name, tc.why,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|