feat: migrate auth0mock from Node.js to Go
Refactor the application to a Go-based architecture for improved performance and maintainability. Replace the Dockerfile to utilize a multi-stage build process, enhancing image efficiency. Implement comprehensive session store tests to ensure reliability and create new OAuth handlers for managing authentication efficiently. Update documentation to reflect these structural changes.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionTTL is the time-to-live for sessions
|
||||
SessionTTL = 5 * time.Minute
|
||||
// CleanupInterval is how often expired sessions are cleaned up
|
||||
CleanupInterval = 60 * time.Second
|
||||
)
|
||||
|
||||
// Session represents an OAuth session
|
||||
type Session struct {
|
||||
Email string
|
||||
Password string
|
||||
State string
|
||||
Nonce string
|
||||
ClientID string
|
||||
CodeChallenge string
|
||||
CodeVerifier string
|
||||
CustomClaims []map[string]interface{}
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SessionStore provides thread-safe session storage with TTL
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session
|
||||
challenges map[string]string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store
|
||||
func NewSessionStore(logger *slog.Logger) *SessionStore {
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]*Session),
|
||||
challenges: make(map[string]string),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Create stores a new session
|
||||
func (s *SessionStore) Create(code string, session *Session) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
session.CreatedAt = time.Now()
|
||||
s.sessions[code] = session
|
||||
s.challenges[code] = code
|
||||
}
|
||||
|
||||
// Get retrieves a session by code
|
||||
func (s *SessionStore) Get(code string) (*Session, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
session, ok := s.sessions[code]
|
||||
return session, ok
|
||||
}
|
||||
|
||||
// Update updates an existing session and optionally re-indexes it
|
||||
func (s *SessionStore) Update(oldCode, newCode string, updateFn func(*Session)) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
session, ok := s.sessions[oldCode]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
updateFn(session)
|
||||
session.CreatedAt = time.Now() // Refresh timestamp
|
||||
|
||||
if oldCode != newCode {
|
||||
s.sessions[newCode] = session
|
||||
s.challenges[newCode] = newCode
|
||||
delete(s.sessions, oldCode)
|
||||
delete(s.challenges, oldCode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Delete removes a session
|
||||
func (s *SessionStore) Delete(code string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.sessions, code)
|
||||
delete(s.challenges, code)
|
||||
}
|
||||
|
||||
// Cleanup removes expired sessions
|
||||
func (s *SessionStore) Cleanup() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cleaned := 0
|
||||
|
||||
for code, session := range s.sessions {
|
||||
if now.Sub(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, code)
|
||||
delete(s.challenges, code)
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// StartCleanup starts a background goroutine to clean up expired sessions
|
||||
func (s *SessionStore) StartCleanup(ctx context.Context) {
|
||||
ticker := time.NewTicker(CleanupInterval)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if cleaned := s.Cleanup(); cleaned > 0 {
|
||||
s.logger.Info("cleaned up expired sessions", "count", cleaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionStore_CreateAndGet(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
session := &Session{
|
||||
Email: "test@example.com",
|
||||
ClientID: "client-123",
|
||||
CodeChallenge: "challenge-abc",
|
||||
}
|
||||
|
||||
store.Create("code-123", session)
|
||||
|
||||
retrieved, ok := store.Get("code-123")
|
||||
if !ok {
|
||||
t.Fatal("expected to find session")
|
||||
}
|
||||
|
||||
if retrieved.Email != "test@example.com" {
|
||||
t.Errorf("expected email test@example.com, got %s", retrieved.Email)
|
||||
}
|
||||
|
||||
if retrieved.CreatedAt.IsZero() {
|
||||
t.Error("expected CreatedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Delete(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
session := &Session{Email: "test@example.com"}
|
||||
store.Create("code-123", session)
|
||||
|
||||
store.Delete("code-123")
|
||||
|
||||
_, ok := store.Get("code-123")
|
||||
if ok {
|
||||
t.Error("expected session to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Update(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
session := &Session{
|
||||
Email: "test@example.com",
|
||||
Nonce: "old-nonce",
|
||||
}
|
||||
store.Create("old-code", session)
|
||||
|
||||
// Update and re-index
|
||||
ok := store.Update("old-code", "new-code", func(s *Session) {
|
||||
s.Nonce = "new-nonce"
|
||||
})
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected update to succeed")
|
||||
}
|
||||
|
||||
// Old code should not exist
|
||||
_, ok = store.Get("old-code")
|
||||
if ok {
|
||||
t.Error("expected old code to be removed")
|
||||
}
|
||||
|
||||
// New code should exist
|
||||
retrieved, ok := store.Get("new-code")
|
||||
if !ok {
|
||||
t.Fatal("expected to find session with new code")
|
||||
}
|
||||
|
||||
if retrieved.Nonce != "new-nonce" {
|
||||
t.Errorf("expected nonce new-nonce, got %s", retrieved.Nonce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_UpdateSameCode(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
session := &Session{
|
||||
Email: "test@example.com",
|
||||
Nonce: "old-nonce",
|
||||
}
|
||||
store.Create("code-123", session)
|
||||
|
||||
originalTime := session.CreatedAt
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Update without re-indexing
|
||||
store.Update("code-123", "code-123", func(s *Session) {
|
||||
s.Nonce = "new-nonce"
|
||||
})
|
||||
|
||||
retrieved, _ := store.Get("code-123")
|
||||
if retrieved.Nonce != "new-nonce" {
|
||||
t.Errorf("expected nonce new-nonce, got %s", retrieved.Nonce)
|
||||
}
|
||||
|
||||
// CreatedAt should be refreshed
|
||||
if !retrieved.CreatedAt.After(originalTime) {
|
||||
t.Error("expected CreatedAt to be refreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_UpdateNotFound(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
ok := store.Update("nonexistent", "new-code", func(s *Session) {})
|
||||
if ok {
|
||||
t.Error("expected update to fail for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Cleanup(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
// Create an expired session
|
||||
session := &Session{Email: "test@example.com"}
|
||||
store.Create("code-123", session)
|
||||
|
||||
// Manually set CreatedAt to expired time
|
||||
store.mu.Lock()
|
||||
store.sessions["code-123"].CreatedAt = time.Now().Add(-10 * time.Minute)
|
||||
store.mu.Unlock()
|
||||
|
||||
// Create a valid session
|
||||
validSession := &Session{Email: "valid@example.com"}
|
||||
store.Create("code-456", validSession)
|
||||
|
||||
// Run cleanup
|
||||
cleaned := store.Cleanup()
|
||||
|
||||
if cleaned != 1 {
|
||||
t.Errorf("expected 1 session cleaned, got %d", cleaned)
|
||||
}
|
||||
|
||||
// Expired session should be gone
|
||||
_, ok := store.Get("code-123")
|
||||
if ok {
|
||||
t.Error("expected expired session to be cleaned up")
|
||||
}
|
||||
|
||||
// Valid session should still exist
|
||||
_, ok = store.Get("code-456")
|
||||
if !ok {
|
||||
t.Error("expected valid session to still exist")
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
Email string `json:"email"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
UserID string `json:"user_id"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
}
|
||||
|
||||
// UserStore provides thread-safe user storage
|
||||
type UserStore struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]*User
|
||||
}
|
||||
|
||||
// NewUserStore creates a new user store
|
||||
func NewUserStore() *UserStore {
|
||||
return &UserStore{
|
||||
users: make(map[string]*User),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromFile loads users from a JSON file
|
||||
func (s *UserStore) LoadFromFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // File doesn't exist, start with empty store
|
||||
}
|
||||
return fmt.Errorf("read users file: %w", err)
|
||||
}
|
||||
|
||||
var rawUsers map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &rawUsers); err != nil {
|
||||
return fmt.Errorf("parse users file: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for email, raw := range rawUsers {
|
||||
var user User
|
||||
if err := json.Unmarshal(raw, &user); err != nil {
|
||||
return fmt.Errorf("parse user %s: %w", email, err)
|
||||
}
|
||||
user.Email = email // Ensure email is set
|
||||
if user.UserID == "" {
|
||||
user.UserID = email
|
||||
}
|
||||
s.users[email] = &user
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by email
|
||||
func (s *UserStore) GetByEmail(email string) (*User, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
user, ok := s.users[email]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Return a copy to prevent external modification
|
||||
userCopy := *user
|
||||
return &userCopy, true
|
||||
}
|
||||
|
||||
// Create adds a new user
|
||||
func (s *UserStore) Create(email string, user *User) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
user.Email = email
|
||||
if user.UserID == "" {
|
||||
user.UserID = email
|
||||
}
|
||||
s.users[email] = user
|
||||
}
|
||||
|
||||
// Update modifies an existing user
|
||||
func (s *UserStore) Update(email string, updates *User) (*User, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
existing, ok := s.users[email]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Apply updates (only non-empty fields)
|
||||
if updates.GivenName != "" {
|
||||
existing.GivenName = updates.GivenName
|
||||
}
|
||||
if updates.FamilyName != "" {
|
||||
existing.FamilyName = updates.FamilyName
|
||||
}
|
||||
if updates.Picture != "" {
|
||||
existing.Picture = updates.Picture
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
userCopy := *existing
|
||||
return &userCopy, true
|
||||
}
|
||||
|
||||
// List returns all users
|
||||
func (s *UserStore) List() []*User {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
users := make([]*User, 0, len(s.users))
|
||||
for _, user := range s.users {
|
||||
userCopy := *user
|
||||
users = append(users, &userCopy)
|
||||
}
|
||||
return users
|
||||
}
|
||||
Reference in New Issue
Block a user