120 lines
5.4 KiB
Go
120 lines
5.4 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"textmachine/platform/internal/auth"
|
|
)
|
|
|
|
// sessions.go is the domain face of the session statements: the SQL and the Scan are generated from
|
|
// `queries/sessions.sql`, and what stays here is what a generator cannot know — the sentinel errors
|
|
// callers switch on, and the domain types they speak in.
|
|
|
|
// Lookup resolves a presented token. Expiry and revocation are clauses of the 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) {
|
|
row, err := s.q.LookupSession(ctx, LookupSessionParams{TokenSha256: digest, Now: now})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return auth.Session{}, auth.ErrNoSession
|
|
}
|
|
if err != nil {
|
|
return auth.Session{}, fmt.Errorf("pgstore: lookup session: %w", err)
|
|
}
|
|
// Field by NAME. The three used to be filled by position from the SELECT list, and generating the
|
|
// Scan is what stops those two from disagreeing.
|
|
//
|
|
// ⚠ It does NOT make the mapping below safe, and saying so is the point: the projection is still
|
|
// hand-written, and a transposed pair of the two deadlines here plans and scans perfectly while
|
|
// stopping the idle window from ever sliding. Measured twice — the swap survived the whole
|
|
// eighteen-package battery both before this conversion and after it. What closes it is
|
|
// TestTheTwoSessionDeadlinesAreNotInterchangeable, not the generator.
|
|
return auth.Session{
|
|
UserID: row.UserID,
|
|
IdleExpiresAt: row.IdleExpiresAt,
|
|
AbsoluteExpiresAt: row.AbsoluteExpiresAt,
|
|
}, 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.
|
|
if err := s.q.TouchSession(ctx, TouchSessionParams{
|
|
TokenSha256: digest,
|
|
Now: now,
|
|
IdleDeadline: now.Add(idleTTL),
|
|
}); err != nil {
|
|
return fmt.Errorf("pgstore: touch session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StillLive re-asks about a session that was already authenticated, for a response that is still
|
|
// open. It is the query behind auth.SessionLife, and the event stream is its only caller.
|
|
//
|
|
// ⚠ THE IDLE CLAUSE IS DELIBERATELY ABSENT, and this is the one predicate over this table where that
|
|
// is true. The idle window slides on a REQUEST (auth.Authenticator.Require), and the event stream is
|
|
// ONE request that lives for hours — so a stream can never slide its own window, and asking the idle
|
|
// question here would end the stream of a user who is sitting and watching it. What is asked instead
|
|
// is the pair of facts a stream cannot influence by existing: was the session revoked, and has its
|
|
// ABSOLUTE ceiling passed.
|
|
//
|
|
// ⚠ A row that is GONE answers ErrNoSession like a revoked one, and that is not a hole in the
|
|
// paragraph above: SweepSessions deletes revoked rows too, so "no row" has to mean dead — otherwise
|
|
// a revoked session would keep its stream for as long as it took the sweep to run, which is the
|
|
// defect this query exists to remove. The cost is named: a session that idled out and was then swept
|
|
// also ends its stream, up to an hour late. That is not the idle window ending a stream, it is the
|
|
// row's absence, and by then every other request of that caller is a 401 anyway.
|
|
func (s *Store) StillLive(ctx context.Context, digest []byte, now time.Time) error {
|
|
_, err := s.q.SessionStillLive(ctx, SessionStillLiveParams{TokenSha256: digest, Now: now})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return auth.ErrNoSession
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: re-check 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 {
|
|
if err := s.q.CreateSession(ctx, CreateSessionParams{
|
|
TokenSha256: digest,
|
|
UserID: userID,
|
|
Now: now,
|
|
IdleExpiresAt: now.Add(idleTTL),
|
|
AbsoluteExpiresAt: 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 {
|
|
if err := s.q.RevokeSession(ctx, RevokeSessionParams{TokenSha256: digest, Now: 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) {
|
|
n, err := s.q.SweepSessions(ctx, now)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pgstore: sweep sessions: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|