package auth import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "errors" "net/http" "time" ) // SessionCookie is the cookie carrying the opaque session token. const SessionCookie = "petal_session" const ( // sessionTTL is how long a session lives without use. Thirty days, sliding: // every authenticated request pushes the expiry back out. An editor that // logs you out mid-draft is hostile, and Petal auto-saves every 1.5s, so a // surprise 401 costs real writing. sessionTTL = 30 * 24 * time.Hour // sessionTTLModifier is the same span as a SQLite datetime() modifier. All // expiry math happens inside SQLite so stored values stay canonical UTC and // never depend on the server's local clock or on Go/SQLite parsing agreeing. sessionTTLModifier = "+30 days" // sessionRenewAfter throttles the sliding extension: a session is only // pushed forward once its expiry has drifted this far from the maximum. It // turns "a write on every request" into "a write at most once an hour per // session" while leaving the sliding window indistinguishable to the user. sessionRenewAfter = "-1 hour" ) // ErrNoSession means the request carried no session cookie, or one that is // unknown or expired. It is not an internal failure: the caller is simply not // signed in. var ErrNoSession = errors.New("no valid session") // SessionStore issues, validates and revokes login sessions, and is itself the // [Resolver] the API middleware runs on. // // The cookie holds a random token; the table stores only its SHA-256. A dump of // the database therefore hands an attacker no usable session — the same reason // passwords are never stored as given. Server-side rows (rather than a signed // stateless cookie) are what make logout and revocation actually revoke. type SessionStore struct { db *sql.DB } // NewSessionStore returns a store backed by the given database. func NewSessionStore(db *sql.DB) *SessionStore { return &SessionStore{db: db} } // Create issues a new session for userID and returns the token to put in the // cookie. The token is never stored; only its hash is. func (s *SessionStore) Create(userID, userAgent string) (string, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } token := base64.RawURLEncoding.EncodeToString(raw) if len(userAgent) > 256 { userAgent = userAgent[:256] } _, err := s.db.Exec( `INSERT INTO sessions (id, user_id, expires_at, user_agent) VALUES (?, ?, datetime('now', ?), ?)`, hashToken(token), userID, sessionTTLModifier, userAgent, ) if err != nil { return "", err } return token, nil } // Resolve implements [Resolver]: it reads the session cookie, validates it, and // returns the user it belongs to — extending the session's life while it does. func (s *SessionStore) Resolve(r *http.Request) (string, error) { c, err := r.Cookie(SessionCookie) if err != nil || c.Value == "" { return "", ErrNoSession } return s.userFor(c.Value) } // userFor validates a raw token and slides its expiry forward. func (s *SessionStore) userFor(token string) (string, error) { id := hashToken(token) var userID string err := s.db.QueryRow( `SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now')`, id, ).Scan(&userID) if errors.Is(err, sql.ErrNoRows) { return "", ErrNoSession } if err != nil { return "", err } // Slide the window. Throttled, and deliberately not fatal: a failed // extension shortens one session's life, which is no reason to reject a // request that is otherwise perfectly authenticated. _, _ = s.db.Exec( `UPDATE sessions SET expires_at = datetime('now', ?) WHERE id = ? AND expires_at < datetime('now', ?, ?)`, sessionTTLModifier, id, sessionTTLModifier, sessionRenewAfter, ) return userID, nil } // Revoke deletes the session behind a token. Unknown tokens are not an error — // signing out of a session that is already gone is a success, not a failure. func (s *SessionStore) Revoke(token string) error { _, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, hashToken(token)) return err } // RevokeAll deletes every session for a user, signing them out everywhere. func (s *SessionStore) RevokeAll(userID string) error { _, err := s.db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID) return err } // Prune removes expired rows and returns how many it deleted. Nothing depends // on it for correctness — expired sessions are already rejected on lookup — it // just keeps the table from accumulating dead rows forever. func (s *SessionStore) Prune() (int64, error) { res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= datetime('now')`) if err != nil { return 0, err } return res.RowsAffected() } // hashToken maps a raw session token to the id stored in the table. func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // SetSessionCookie writes the session cookie. Secure is set only when Petal is // served over https — flagging it on a plain-http dev server would make the // browser drop the cookie and silently break local login. func SetSessionCookie(w http.ResponseWriter, token string, secure bool) { http.SetCookie(w, &http.Cookie{ Name: SessionCookie, Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL / time.Second), }) } // ClearSessionCookie expires the session cookie in the browser. The matching // server-side row must be revoked separately — that's the half that counts. func ClearSessionCookie(w http.ResponseWriter, secure bool) { http.SetCookie(w, &http.Cookie{ Name: SessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) }