package auth import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Session is the application-side view of a row in the sessions table. type Session struct { ID uuid.UUID UserID uuid.UUID ExpiresAt time.Time RevokedAt *time.Time } // ErrSessionNotFound is returned when no (valid, non-expired, non-revoked) // session matches the given token. var ErrSessionNotFound = errors.New("session not found") // SessionConfig controls token generation and persistence. type SessionConfig struct { TokenBytes int // entropy of the raw token before base64url encoding TTL time.Duration // validity window from creation } // SessionStore wraps database access for the sessions table and handles opaque // token generation. Only the SHA-256 hash of a token is ever stored. type SessionStore struct { pool *pgxpool.Pool cfg SessionConfig } // NewSessionStore constructs a SessionStore. tokenBytes must be >= 16. func NewSessionStore(pool *pgxpool.Pool, cfg SessionConfig) *SessionStore { if cfg.TokenBytes < 16 { cfg.TokenBytes = 32 } return &SessionStore{pool: pool, cfg: cfg} } // Create issues a new session for the user and returns the raw token (to send to // the client exactly once) together with the persisted Session row. func (s *SessionStore) Create(ctx context.Context, userID uuid.UUID, userAgent, ip string) (token string, sess Session, err error) { raw := make([]byte, s.cfg.TokenBytes) if _, err = rand.Read(raw); err != nil { return "", Session{}, fmt.Errorf("generate session token: %w", err) } token = base64.RawURLEncoding.EncodeToString(raw) tokenHash := hashToken(token) expiresAt := time.Now().Add(s.cfg.TTL) const q = ` INSERT INTO sessions (user_id, token_hash, expires_at, user_agent, ip) VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, '')) RETURNING id, user_id, expires_at, revoked_at` err = s.pool.QueryRow(ctx, q, userID, tokenHash, expiresAt, userAgent, ip). Scan(&sess.ID, &sess.UserID, &sess.ExpiresAt, &sess.RevokedAt) if err != nil { return "", Session{}, fmt.Errorf("insert session: %w", err) } return token, sess, nil } // Lookup returns the active session for a raw token. It rejects expired and // revoked sessions and updates last_seen_at (best-effort, errors logged by caller). func (s *SessionStore) Lookup(ctx context.Context, token string) (Session, error) { if token == "" { return Session{}, ErrSessionNotFound } const q = ` UPDATE sessions SET last_seen_at = now() WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > now() RETURNING id, user_id, expires_at, revoked_at` var sess Session err := s.pool.QueryRow(ctx, q, hashToken(token)).Scan( &sess.ID, &sess.UserID, &sess.ExpiresAt, &sess.RevokedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrSessionNotFound } return Session{}, fmt.Errorf("lookup session: %w", err) } return sess, nil } // Revoke marks the session matching token as revoked. Missing tokens are a no-op // so logout stays idempotent. func (s *SessionStore) Revoke(ctx context.Context, token string) error { if token == "" { return nil } const q = `UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL` _, err := s.pool.Exec(ctx, q, hashToken(token)) if err != nil { return fmt.Errorf("revoke session: %w", err) } return nil } // hashToken returns the lowercase hex SHA-256 digest of a raw token. The hash is // what we store; the raw token never touches the database. func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return base64.RawURLEncoding.EncodeToString(sum[:]) }