Repository: gomods/athens

Issue:
Redis Sentinel doesn't support master node authorization
**Describe the bug**
SingleFlight mode with Redis Sentinel dowesn't support master node authorization param as a result the error `NOAUTH Authentication required` appears due to empty master node password.

**Error Message**
If applicable add error message to help explain your problem or console output enclose in triple back-ticks eg,

<pre>
```
INFO[10:05AM]: Exporter not specified. Traces won't be exported
INFO[10:05M]: sentinel: discovered new sentinel="XX.XX.XX.XX:26379" for master="athens"
INFO[10:05AM]: sentinel: new master="athens" addr="XX.XX.XX.XX:6379"
FATAL[10:05AM]: Could not create App     error=adding proxy routes: NOAUTH Authentication required.
```
</pre>


**To Reproduce**
Steps to reproduce the behavior:

Apply `redis-sentinel` values as a SingleFlight type
```
ATHENS_SINGLE_FLIGHT_TYPE: "redis-sentinel"
ATHENS_REDIS_SENTINEL_ENDPOINTS: "127.0.0.1:26379"
ATHENS_REDIS_SENTINEL_MASTER_NAME: "athens"
```

and run the application.

**Expected behavior**
The application has started and is running:

```
INFO[1:23PM]: Exporter not specified. Traces won't be exported	
INFO[1:23PM]: sentinel: new master="athens" addr="127.0.0.1:6379"	
INFO[1:23PM]: Starting application	tcpPort=:3000 
```

**Environment (please complete the following information):**
 - OS: Any
 - Go version : 1.22+
 - Proxy version: latest (from main branch)
 - Storage (fs/mongodb/s3 etc.): fs


Discussion:

Solution patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 47f3e4c7..5fe8b02a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,6 +34,9 @@ jobs:
       REDIS_SENTINEL_TEST_MASTER_NAME: redis-1
       REDIS_SENTINEL_TEST_PASSWORD: sekret
       PROTECTED_REDIS_TEST_ENDPOINT: localhost:6380
+      PROTECTED_REDIS_TEST_USERNAME: default
+      REDIS_SENTINEL_TEST_PROTECTED_ENDPOINT: localhost:26380
+      REDIS_SENTINEL_TEST_PROTECTED_MASTER_NAME: protectedredis-1
       ATHENS_PROTECTED_REDIS_PASSWORD: AthensPass1
       GA_PULL_REQUEST: ${{github.event.number}}
     runs-on: ubuntu-latest
@@ -69,6 +72,17 @@ jobs:
         env:
           REDIS_PORT_NUMBER: 6380
           REDIS_PASSWORD: AthensPass1
+      redis-sentinel-protected-redis:
+        image: bitnami/redis-sentinel
+        env:
+          REDIS_MASTER_HOST: protectedredis
+          REDIS_MASTER_PORT_NUMBER: 6380
+          REDIS_MASTER_SET: protectedredis-1
+          REDIS_SENTINEL_PASSWORD: sekret
+          REDIS_SENTINEL_QUORUM: "1"
+          REDIS_SENTINEL_PORT_NUMBER: 26380
+        ports:
+          - 26380:26380
     steps:
       - uses: actions/checkout@v4
       - name: Set up Go
diff --git a/cmd/proxy/actions/app_proxy.go b/cmd/proxy/actions/app_proxy.go
index 86c2a247..4c2025ee 100644
--- a/cmd/proxy/actions/app_proxy.go
+++ b/cmd/proxy/actions/app_proxy.go
@@ -166,6 +166,8 @@ func getSingleFlight(l *log.Logger, c *config.Config, s storage.Backend, checker
 			c.SingleFlight.RedisSentinel.Endpoints,
 			c.SingleFlight.RedisSentinel.MasterName,
 			c.SingleFlight.RedisSentinel.SentinelPassword,
+			c.SingleFlight.RedisSentinel.RedisUsername,
+			c.SingleFlight.RedisSentinel.RedisPassword,
 			checker,
 			c.SingleFlight.RedisSentinel.LockConfig,
 		)
diff --git a/config.dev.toml b/config.dev.toml
index 5049a89f..0784be96 100755
--- a/config.dev.toml
+++ b/config.dev.toml
@@ -369,6 +369,12 @@ ShutdownTimeout = 60
         # Env override: ATHENS_REDIS_SENTINEL_PASSWORD
         SentinelPassword = "sekret"
 
+        # The Redis master authorization parameters
+        # Env override: ATHENS_REDIS_USERNAME
+        RedisUsername = ""
+        # Env override: ATHENS_REDIS_PASSWORD
+        RedisPassword = ""
+
         [SingleFlight.RedisSentinel.LockConfig]
             # TTL for the lock in seconds. Defaults to 900 seconds (15 minutes).
             # Env override: ATHENS_REDIS_LOCK_TTL
diff --git a/pkg/config/config.go b/pkg/config/config.go
index b591b949..0fb5fa8f 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -179,6 +179,8 @@ func defaultConfig() *Config {
 				Endpoints:        []string{"127.0.0.1:26379"},
 				MasterName:       "redis-1",
 				SentinelPassword: "sekret",
+				RedisUsername:    "",
+				RedisPassword:    "",
 				LockConfig:       DefaultRedisLockConfig(),
 			},
 			GCP: DefaultGCPConfig(),
diff --git a/pkg/config/singleflight.go b/pkg/config/singleflight.go
index b3b6fe04..eae580f7 100644
--- a/pkg/config/singleflight.go
+++ b/pkg/config/singleflight.go
@@ -31,6 +31,8 @@ type RedisSentinel struct {
 	Endpoints        []string `envconfig:"ATHENS_REDIS_SENTINEL_ENDPOINTS"`
 	MasterName       string   `envconfig:"ATHENS_REDIS_SENTINEL_MASTER_NAME"`
 	SentinelPassword string   `envconfig:"ATHENS_REDIS_SENTINEL_PASSWORD"`
+	RedisUsername    string   `envconfig:"ATHENS_REDIS_USERNAME"`
+	RedisPassword    string   `envconfig:"ATHENS_REDIS_PASSWORD"`
 	LockConfig       *RedisLockConfig
 }
 
diff --git a/pkg/stash/with_redis_sentinel.go b/pkg/stash/with_redis_sentinel.go
index bf890b20..fbc554c0 100644
--- a/pkg/stash/with_redis_sentinel.go
+++ b/pkg/stash/with_redis_sentinel.go
@@ -11,7 +11,7 @@ import (
 
 // WithRedisSentinelLock returns a distributed singleflight
 // with a redis cluster that utilizes sentinel for quorum and failover.
-func WithRedisSentinelLock(l RedisLogger, endpoints []string, master, password string, checker storage.Checker, lockConfig *config.RedisLockConfig) (Wrapper, error) {
+func WithRedisSentinelLock(l RedisLogger, endpoints []string, master, sentinelPassword, redisUsername, redisPassword string, checker storage.Checker, lockConfig *config.RedisLockConfig) (Wrapper, error) {
 	redis.SetLogger(l)
 
 	const op errors.Op = "stash.WithRedisSentinelLock"
@@ -23,7 +23,9 @@ func WithRedisSentinelLock(l RedisLogger, endpoints []string, master, password s
 	client := redis.NewFailoverClient(&redis.FailoverOptions{
 		MasterName:       master,
 		SentinelAddrs:    endpoints,
-		SentinelPassword: password,
+		SentinelPassword: sentinelPassword,
+		Username:         redisUsername,
+		Password:         redisPassword,
 	})
 	_, err := client.Ping(context.Background()).Result()
 	if err != nil {



Test patch:
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 2fad9c5e..4398400b 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -375,7 +375,7 @@ func getEnvMap(config *Config) map[string]string {
 		if singleFlight.Redis != nil {
 			envVars["ATHENS_SINGLE_FLIGHT_TYPE"] = "redis"
 			envVars["ATHENS_REDIS_ENDPOINT"] = singleFlight.Redis.Endpoint
-			envVars["ATHENS_REDIS_PASSWORD"] = singleFlight.Redis.Endpoint
+			envVars["ATHENS_REDIS_PASSWORD"] = singleFlight.Redis.Password
 			if singleFlight.Redis.LockConfig != nil {
 				envVars["ATHENS_REDIS_LOCK_TTL"] = strconv.Itoa(singleFlight.Redis.LockConfig.TTL)
 				envVars["ATHENS_REDIS_LOCK_TIMEOUT"] = strconv.Itoa(singleFlight.Redis.LockConfig.Timeout)
@@ -386,6 +386,8 @@ func getEnvMap(config *Config) map[string]string {
 			envVars["ATHENS_REDIS_SENTINEL_ENDPOINTS"] = strings.Join(singleFlight.RedisSentinel.Endpoints, ",")
 			envVars["ATHENS_REDIS_SENTINEL_MASTER_NAME"] = singleFlight.RedisSentinel.MasterName
 			envVars["ATHENS_REDIS_SENTINEL_PASSWORD"] = singleFlight.RedisSentinel.SentinelPassword
+			envVars["ATHENS_REDIS_USERNAME"] = singleFlight.RedisSentinel.RedisUsername
+			envVars["ATHENS_REDIS_PASSWORD"] = singleFlight.RedisSentinel.RedisPassword
 			if singleFlight.RedisSentinel.LockConfig != nil {
 				envVars["ATHENS_REDIS_LOCK_TTL"] = strconv.Itoa(singleFlight.RedisSentinel.LockConfig.TTL)
 				envVars["ATHENS_REDIS_LOCK_TIMEOUT"] = strconv.Itoa(singleFlight.RedisSentinel.LockConfig.Timeout)
diff --git a/pkg/stash/with_redis_sentinel_test.go b/pkg/stash/with_redis_sentinel_test.go
index ec2ee29d..e5af6ca3 100644
--- a/pkg/stash/with_redis_sentinel_test.go
+++ b/pkg/stash/with_redis_sentinel_test.go
@@ -18,8 +18,8 @@ import (
 func TestWithRedisSentinelLock(t *testing.T) {
 	endpoint := os.Getenv("REDIS_SENTINEL_TEST_ENDPOINT")
 	masterName := os.Getenv("REDIS_SENTINEL_TEST_MASTER_NAME")
-	password := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD")
-	if len(endpoint) == 0 || len(masterName) == 0 || len(password) == 0 {
+	sentinelPassword := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD")
+	if len(endpoint) == 0 || len(masterName) == 0 || len(sentinelPassword) == 0 {
 		t.SkipNow()
 	}
 	strg, err := mem.NewStorage()
@@ -29,7 +29,7 @@ func TestWithRedisSentinelLock(t *testing.T) {
 	ms := &mockRedisStasher{strg: strg}
 	l := &testingRedisLogger{t: t}
 
-	wrapper, err := WithRedisSentinelLock(l, []string{endpoint}, masterName, password, storage.WithChecker(strg), config.DefaultRedisLockConfig())
+	wrapper, err := WithRedisSentinelLock(l, []string{endpoint}, masterName, sentinelPassword, "", "", storage.WithChecker(strg), config.DefaultRedisLockConfig())
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -50,3 +50,109 @@ func TestWithRedisSentinelLock(t *testing.T) {
 		t.Fatal(err)
 	}
 }
+
+// TestWithRedisSentinelLockWithRedisPassword verifies WithRedisSentinelLock working with
+// password protected redis sentinel and redis master nodes
+func TestWithRedisSentinelLockWithRedisPassword(t *testing.T) {
+	endpoint := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_ENDPOINT")
+	masterName := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_MASTER_NAME")
+	sentinelPassword := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD")
+	redisPassword := os.Getenv("ATHENS_PROTECTED_REDIS_PASSWORD")
+	if len(endpoint) == 0 || len(masterName) == 0 {
+		t.SkipNow()
+	}
+	strg, err := mem.NewStorage()
+	if err != nil {
+		t.Fatal(err)
+	}
+	ms := &mockRedisStasher{strg: strg}
+	l := &testingRedisLogger{t: t}
+	wrapper, err := WithRedisSentinelLock(l, []string{endpoint}, masterName, sentinelPassword, "", redisPassword, storage.WithChecker(strg), config.DefaultRedisLockConfig())
+	if err != nil {
+		t.Fatal(err)
+	}
+	s := wrapper(ms)
+
+	var eg errgroup.Group
+	for i := 0; i < 5; i++ {
+		eg.Go(func() error {
+			ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+			defer cancel()
+			_, err := s.Stash(ctx, "mod", "ver")
+			return err
+		})
+	}
+
+	err = eg.Wait()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+// TestWithRedisSentinelLockWithPassword verifies WithRedisSentinelLock working with
+// username & password protected master node under the redis sentinel
+func TestWithRedisSentinelLockWithUsernameAndPassword(t *testing.T) {
+	endpoint := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_ENDPOINT")
+	masterName := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_MASTER_NAME")
+	sentinelPassword := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD")
+	redisPassword := os.Getenv("ATHENS_PROTECTED_REDIS_PASSWORD")
+	redisUsername := os.Getenv("PROTECTED_REDIS_TEST_USERNAME")
+	if len(endpoint) == 0 || len(masterName) == 0 {
+		t.SkipNow()
+	}
+	strg, err := mem.NewStorage()
+	if err != nil {
+		t.Fatal(err)
+	}
+	ms := &mockRedisStasher{strg: strg}
+	l := &testingRedisLogger{t: t}
+	wrapper, err := WithRedisSentinelLock(l, []string{endpoint}, masterName, sentinelPassword, redisUsername, redisPassword, storage.WithChecker(strg), config.DefaultRedisLockConfig())
+	if err != nil {
+		t.Fatal(err)
+	}
+	s := wrapper(ms)
+
+	var eg errgroup.Group
+	for i := 0; i < 5; i++ {
+		eg.Go(func() error {
+			ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+			defer cancel()
+			_, err := s.Stash(ctx, "mod", "ver")
+			return err
+		})
+	}
+
+	err = eg.Wait()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+// TestWithRedisSentinelLockWithWrongPassword verifies the WithRedisSentinelLock fails
+// with the correct error when trying to connect with wrong passwords
+func TestWithRedisSentinelLockWithWrongRedisPassword(t *testing.T) {
+	endpoint := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_ENDPOINT")
+	masterName := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_MASTER_NAME")
+	sentinelPassword := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD")
+	redisPassword := os.Getenv("ATHENS_PROTECTED_REDIS_PASSWORD")
+	if len(endpoint) == 0 || len(masterName) == 0 {
+		t.SkipNow()
+	}
+	strg, err := mem.NewStorage()
+	if err != nil {
+		t.Fatal(err)
+	}
+	l := &testingRedisLogger{t: t}
+
+	// Test with wrong sentinel password
+	_, err = WithRedisSentinelLock(l, []string{endpoint}, masterName, "wrong-sentinel-password", "", redisPassword, storage.WithChecker(strg), config.DefaultRedisLockConfig())
+	if err == nil {
+		t.Fatal("Expected Connection Error for wrong sentinel password")
+	}
+
+	// Test with wrong redis password
+	_, err = WithRedisSentinelLock(l, []string{endpoint}, masterName, sentinelPassword, "", "wrong-redis-password", storage.WithChecker(strg), config.DefaultRedisLockConfig())
+	if err == nil {
+		t.Fatal("Expected Connection Error for wrong redis password")
+	}
+}