diff --git a/AGENTS.md b/AGENTS.md index d3312d8..b6e2b1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Trust `gofmt`; avoid manual formatting. Use CamelCase for exported Go identifier ## Testing Guidelines -Adopt Go’s `testing` package with table-driven cases. Name files `_test.go`, colocated with the code under test. Run `go test ./...` (and `go test -cover ./...` for major features) before opening a PR, ensuring new branches maintain or raise coverage. +Adopt Go’s `testing` package with table-driven cases. Name files `_test.go`, colocated with the code under test. Run `go test ./... -cover -count=1` before opening a PR so coverage is measured on fresh binaries; we don’t enforce a target, but avoid notable drops when adding code. ## Commit & Pull Request Guidelines diff --git a/cmd/server/main.go b/cmd/server/main.go index 7e6c4f8..e9ceeb9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,29 +6,37 @@ import ( "net/http" "os" + "github.com/rjnemo/auth/internal/config" "github.com/rjnemo/auth/internal/logging" "github.com/rjnemo/auth/internal/server" - "gorm.io/gorm/logger" ) -const listenAddr = ":8000" - func main() { - if err := run(logger); err != nil { + baseLogger := logging.New(os.Stdout, logging.ModeText, &slog.HandlerOptions{AddSource: true}) + + cfg, err := config.New() + if err != nil { + baseLogger.Error("configuration error", slog.Any("error", err)) + os.Exit(1) + } + + logger := logging.New(os.Stdout, cfg.LogMode, &slog.HandlerOptions{AddSource: cfg.Environment == "development"}) + logger = logger.With(slog.String("env", cfg.Environment)) + + if err := run(cfg, logger); err != nil { logger.Error("server exited", slog.Any("error", err)) os.Exit(1) } } -func run() error { - logger := logging.New(os.Stdout, logging.ModeText, &slog.HandlerOptions{AddSource: true}) - srv, err := server.New(logger) +func run(cfg *config.Config, logger *slog.Logger) error { + srv, err := server.New(*cfg, logger) if err != nil { return fmt.Errorf("initialise server: %w", err) } - logger.Info("starting server", slog.String("addr", listenAddr)) - if err := http.ListenAndServe(listenAddr, srv.Router()); err != nil { + logger.Info("starting server", slog.String("addr", fmt.Sprintf("http://localhost%s", cfg.ListenAddr))) + if err := http.ListenAndServe(cfg.ListenAddr, srv.Router()); err != nil { return fmt.Errorf("listen: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4110158 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,58 @@ +package config + +import ( + "cmp" + "encoding/base64" + "fmt" + "os" + "strings" + + "github.com/rjnemo/auth/internal/logging" +) + +const ( + envListenAddr = "AUTH_LISTEN_ADDR" + envLogMode = "AUTH_LOG_MODE" + envEnvironment = "AUTH_ENV" + envSessionSecret = "AUTH_SESSION_SECRET" + + defaultListenAddr = ":8000" + defaultEnvironment = "development" +) + +// Config holds application configuration derived from environment variables. +type Config struct { + ListenAddr string + LogMode logging.Mode + Environment string + SessionSecret []byte +} + +// New loads configuration from environment variables, applying defaults and validation. +func New() (*Config, error) { + listenAddr := cmp.Or(strings.TrimSpace(os.Getenv(envListenAddr)), defaultListenAddr) + environment := cmp.Or(strings.TrimSpace(os.Getenv(envEnvironment)), defaultEnvironment) + logMode := logging.ModeText + if rawMode := strings.TrimSpace(os.Getenv(envLogMode)); rawMode != "" { + logMode = logging.ParseMode(rawMode) + } + + secretRaw, ok := os.LookupEnv(envSessionSecret) + if !ok || strings.TrimSpace(secretRaw) == "" { + return nil, fmt.Errorf("missing required configuration: set %s to a base64-encoded secret", envSessionSecret) + } + + secret, err := base64.StdEncoding.DecodeString(secretRaw) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", envSessionSecret, err) + } + + cfg := &Config{ + ListenAddr: listenAddr, + LogMode: logMode, + Environment: environment, + SessionSecret: secret, + } + + return cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..89c7fdf --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "encoding/base64" + "testing" + + "github.com/rjnemo/auth/internal/logging" +) + +func TestNewDefaults(t *testing.T) { + t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(32))) + cfg, err := New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.ListenAddr != ":8000" { + t.Fatalf("expected default listen addr, got %s", cfg.ListenAddr) + } + if cfg.LogMode != logging.ModeText { + t.Fatalf("expected default log mode text, got %s", cfg.LogMode) + } + if cfg.Environment != "development" { + t.Fatalf("expected default environment, got %s", cfg.Environment) + } + if got := len(cfg.SessionSecret); got != 32 { + t.Fatalf("expected secret length 32, got %d", got) + } +} + +func TestNewOverrides(t *testing.T) { + secret := base64.StdEncoding.EncodeToString(bytesOfLength(40)) + t.Setenv("AUTH_SESSION_SECRET", secret) + t.Setenv("AUTH_LISTEN_ADDR", "127.0.0.1:9000") + t.Setenv("AUTH_LOG_MODE", "json") + t.Setenv("AUTH_ENV", "production") + + cfg, err := New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.ListenAddr != "127.0.0.1:9000" { + t.Fatalf("expected overridden listen addr, got %s", cfg.ListenAddr) + } + if cfg.LogMode != logging.ModeJSON { + t.Fatalf("expected json mode, got %s", cfg.LogMode) + } + if cfg.Environment != "production" { + t.Fatalf("expected environment production, got %s", cfg.Environment) + } + if len(cfg.SessionSecret) != 40 { + t.Fatalf("expected secret length 40, got %d", len(cfg.SessionSecret)) + } +} + +func TestNewMissingSecret(t *testing.T) { + t.Setenv("AUTH_SESSION_SECRET", "") + if _, err := New(); err == nil { + t.Fatalf("expected error for missing secret") + } +} + +func TestNewInvalidSecret(t *testing.T) { + t.Setenv("AUTH_SESSION_SECRET", "not-base64") + if _, err := New(); err == nil { + t.Fatalf("expected error for invalid secret") + } +} + +func TestNewShortSecretAccepted(t *testing.T) { + t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(16))) + if _, err := New(); err != nil { + t.Fatalf("expected short secret to pass config load, got %v", err) + } +} + +func bytesOfLength(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i % 255) + } + return b +} diff --git a/internal/server/server.go b/internal/server/server.go index 78c22b7..4074c2d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,13 +2,13 @@ package server import ( "context" - "crypto/rand" "fmt" "html/template" "io" "log/slog" "time" + "github.com/rjnemo/auth/internal/config" "github.com/rjnemo/auth/internal/logging" "github.com/rjnemo/auth/internal/service/auth" "github.com/rjnemo/auth/web" @@ -21,14 +21,15 @@ const ( // Server holds HTTP dependencies for the application. type Server struct { - templates *template.Template - authService *auth.Service - sessions *SessionStore - logger *slog.Logger + templates *template.Template + authService *auth.Service + sessions *SessionStore + logger *slog.Logger + configuration config.Config } // New constructs a Server with parsed templates and default state. -func New(logger *slog.Logger) (*Server, error) { +func New(cfg config.Config, logger *slog.Logger) (*Server, error) { tmpl, err := template.ParseFS( web.Templates, "templates/login.html", @@ -45,12 +46,7 @@ func New(logger *slog.Logger) (*Server, error) { return nil, fmt.Errorf("seed user: %w", err) } - secret := make([]byte, 32) - if _, err := rand.Read(secret); err != nil { - return nil, fmt.Errorf("session secret: %w", err) - } - - sessionStore, err := NewSessionStore(secret) + sessionStore, err := NewSessionStore(cfg.SessionSecret) if err != nil { return nil, fmt.Errorf("session store: %w", err) } @@ -61,10 +57,11 @@ func New(logger *slog.Logger) (*Server, error) { logger = logger.With(slog.String("service", "http")) return &Server{ - templates: tmpl, - authService: auth.NewService(store), - sessions: sessionStore, - logger: logger, + templates: tmpl, + authService: auth.NewService(store), + sessions: sessionStore, + logger: logger, + configuration: cfg, }, nil } diff --git a/internal/service/auth/password.go b/internal/service/auth/password.go index 1b626d3..0d08732 100644 --- a/internal/service/auth/password.go +++ b/internal/service/auth/password.go @@ -12,8 +12,10 @@ import ( "unicode/utf8" ) -const saltLen = 32 -const passwordMinLength = 8 +const ( + saltLen = 32 + passwordMinLength = 8 +) var ErrWeakPassword = errors.New("auth: password does not meet complexity requirements")