a73x

internal/server/store/sessions.go

Ref:   Size: 3.0 KiB   History

package store

import (
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/a73x/eitri/internal/random"
)

// CreateSession mints a server-side console session for tenant, valid for ttl,
// and returns its id — a 256-bit random hex string used verbatim as the
// eitri_session cookie value. Server-side rows mean revocation works and a
// restart keeps users signed in.
//
// Only the SHA-256 of the id is stored, exactly as a PAT is (see hashToken):
// the row is a verifier, not a credential, so a copied database or a backup
// yields nothing anyone can present. The cookie is unchanged — the client still
// holds the id itself, which is the only place it exists.
func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error) {
	id := random.Hex(32)
	now := time.Now().UTC()
	_, err := s.db.Exec(
		`INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`,
		hashToken(id), tenant, now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339),
	)
	if err != nil {
		return "", fmt.Errorf("create session: %w", err)
	}
	return id, nil
}

// SessionTenant resolves a session id to its tenant, enforcing expiry on read.
// ok=false (no error) for an unknown, deleted, or expired session — the caller
// treats all three identically (redirect to sign-in).
//
// Keyed on the hash of the presented id, so the stored value is never compared
// as a secret and there is no timing side-channel to work.
//
// Rows written before ids were hashed hold the id itself. They cannot match a
// hashed lookup, so they authenticate nobody and simply age out at their own
// expiry — the one visible effect of the change is that everyone signs in once
// more. They are not deleted here because a stored id and a stored hash are both
// 64 hex characters and cannot be told apart, so a blanket purge would sign
// every user out on every restart rather than once.
func (s *Store) SessionTenant(id string) (string, bool, error) {
	var tenant string
	err := s.db.QueryRow(
		`SELECT tenant FROM sessions WHERE id=? AND expires_at > ?`,
		hashToken(id), time.Now().UTC().Format(time.RFC3339),
	).Scan(&tenant)
	if errors.Is(err, sql.ErrNoRows) {
		return "", false, nil
	}
	if err != nil {
		return "", false, fmt.Errorf("session tenant: %w", err)
	}
	return tenant, true, nil
}

// DeleteSession revokes a session (sign-out). Deleting an absent id is a no-op.
// Keyed on the hash, like every other read of this table.
func (s *Store) DeleteSession(id string) error {
	if _, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, hashToken(id)); err != nil {
		return fmt.Errorf("delete session: %w", err)
	}
	return nil
}

// ReapSessions deletes every expired session row and reports how many. Run
// opportunistically to keep the table bounded; expiry is already enforced on
// read, so this is hygiene, not correctness.
func (s *Store) ReapSessions() (int64, error) {
	res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`,
		time.Now().UTC().Format(time.RFC3339))
	if err != nil {
		return 0, fmt.Errorf("reap sessions: %w", err)
	}
	return res.RowsAffected()
}