From 1aeabfd32be8ef751bb179f48b40da31e49ee0c1 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 20 Feb 2026 01:44:51 +0000 Subject: [PATCH] feat(auth): add SessionStore interface with SQLite persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract in-memory session map into SessionStore interface with two implementations: MemorySessionStore (default, backward-compatible) and SQLiteSessionStore (persistent via go-store). Add WithSessionStore option, background cleanup goroutine, and comprehensive tests including persistence verification and concurrency safety. Phase 1: Session Persistence — complete. Co-Authored-By: Virgil --- CLAUDE.md | 9 +- TODO.md | 8 +- auth/auth.go | 93 ++++--- auth/auth_test.go | 16 +- auth/session_store.go | 100 +++++++ auth/session_store_sqlite.go | 136 ++++++++++ auth/session_store_test.go | 503 +++++++++++++++++++++++++++++++++++ go.mod | 17 +- go.sum | 63 ++++- 9 files changed, 892 insertions(+), 53 deletions(-) create mode 100644 auth/session_store.go create mode 100644 auth/session_store_sqlite.go create mode 100644 auth/session_store_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 11168e6..8870b8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,7 @@ go vet ./... # Static analysis | Module | Local Path | Notes | |--------|-----------|-------| | `forge.lthn.ai/core/go` | `../go` | Framework (core.E, core.Crypt, io.Medium) | +| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store (session persistence) | **Do NOT change the replace directive path.** Use go.work for local resolution if needed. @@ -48,7 +49,11 @@ go vet ./... # Static analysis 2. Client signs offline 3. `ReadResponseFile(userID, path)` → verify, issue session -**Session management**: In-memory `sync.RWMutex`-protected map. 32-byte hex tokens, 24h TTL. `ValidateSession`, `RefreshSession`, `RevokeSession`. +**Session management**: Abstracted behind `SessionStore` interface. 32-byte hex tokens, 24h TTL. `ValidateSession`, `RefreshSession`, `RevokeSession`. Two implementations: +- `MemorySessionStore` — in-memory `sync.RWMutex`-protected map (default, sessions lost on restart) +- `SQLiteSessionStore` — persistent via go-store (SQLite KV), mutex-serialised for single-writer safety + +Configure via `WithSessionStore(store)` option. Background cleanup via `StartCleanup(ctx, interval)`. **Protected users**: `"server"` cannot be deleted. @@ -118,7 +123,7 @@ Service wrapper implementing `core.Crypt` interface. RSA-4096, SHA-256, AES-256. ## Security Considerations 1. **LTHN hash is NOT for passwords** — deterministic, no random salt. Use `HashPassword()` (Argon2id) instead. -2. **Sessions are in-memory only** — lost on restart. No Redis/DB backend yet. +2. **Sessions default to in-memory** — use `WithSessionStore(NewSQLiteSessionStore(path))` for persistence across restarts. 3. **PGP output is armored** — ~33% Base64 overhead. Consider compression for large payloads. 4. **Policy engine returns decisions but doesn't enforce approval workflow** — higher-level layer needed. 5. **Challenge nonces are 32 bytes** — 256-bit, cryptographically random. diff --git a/TODO.md b/TODO.md index 580f330..ff83df0 100644 --- a/TODO.md +++ b/TODO.md @@ -15,10 +15,10 @@ Dispatched from core/go orchestration. Pick up tasks in order. ## Phase 1: Session Persistence -- [ ] **Session storage interface** — Extract in-memory session map into `SessionStore` interface with `Get`, `Set`, `Delete`, `Cleanup` methods. -- [ ] **SQLite session store** — Implement `SessionStore` backed by go-store (SQLite KV). Migrate session tokens + expiry to persistent storage. -- [ ] **Background cleanup** — Goroutine to purge expired sessions periodically. Configurable interval. -- [ ] **Session migration** — Backward-compatible: in-memory as default, optional persistent store via config. +- [x] **Session storage interface** — Extracted in-memory session map into `SessionStore` interface with `Get`, `Set`, `Delete`, `DeleteByUser`, `Cleanup` methods. `MemorySessionStore` wraps the original map+mutex pattern. `ErrSessionNotFound` sentinel error. +- [x] **SQLite session store** — `SQLiteSessionStore` backed by go-store (SQLite KV). Sessions stored as JSON in `"sessions"` group. Mutex-serialised for SQLite single-writer safety. +- [x] **Background cleanup** — `StartCleanup(ctx, interval)` goroutine purges expired sessions periodically. Stops on context cancellation. +- [x] **Session migration** — Backward-compatible: `MemorySessionStore` is default, `WithSessionStore(store)` option for persistent store. All existing tests updated and passing. ## Phase 2: Key Management diff --git a/auth/auth.go b/auth/auth.go index 103ece3..cba5ca5 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -25,6 +25,7 @@ package auth import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -94,23 +95,34 @@ func WithSessionTTL(d time.Duration) Option { } } +// WithSessionStore sets the SessionStore implementation. +// If not provided, an in-memory store is used (sessions lost on restart). +func WithSessionStore(s SessionStore) Option { + return func(a *Authenticator) { + a.store = s + } +} + // Authenticator manages PGP-based challenge-response authentication. // All user data and keys are persisted through an io.Medium, which may // be backed by disk, memory (MockMedium), or any other storage backend. +// Sessions are persisted via a SessionStore (in-memory by default, +// optionally SQLite-backed for crash recovery). type Authenticator struct { medium io.Medium - sessions map[string]*Session + store SessionStore challenges map[string]*Challenge // userID -> pending challenge - mu sync.RWMutex + mu sync.RWMutex // protects challenges map only challengeTTL time.Duration sessionTTL time.Duration } // New creates an Authenticator that persists user data via the given Medium. +// By default, sessions are stored in memory. Use WithSessionStore to provide +// a persistent implementation (e.g. SQLiteSessionStore). func New(m io.Medium, opts ...Option) *Authenticator { a := &Authenticator{ medium: m, - sessions: make(map[string]*Session), challenges: make(map[string]*Challenge), challengeTTL: DefaultChallengeTTL, sessionTTL: DefaultSessionTTL, @@ -118,6 +130,10 @@ func New(m io.Medium, opts ...Option) *Authenticator { for _, opt := range opts { opt(a) } + // Default to in-memory store if none provided via WithSessionStore + if a.store == nil { + a.store = NewMemorySessionStore() + } return a } @@ -278,18 +294,13 @@ func (a *Authenticator) ValidateResponse(userID string, signedNonce []byte) (*Se func (a *Authenticator) ValidateSession(token string) (*Session, error) { const op = "auth.ValidateSession" - a.mu.RLock() - session, exists := a.sessions[token] - a.mu.RUnlock() - - if !exists { + session, err := a.store.Get(token) + if err != nil { return nil, coreerr.E(op, "session not found", nil) } if time.Now().After(session.ExpiresAt) { - a.mu.Lock() - delete(a.sessions, token) - a.mu.Unlock() + _ = a.store.Delete(token) return nil, coreerr.E(op, "session expired", nil) } @@ -300,20 +311,20 @@ func (a *Authenticator) ValidateSession(token string) (*Session, error) { func (a *Authenticator) RefreshSession(token string) (*Session, error) { const op = "auth.RefreshSession" - a.mu.Lock() - defer a.mu.Unlock() - - session, exists := a.sessions[token] - if !exists { + session, err := a.store.Get(token) + if err != nil { return nil, coreerr.E(op, "session not found", nil) } if time.Now().After(session.ExpiresAt) { - delete(a.sessions, token) + _ = a.store.Delete(token) return nil, coreerr.E(op, "session expired", nil) } session.ExpiresAt = time.Now().Add(a.sessionTTL) + if err := a.store.Set(session); err != nil { + return nil, coreerr.E(op, "failed to update session", err) + } return session, nil } @@ -321,14 +332,9 @@ func (a *Authenticator) RefreshSession(token string) (*Session, error) { func (a *Authenticator) RevokeSession(token string) error { const op = "auth.RevokeSession" - a.mu.Lock() - defer a.mu.Unlock() - - if _, exists := a.sessions[token]; !exists { + if err := a.store.Delete(token); err != nil { return coreerr.E(op, "session not found", nil) } - - delete(a.sessions, token) return nil } @@ -360,13 +366,7 @@ func (a *Authenticator) DeleteUser(userID string) error { } // Revoke any active sessions for this user - a.mu.Lock() - for token, session := range a.sessions { - if session.UserID == userID { - delete(a.sessions, token) - } - } - a.mu.Unlock() + _ = a.store.DeleteByUser(userID) return nil } @@ -434,7 +434,7 @@ func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error) } // createSession generates a cryptographically random session token and -// stores the session in the in-memory session map. +// stores the session via the SessionStore. func (a *Authenticator) createSession(userID string) (*Session, error) { tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { @@ -447,9 +447,34 @@ func (a *Authenticator) createSession(userID string) (*Session, error) { ExpiresAt: time.Now().Add(a.sessionTTL), } - a.mu.Lock() - a.sessions[session.Token] = session - a.mu.Unlock() + if err := a.store.Set(session); err != nil { + return nil, fmt.Errorf("auth: failed to persist session: %w", err) + } return session, nil } + +// StartCleanup runs a background goroutine that periodically removes expired +// sessions from the store. It stops when the context is cancelled. +func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + count, err := a.store.Cleanup() + if err != nil { + fmt.Printf("auth: session cleanup error: %v\n", err) + continue + } + if count > 0 { + fmt.Printf("auth: cleaned up %d expired session(s)\n", count) + } + } + } + }() +} diff --git a/auth/auth_test.go b/auth/auth_test.go index 64dab79..afd39dc 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -315,7 +315,7 @@ func TestDeleteUser_Good(t *testing.T) { userID := lthn.Hash("larry") // Also create a session that should be cleaned up - _, err = a.Login(userID, "pass") + session, err := a.Login(userID, "pass") require.NoError(t, err) err = a.DeleteUser(userID) @@ -328,16 +328,10 @@ func TestDeleteUser_Good(t *testing.T) { assert.False(t, m.IsFile(userPath(userID, ".json"))) assert.False(t, m.IsFile(userPath(userID, ".lthn"))) - // Session should be gone - a.mu.RLock() - sessionCount := 0 - for _, s := range a.sessions { - if s.UserID == userID { - sessionCount++ - } - } - a.mu.RUnlock() - assert.Equal(t, 0, sessionCount) + // Session should be gone (validate returns error) + _, err = a.ValidateSession(session.Token) + assert.Error(t, err) + assert.Contains(t, err.Error(), "session not found") } func TestDeleteUser_Bad(t *testing.T) { diff --git a/auth/session_store.go b/auth/session_store.go new file mode 100644 index 0000000..955f3fc --- /dev/null +++ b/auth/session_store.go @@ -0,0 +1,100 @@ +package auth + +import ( + "errors" + "sync" + "time" +) + +// ErrSessionNotFound is returned when a session token is not found. +var ErrSessionNotFound = errors.New("auth: session not found") + +// SessionStore abstracts session persistence. +type SessionStore interface { + Get(token string) (*Session, error) + Set(session *Session) error + Delete(token string) error + DeleteByUser(userID string) error + Cleanup() (int, error) // Remove expired sessions, return count removed +} + +// MemorySessionStore is an in-memory SessionStore backed by a map. +type MemorySessionStore struct { + mu sync.RWMutex + sessions map[string]*Session +} + +// NewMemorySessionStore creates a new in-memory session store. +func NewMemorySessionStore() *MemorySessionStore { + return &MemorySessionStore{ + sessions: make(map[string]*Session), + } +} + +// Get retrieves a session by token. +func (m *MemorySessionStore) Get(token string) (*Session, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + session, exists := m.sessions[token] + if !exists { + return nil, ErrSessionNotFound + } + + // Return a copy to prevent mutation outside the lock + s := *session + return &s, nil +} + +// Set stores a session, keyed by its token. +func (m *MemorySessionStore) Set(session *Session) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Store a copy to prevent external mutation + s := *session + m.sessions[session.Token] = &s + return nil +} + +// Delete removes a session by token. +func (m *MemorySessionStore) Delete(token string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.sessions[token]; !exists { + return ErrSessionNotFound + } + + delete(m.sessions, token) + return nil +} + +// DeleteByUser removes all sessions belonging to the given user. +func (m *MemorySessionStore) DeleteByUser(userID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for token, session := range m.sessions { + if session.UserID == userID { + delete(m.sessions, token) + } + } + return nil +} + +// Cleanup removes all expired sessions and returns the count removed. +func (m *MemorySessionStore) Cleanup() (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now() + count := 0 + for token, session := range m.sessions { + if now.After(session.ExpiresAt) { + delete(m.sessions, token) + count++ + } + } + return count, nil +} diff --git a/auth/session_store_sqlite.go b/auth/session_store_sqlite.go new file mode 100644 index 0000000..843ae58 --- /dev/null +++ b/auth/session_store_sqlite.go @@ -0,0 +1,136 @@ +package auth + +import ( + "encoding/json" + "errors" + "sync" + "time" + + "forge.lthn.ai/core/go-store" +) + +const sessionGroup = "sessions" + +// SQLiteSessionStore is a SessionStore backed by go-store (SQLite KV). +// A mutex serialises all operations because SQLite is single-writer. +type SQLiteSessionStore struct { + mu sync.Mutex + store *store.Store +} + +// NewSQLiteSessionStore creates a new SQLite-backed session store. +// Use ":memory:" for testing or a file path for persistent storage. +func NewSQLiteSessionStore(dbPath string) (*SQLiteSessionStore, error) { + s, err := store.New(dbPath) + if err != nil { + return nil, err + } + return &SQLiteSessionStore{store: s}, nil +} + +// Get retrieves a session by token from SQLite. +func (s *SQLiteSessionStore) Get(token string) (*Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + + val, err := s.store.Get(sessionGroup, token) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, ErrSessionNotFound + } + return nil, err + } + + var session Session + if err := json.Unmarshal([]byte(val), &session); err != nil { + return nil, err + } + return &session, nil +} + +// Set stores a session in SQLite, keyed by its token. +func (s *SQLiteSessionStore) Set(session *Session) error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := json.Marshal(session) + if err != nil { + return err + } + return s.store.Set(sessionGroup, session.Token, string(data)) +} + +// Delete removes a session by token from SQLite. +func (s *SQLiteSessionStore) Delete(token string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Check existence first to return ErrSessionNotFound + _, err := s.store.Get(sessionGroup, token) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return ErrSessionNotFound + } + return err + } + return s.store.Delete(sessionGroup, token) +} + +// DeleteByUser removes all sessions belonging to the given user. +func (s *SQLiteSessionStore) DeleteByUser(userID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + all, err := s.store.GetAll(sessionGroup) + if err != nil { + return err + } + + for token, val := range all { + var session Session + if err := json.Unmarshal([]byte(val), &session); err != nil { + continue // Skip malformed entries + } + if session.UserID == userID { + if err := s.store.Delete(sessionGroup, token); err != nil { + return err + } + } + } + return nil +} + +// Cleanup removes all expired sessions and returns the count removed. +func (s *SQLiteSessionStore) Cleanup() (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + all, err := s.store.GetAll(sessionGroup) + if err != nil { + return 0, err + } + + now := time.Now() + count := 0 + for token, val := range all { + var session Session + if err := json.Unmarshal([]byte(val), &session); err != nil { + continue // Skip malformed entries + } + if now.After(session.ExpiresAt) { + if err := s.store.Delete(sessionGroup, token); err != nil { + return count, err + } + count++ + } + } + return count, nil +} + +// Close closes the underlying SQLite store. +func (s *SQLiteSessionStore) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + return s.store.Close() +} diff --git a/auth/session_store_test.go b/auth/session_store_test.go new file mode 100644 index 0000000..1276d6f --- /dev/null +++ b/auth/session_store_test.go @@ -0,0 +1,503 @@ +package auth + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "forge.lthn.ai/core/go-crypt/crypt/lthn" + "forge.lthn.ai/core/go/pkg/io" +) + +// --- MemorySessionStore --- + +func TestMemorySessionStore_GetSetDelete_Good(t *testing.T) { + store := NewMemorySessionStore() + + session := &Session{ + Token: "test-token-abc", + UserID: "user-123", + ExpiresAt: time.Now().Add(1 * time.Hour), + } + + // Set + err := store.Set(session) + require.NoError(t, err) + + // Get + got, err := store.Get("test-token-abc") + require.NoError(t, err) + assert.Equal(t, session.Token, got.Token) + assert.Equal(t, session.UserID, got.UserID) + + // Delete + err = store.Delete("test-token-abc") + require.NoError(t, err) + + // Get after delete should fail + _, err = store.Get("test-token-abc") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestMemorySessionStore_GetNotFound_Bad(t *testing.T) { + store := NewMemorySessionStore() + + _, err := store.Get("nonexistent-token") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestMemorySessionStore_DeleteNotFound_Bad(t *testing.T) { + store := NewMemorySessionStore() + + err := store.Delete("nonexistent-token") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestMemorySessionStore_DeleteByUser_Good(t *testing.T) { + store := NewMemorySessionStore() + + // Create sessions for two users + for i := 0; i < 3; i++ { + err := store.Set(&Session{ + Token: fmt.Sprintf("user-a-token-%d", i), + UserID: "user-a", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + require.NoError(t, err) + } + + err := store.Set(&Session{ + Token: "user-b-token", + UserID: "user-b", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + require.NoError(t, err) + + // Delete all user-a sessions + err = store.DeleteByUser("user-a") + require.NoError(t, err) + + // user-a sessions should be gone + for i := 0; i < 3; i++ { + _, err := store.Get(fmt.Sprintf("user-a-token-%d", i)) + assert.ErrorIs(t, err, ErrSessionNotFound) + } + + // user-b session should remain + got, err := store.Get("user-b-token") + require.NoError(t, err) + assert.Equal(t, "user-b", got.UserID) +} + +func TestMemorySessionStore_Cleanup_Good(t *testing.T) { + store := NewMemorySessionStore() + + // Create expired and valid sessions + err := store.Set(&Session{ + Token: "expired-1", + UserID: "user", + ExpiresAt: time.Now().Add(-1 * time.Hour), + }) + require.NoError(t, err) + + err = store.Set(&Session{ + Token: "expired-2", + UserID: "user", + ExpiresAt: time.Now().Add(-30 * time.Minute), + }) + require.NoError(t, err) + + err = store.Set(&Session{ + Token: "valid-1", + UserID: "user", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + require.NoError(t, err) + + count, err := store.Cleanup() + require.NoError(t, err) + assert.Equal(t, 2, count) + + // Valid session should remain + _, err = store.Get("valid-1") + assert.NoError(t, err) + + // Expired sessions should be gone + _, err = store.Get("expired-1") + assert.ErrorIs(t, err, ErrSessionNotFound) + _, err = store.Get("expired-2") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestMemorySessionStore_Concurrent_Good(t *testing.T) { + store := NewMemorySessionStore() + + const n = 20 + var wg sync.WaitGroup + wg.Add(n) + + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + token := fmt.Sprintf("concurrent-token-%d", idx) + + err := store.Set(&Session{ + Token: token, + UserID: fmt.Sprintf("user-%d", idx%5), + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + assert.NoError(t, err) + + got, err := store.Get(token) + assert.NoError(t, err) + assert.Equal(t, token, got.Token) + }(i) + } + + wg.Wait() +} + +// --- SQLiteSessionStore --- + +func TestSQLiteSessionStore_GetSetDelete_Good(t *testing.T) { + store, err := NewSQLiteSessionStore(":memory:") + require.NoError(t, err) + defer store.Close() + + session := &Session{ + Token: "sqlite-token-abc", + UserID: "user-456", + ExpiresAt: time.Now().Add(1 * time.Hour), + } + + // Set + err = store.Set(session) + require.NoError(t, err) + + // Get + got, err := store.Get("sqlite-token-abc") + require.NoError(t, err) + assert.Equal(t, session.Token, got.Token) + assert.Equal(t, session.UserID, got.UserID) + + // Delete + err = store.Delete("sqlite-token-abc") + require.NoError(t, err) + + // Get after delete should fail + _, err = store.Get("sqlite-token-abc") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestSQLiteSessionStore_GetNotFound_Bad(t *testing.T) { + store, err := NewSQLiteSessionStore(":memory:") + require.NoError(t, err) + defer store.Close() + + _, err = store.Get("nonexistent-token") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestSQLiteSessionStore_DeleteNotFound_Bad(t *testing.T) { + store, err := NewSQLiteSessionStore(":memory:") + require.NoError(t, err) + defer store.Close() + + err = store.Delete("nonexistent-token") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestSQLiteSessionStore_DeleteByUser_Good(t *testing.T) { + store, err := NewSQLiteSessionStore(":memory:") + require.NoError(t, err) + defer store.Close() + + // Create sessions for two users + for i := 0; i < 3; i++ { + err := store.Set(&Session{ + Token: fmt.Sprintf("sqlite-user-a-%d", i), + UserID: "user-a", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + require.NoError(t, err) + } + + err = store.Set(&Session{ + Token: "sqlite-user-b", + UserID: "user-b", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + require.NoError(t, err) + + // Delete all user-a sessions + err = store.DeleteByUser("user-a") + require.NoError(t, err) + + // user-a sessions should be gone + for i := 0; i < 3; i++ { + _, err := store.Get(fmt.Sprintf("sqlite-user-a-%d", i)) + assert.ErrorIs(t, err, ErrSessionNotFound) + } + + // user-b session should remain + got, err := store.Get("sqlite-user-b") + require.NoError(t, err) + assert.Equal(t, "user-b", got.UserID) +} + +func TestSQLiteSessionStore_Cleanup_Good(t *testing.T) { + store, err := NewSQLiteSessionStore(":memory:") + require.NoError(t, err) + defer store.Close() + + // Create expired and valid sessions + err = store.Set(&Session{ + Token: "sqlite-expired-1", + UserID: "user", + ExpiresAt: time.Now().Add(-1 * time.Hour), + }) + require.NoError(t, err) + + err = store.Set(&Session{ + Token: "sqlite-expired-2", + UserID: "user", + ExpiresAt: time.Now().Add(-30 * time.Minute), + }) + require.NoError(t, err) + + err = store.Set(&Session{ + Token: "sqlite-valid-1", + UserID: "user", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + require.NoError(t, err) + + count, err := store.Cleanup() + require.NoError(t, err) + assert.Equal(t, 2, count) + + // Valid session should remain + _, err = store.Get("sqlite-valid-1") + assert.NoError(t, err) + + // Expired sessions should be gone + _, err = store.Get("sqlite-expired-1") + assert.ErrorIs(t, err, ErrSessionNotFound) + _, err = store.Get("sqlite-expired-2") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestSQLiteSessionStore_Persistence_Good(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "sessions.db") + + // Write a session + store1, err := NewSQLiteSessionStore(dbPath) + require.NoError(t, err) + + session := &Session{ + Token: "persist-token", + UserID: "persist-user", + ExpiresAt: time.Now().Add(1 * time.Hour), + } + err = store1.Set(session) + require.NoError(t, err) + + // Close the store + err = store1.Close() + require.NoError(t, err) + + // Reopen and verify data persists + store2, err := NewSQLiteSessionStore(dbPath) + require.NoError(t, err) + defer store2.Close() + + got, err := store2.Get("persist-token") + require.NoError(t, err) + assert.Equal(t, "persist-user", got.UserID) + assert.Equal(t, "persist-token", got.Token) +} + +func TestSQLiteSessionStore_Concurrent_Good(t *testing.T) { + // Use a temp file — :memory: SQLite has concurrency limitations + dbPath := filepath.Join(t.TempDir(), "concurrent.db") + store, err := NewSQLiteSessionStore(dbPath) + require.NoError(t, err) + defer store.Close() + + const n = 20 + var wg sync.WaitGroup + wg.Add(n) + + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + token := fmt.Sprintf("sqlite-concurrent-%d", idx) + + err := store.Set(&Session{ + Token: token, + UserID: fmt.Sprintf("user-%d", idx%5), + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + assert.NoError(t, err) + + got, err := store.Get(token) + assert.NoError(t, err) + if got != nil { + assert.Equal(t, token, got.Token) + } + }(i) + } + + wg.Wait() +} + +// --- Authenticator with SessionStore --- + +func TestAuthenticator_WithSessionStore_Good(t *testing.T) { + sqliteStore, err := NewSQLiteSessionStore(":memory:") + require.NoError(t, err) + defer sqliteStore.Close() + + m := io.NewMockMedium() + a := New(m, WithSessionStore(sqliteStore)) + + // Register user + _, err = a.Register("store-test-user", "pass") + require.NoError(t, err) + userID := lthn.Hash("store-test-user") + + // Login creates session in SQLite store + session, err := a.Login(userID, "pass") + require.NoError(t, err) + require.NotNil(t, session) + + // Validate session from store + validated, err := a.ValidateSession(session.Token) + require.NoError(t, err) + assert.Equal(t, session.Token, validated.Token) + assert.Equal(t, userID, validated.UserID) + + // Refresh session + refreshed, err := a.RefreshSession(session.Token) + require.NoError(t, err) + assert.Equal(t, session.Token, refreshed.Token) + + // Revoke session + err = a.RevokeSession(session.Token) + require.NoError(t, err) + + // Session should be gone + _, err = a.ValidateSession(session.Token) + assert.Error(t, err) + assert.Contains(t, err.Error(), "session not found") +} + +func TestAuthenticator_DefaultStore_Good(t *testing.T) { + m := io.NewMockMedium() + a := New(m) + + // Default store should be MemorySessionStore + _, ok := a.store.(*MemorySessionStore) + assert.True(t, ok, "default store should be MemorySessionStore") +} + +func TestAuthenticator_StartCleanup_Good(t *testing.T) { + m := io.NewMockMedium() + a := New(m, WithSessionTTL(1*time.Millisecond)) + + // Register and login to create a session + _, err := a.Register("cleanup-test", "pass") + require.NoError(t, err) + userID := lthn.Hash("cleanup-test") + + session, err := a.Login(userID, "pass") + require.NoError(t, err) + + // Wait for session to expire + time.Sleep(5 * time.Millisecond) + + // Start cleanup with a short interval + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + a.StartCleanup(ctx, 10*time.Millisecond) + + // Wait for at least one cleanup cycle + time.Sleep(50 * time.Millisecond) + + // Session should have been cleaned up + _, err = a.ValidateSession(session.Token) + assert.Error(t, err) + assert.Contains(t, err.Error(), "session not found") +} + +func TestAuthenticator_StartCleanup_CancelStops_Good(t *testing.T) { + m := io.NewMockMedium() + a := New(m) + + ctx, cancel := context.WithCancel(context.Background()) + a.StartCleanup(ctx, 10*time.Millisecond) + + // Cancel should stop the goroutine without panic + cancel() + time.Sleep(50 * time.Millisecond) +} + +func TestSQLiteSessionStore_UpdateExisting_Good(t *testing.T) { + store, err := NewSQLiteSessionStore(":memory:") + require.NoError(t, err) + defer store.Close() + + original := &Session{ + Token: "update-token", + UserID: "user-1", + ExpiresAt: time.Now().Add(1 * time.Hour), + } + err = store.Set(original) + require.NoError(t, err) + + // Update with new expiry + updated := &Session{ + Token: "update-token", + UserID: "user-1", + ExpiresAt: time.Now().Add(2 * time.Hour), + } + err = store.Set(updated) + require.NoError(t, err) + + got, err := store.Get("update-token") + require.NoError(t, err) + assert.True(t, got.ExpiresAt.After(original.ExpiresAt), + "updated session should have later expiry") +} + +func TestSQLiteSessionStore_TempFile_Good(t *testing.T) { + // Verify we can use a real temp file (not :memory:) + tmpFile := filepath.Join(os.TempDir(), "go-crypt-test-session-store.db") + defer os.Remove(tmpFile) + + store, err := NewSQLiteSessionStore(tmpFile) + require.NoError(t, err) + + err = store.Set(&Session{ + Token: "temp-file-token", + UserID: "user", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + require.NoError(t, err) + + got, err := store.Get("temp-file-token") + require.NoError(t, err) + assert.Equal(t, "temp-file-token", got.Token) + + err = store.Close() + require.NoError(t, err) +} diff --git a/go.mod b/go.mod index fd2bcf4..b927e7e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.5 require ( forge.lthn.ai/core/go v0.0.0 + forge.lthn.ai/core/go-store v0.0.0 github.com/ProtonMail/go-crypto v1.3.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.48.0 @@ -12,9 +13,23 @@ require ( require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/sys v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect ) -replace forge.lthn.ai/core/go => ../go +replace ( + forge.lthn.ai/core/go => ../go + forge.lthn.ai/core/go-store => ../go-store +) diff --git a/go.sum b/go.sum index ecc0c9a..3908cda 100644 --- a/go.sum +++ b/go.sum @@ -2,17 +2,78 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI= +modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=