2026-10-03 10:20:14 -04:00
|
|
|
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{
|
2026-10-03 10:30:07 -04:00
|
|
|
Issuer: p.issuer(),
|
|
|
|
|
ClientID: testClientID,
|
|
|
|
|
ClientSecret: testClientSecret,
|
|
|
|
|
PublicURL: testPublicURL,
|
|
|
|
|
SecretKey: []byte("server-secret-key"),
|
|
|
|
|
AllowedGroups: testAllowedGroups,
|
2026-10-03 10:20:14 -04:00
|
|
|
}, 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 max with fractional seconds rounds the cookie Max-Age up, so the browser
|
|
|
|
|
// keeps the cookie until the server-side absolute limit.
|
|
|
|
|
func TestSessionCookieOutlivesFractionalMax(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
h := newSessionHarness(t)
|
|
|
|
|
h.sessions.idle, h.sessions.max = 1500*time.Millisecond, 2500*time.Millisecond
|
|
|
|
|
_, cookie := h.signIn(t)
|
|
|
|
|
if cookie.MaxAge != 3 {
|
|
|
|
|
t.Errorf("session cookie MaxAge = %d, want 3", cookie.MaxAge)
|
|
|
|
|
}
|
|
|
|
|
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(1500 * time.Millisecond)) {
|
|
|
|
|
t.Errorf("expires_at = %v, want %v", r.expires, testEpoch.Add(1500*time.Millisecond))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.advance(time.Second)
|
|
|
|
|
if _, err := h.use(cookie); err != nil {
|
|
|
|
|
t.Fatalf("at 1s SessionFor = %v, want the session", err)
|
|
|
|
|
}
|
|
|
|
|
h.clock = testEpoch.Add(2500*time.Millisecond - 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(2500 * time.Millisecond)
|
|
|
|
|
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
|
|
|
|
t.Errorf("at the absolute limit SessionFor = %v, want ErrNoSession", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A lifetime near the largest accepted duration still yields a positive,
|
|
|
|
|
// rounded-up cookie Max-Age rather than overflowing.
|
|
|
|
|
func TestSessionCookieMaxAgeNearLargestMax(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
largest, err := time.ParseDuration("2562047h47m16.854s")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
h := newSessionHarness(t)
|
|
|
|
|
h.sessions.max = largest
|
|
|
|
|
_, cookie := h.signIn(t)
|
|
|
|
|
if want := 9223372037; cookie.MaxAge != want {
|
|
|
|
|
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
2026-10-05 15:22:37 -04:00
|
|
|
if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` {
|
|
|
|
|
t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got)
|
|
|
|
|
}
|
2026-10-03 10:20:14 -04:00
|
|
|
|
|
|
|
|
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))
|
2026-10-05 15:22:37 -04:00
|
|
|
if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` {
|
|
|
|
|
t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got)
|
|
|
|
|
}
|
2026-10-03 10:20:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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{
|
2026-10-03 10:30:07 -04:00
|
|
|
Issuer: p.issuer(),
|
|
|
|
|
ClientID: testClientID,
|
|
|
|
|
ClientSecret: testClientSecret,
|
|
|
|
|
PublicURL: testPublicURL,
|
|
|
|
|
SecretKey: []byte("k"),
|
|
|
|
|
AllowedGroups: testAllowedGroups,
|
2026-10-03 10:20:14 -04:00
|
|
|
}, &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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|