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:
2025-12-29 16:30:37 +01:00
parent 96453e1d15
commit 9992fb4ef1
25 changed files with 1976 additions and 1991 deletions
+133
View File
@@ -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)
}
}
}
}()
}
+161
View File
@@ -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
View File
@@ -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
}