// Package session provides encrypted session cookie management. package session import ( "errors" "net/http" "time" "github.com/gorilla/securecookie" "sneak.berlin/go/pixa/internal/seal" ) // Session configuration constants. const ( CookieName = "pixa_session" SessionTTL = 30 * 24 * time.Hour // 30 days // HKDF salts for key derivation hashKeySalt = "pixa-session-hash-v1" blockKeySalt = "pixa-session-block-v1" ) // Errors returned by session operations. var ( ErrInvalidSession = errors.New("invalid or expired session") ErrNoSession = errors.New("no session cookie present") ) // Data contains the session payload stored in the encrypted cookie. type Data struct { Authenticated bool `json:"auth"` CreatedAt time.Time `json:"created"` ExpiresAt time.Time `json:"expires"` } // Manager handles session creation and validation using encrypted cookies. type Manager struct { sc *securecookie.SecureCookie secure bool // Set Secure flag on cookies (should be true in production) sameSite http.SameSite } // NewManager creates a session manager with keys derived from the signing key. // Set secure=true in production to require HTTPS for cookies. func NewManager(signingKey string, secure bool) (*Manager, error) { masterKey := []byte(signingKey) // Derive separate keys for HMAC (hash) and encryption (block) hashKey, err := seal.DeriveKey(masterKey, hashKeySalt) if err != nil { return nil, err } blockKey, err := seal.DeriveKey(masterKey, blockKeySalt) if err != nil { return nil, err } sc := securecookie.New(hashKey[:], blockKey[:]) sc.MaxAge(int(SessionTTL.Seconds())) return &Manager{ sc: sc, secure: secure, sameSite: http.SameSiteStrictMode, }, nil } // CreateSession creates a new authenticated session and sets the cookie. func (m *Manager) CreateSession(w http.ResponseWriter) error { now := time.Now() data := &Data{ Authenticated: true, CreatedAt: now, ExpiresAt: now.Add(SessionTTL), } encoded, err := m.sc.Encode(CookieName, data) if err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: encoded, Path: "/", MaxAge: int(SessionTTL.Seconds()), HttpOnly: true, Secure: m.secure, SameSite: m.sameSite, }) return nil } // ValidateSession checks if the request has a valid session cookie. // Returns the session data if valid, or an error if invalid/missing. func (m *Manager) ValidateSession(r *http.Request) (*Data, error) { cookie, err := r.Cookie(CookieName) if err != nil { if errors.Is(err, http.ErrNoCookie) { return nil, ErrNoSession } return nil, err } var data Data if err := m.sc.Decode(CookieName, cookie.Value, &data); err != nil { return nil, ErrInvalidSession } // Check if session has expired (defense in depth - cookie MaxAge should handle this) if time.Now().After(data.ExpiresAt) { return nil, ErrInvalidSession } if !data.Authenticated { return nil, ErrInvalidSession } return &data, nil } // ClearSession removes the session cookie. func (m *Manager) ClearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: "", Path: "/", MaxAge: -1, // Delete immediately HttpOnly: true, Secure: m.secure, SameSite: m.sameSite, }) } // IsAuthenticated is a convenience method that returns true if the request // has a valid authenticated session. func (m *Manager) IsAuthenticated(r *http.Request) bool { data, err := m.ValidateSession(r) return err == nil && data != nil && data.Authenticated }