Add server-side sessions, session cookie and logout
A successful OIDC callback now creates a sessions row through the new auth.SessionManager: a random 256-bit ID whose SHA-256 hash is the only stored form, the subject, email, name and groups claims, a separate random CSRF token and created/last_seen/expires timestamps. The ID travels only in the __Host-mealquest_session cookie (HttpOnly, Secure, SameSite=Lax, Path=/, no Domain). SessionFor rejects a session unused for MEALQUEST_SESSION_IDLE or older than MEALQUEST_SESSION_MAX, deletes it, and otherwise slides last_seen_at. POST /auth/logout deletes the row, clears the cookie and redirects to the provider's end_session_endpoint, which discovery now requires. The session lifetimes are optional configuration (defaults 720h and 4320h, per D-004); set values must be positive with MAX at least IDLE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const (
|
||||
testIdle = 30 * 24 * time.Hour
|
||||
testMax = 180 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// testEpoch is the fake clock's starting instant.
|
||||
var testEpoch = time.Date(2026, 10, 3, 18, 30, 0, 0, time.UTC)
|
||||
|
||||
// sessionHarness runs the real login flow against a fake provider with a
|
||||
// SessionManager over a migrated database and a fake clock.
|
||||
type sessionHarness struct {
|
||||
*harness
|
||||
db *store.Store
|
||||
sessions *SessionManager
|
||||
auth *Auth
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newSessionHarness(t *testing.T) *sessionHarness {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions, err := NewSessionManager(db, testIdle, testMax)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &sessionHarness{db: db, sessions: sessions, clock: testEpoch}
|
||||
sessions.now = func() time.Time { return h.clock }
|
||||
|
||||
p := newFakeProvider(t)
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(ctx, Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
a.Mount(r)
|
||||
h.harness = &harness{provider: p, router: r, logs: logs}
|
||||
h.auth = a
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *sessionHarness) advance(d time.Duration) { h.clock = h.clock.Add(d) }
|
||||
|
||||
// signIn completes a login and returns the callback response and the
|
||||
// session cookie it set.
|
||||
func (h *sessionHarness) signIn(t *testing.T) (*http.Response, *http.Cookie) {
|
||||
t.Helper()
|
||||
l := h.login(t, LoginPath)
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == SessionCookieName {
|
||||
return resp, c
|
||||
}
|
||||
}
|
||||
t.Fatalf("callback set no %s cookie", SessionCookieName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// use looks up the session cookie c names.
|
||||
func (h *sessionHarness) use(c *http.Cookie) (Session, error) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/anything", nil)
|
||||
if c != nil {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
return h.sessions.SessionFor(req)
|
||||
}
|
||||
|
||||
type sessionRow struct {
|
||||
idHash, subject, email, name, groups, csrf string
|
||||
created, lastSeen, expires store.Timestamp
|
||||
}
|
||||
|
||||
func (h *sessionHarness) rows(t *testing.T) []sessionRow {
|
||||
t.Helper()
|
||||
rs, err := h.db.Reader.Query(`SELECT id_hash, subject, email, name, groups, csrf_token,
|
||||
created_at, last_seen_at, expires_at FROM sessions`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rs.Close()
|
||||
var out []sessionRow
|
||||
for rs.Next() {
|
||||
var r sessionRow
|
||||
if err := rs.Scan(&r.idHash, &r.subject, &r.email, &r.name, &r.groups, &r.csrf,
|
||||
&r.created, &r.lastSeen, &r.expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sessionSetCookie returns the raw Set-Cookie header for the session cookie.
|
||||
func sessionSetCookie(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
for _, v := range resp.Header.Values("Set-Cookie") {
|
||||
if strings.HasPrefix(v, SessionCookieName+"=") {
|
||||
return v
|
||||
}
|
||||
}
|
||||
t.Fatalf("no Set-Cookie for %s in %q", SessionCookieName, resp.Header.Values("Set-Cookie"))
|
||||
return ""
|
||||
}
|
||||
|
||||
// assertSessionCookieAttributes checks the attributes the __Host- prefix
|
||||
// and D-004 require, on the raw header so nothing is lost in parsing.
|
||||
func assertSessionCookieAttributes(t *testing.T, raw string) {
|
||||
t.Helper()
|
||||
attrs := map[string]bool{}
|
||||
for _, a := range strings.Split(raw, ";")[1:] {
|
||||
name, _, _ := strings.Cut(strings.TrimSpace(a), "=")
|
||||
attrs[strings.ToLower(name)] = true
|
||||
}
|
||||
for _, want := range []string{"Path=/", "HttpOnly", "Secure", "SameSite=Lax"} {
|
||||
if !slices.Contains(strings.Split(raw, "; "), want) {
|
||||
t.Errorf("session Set-Cookie %q lacks %s", raw, want)
|
||||
}
|
||||
}
|
||||
if attrs["domain"] {
|
||||
t.Errorf("session Set-Cookie %q has a Domain", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestCallbackStartsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
resp, cookie := h.signIn(t)
|
||||
|
||||
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
|
||||
if cookie.MaxAge != int(testMax/time.Second) {
|
||||
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, int(testMax/time.Second))
|
||||
}
|
||||
|
||||
rows := h.rows(t)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("sessions has %d rows, want 1", len(rows))
|
||||
}
|
||||
r := rows[0]
|
||||
if len(cookie.Value) != 43 {
|
||||
t.Errorf("session ID %q is %d chars, want 43 (256 bits, base64url)", cookie.Value, len(cookie.Value))
|
||||
}
|
||||
if r.idHash != sha256Hex(cookie.Value) {
|
||||
t.Errorf("id_hash = %q, want the SHA-256 of the cookie value", r.idHash)
|
||||
}
|
||||
if r.subject != "user-123" || r.email != "cook@example.com" || r.name != "Home Cook" ||
|
||||
r.groups != `["mealquest-admin"]` {
|
||||
t.Errorf("session row = %+v, want the ID token claims", r)
|
||||
}
|
||||
if len(r.csrf) != 43 || r.csrf == cookie.Value {
|
||||
t.Errorf("csrf_token = %q, want a separate 256-bit token", r.csrf)
|
||||
}
|
||||
if !r.created.Equal(testEpoch) || !r.lastSeen.Equal(testEpoch) || !r.expires.Equal(testEpoch.Add(testIdle)) {
|
||||
t.Errorf("created, last_seen, expires = %v, %v, %v, want %v, %v, %v",
|
||||
r.created, r.lastSeen, r.expires, testEpoch, testEpoch, testEpoch.Add(testIdle))
|
||||
}
|
||||
|
||||
s, err := h.use(cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionFor = %v, want the new session", err)
|
||||
}
|
||||
if s.Subject != "user-123" || s.CSRFToken != r.csrf || !slices.Equal(s.Groups, []string{"mealquest-admin"}) {
|
||||
t.Errorf("SessionFor = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartSessionStoresOnlyTheHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
var n int
|
||||
err := h.db.Reader.QueryRow(`SELECT count(*) FROM sessions WHERE
|
||||
id_hash = ?1 OR subject = ?1 OR email = ?1 OR name = ?1 OR groups = ?1 OR csrf_token = ?1`,
|
||||
cookie.Value).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("the raw session ID is stored in the sessions table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLastSeenSlides(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
// Used every 20 days, the session outlives several idle periods.
|
||||
for range 4 {
|
||||
h.advance(20 * 24 * time.Hour)
|
||||
s, err := h.use(cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
|
||||
}
|
||||
if !s.LastSeenAt.Equal(h.clock) || !s.ExpiresAt.Equal(h.clock.Add(testIdle)) {
|
||||
t.Errorf("at %v session last seen %v, expires %v, want %v, %v",
|
||||
h.clock, s.LastSeenAt, s.ExpiresAt, h.clock, h.clock.Add(testIdle))
|
||||
}
|
||||
r := h.rows(t)[0]
|
||||
if !r.lastSeen.Equal(h.clock) || !r.expires.Equal(h.clock.Add(testIdle)) || !r.created.Equal(testEpoch) {
|
||||
t.Errorf("at %v row created %v, last seen %v, expires %v", h.clock, r.created, r.lastSeen, r.expires)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIdleExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
h.advance(testIdle - time.Millisecond)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("just inside the idle limit SessionFor = %v, want the session", err)
|
||||
}
|
||||
h.advance(testIdle)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("after idle SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
if n := len(h.rows(t)); n != 0 {
|
||||
t.Errorf("expired session left %d rows, want 0", n)
|
||||
}
|
||||
// Turning the clock back cannot revive it.
|
||||
h.clock = testEpoch
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("after deletion SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAbsoluteExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
for h.clock.Before(testEpoch.Add(testMax - 20*24*time.Hour)) {
|
||||
h.advance(20 * 24 * time.Hour)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
|
||||
}
|
||||
}
|
||||
// Last used 20 days before the absolute limit: the idle window would run
|
||||
// past it, so expiry is capped there.
|
||||
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(testMax)) {
|
||||
t.Errorf("expires_at = %v, want the absolute limit %v", r.expires, testEpoch.Add(testMax))
|
||||
}
|
||||
|
||||
h.clock = testEpoch.Add(testMax - time.Millisecond)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
|
||||
}
|
||||
h.clock = testEpoch.Add(testMax)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("at the absolute limit, used 1ms ago, SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
if n := len(h.rows(t)); n != 0 {
|
||||
t.Errorf("expired session left %d rows, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A lifetime shortened by configuration applies to existing sessions even
|
||||
// though their stored expires_at is later.
|
||||
func TestSessionLifetimeChecksUseCurrentConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
h.sessions.idle = 24 * time.Hour
|
||||
h.advance(24 * time.Hour)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("past the shortened idle SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
|
||||
_, cookie = h.signIn(t)
|
||||
h.sessions.idle, h.sessions.max = 24*time.Hour, 48*time.Hour
|
||||
for range 3 {
|
||||
h.advance(12 * time.Hour)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("SessionFor = %v, want the session", err)
|
||||
}
|
||||
}
|
||||
h.advance(12 * time.Hour)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("past the shortened max SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
|
||||
// Used recently and with expires_at still ahead, but older than a max
|
||||
// shortened after that use: only the absolute check ends it.
|
||||
h.sessions.idle, h.sessions.max = testIdle, testMax
|
||||
_, cookie = h.signIn(t)
|
||||
h.advance(10 * 24 * time.Hour)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("SessionFor = %v, want the session", err)
|
||||
}
|
||||
h.sessions.max = 10 * 24 * time.Hour
|
||||
h.advance(time.Hour)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("past a max shortened after the last use SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionForRejectsBadCookies(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
h.signIn(t)
|
||||
for name, c := range map[string]*http.Cookie{
|
||||
"missing": nil,
|
||||
"empty": {Name: SessionCookieName, Value: ""},
|
||||
"not base64": {Name: SessionCookieName, Value: strings.Repeat("!", 43)},
|
||||
"short": {Name: SessionCookieName, Value: "AAAA"},
|
||||
"unknown ID": {Name: SessionCookieName, Value: randomToken()},
|
||||
"other cookie": {Name: "mealquest_session", Value: randomToken()},
|
||||
} {
|
||||
if _, err := h.use(c); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("%s: SessionFor = %v, want ErrNoSession", name, err)
|
||||
}
|
||||
}
|
||||
if n := len(h.rows(t)); n != 1 {
|
||||
t.Errorf("sessions has %d rows, want the signed-in session only", n)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *sessionHarness) logout(cookies ...*http.Cookie) *http.Response {
|
||||
req := httptest.NewRequest(http.MethodPost, LogoutPath, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.router.ServeHTTP(rec, req)
|
||||
return rec.Result()
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
_, other := h.signIn(t)
|
||||
|
||||
resp := h.logout(cookie)
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("logout status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
want := h.provider.issuer() + "/end-session/"
|
||||
if got := resp.Header.Get("Location"); got != want || h.auth.EndSessionURL() != want {
|
||||
t.Errorf("logout redirect = %q, EndSessionURL = %q, want %q", got, h.auth.EndSessionURL(), want)
|
||||
}
|
||||
raw := sessionSetCookie(t, resp)
|
||||
assertSessionCookieAttributes(t, raw)
|
||||
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
|
||||
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
|
||||
}
|
||||
|
||||
rows := h.rows(t)
|
||||
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
|
||||
t.Errorf("after logout sessions = %+v, want only the other session", rows)
|
||||
}
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("logged-out cookie: SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
if _, err := h.use(other); err != nil {
|
||||
t.Errorf("other session: SessionFor = %v, want it untouched", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutWithoutSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
resp := h.logout()
|
||||
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != h.provider.issuer()+"/end-session/" {
|
||||
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
|
||||
}
|
||||
|
||||
func TestLogoutIsPostOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
if resp := h.get(LogoutPath, cookie); resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET %s status = %d, want 405", LogoutPath, resp.StatusCode)
|
||||
}
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Errorf("after GET %s SessionFor = %v, want the session", LogoutPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutSessionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.sessions.err = errors.New("database down")
|
||||
rec := httptest.NewRecorder()
|
||||
h.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, LogoutPath, nil))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("logout status = %d, want 500", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresEndSessionEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
|
||||
t.Errorf("New error = %v, want the missing end session endpoint named", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionManagerValidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := &store.Store{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
db *store.Store
|
||||
idle, max time.Duration
|
||||
}{
|
||||
{"nil store", nil, time.Hour, time.Hour},
|
||||
{"zero idle", db, 0, time.Hour},
|
||||
{"negative max", db, time.Hour, -time.Hour},
|
||||
{"max below idle", db, 2 * time.Hour, time.Hour},
|
||||
} {
|
||||
if _, err := NewSessionManager(tc.db, tc.idle, tc.max); err == nil {
|
||||
t.Errorf("%s: NewSessionManager succeeded", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user