package auth import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "time" "backend/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) // Store manages session lifecycle: create, lookup, delete, rotate, and // lazy-extend. It uses the sqlc-generated Queries for all DB operations. // // The raw 32-byte token is held only in memory (as the cookie value); // the DB row stores hex(sha256(token)) so a DB-read leak does not expose // live sessions (D-05). type Store struct { q *sqlc.Queries now func() time.Time // injectable for testing (D-09 MaybeExtend) } // NewStore returns a Store backed by q. The store's clock defaults to // time.Now; tests may override store.now after construction. func NewStore(q *sqlc.Queries) *Store { return &Store{q: q, now: time.Now} } // Create generates a fresh 32-byte crypto/rand token, stores its SHA-256 hash // in the sessions table, and returns the base64url-encoded cookie value plus // the session expiry time. // // This is the only place tokens are generated. The cookie value is the raw // token (base64url); the DB stores hex(sha256(rawToken)) only (D-05). func (s *Store) Create(ctx context.Context, userID uuid.UUID) (cookieValue string, expiresAt time.Time, err error) { raw := make([]byte, 32) if _, err = rand.Read(raw); err != nil { return "", time.Time{}, fmt.Errorf("auth: generate session token: %w", err) } cookieValue = base64.RawURLEncoding.EncodeToString(raw) sum := sha256.Sum256(raw) // D-05: store hash, never raw token id := hex.EncodeToString(sum[:]) expiresAt = s.now().Add(SessionTTL) pgExpires := pgtype.Timestamptz{Time: expiresAt, Valid: true} if err = s.q.InsertSession(ctx, sqlc.InsertSessionParams{ ID: id, UserID: userID, ExpiresAt: pgExpires, }); err != nil { return "", time.Time{}, fmt.Errorf("auth: insert session: %w", err) } return cookieValue, expiresAt, nil } // Lookup decodes the cookie value, hashes it, and retrieves the matching // live session + user from the DB. Returns ErrSessionNotFound if the cookie // is malformed, the session does not exist, or it has expired (D-07). func (s *Store) Lookup(ctx context.Context, cookieValue string) (*Session, *User, error) { raw, err := base64.RawURLEncoding.DecodeString(cookieValue) if err != nil || len(raw) != 32 { return nil, nil, ErrSessionNotFound } sum := sha256.Sum256(raw) // D-05: hash on every lookup, never store raw token id := hex.EncodeToString(sum[:]) row, err := s.q.GetSessionWithUser(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil, ErrSessionNotFound } return nil, nil, fmt.Errorf("auth: lookup session: %w", err) } sess := &Session{ ID: row.ID, UserID: row.UserID, CreatedAt: row.CreatedAt.Time, ExpiresAt: row.ExpiresAt.Time, } user := &User{ ID: row.UID, Email: row.Email, PasswordHash: row.PasswordHash, CreatedAt: row.UCreatedAt.Time, UpdatedAt: row.UUpdatedAt.Time, } return sess, user, nil } // Delete hard-deletes the session row by its hashed ID (D-06). func (s *Store) Delete(ctx context.Context, id string) error { if err := s.q.DeleteSession(ctx, id); err != nil { return fmt.Errorf("auth: delete session: %w", err) } return nil } // Rotate deletes the old session row (best-effort) and creates a new one. // This mitigates session fixation on every login and signup (D-10, T-2-04). // If oldID is empty the delete step is skipped. func (s *Store) Rotate(ctx context.Context, oldID string, userID uuid.UUID) (string, time.Time, error) { if oldID != "" { // Best-effort: ignore delete error; even if it fails the new session is safe. _ = s.q.DeleteSession(ctx, oldID) } return s.Create(ctx, userID) } // MaybeExtend updates expires_at only when the remaining session lifetime drops // below SessionExtendThreshold (~7 days). This provides a sliding-window TTL // with at most one DB write per ~23 days (D-09). func (s *Store) MaybeExtend(ctx context.Context, id string, expiresAt time.Time) error { remaining := expiresAt.Sub(s.now()) if remaining >= SessionExtendThreshold { // Plenty of time left — no update needed. return nil } newExpiry := s.now().Add(SessionTTL) if err := s.q.ExtendSession(ctx, sqlc.ExtendSessionParams{ ID: id, ExpiresAt: pgtype.Timestamptz{Time: newExpiry, Valid: true}, }); err != nil { return fmt.Errorf("auth: extend session: %w", err) } return nil }