feat(auth): add SessionStore interface with SQLite persistence

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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 01:44:51 +00:00
parent 9331fc6eac
commit 1aeabfd32b
9 changed files with 892 additions and 53 deletions

View file

@ -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.

View file

@ -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

View file

@ -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)
}
}
}
}()
}

View file

@ -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) {

100
auth/session_store.go Normal file
View file

@ -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
}

View file

@ -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()
}

503
auth/session_store_test.go Normal file
View file

@ -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)
}

17
go.mod
View file

@ -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
)

63
go.sum
View file

@ -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=