textmachine/platform/internal/pgstore/sessions.go

93 lines
3.5 KiB
Go

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
}