package auth import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // Querier is satisfied by *pgxpool.Pool and by pgx.Tx, so every method below // works inside or outside a transaction. // // Declared here rather than imported from internal/repo: that package's // Querier is identical, but this one keeps the authentication foundation // independent of the resource/descriptor layer, which it otherwise shares // nothing with. Go interfaces are structural, so both are satisfied by the // same values. type Querier interface { Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) } // PGStore is the sessions table. // // Every statement here is a constant string with bind parameters. Nothing — // not an id, not a token hash, not a timestamp — is ever formatted into SQL. // There is no identifier taken from a caller, so there is nothing to quote and // nothing to escape. type PGStore struct { db Querier } // NewPGStore builds the store over a pool or a transaction. func NewPGStore(db Querier) *PGStore { return &PGStore{db: db} } // Compile-time check that the persistence layer satisfies the lifecycle's // expectations. If Store gains a method, this line is where it is noticed. var _ Store = (*PGStore)(nil) // sessionColumns is the projection every read below shares, in the order the // scan expects. const sessionColumns = `id::text, user_id::text, token_hash, expires_at, absolute_expires_at, created_date, last_seen_at` // Create inserts a session. // // The id is left to the database's gen_random_uuid() default when the caller // did not choose one, and returned so the caller's Session is complete. // created_date and last_seen_at come from the caller rather than now(), so the // row agrees with the deadlines the Manager computed from the same instant. func (s *PGStore) Create(ctx context.Context, sess *Session) error { if sess.UserID == "" { return errors.New("auth: session user id is required") } // The last line of defence against writing a raw token to disk. The // database CHECK enforces the same shape; this turns it into a Go error // naming the actual mistake instead of a constraint violation. if !IsTokenHash(sess.TokenHash) { return errors.New("auth: session token_hash is not a SHA-256 hex digest") } const q = `INSERT INTO sessions (id, user_id, token_hash, expires_at, absolute_expires_at, created_date, last_seen_at) VALUES (COALESCE($1::uuid, gen_random_uuid()), $2::uuid, $3::text, $4::timestamptz, $5::timestamptz, $6::timestamptz, $7::timestamptz) RETURNING id::text` var id *string if sess.ID != "" { id = &sess.ID } if err := s.db.QueryRow(ctx, q, id, sess.UserID, sess.TokenHash, sess.ExpiresAt, sess.AbsoluteExpiresAt, sess.CreatedDate, sess.LastSeenAt, ).Scan(&sess.ID); err != nil { return fmt.Errorf("auth: create session: %w", err) } return nil } // FindByTokenHash reads one session by the hash of its token. // // The parameter is a hash, never a token: the Manager hashes before it calls // here, so a raw secret never reaches the query layer at all. Returns // ErrSessionNotFound when no row matches, which callers must not distinguish // from an expired session when answering a client. // // Expiry is deliberately not filtered in SQL. The caller decides what an // expired row means — Authenticate deletes it, a diagnostic might report it — // and a WHERE clause here would collapse "revoked" and "timed out" into one // indistinguishable answer at the wrong layer. func (s *PGStore) FindByTokenHash(ctx context.Context, tokenHash string) (Session, error) { if tokenHash == "" { return Session{}, ErrEmptyToken } if !IsTokenHash(tokenHash) { // A value of the wrong shape cannot match any row, and querying with // it would be an unnecessary round trip on every malformed cookie. return Session{}, ErrSessionNotFound } const q = `SELECT ` + sessionColumns + ` FROM sessions WHERE token_hash = $1::text` var out Session err := s.db.QueryRow(ctx, q, tokenHash).Scan( &out.ID, &out.UserID, &out.TokenHash, &out.ExpiresAt, &out.AbsoluteExpiresAt, &out.CreatedDate, &out.LastSeenAt) if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrSessionNotFound } if err != nil { return Session{}, fmt.Errorf("auth: find session: %w", err) } return out, nil } // Touch moves the sliding deadline and records the activity. // // The UPDATE is guarded by `expires_at <= absolute_expires_at` in the database // CHECK; the Manager clamps before calling, so a violation here would mean a // bug rather than a race. func (s *PGStore) Touch(ctx context.Context, id string, expiresAt, lastSeenAt time.Time) error { if id == "" { return errors.New("auth: session id is required") } const q = `UPDATE sessions SET expires_at = $2::timestamptz, last_seen_at = $3::timestamptz WHERE id = $1::uuid` tag, err := s.db.Exec(ctx, q, id, expiresAt, lastSeenAt) if err != nil { return fmt.Errorf("auth: touch session: %w", err) } if tag.RowsAffected() == 0 { // The session was revoked between the read and this write. Reporting // it as absent is honest; the caller treats it as a failed lookup. return ErrSessionNotFound } return nil } // Delete removes one session by id. Returns ErrSessionNotFound if there was // nothing to remove; Manager.RevokeID absorbs that, because logging out of a // session that is already gone is a success. func (s *PGStore) Delete(ctx context.Context, id string) error { if id == "" { return errors.New("auth: session id is required") } const q = `DELETE FROM sessions WHERE id = $1::uuid` tag, err := s.db.Exec(ctx, q, id) if err != nil { return fmt.Errorf("auth: delete session: %w", err) } if tag.RowsAffected() == 0 { return ErrSessionNotFound } return nil } // DeleteByTokenHash removes one session by the hash of its token. This is the // logout path: the cookie is all the client has. func (s *PGStore) DeleteByTokenHash(ctx context.Context, tokenHash string) error { if tokenHash == "" { return ErrEmptyToken } if !IsTokenHash(tokenHash) { return ErrSessionNotFound } const q = `DELETE FROM sessions WHERE token_hash = $1::text` tag, err := s.db.Exec(ctx, q, tokenHash) if err != nil { return fmt.Errorf("auth: delete session by token: %w", err) } if tag.RowsAffected() == 0 { return ErrSessionNotFound } return nil } // DeleteExpired removes every session past either deadline and reports the // count. // // Both deadlines are tested. Filtering on expires_at alone would leave behind // a session whose sliding window is still open but whose absolute ceiling has // passed — precisely the row the absolute bound exists to kill. func (s *PGStore) DeleteExpired(ctx context.Context, now time.Time) (int64, error) { const q = `DELETE FROM sessions WHERE expires_at <= $1::timestamptz OR absolute_expires_at <= $1::timestamptz` tag, err := s.db.Exec(ctx, q, now) if err != nil { return 0, fmt.Errorf("auth: delete expired sessions: %w", err) } return tag.RowsAffected(), nil }