package auth import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "errors" "net/http" "time" ) // The cookie carrying the opaque session token, in its two spellings. // // Over https the name takes the __Host- prefix, which is not decoration: the // browser will only accept such a cookie if it is Secure, Path=/, and carries // no Domain attribute — and, crucially, refuses to let any other host set it. // Without the prefix, anything that can write cookies for a sibling name under // parodia.dev (another service on the box, a subdomain takeover) can plant a // session cookie in her browser that Petal will then read as hers. // // The prefix is impossible over plain http, because it requires Secure and a // browser drops a Secure cookie on an insecure origin. So local development // keeps the bare name, and the name in use follows the same `secure` flag the // rest of the cookie does. const ( SessionCookie = "petal_session" HostSessionCookie = "__Host-petal_session" ) // sessionCookieName is the name to *write* under this scheme. func sessionCookieName(secure bool) string { if secure { return HostSessionCookie } return SessionCookie } 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) { token := SessionToken(r) if token == "" { return "", ErrNoSession } return s.userFor(token) } // SessionToken pulls the raw session token out of a request, preferring the // __Host- spelling. // // Both are read because a deployment that was signing people in before the // prefix existed has browsers holding the old name; those sessions stay valid // and quietly re-issue under the new name at the next sign-in. The prefixed one // wins where both are present, since it is the one another host could not have // planted. func SessionToken(r *http.Request) string { for _, name := range []string{HostSessionCookie, SessionCookie} { if c, err := r.Cookie(name); err == nil && c.Value != "" { return c.Value } } return "" } // 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: sessionCookieName(secure), 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. // // Both spellings are expired, not just the one currently written: a browser // carrying a pre-prefix cookie must not be left holding it after signing out, // which is precisely the case where "clear the cookie" is the part the user can // see working. func ClearSessionCookie(w http.ResponseWriter, secure bool) { for _, name := range []string{HostSessionCookie, SessionCookie} { if name == HostSessionCookie && !secure { continue // the browser would reject a non-Secure __Host- cookie } http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } }