Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: CI

on:
push:
branches: [ main, "feat/**", "feature/**", "fix/**" ]
pull_request:
branches: [ main ]

env:
GO_VERSION: "1.21"

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true

- name: Run go vet
run: go vet ./...

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true

- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...

- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.out
continue-on-error: true

build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true

- name: Build binary
run: go build -v ./cmd/auth-server/...
156 changes: 53 additions & 103 deletions cmd/auth-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,39 +25,30 @@ import (
)

func main() {
// Load environment variables (optional, for backward compatibility)
if err := godotenv.Load(); err != nil {
logrus.Warn("No .env file found, using environment variables")
}

// Initialize configuration from YAML file
cfg, err := config.Load()
if err != nil {
logrus.Fatalf("Failed to load configuration: %v", err)
}

// Setup logging
logger := setupLogging(cfg)

logger.Info("Starting Authorization Server...")

// Initialize root context for background workers (cancelled on shutdown)
rootCtx, cancelRoot := context.WithCancel(context.Background())
defer cancelRoot()

// Initialize database
logger.Info("Connecting to database...")
db, err := database.Initialize(cfg.DatabaseURL)
if err != nil {
logger.Fatalf("Failed to initialize database: %v", err)
}

// Run database migrations
if err := database.Migrate(db); err != nil {
logger.Fatalf("Failed to run database migrations: %v", err)
}

// Initialize Redis (optional)
var redisClient *database.RedisClient
if cfg.RedisURL != "" {
redisClient, err = database.InitializeRedis(cfg.RedisURL)
Expand All @@ -69,69 +60,38 @@ func main() {
}
}

// Initialize repositories
repos := gormrepo.NewRepositories(db)

// Initialize services
auditService := services.NewAuditService(repos.AuditLog, logger)
emailService := services.NewEmailService(
repos.EmailTemplate,
repos.EmailQueue,
repos.EmailVerification,
repos.PasswordReset,
repos.User,
auditService,
cfg,
logger,
)
tenantService := services.NewTenantService(repos, logger)
userService := services.NewUserService(repos, logger)
clientService := services.NewClientService(repos, logger)
authService := services.NewAuthService(repos, cfg, logger)
mfaService := services.NewMFAService(repos, logger)
sessionService := services.NewSessionService(repos, logger)

// Start background email queue processor
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()

for {
select {
case <-rootCtx.Done():
logger.Info("Email queue processor shutting down")
return
case <-ticker.C:
if err := emailService.ProcessQueue(rootCtx); err != nil {
logger.WithError(err).Error("Failed to process email queue")
}
}
}
}()
// Background workers
go runSessionCleanup(rootCtx, sessionService, logger)

// Setup Gin router
if cfg.GinMode != "" {
gin.SetMode(cfg.GinMode)
}

router := gin.New()

// Add middleware
router.Use(gin.Logger())
router.Use(gin.Recovery())
router.Use(middleware.CORS(cfg))
router.Use(middleware.RateLimit(cfg))
router.Use(middleware.TenantContext(cfg))
router.Use(middleware.RequestID())

// Initialize handlers
tenantHandler := handlers.NewTenantHandler(tenantService, logger)
userHandler := handlers.NewUserHandler(userService, logger)
clientHandler := handlers.NewClientHandler(clientService, logger)
oauthHandler := handlers.NewOAuthHandler(tenantService, userService, clientService, authService, logger)
mfaHandler := handlers.NewMFAHandler(mfaService, logger)
sessionHandler := handlers.NewSessionHandler(sessionService, logger)

// Setup routes
setupRoutes(cfg, db, redisClient, router, tenantHandler, userHandler, clientHandler, oauthHandler)
setupRoutes(cfg, db, redisClient, router, tenantHandler, userHandler, clientHandler, oauthHandler, mfaHandler, sessionHandler)

// Create HTTP server
server := &http.Server{
Addr: fmt.Sprintf(":%s", cfg.Port),
Handler: router,
Expand All @@ -140,59 +100,57 @@ func main() {
IdleTimeout: 60 * time.Second,
}

// Start server in a goroutine
go func() {
logger.Infof("Server starting on port %s", cfg.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Failed to start server: %v", err)
}
}()

// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

logger.Info("Shutting down server...")

// Cancel background workers
cancelRoot()

// Give outstanding requests 30 seconds to complete
ctx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()

if err := server.Shutdown(ctx); err != nil {
logger.Fatalf("Server forced to shutdown: %v", err)
}

logger.Info("Server exited")
}

func setupLogging(cfg *config.Config) *logrus.Logger {
logger := logrus.New()

// Set log level
level, err := logrus.ParseLevel(cfg.LogLevel)
if err != nil {
level = logrus.InfoLevel
}
logger.SetLevel(level)

// Set log format
if cfg.LogFormat == "json" {
logger.SetFormatter(&logrus.JSONFormatter{
TimestampFormat: time.RFC3339,
})
logger.SetFormatter(&logrus.JSONFormatter{TimestampFormat: time.RFC3339})
} else {
logger.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
})
logger.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
}

return logger
}

func runSessionCleanup(ctx context.Context, svc services.SessionService, logger *logrus.Logger) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := svc.CleanupExpired(ctx); err != nil {
logger.WithError(err).Error("session cleanup failed")
}
}
}
}

func setupRoutes(
cfg *config.Config,
db *gorm.DB,
Expand All @@ -202,74 +160,66 @@ func setupRoutes(
userHandler *handlers.UserHandler,
clientHandler *handlers.ClientHandler,
oauthHandler *handlers.OAuthHandler,
mfaHandler *handlers.MFAHandler,
sessionHandler *handlers.SessionHandler,
) {
// Health check endpoint
// Health check
router.GET("/health", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()

status := "ok"
dbStatus := "ok"
redisStatus := "ok"
status, dbStatus, redisStatus := "ok", "ok", "ok"
httpStatus := http.StatusOK

if db != nil {
sqlDB, err := db.DB()
if err != nil || sqlDB.PingContext(ctx) != nil {
dbStatus = "error"
status = "degraded"
httpStatus = http.StatusServiceUnavailable
dbStatus = "error"; status = "degraded"; httpStatus = http.StatusServiceUnavailable
}
} else {
dbStatus = "disabled"
}

if redisClient == nil {
redisStatus = "disabled"
} else {
if err := redisClient.Ping(ctx); err != nil {
redisStatus = "error"
status = "degraded"
httpStatus = http.StatusServiceUnavailable
}
} else if err := redisClient.Ping(ctx); err != nil {
redisStatus = "error"; status = "degraded"; httpStatus = http.StatusServiceUnavailable
}

c.JSON(httpStatus, gin.H{
"status": status,
"db": dbStatus,
"redis": redisStatus,
"timestamp": time.Now().UTC(),
"version": "1.0.0",
"status": status, "db": dbStatus, "redis": redisStatus,
"timestamp": time.Now().UTC(), "version": "1.0.0",
"environment": cfg.GinMode,
})
})

// OAuth 2.0 and OpenID Connect endpoints (no versioning per spec)
// OAuth 2.0 / OIDC endpoints
oauthHandler.RegisterRoutes(router.Group(""))

// Management API endpoints (versioned)
// Management API (authenticated)
bf := middleware.BruteForceProtection(middleware.BruteForceConfig{
MaxFailures: 5,
Window: 15 * time.Minute,
})

api := router.Group("/v1")
api.Use(middleware.RequireAuth(cfg)) // Require authentication for management APIs
api.Use(middleware.RequireAuth(cfg))
{
// Tenant management
tenantHandler.RegisterRoutes(api.Group("/tenants"))

// User management
userHandler.RegisterRoutes(api.Group("/users"))

// Client management
clientHandler.RegisterRoutes(api.Group("/clients"))
mfaHandler.RegisterRoutes(api.Group("/mfa"))
sessionHandler.RegisterRoutes(api.Group("/sessions"))
}

// Serve static files and templates
router.Static("/static", "./static")
// Auth endpoints get brute-force protection (applied before RequireAuth)
auth := router.Group("/v1/auth")
auth.Use(bf)
{
auth.POST("/login", func(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"message": "use /oauth/token"})
})
}

// Add custom template functions
router.Static("/static", "./static")
router.SetFuncMap(template.FuncMap{
"contains": func(s, substr string) bool {
return strings.Contains(s, substr)
},
"contains": strings.Contains,
})

router.LoadHTMLGlob("templates/*")
}
Loading
Loading