diff --git a/cmd/root_cmd.go b/cmd/root_cmd.go index bd2749523..e8783d463 100644 --- a/cmd/root_cmd.go +++ b/cmd/root_cmd.go @@ -9,7 +9,10 @@ import ( "github.com/supabase/auth/internal/observability" ) -var configFile = "" +var ( + configFile = "" + watchDir = "" +) var rootCmd = cobra.Command{ Use: "gotrue", @@ -22,8 +25,8 @@ var rootCmd = cobra.Command{ // RootCommand will setup and return the root command func RootCommand() *cobra.Command { rootCmd.AddCommand(&serveCmd, &migrateCmd, &versionCmd, adminCmd()) - rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "the config file to use") - + rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "base configuration file to load") + rootCmd.PersistentFlags().StringVarP(&watchDir, "config-dir", "d", "", "directory containing a sorted list of config files to watch for changes") return &rootCmd } diff --git a/cmd/serve_cmd.go b/cmd/serve_cmd.go index 8423509ae..5a0745a2e 100644 --- a/cmd/serve_cmd.go +++ b/cmd/serve_cmd.go @@ -3,11 +3,16 @@ package cmd import ( "context" "net" + "net/http" + "sync" + "time" + "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/supabase/auth/internal/api" "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/reloader" "github.com/supabase/auth/internal/storage" "github.com/supabase/auth/internal/utilities" ) @@ -21,7 +26,15 @@ var serveCmd = cobra.Command{ } func serve(ctx context.Context) { - config, err := conf.LoadGlobal(configFile) + if err := conf.LoadFile(configFile); err != nil { + logrus.WithError(err).Fatal("unable to load config") + } + + if err := conf.LoadDirectory(watchDir); err != nil { + logrus.WithError(err).Fatal("unable to load config from watch dir") + } + + config, err := conf.LoadGlobalFromEnv() if err != nil { logrus.WithError(err).Fatal("unable to load config") } @@ -32,10 +45,63 @@ func serve(ctx context.Context) { } defer db.Close() - api := api.NewAPIWithVersion(config, db, utilities.Version) - addr := net.JoinHostPort(config.API.Host, config.API.Port) logrus.Infof("GoTrue API started on: %s", addr) - api.ListenAndServe(ctx, addr) + a := api.NewAPIWithVersion(config, db, utilities.Version) + ah := reloader.NewAtomicHandler(a) + + baseCtx, baseCancel := context.WithCancel(context.Background()) + defer baseCancel() + + httpSrv := &http.Server{ + Addr: addr, + Handler: ah, + ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack + BaseContext: func(net.Listener) context.Context { + return baseCtx + }, + } + log := logrus.WithField("component", "api") + + var wg sync.WaitGroup + defer wg.Wait() // Do not return to caller until this goroutine is done. + + if watchDir != "" { + wg.Add(1) + go func() { + defer wg.Done() + + fn := func(latestCfg *conf.GlobalConfiguration) { + log.Info("reloading api with new configuration") + latestAPI := api.NewAPIWithVersion(latestCfg, db, utilities.Version) + ah.Store(latestAPI) + } + + rl := reloader.NewReloader(watchDir) + if err := rl.Watch(ctx, fn); err != nil { + log.WithError(err).Error("watcher is exiting") + } + }() + } + + wg.Add(1) + go func() { + defer wg.Done() + + <-ctx.Done() + + defer baseCancel() // close baseContext + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Minute) + defer shutdownCancel() + + if err := httpSrv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) { + log.WithError(err).Error("shutdown failed") + } + }() + + if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed { + log.WithError(err).Fatal("http server listen failed") + } } diff --git a/go.mod b/go.mod index 3e4af443d..4ef25e611 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( require ( github.com/bits-and-blooms/bitset v1.10.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/gobuffalo/nulls v0.4.2 // indirect github.com/goccy/go-json v0.10.3 // indirect diff --git a/go.sum b/go.sum index b30c1baaf..73f647b56 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= diff --git a/internal/api/api.go b/internal/api/api.go index 49b810696..054167136 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -357,3 +357,9 @@ func (a *API) Mailer() mailer.Mailer { config := a.config return mailer.NewMailer(config) } + +// ServeHTTP implements the http.Handler interface by passing the request along +// to its underlying Handler. +func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.handler.ServeHTTP(w, r) +} diff --git a/internal/api/cleanup.go b/internal/api/cleanup.go deleted file mode 100644 index aebe0ddd4..000000000 --- a/internal/api/cleanup.go +++ /dev/null @@ -1,18 +0,0 @@ -package api - -import ( - "context" - "sync" - - "github.com/supabase/auth/internal/utilities" -) - -var ( - cleanupWaitGroup sync.WaitGroup -) - -// WaitForCleanup waits until all API servers are shut down cleanly or until -// the provided context signals done, whichever comes first. -func WaitForCleanup(ctx context.Context) { - utilities.WaitForCleanup(ctx, &cleanupWaitGroup) -} diff --git a/internal/api/listener.go b/internal/api/listener.go deleted file mode 100644 index 5cf5598e3..000000000 --- a/internal/api/listener.go +++ /dev/null @@ -1,47 +0,0 @@ -package api - -import ( - "context" - "errors" - "net" - "net/http" - "time" - - "github.com/sirupsen/logrus" -) - -// ListenAndServe starts the REST API -func (a *API) ListenAndServe(ctx context.Context, hostAndPort string) { - baseCtx, cancel := context.WithCancel(context.Background()) - - log := logrus.WithField("component", "api") - - server := &http.Server{ - Addr: hostAndPort, - Handler: a.handler, - ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack - BaseContext: func(net.Listener) context.Context { - return baseCtx - }, - } - - cleanupWaitGroup.Add(1) - go func() { - defer cleanupWaitGroup.Done() - - <-ctx.Done() - - defer cancel() // close baseContext - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Minute) - defer shutdownCancel() - - if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) { - log.WithError(err).Error("shutdown failed") - } - }() - - if err := server.ListenAndServe(); err != http.ErrServerClosed { - log.WithError(err).Fatal("http server listen failed") - } -} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 792061a1a..f96f27413 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -7,6 +7,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "regexp" "strings" "text/template" @@ -647,59 +648,140 @@ func (e *ExtensibilityPointConfiguration) PopulateExtensibilityPoint() error { return nil } +// LoadFile calls godotenv.Load() when the given filename is empty ignoring any +// errors loading, otherwise it calls godotenv.Overload(filename). +// +// godotenv.Load: preserves env, ".env" path is optional +// godotenv.Overload: overrides env, "filename" path must exist +func LoadFile(filename string) error { + var err error + if filename != "" { + err = godotenv.Overload(filename) + } else { + err = godotenv.Load() + // handle if .env file does not exist, this is OK + if os.IsNotExist(err) { + return nil + } + } + return err +} + +// LoadDirectory does nothing when configDir is empty, otherwise it will attempt +// to load a list of configuration files located in configDir by using ReadDir +// to obtain a sorted list of files containing a .env suffix. +// +// When the list is empty it will do nothing, otherwise it passes the file list +// to godotenv.Overload to pull them into the current environment. +func LoadDirectory(configDir string) error { + if configDir == "" { + return nil + } + + // Returns entries sorted by filename + ents, err := os.ReadDir(configDir) + if err != nil { + // We mimic the behavior of LoadGlobal here, if an explicit path is + // provided we return an error. + return err + } + + var paths []string + for _, ent := range ents { + if ent.IsDir() { + continue // ignore directories + } + + // We only read files ending in .env + name := ent.Name() + if !strings.HasSuffix(name, ".env") { + continue + } + + // ent.Name() does not include the watch dir. + paths = append(paths, filepath.Join(configDir, name)) + } + + // If at least one path was found we load the configuration files in the + // directory. We don't call override without config files because it will + // override the env vars previously set with a ".env", if one exists. + if len(paths) > 0 { + if err := godotenv.Overload(paths...); err != nil { + return err + } + } + return nil +} + +// LoadGlobalFromEnv will return a new *GlobalConfiguration value from the +// currently configured environment. +func LoadGlobalFromEnv() (*GlobalConfiguration, error) { + config := new(GlobalConfiguration) + if err := loadGlobal(config); err != nil { + return nil, err + } + return config, nil +} + func LoadGlobal(filename string) (*GlobalConfiguration, error) { if err := loadEnvironment(filename); err != nil { return nil, err } config := new(GlobalConfiguration) + if err := loadGlobal(config); err != nil { + return nil, err + } + return config, nil +} +func loadGlobal(config *GlobalConfiguration) error { // although the package is called "auth" it used to be called "gotrue" // so environment configs will remain to be called "GOTRUE" if err := envconfig.Process("gotrue", config); err != nil { - return nil, err + return err } if err := config.ApplyDefaults(); err != nil { - return nil, err + return err } if err := config.Validate(); err != nil { - return nil, err + return err } if config.Hook.PasswordVerificationAttempt.Enabled { if err := config.Hook.PasswordVerificationAttempt.PopulateExtensibilityPoint(); err != nil { - return nil, err + return err } } if config.Hook.SendSMS.Enabled { if err := config.Hook.SendSMS.PopulateExtensibilityPoint(); err != nil { - return nil, err + return err } } if config.Hook.SendEmail.Enabled { if err := config.Hook.SendEmail.PopulateExtensibilityPoint(); err != nil { - return nil, err + return err } } if config.Hook.MFAVerificationAttempt.Enabled { if err := config.Hook.MFAVerificationAttempt.PopulateExtensibilityPoint(); err != nil { - return nil, err + return err } } if config.Hook.CustomAccessToken.Enabled { if err := config.Hook.CustomAccessToken.PopulateExtensibilityPoint(); err != nil { - return nil, err + return err } } if config.SAML.Enabled { if err := config.SAML.PopulateFields(config.API.ExternalURL); err != nil { - return nil, err + return err } } else { config.SAML.PrivateKey = "" @@ -712,7 +794,7 @@ func LoadGlobal(filename string) (*GlobalConfiguration, error) { } template, err := template.New("").Parse(SMSTemplate) if err != nil { - return nil, err + return err } config.Sms.SMSTemplate = template } @@ -724,12 +806,12 @@ func LoadGlobal(filename string) (*GlobalConfiguration, error) { } template, err := template.New("").Parse(smsTemplate) if err != nil { - return nil, err + return err } config.MFA.Phone.SMSTemplate = template } - return config, nil + return nil } // ApplyDefaults sets defaults for a GlobalConfiguration diff --git a/internal/reloader/handler.go b/internal/reloader/handler.go new file mode 100644 index 000000000..bdd15ca88 --- /dev/null +++ b/internal/reloader/handler.go @@ -0,0 +1,42 @@ +package reloader + +import ( + "net/http" + "sync/atomic" +) + +// AtomicHandler provides an atomic http.Handler implementation, allowing safe +// handler replacement at runtime. AtomicHandler must be initialized with a call +// to NewAtomicHandler. It will never panic and is safe for concurrent use. +type AtomicHandler struct { + val atomic.Value +} + +// atomicHandlerValue is the value stored within an atomicHandler. +type atomicHandlerValue struct{ http.Handler } + +// NewAtomicHandler creates a new AtomicHandler ready for use. +func NewAtomicHandler(h http.Handler) *AtomicHandler { + ah := new(AtomicHandler) + ah.Store(h) + return ah +} + +// String implements fmt.Stringer by returning a string literal. +func (ah *AtomicHandler) String() string { return "reloader.AtomicHandler" } + +// Store will update this http.Handler to serve future requests using h. +func (ah *AtomicHandler) Store(h http.Handler) { + ah.val.Store(&atomicHandlerValue{h}) +} + +// load will return the underlying http.Handler used to serve requests. +func (ah *AtomicHandler) load() http.Handler { + return ah.val.Load().(*atomicHandlerValue).Handler +} + +// ServeHTTP implements the standard libraries http.Handler interface by +// atomically passing the request along to the most recently stored handler. +func (ah *AtomicHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ah.load().ServeHTTP(w, r) +} diff --git a/internal/reloader/handler_race_test.go b/internal/reloader/handler_race_test.go new file mode 100644 index 000000000..4d7b5e0d6 --- /dev/null +++ b/internal/reloader/handler_race_test.go @@ -0,0 +1,64 @@ +//go:build race +// +build race + +package reloader + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAtomicHandlerRaces(t *testing.T) { + type testHandler struct{ http.Handler } + + hrFn := func() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + } + + const count = 8 + hrFuncMap := make(map[http.Handler]struct{}, count) + for i := 0; i < count; i++ { + hrFuncMap[&testHandler{hrFn()}] = struct{}{} + } + + hr := NewAtomicHandler(nil) + assert.NotNil(t, hr) + + var wg sync.WaitGroup + defer wg.Wait() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second/4) + defer cancel() + + // We create 8 goroutines reading & writing to the handler concurrently. If + // a race condition occurs the test will fail and halt. + for i := 0; i < count; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + for hrFunc := range hrFuncMap { + select { + case <-ctx.Done(): + default: + } + + hr.Store(hrFunc) + + got := hr.load() + _, ok := hrFuncMap[got] + if !ok { + // This will trigger a race failure / exit test + t.Fatal("unknown handler returned from load()") + return + } + } + }() + } + wg.Wait() +} diff --git a/internal/reloader/handler_test.go b/internal/reloader/handler_test.go new file mode 100644 index 000000000..182c52671 --- /dev/null +++ b/internal/reloader/handler_test.go @@ -0,0 +1,46 @@ +package reloader + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAtomicHandler(t *testing.T) { + // for ptr identity + type testHandler struct{ http.Handler } + + hrFn := func() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + } + + hrFunc1 := &testHandler{hrFn()} + hrFunc2 := &testHandler{hrFn()} + assert.NotEqual(t, hrFunc1, hrFunc2) + + // a new AtomicHandler should be non-nil + hr := NewAtomicHandler(nil) + assert.NotNil(t, hr) + + // should have no stored handler + { + hrCur := hr.load() + assert.Nil(t, hrCur) + assert.Equal(t, true, hrCur == nil) + } + + // should be non-nil after store + for i := 0; i < 3; i++ { + hr.Store(hrFunc1) + assert.NotNil(t, hr.load()) + assert.Equal(t, hr.load(), hrFunc1) + assert.Equal(t, hr.load() == hrFunc1, true) + + // should update to hrFunc2 + hr.Store(hrFunc2) + assert.NotNil(t, hr.load()) + assert.Equal(t, hr.load(), hrFunc2) + assert.Equal(t, hr.load() == hrFunc2, true) + } +} diff --git a/internal/reloader/reloader.go b/internal/reloader/reloader.go new file mode 100644 index 000000000..2b2b55e91 --- /dev/null +++ b/internal/reloader/reloader.go @@ -0,0 +1,141 @@ +// Package reloader provides support for live configuration reloading. +package reloader + +import ( + "context" + "log" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/sirupsen/logrus" + "github.com/supabase/auth/internal/conf" +) + +const ( + // reloadInterval is the interval between configuration reloading. At most + // one configuration change may be made between this duration. + reloadInterval = time.Second * 10 + + // tickerInterval is the maximum latency between configuration reloads. + tickerInterval = reloadInterval / 10 +) + +type ConfigFunc func(*conf.GlobalConfiguration) + +type Reloader struct { + watchDir string + reloadIval time.Duration + tickerIval time.Duration +} + +func NewReloader(watchDir string) *Reloader { + return &Reloader{ + watchDir: watchDir, + reloadIval: reloadInterval, + tickerIval: tickerInterval, + } +} + +// reload attempts to create a new *conf.GlobalConfiguration after loading the +// currently configured watchDir. +func (rl *Reloader) reload() (*conf.GlobalConfiguration, error) { + if err := conf.LoadDirectory(rl.watchDir); err != nil { + return nil, err + } + + cfg, err := conf.LoadGlobalFromEnv() + if err != nil { + return nil, err + } + return cfg, nil +} + +// reloadCheckAt checks if reloadConfig should be called, returns true if config +// should be reloaded or false otherwise. +func (rl *Reloader) reloadCheckAt(at, lastUpdate time.Time) bool { + if lastUpdate.IsZero() { + return false // no pending updates + } + if at.Sub(lastUpdate) < rl.reloadIval { + return false // waiting for reload interval + } + + // Update is pending. + return true +} + +func (rl *Reloader) Watch(ctx context.Context, fn ConfigFunc) error { + wr, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + defer wr.Close() + + tr := time.NewTicker(rl.tickerIval) + defer tr.Stop() + + // Ignore errors, if watch dir doesn't exist we can add it later. + if err := wr.Add(rl.watchDir); err != nil { + logrus.WithError(err).Error("watch dir failed") + } + + var lastUpdate time.Time + for { + select { + case <-ctx.Done(): + return ctx.Err() + + case <-tr.C: + // This is a simple way to solve watch dir being added later or + // being moved and then recreated. I've tested all of these basic + // scenarios and wr.WatchList() does not grow which aligns with + // the documented behavior. + if err := wr.Add(rl.watchDir); err != nil { + logrus.WithError(err).Error("watch dir failed") + } + + // Check to see if the config is ready to be relaoded. + if !rl.reloadCheckAt(time.Now(), lastUpdate) { + continue + } + + // Reset the last update time before we try to reload the config. + lastUpdate = time.Time{} + + cfg, err := rl.reload() + if err != nil { + logrus.WithError(err).Error("config reload failed") + continue + } + + // Call the callback function with the latest cfg. + fn(cfg) + + case evt, ok := <-wr.Events: + if !ok { + logrus.WithError(err).Error("fsnotify has exited") + return nil + } + + // We only read files ending in .env + if !strings.HasSuffix(evt.Name, ".env") { + continue + } + + switch { + case evt.Op.Has(fsnotify.Create), + evt.Op.Has(fsnotify.Remove), + evt.Op.Has(fsnotify.Rename), + evt.Op.Has(fsnotify.Write): + lastUpdate = time.Now() + } + case err, ok := <-wr.Errors: + if !ok { + logrus.Error("fsnotify has exited") + return nil + } + logrus.WithError(err).Error("fsnotify has reported an error") + } + } +} diff --git a/internal/reloader/reloader_test.go b/internal/reloader/reloader_test.go new file mode 100644 index 000000000..ec8e04b23 --- /dev/null +++ b/internal/reloader/reloader_test.go @@ -0,0 +1,173 @@ +package reloader + +import ( + "bytes" + "log" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestReloadConfig(t *testing.T) { + dir, cleanup := helpTestDir(t) + defer cleanup() + + rl := NewReloader(dir) + + // Copy the full and valid example configuration. + helpCopyEnvFile(t, dir, "01_example.env", "testdata/50_example.env") + { + cfg, err := rl.reload() + if err != nil { + t.Fatal(err) + } + assert.NotNil(t, cfg) + assert.Equal(t, cfg.External.Apple.Enabled, false) + } + + helpWriteEnvFile(t, dir, "02_example.env", map[string]string{ + "GOTRUE_EXTERNAL_APPLE_ENABLED": "true", + }) + { + cfg, err := rl.reload() + if err != nil { + t.Fatal(err) + } + assert.NotNil(t, cfg) + assert.Equal(t, cfg.External.Apple.Enabled, true) + } + + helpWriteEnvFile(t, dir, "03_example.env.bak", map[string]string{ + "GOTRUE_EXTERNAL_APPLE_ENABLED": "false", + }) + { + cfg, err := rl.reload() + if err != nil { + t.Fatal(err) + } + assert.NotNil(t, cfg) + assert.Equal(t, cfg.External.Apple.Enabled, true) + } +} + +func TestReloadCheckAt(t *testing.T) { + const s10 = time.Second * 10 + + now := time.Now() + tests := []struct { + rl *Reloader + at, lastUpdate time.Time + exp bool + }{ + // no lastUpdate is set (time.IsZero()) + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + exp: false, + }, + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + exp: false, + }, + + // last update within reload interval + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + lastUpdate: now.Add(-s10 + 1), + exp: false, + }, + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + lastUpdate: now, + exp: false, + }, + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + lastUpdate: now.Add(s10 - 1), + exp: false, + }, + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + lastUpdate: now.Add(s10), + exp: false, + }, + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + lastUpdate: now.Add(s10 + 1), + exp: false, + }, + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + lastUpdate: now.Add(s10 * 2), + exp: false, + }, + + // last update was outside our reload interval + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + lastUpdate: now.Add(-s10), + exp: true, + }, + { + rl: &Reloader{reloadIval: s10, tickerIval: s10 / 10}, + at: now, + lastUpdate: now.Add(-s10 - 1), + exp: true, + }, + } + for _, tc := range tests { + rl := tc.rl + assert.NotNil(t, rl) + assert.Equal(t, rl.reloadCheckAt(tc.at, tc.lastUpdate), tc.exp) + } +} + +func helpTestDir(t testing.TB) (dir string, cleanup func()) { + dir = filepath.Join("testdata", t.Name()) + err := os.MkdirAll(dir, 0750) + if err != nil && !os.IsExist(err) { + t.Fatal(err) + } + return dir, func() { os.RemoveAll(dir) } +} + +func helpCopyEnvFile(t testing.TB, dir, name, src string) string { + data, err := os.ReadFile(src) // #nosec G304 + if err != nil { + log.Fatal(err) + } + + dst := filepath.Join(dir, name) + err = os.WriteFile(dst, data, 0600) + if err != nil { + t.Fatal(err) + } + return dst +} + +func helpWriteEnvFile(t testing.TB, dir, name string, values map[string]string) string { + var buf bytes.Buffer + for k, v := range values { + buf.WriteString(k) + buf.WriteString("=") + buf.WriteString(v) + buf.WriteString("\n") + } + + dst := filepath.Join(dir, name) + err := os.WriteFile(dst, buf.Bytes(), 0600) + if err != nil { + t.Fatal(err) + } + return dst +} diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env new file mode 100644 index 000000000..1002d8be1 --- /dev/null +++ b/internal/reloader/testdata/50_example.env @@ -0,0 +1,235 @@ +# General Config +# NOTE: The service_role key is required as an authorization header for /admin endpoints + +GOTRUE_JWT_SECRET="CHANGE-THIS! VERY IMPORTANT!" +GOTRUE_JWT_EXP="3600" +GOTRUE_JWT_AUD="authenticated" +GOTRUE_JWT_DEFAULT_GROUP_NAME="authenticated" +GOTRUE_JWT_ADMIN_ROLES="supabase_admin,service_role" + +# Database & API connection details +GOTRUE_DB_DRIVER="postgres" +DB_NAMESPACE="auth" +DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres" +API_EXTERNAL_URL="http://localhost:9999" +GOTRUE_API_HOST="localhost" +PORT="9999" + +# SMTP config (generate credentials for signup to work) +GOTRUE_SMTP_HOST="" +GOTRUE_SMTP_PORT="587" +GOTRUE_SMTP_USER="" +GOTRUE_SMTP_MAX_FREQUENCY="5s" +GOTRUE_SMTP_PASS="" +GOTRUE_SMTP_ADMIN_EMAIL="" +GOTRUE_SMTP_SENDER_NAME="" + +# Mailer config +GOTRUE_MAILER_AUTOCONFIRM="true" +GOTRUE_MAILER_URLPATHS_CONFIRMATION="/verify" +GOTRUE_MAILER_URLPATHS_INVITE="/verify" +GOTRUE_MAILER_URLPATHS_RECOVERY="/verify" +GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE="/verify" +GOTRUE_MAILER_SUBJECTS_CONFIRMATION="Confirm Your Email" +GOTRUE_MAILER_SUBJECTS_RECOVERY="Reset Your Password" +GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link" +GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change" +GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited" +GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" + +# Custom mailer template config +GOTRUE_MAILER_TEMPLATES_INVITE="" +GOTRUE_MAILER_TEMPLATES_CONFIRMATION="" +GOTRUE_MAILER_TEMPLATES_RECOVERY="" +GOTRUE_MAILER_TEMPLATES_MAGIC_LINK="" +GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE="" + +# Signup config +GOTRUE_DISABLE_SIGNUP="false" +GOTRUE_SITE_URL="http://localhost:3000" +GOTRUE_EXTERNAL_EMAIL_ENABLED="true" +GOTRUE_EXTERNAL_PHONE_ENABLED="true" +GOTRUE_EXTERNAL_IOS_BUNDLE_ID="com.supabase.auth" + +# Whitelist redirect to URLs here, a comma separated list of URIs (e.g. "https://foo.example.com,https://*.foo.example.com,https://bar.example.com") +GOTRUE_URI_ALLOW_LIST="http://localhost:3000" + +# Apple OAuth config +GOTRUE_EXTERNAL_APPLE_ENABLED="false" +GOTRUE_EXTERNAL_APPLE_CLIENT_ID="" +GOTRUE_EXTERNAL_APPLE_SECRET="" +GOTRUE_EXTERNAL_APPLE_REDIRECT_URI="http://localhost:9999/callback" + +# Azure OAuth config +GOTRUE_EXTERNAL_AZURE_ENABLED="false" +GOTRUE_EXTERNAL_AZURE_CLIENT_ID="" +GOTRUE_EXTERNAL_AZURE_SECRET="" +GOTRUE_EXTERNAL_AZURE_REDIRECT_URI="https://localhost:9999/callback" + +# Bitbucket OAuth config +GOTRUE_EXTERNAL_BITBUCKET_ENABLED="false" +GOTRUE_EXTERNAL_BITBUCKET_CLIENT_ID="" +GOTRUE_EXTERNAL_BITBUCKET_SECRET="" +GOTRUE_EXTERNAL_BITBUCKET_REDIRECT_URI="http://localhost:9999/callback" + +# Discord OAuth config +GOTRUE_EXTERNAL_DISCORD_ENABLED="false" +GOTRUE_EXTERNAL_DISCORD_CLIENT_ID="" +GOTRUE_EXTERNAL_DISCORD_SECRET="" +GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI="https://localhost:9999/callback" + +# Facebook OAuth config +GOTRUE_EXTERNAL_FACEBOOK_ENABLED="false" +GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID="" +GOTRUE_EXTERNAL_FACEBOOK_SECRET="" +GOTRUE_EXTERNAL_FACEBOOK_REDIRECT_URI="https://localhost:9999/callback" + +# Figma OAuth config +GOTRUE_EXTERNAL_FIGMA_ENABLED="false" +GOTRUE_EXTERNAL_FIGMA_CLIENT_ID="" +GOTRUE_EXTERNAL_FIGMA_SECRET="" +GOTRUE_EXTERNAL_FIGMA_REDIRECT_URI="https://localhost:9999/callback" + +# Gitlab OAuth config +GOTRUE_EXTERNAL_GITLAB_ENABLED="false" +GOTRUE_EXTERNAL_GITLAB_CLIENT_ID="" +GOTRUE_EXTERNAL_GITLAB_SECRET="" +GOTRUE_EXTERNAL_GITLAB_REDIRECT_URI="http://localhost:9999/callback" + +# Google OAuth config +GOTRUE_EXTERNAL_GOOGLE_ENABLED="false" +GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID="" +GOTRUE_EXTERNAL_GOOGLE_SECRET="" +GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI="http://localhost:9999/callback" + +# Github OAuth config +GOTRUE_EXTERNAL_GITHUB_ENABLED="false" +GOTRUE_EXTERNAL_GITHUB_CLIENT_ID="" +GOTRUE_EXTERNAL_GITHUB_SECRET="" +GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI="http://localhost:9999/callback" + +# Kakao OAuth config +GOTRUE_EXTERNAL_KAKAO_ENABLED="false" +GOTRUE_EXTERNAL_KAKAO_CLIENT_ID="" +GOTRUE_EXTERNAL_KAKAO_SECRET="" +GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback" + +# Notion OAuth config +GOTRUE_EXTERNAL_NOTION_ENABLED="false" +GOTRUE_EXTERNAL_NOTION_CLIENT_ID="" +GOTRUE_EXTERNAL_NOTION_SECRET="" +GOTRUE_EXTERNAL_NOTION_REDIRECT_URI="https://localhost:9999/callback" + +# Twitter OAuth1 config +GOTRUE_EXTERNAL_TWITTER_ENABLED="false" +GOTRUE_EXTERNAL_TWITTER_CLIENT_ID="" +GOTRUE_EXTERNAL_TWITTER_SECRET="" +GOTRUE_EXTERNAL_TWITTER_REDIRECT_URI="http://localhost:9999/callback" + +# Twitch OAuth config +GOTRUE_EXTERNAL_TWITCH_ENABLED="false" +GOTRUE_EXTERNAL_TWITCH_CLIENT_ID="" +GOTRUE_EXTERNAL_TWITCH_SECRET="" +GOTRUE_EXTERNAL_TWITCH_REDIRECT_URI="http://localhost:9999/callback" + +# Spotify OAuth config +GOTRUE_EXTERNAL_SPOTIFY_ENABLED="false" +GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID="" +GOTRUE_EXTERNAL_SPOTIFY_SECRET="" +GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI="http://localhost:9999/callback" + +# Keycloak OAuth config +GOTRUE_EXTERNAL_KEYCLOAK_ENABLED="false" +GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID="" +GOTRUE_EXTERNAL_KEYCLOAK_SECRET="" +GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI="http://localhost:9999/callback" +GOTRUE_EXTERNAL_KEYCLOAK_URL="https://keycloak.example.com/auth/realms/myrealm" + +# Linkedin OAuth config +GOTRUE_EXTERNAL_LINKEDIN_ENABLED="true" +GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID="" +GOTRUE_EXTERNAL_LINKEDIN_SECRET="" + +# Slack OAuth config +GOTRUE_EXTERNAL_SLACK_ENABLED="false" +GOTRUE_EXTERNAL_SLACK_CLIENT_ID="" +GOTRUE_EXTERNAL_SLACK_SECRET="" +GOTRUE_EXTERNAL_SLACK_REDIRECT_URI="http://localhost:9999/callback" + +# WorkOS OAuth config +GOTRUE_EXTERNAL_WORKOS_ENABLED="true" +GOTRUE_EXTERNAL_WORKOS_CLIENT_ID="" +GOTRUE_EXTERNAL_WORKOS_SECRET="" +GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI="http://localhost:9999/callback" + +# Zoom OAuth config +GOTRUE_EXTERNAL_ZOOM_ENABLED="false" +GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" +GOTRUE_EXTERNAL_ZOOM_SECRET="" +GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" + +# Anonymous auth config +GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" + +# PKCE Config +GOTRUE_EXTERNAL_FLOW_STATE_EXPIRY_DURATION="300s" + +# Phone provider config +GOTRUE_SMS_AUTOCONFIRM="false" +GOTRUE_SMS_MAX_FREQUENCY="5s" +GOTRUE_SMS_OTP_EXP="6000" +GOTRUE_SMS_OTP_LENGTH="6" +GOTRUE_SMS_PROVIDER="twilio" +GOTRUE_SMS_TWILIO_ACCOUNT_SID="" +GOTRUE_SMS_TWILIO_AUTH_TOKEN="" +GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID="" +GOTRUE_SMS_TEMPLATE="This is from supabase. Your code is {{ .Code }} ." +GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY="" +GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR="" +GOTRUE_SMS_TEXTLOCAL_API_KEY="" +GOTRUE_SMS_TEXTLOCAL_SENDER="" +GOTRUE_SMS_VONAGE_API_KEY="" +GOTRUE_SMS_VONAGE_API_SECRET="" +GOTRUE_SMS_VONAGE_FROM="" + +# Captcha config +GOTRUE_SECURITY_CAPTCHA_ENABLED="false" +GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha" +GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000" +GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s" +GOTRUE_SESSION_KEY="" + +# SAML config +GOTRUE_EXTERNAL_SAML_ENABLED="true" +GOTRUE_EXTERNAL_SAML_METADATA_URL="" +GOTRUE_EXTERNAL_SAML_API_BASE="http://localhost:9999" +GOTRUE_EXTERNAL_SAML_NAME="auth0" +GOTRUE_EXTERNAL_SAML_SIGNING_CERT="" +GOTRUE_EXTERNAL_SAML_SIGNING_KEY="" + +# Additional Security config +GOTRUE_LOG_LEVEL="debug" +GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED="false" +GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL="0" +GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION="false" +GOTRUE_OPERATOR_TOKEN="unused-operator-token" +GOTRUE_RATE_LIMIT_HEADER="X-Forwarded-For" +GOTRUE_RATE_LIMIT_EMAIL_SENT="100" + +GOTRUE_MAX_VERIFIED_FACTORS=10 + +# Auth Hook Configuration +GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED=false +GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI="" +# Only for HTTPS Hooks +GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRET="" + +GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_ENABLED=false +GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_URI="" +# Only for HTTPS Hooks +GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_SECRET="" + + +# Test OTP Config +GOTRUE_SMS_TEST_OTP=":, :..." +GOTRUE_SMS_TEST_OTP_VALID_UNTIL="2050-01-01T01:00:00Z" # (e.g. 2023-09-29T08:14:06Z) diff --git a/main.go b/main.go index 1e2dc5f1e..8cae8acdb 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "github.com/sirupsen/logrus" "github.com/supabase/auth/cmd" - "github.com/supabase/auth/internal/api" "github.com/supabase/auth/internal/observability" ) @@ -37,14 +36,6 @@ func main() { var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - - // wait for API servers to shut down gracefully - api.WaitForCleanup(shutdownCtx) - }() - wg.Add(1) go func() { defer wg.Done()