// Package sessions implements the Session → Preview → Confirm pipeline on // top of a Store. Keeping it one layer above the HTTP handlers lets the // unit tests exercise business logic without an httptest server. package sessions import ( "context" "crypto/rand" "encoding/base64" "errors" "fmt" "time" "basicstack.de/proofedmesh/services/approval-service/internal/store" ) // IDGenerator returns opaque collision-resistant IDs. The default // implementation uses crypto/rand; tests inject a deterministic one. type IDGenerator func(prefix string) (string, error) // DefaultIDGenerator produces "_" + 24 random base64url chars. func DefaultIDGenerator(prefix string) (string, error) { b := make([]byte, 18) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("id: %w", err) } return prefix + "_" + base64.RawURLEncoding.EncodeToString(b), nil } // Manager orchestrates sessions on top of a Store. Its only state is the // clock and ID generator; all persistence happens through Store. type Manager struct { Store store.Store Now func() time.Time GenID IDGenerator SessionTTL time.Duration ChallengeTTL time.Duration } // New constructs a Manager with safe defaults for Now, GenID, SessionTTL // (15m) and ChallengeTTL (2m) when the caller leaves them zero. func New(s store.Store) *Manager { return &Manager{ Store: s, Now: time.Now, GenID: DefaultIDGenerator, SessionTTL: 15 * time.Minute, ChallengeTTL: 2 * time.Minute, } } // StartSession creates a fresh approval_session for userSub. func (m *Manager) StartSession(ctx context.Context, userSub string) (store.Session, error) { if userSub == "" { return store.Session{}, errors.New("sessions: userSub is empty") } id, err := m.GenID("sess") if err != nil { return store.Session{}, err } now := m.Now() s := store.Session{ ID: id, UserSub: userSub, ActiveUntil: now.Add(m.SessionTTL), CreatedAt: now, } if err := m.Store.CreateSession(ctx, s); err != nil { return store.Session{}, err } return s, nil } // ErrSessionExpired is returned by GetActiveSession when the session // row exists but active_until is in the past. var ErrSessionExpired = errors.New("sessions: session expired") // GetActiveSession returns the session when it is still within // active_until. Enforces session_id ↔ user_sub binding (the caller passes // the authenticated userSub so a leaked session_id cannot be used from // another account). func (m *Manager) GetActiveSession(ctx context.Context, sessionID, userSub string) (store.Session, error) { s, err := m.Store.GetSession(ctx, sessionID) if err != nil { return store.Session{}, err } if s.UserSub != userSub { // Explicit NotFound so handlers return 404 instead of leaking the // mismatch — a probing attacker cannot distinguish "wrong user" // from "unknown session". return store.Session{}, store.ErrNotFound } if !m.Now().Before(s.ActiveUntil) { return store.Session{}, ErrSessionExpired } return s, nil }