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. // // Its WHERE matches Lookup's, idle clause included (PD-4): reachable only after a successful // Lookup today, but a query that can resurrect an idle-expired session is not one to leave lying // around for the next caller. 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 idle_expires_at > $2 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 the 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. 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 } // SweepSessions deletes rows nothing can authenticate with again: past either expiry, or revoked. // A revoked row is the one a compromised account most wants gone, and it used to sit until its // absolute expiry ninety days later. The audit lives in the login journal, not here. func (s *Store) SweepSessions(ctx context.Context, now time.Time) (int64, error) { const q = ` delete from sessions where absolute_expires_at <= $1 or idle_expires_at <= $1 or revoked_at is not null` tag, err := s.pool.Exec(ctx, q, now) if err != nil { return 0, fmt.Errorf("pgstore: sweep sessions: %w", err) } return tag.RowsAffected(), nil }