package pgstore import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "textmachine/platform/internal/auth" ) // Lookup resolves a presented token. Expiry and revocation are clauses of THIS query, not checks a // caller could forget: a row that comes back is live by construction. func (s *Store) Lookup(ctx context.Context, digest []byte, now time.Time) (auth.Session, error) { const q = ` select user_id, idle_expires_at, absolute_expires_at from sessions where token_sha256 = $1 and revoked_at is null and idle_expires_at > $2 and absolute_expires_at > $2` var out auth.Session err := s.pool.QueryRow(ctx, q, digest, now). Scan(&out.UserID, &out.IdleExpiresAt, &out.AbsoluteExpiresAt) if errors.Is(err, pgx.ErrNoRows) { return auth.Session{}, auth.ErrNoSession } if err != nil { return auth.Session{}, fmt.Errorf("pgstore: lookup session: %w", err) } return out, nil } // Touch slides the idle window. It never moves the absolute expiry — that is the point of having // two clocks — and it is called only in the window's second half, so reads stay reads. func (s *Store) Touch(ctx context.Context, digest []byte, now time.Time, idleTTL time.Duration) error { // Deadlines are computed in Go and travel as timestamps: one clock, one place, and no interval // encoding to reason about. const q = ` update sessions set last_used_at = $2, idle_expires_at = least($3::timestamptz, absolute_expires_at) where token_sha256 = $1 and revoked_at is null and absolute_expires_at > $2` if _, err := s.pool.Exec(ctx, q, digest, now, now.Add(idleTTL)); err != nil { return fmt.Errorf("pgstore: touch session: %w", err) } return nil } // CreateSession stores a freshly minted token's digest. The plaintext never reaches this package. func (s *Store) CreateSession(ctx context.Context, digest []byte, userID string, now time.Time, idleTTL, maxAge time.Duration) error { const q = ` insert into sessions (token_sha256, user_id, created_at, last_used_at, idle_expires_at, absolute_expires_at) values ($1, $2, $3, $3, $4, $5)` if _, err := s.pool.Exec(ctx, q, digest, userID, now, now.Add(idleTTL), now.Add(maxAge)); err != nil { return fmt.Errorf("pgstore: create session: %w", err) } return nil } // RevokeSession ends one session immediately — the property an opaque server-side session has and // a self-verifying token does not. func (s *Store) RevokeSession(ctx context.Context, digest []byte, now time.Time) error { const q = `update sessions set revoked_at = $2 where token_sha256 = $1 and revoked_at is null` if _, err := s.pool.Exec(ctx, q, digest, now); err != nil { return fmt.Errorf("pgstore: revoke session: %w", err) } return nil } // DeleteExpiredSessions is the sweep. Expired rows are deleted rather than kept: a session table is // not an audit log, and "who was logged in last spring" is not a question we want to be able to // answer from it. func (s *Store) DeleteExpiredSessions(ctx context.Context, now time.Time) (int64, error) { const q = `delete from sessions where absolute_expires_at <= $1` tag, err := s.pool.Exec(ctx, q, now) if err != nil { return 0, fmt.Errorf("pgstore: sweep sessions: %w", err) } return tag.RowsAffected(), nil }