feat: add env-driven configuration

This commit is contained in:
Ruidy 2025-09-20 14:45:07 +02:00
parent 38b8fa49dc
commit bc06a30299
No known key found for this signature in database
GPG key ID: 705C24D202990805
6 changed files with 175 additions and 28 deletions

View file

@ -24,7 +24,7 @@ Trust `gofmt`; avoid manual formatting. Use CamelCase for exported Go identifier
## Testing Guidelines
Adopt Gos `testing` package with table-driven cases. Name files `<feature>_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 Gos `testing` package with table-driven cases. Name files `<feature>_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 dont enforce a target, but avoid notable drops when adding code.
## Commit & Pull Request Guidelines

View file

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

58
internal/config/config.go Normal file
View file

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

View file

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

View file

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

View file

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