diff --git a/pkg/authprovider/authx/dynamic.go b/pkg/authprovider/authx/dynamic.go index 28ec59298a..ab5b20a8a3 100644 --- a/pkg/authprovider/authx/dynamic.go +++ b/pkg/authprovider/authx/dynamic.go @@ -3,9 +3,9 @@ package authx import ( "fmt" "strings" + "sync" "sync/atomic" - "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/replacer" "github.com/projectdiscovery/nuclei/v3/pkg/utils/json" "github.com/projectdiscovery/utils/errkit" @@ -23,18 +23,54 @@ var ( // ex: username and password are dynamic secrets, the actual secret is the token obtained // after authenticating with the username and password type Dynamic struct { - *Secret `yaml:",inline"` // this is a static secret that will be generated after the dynamic secret is resolved - Secrets []*Secret `yaml:"secrets"` - TemplatePath string `json:"template" yaml:"template"` - Variables []KV `json:"variables" yaml:"variables"` - Input string `json:"input" yaml:"input"` // (optional) target for the dynamic secret - Extracted map[string]interface{} `json:"-" yaml:"-"` // extracted values from the dynamic secret - fetchCallback LazyFetchSecret `json:"-" yaml:"-"` - fetched *atomic.Bool `json:"-" yaml:"-"` // atomic flag to check if the secret has been fetched - fetching *atomic.Bool `json:"-" yaml:"-"` // atomic flag to prevent recursive fetch calls - error error `json:"-" yaml:"-"` // error if any + *Secret `yaml:",inline"` // this is a static secret that will be generated after the dynamic secret is resolved + Secrets []*Secret `yaml:"secrets"` + TemplatePath string `json:"template" yaml:"template"` + Variables []KV `json:"variables" yaml:"variables"` + Input string `json:"input" yaml:"input"` // (optional) target for the dynamic secret + Extracted map[string]interface{} `json:"-" yaml:"-"` // extracted values from the dynamic secret + fetchCallback LazyFetchSecret `json:"-" yaml:"-"` + once *atomic.Pointer[*sync.Once] `json:"-" yaml:"-"` // stores *sync.Once, allows retry on failure + fetched *atomic.Bool `json:"-" yaml:"-"` // atomic flag to check if the secret has been fetched + mu sync.RWMutex `json:"-" yaml:"-"` // protects err field + err error `json:"-" yaml:"-"` // error if any } +// getOnce returns the current sync.Once instance, creating a new one if needed. +// It uses double-checked locking pattern for thread-safe lazy initialization +// and is safe for concurrent use. It also handles the case where Validate() +// was not called by lazily initializing the atomic.Pointer. +func (d *Dynamic) getOnce() *sync.Once { + // Handle case where Validate() was not called + if d.once == nil { + d.once = &atomic.Pointer[*sync.Once]{} + } + // Fast path - check if already initialized + ptr := d.once.Load() + if ptr != nil { + return *ptr + } + // Slow path - create new sync.Once + once := &sync.Once{} + // Try to store - if another goroutine beat us, use theirs + if !d.once.CompareAndSwap(nil, &once) { + // Someone else stored, use their value + return *d.once.Load() + } + return once +} + +// resetOnce atomically replaces the sync.Once with a new instance, +// allowing the fetch operation to be retried. This is called when +// fetch fails to enable retry on the next call. +func (d *Dynamic) resetOnce() { + // Atomically swap with a new sync.Once + once := &sync.Once{} + d.once.Store(&once) +} + +// GetDomainAndDomainRegex returns all domains and domain regexes from the dynamic +// secret and its embedded secrets. It deduplicates the results before returning. func (d *Dynamic) GetDomainAndDomainRegex() ([]string, []string) { var domains []string var domainRegex []string @@ -51,6 +87,8 @@ func (d *Dynamic) GetDomainAndDomainRegex() ([]string, []string) { return uniqueDomains, uniqueDomainRegex } +// UnmarshalJSON implements json.Unmarshaler for Dynamic. +// It handles the inline Secret embedding correctly during JSON unmarshalling. func (d *Dynamic) UnmarshalJSON(data []byte) error { if d == nil { return errkit.New("cannot unmarshal into nil Dynamic struct") @@ -70,8 +108,10 @@ func (d *Dynamic) UnmarshalJSON(data []byte) error { // Validate validates the dynamic secret func (d *Dynamic) Validate() error { + d.once = &atomic.Pointer[*sync.Once]{} + once := &sync.Once{} + d.once.Store(&once) d.fetched = &atomic.Bool{} - d.fetching = &atomic.Bool{} if d.TemplatePath == "" { return errkit.New(" template-path is required for dynamic secret") } @@ -94,32 +134,16 @@ func (d *Dynamic) Validate() error { return nil } -// SetLazyFetchCallback sets the lazy fetch callback for the dynamic secret +// SetLazyFetchCallback sets the lazy fetch callback for the dynamic secret. +// The callback will be invoked when Fetch() or GetStrategies() is first called. func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) { - d.fetchCallback = func(d *Dynamic) error { - err := callback(d) - if err != nil { - return err - } - if len(d.Extracted) == 0 { - return fmt.Errorf("no extracted values found for dynamic secret") - } - - if d.Secret != nil { - if err := d.applyValuesToSecret(d.Secret); err != nil { - return err - } - } - - for _, secret := range d.Secrets { - if err := d.applyValuesToSecret(secret); err != nil { - return err - } - } - return nil - } + d.fetchCallback = callback } +// applyValuesToSecret replaces template variables (e.g., {{token}}) in the +// secret's headers, cookies, params, username, password, and token fields +// with the corresponding values from the Dynamic's Extracted map. +// It also parses raw cookies after template replacement. func (d *Dynamic) applyValuesToSecret(secret *Secret) error { // evaluate headers for i, header := range secret.Headers { @@ -181,20 +205,79 @@ func (d *Dynamic) applyValuesToSecret(secret *Secret) error { return nil } -// GetStrategy returns the auth strategies for the dynamic secret -func (d *Dynamic) GetStrategies() []AuthStrategy { - if d.fetched.Load() { - if d.error != nil { - return nil +// fetchAndHydrate executes the fetch callback and hydrates all secrets with +// the extracted values in a single atomic operation. This method MUST be called +// under sync.Once guard to ensure thread-safe fetch-and-hydrate semantics. +// On error, the once guard is reset to allow retry on the next call. +func (d *Dynamic) fetchAndHydrate() { + d.mu.Lock() + // Check if fetchCallback is nil before calling + if d.fetchCallback == nil { + d.err = fmt.Errorf("fetchCallback is not set for dynamic secret") + d.mu.Unlock() + // Reset once to allow retry on next call + d.resetOnce() + return + } + d.err = d.fetchCallback(d) + if d.err != nil { + d.mu.Unlock() + // Reset once to allow retry on next call + d.resetOnce() + return + } + if len(d.Extracted) == 0 { + d.err = fmt.Errorf("no extracted values found for dynamic secret") + d.mu.Unlock() + // Reset once to allow retry on next call + d.resetOnce() + return + } + + if d.Secret != nil { + if err := d.applyValuesToSecret(d.Secret); err != nil { + d.err = err + d.mu.Unlock() + // Reset once to allow retry on next call + d.resetOnce() + return } - } else { - // Try to fetch if not already fetched - _ = d.Fetch(true) } - if d.error != nil { + for _, secret := range d.Secrets { + if err := d.applyValuesToSecret(secret); err != nil { + d.err = err + d.mu.Unlock() + // Reset once to allow retry on next call + d.resetOnce() + return + } + } + d.mu.Unlock() + + // Mark as fetched successfully (only after successful fetch and hydration) + // Check for nil to handle case where Validate() was not called + if d.fetched != nil { + d.fetched.Store(true) + } +} + +// GetStrategies returns the auth strategies for the dynamic secret. +// It ensures that fetch and hydrate are called exactly once, and all concurrent +// callers block until the operation completes. If the fetch fails, it returns nil. +// The once guard is reset on failure to allow retry on the next call. +func (d *Dynamic) GetStrategies() []AuthStrategy { + // Use sync.Once to ensure fetch and hydrate are called exactly once and all callers block until complete + d.getOnce().Do(d.fetchAndHydrate) + + // If fetch failed, return nil strategies + d.mu.RLock() + hasError := d.err != nil + d.mu.RUnlock() + if hasError { return nil } + var strategies []AuthStrategy if d.Secret != nil { strategies = append(strategies, d.GetStrategy()) @@ -205,33 +288,46 @@ func (d *Dynamic) GetStrategies() []AuthStrategy { return strategies } -// Fetch fetches the dynamic secret -// if isFatal is true, it will stop the execution if the secret could not be fetched -func (d *Dynamic) Fetch(isFatal bool) error { - if d.fetched.Load() { - return d.error - } - - // Try to set fetching flag atomically - if !d.fetching.CompareAndSwap(false, true) { - // Already fetching, return current error - return d.error - } +// Fetch triggers the lazy fetch of the dynamic secret and returns any error. +// It ensures fetch and hydrate are called exactly once, and all concurrent +// callers block until the operation completes. On error, the once guard is +// reset to allow retry on the next call. +func (d *Dynamic) Fetch() error { + // Use sync.Once to ensure fetch and hydrate are called exactly once and all callers block until complete + d.getOnce().Do(d.fetchAndHydrate) - // We're the only one fetching, call the callback - d.error = d.fetchCallback(d) + d.mu.RLock() + defer d.mu.RUnlock() + return d.err +} - // Mark as fetched and clear fetching flag - d.fetched.Store(true) - d.fetching.Store(false) +// Error returns the error from the last fetch operation, if any. +// It is safe for concurrent use. +func (d *Dynamic) Error() error { + d.mu.RLock() + defer d.mu.RUnlock() + return d.err +} - if d.error != nil && isFatal { - gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", d.error) +// Reset resets the fetch state, allowing a fresh fetch on next call +// This is useful when you want to force a re-fetch of the dynamic secret +// Call Validate() again after Reset() if you want to ensure the secret is still valid +func (d *Dynamic) Reset() { + once := &sync.Once{} + d.once.Store(&once) + if d.fetched != nil { + d.fetched.Store(false) } - return d.error + d.mu.Lock() + d.err = nil + d.Extracted = nil + d.mu.Unlock() } -// Error returns the error if any -func (d *Dynamic) Error() error { - return d.error +// IsFetched returns true if the dynamic secret has been successfully fetched +func (d *Dynamic) IsFetched() bool { + if d.fetched == nil { + return false + } + return d.fetched.Load() } diff --git a/pkg/authprovider/authx/dynamic_test.go b/pkg/authprovider/authx/dynamic_test.go index ffa38ea83c..78f7ebb660 100644 --- a/pkg/authprovider/authx/dynamic_test.go +++ b/pkg/authprovider/authx/dynamic_test.go @@ -1,8 +1,13 @@ package authx import ( + "fmt" + "sync" + "sync/atomic" "testing" + "time" + "github.com/projectdiscovery/utils/errkit" "github.com/stretchr/testify/require" ) @@ -93,8 +98,8 @@ func TestDynamicUnmarshalJSON(t *testing.T) { require.Equal(t, "HeadersAuth", d.Type) require.Equal(t, []string{"api.test.com"}, d.Domains) require.Len(t, d.Headers, 1) - require.Equal(t, "X-API-Key", d.Secret.Headers[0].Key) - require.Equal(t, "secret-key", d.Secret.Headers[0].Value) + require.Equal(t, "X-API-Key", d.Headers[0].Key) + require.Equal(t, "secret-key", d.Headers[0].Value) // Dynamic fields require.Equal(t, "test-template.yaml", d.TemplatePath) @@ -123,3 +128,521 @@ func TestDynamicUnmarshalJSON(t *testing.T) { require.NoError(t, err) }) } + +// TestDynamicFetchRaceCondition tests that concurrent calls to GetStrategies +// do not result in a race condition where some callers return nil before +// the fetch completes. This is the fix for Issue #6592. +func TestDynamicFetchRaceCondition(t *testing.T) { + t.Run("concurrent-get-strategies", func(t *testing.T) { + // Create a dynamic secret with a slow fetch callback + d := &Dynamic{ + Secret: &Secret{ + Type: "BearerToken", + Domains: []string{"example.com"}, + Token: "initial", + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "Bearer token"}, + }, + } + + // Validate initializes the sync.Once + err := d.Validate() + require.NoError(t, err) + + fetchCalled := atomic.Int32{} + fetchCompleted := atomic.Int32{} + + // Set a slow fetch callback that simulates network delay + fetchDone := make(chan struct{}) + d.SetLazyFetchCallback(func(d *Dynamic) error { + fetchCalled.Add(1) + // Simulate slow network request - use channel for deterministic sync + <-fetchDone + d.Extracted = map[string]interface{}{"token": "extracted-token"} + d.Token = "extracted-token" + fetchCompleted.Add(1) + return nil + }) + + // Launch multiple concurrent goroutines calling GetStrategies + const goroutines = 20 + var wg sync.WaitGroup + results := make([][]AuthStrategy, goroutines) + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx] = d.GetStrategies() + }(i) + } + + // Let all goroutines block on GetStrategies, then complete fetch + time.Sleep(10 * time.Millisecond) + close(fetchDone) + wg.Wait() + + // Verify fetch was called exactly once + require.Equal(t, int32(1), fetchCalled.Load(), "fetch should be called exactly once") + require.Equal(t, int32(1), fetchCompleted.Load(), "fetch should complete exactly once") + + // Verify ALL goroutines received the same non-nil strategies + for i, result := range results { + require.NotNil(t, result, "goroutine %d should receive non-nil strategies", i) + require.Len(t, result, 1, "goroutine %d should receive exactly 1 strategy", i) + } + + // Verify the token was properly extracted and applied + require.Equal(t, "extracted-token", d.Token, "token should be updated after fetch") + }) + + t.Run("concurrent-fetch-with-error", func(t *testing.T) { + d := &Dynamic{ + Secret: &Secret{ + Type: "BearerToken", + Domains: []string{"example.com"}, + Token: "initial", + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "Bearer token"}, + }, + } + + err := d.Validate() + require.NoError(t, err) + + fetchCalled := atomic.Int32{} + + // Set a fetch callback that returns an error + fetchDone := make(chan struct{}) + d.SetLazyFetchCallback(func(d *Dynamic) error { + fetchCalled.Add(1) + <-fetchDone + return errkit.New("fetch failed intentionally") + }) + + const goroutines = 10 + var wg sync.WaitGroup + results := make([][]AuthStrategy, goroutines) + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx] = d.GetStrategies() + }(i) + } + + // Let all goroutines block, then complete fetch + time.Sleep(10 * time.Millisecond) + close(fetchDone) + wg.Wait() + + // Verify fetch was called exactly once + require.Equal(t, int32(1), fetchCalled.Load(), "fetch should be called exactly once") + + // Verify ALL goroutines received nil strategies due to error + for i, result := range results { + require.Nil(t, result, "goroutine %d should receive nil strategies on error", i) + } + + // Verify error is recorded + require.Error(t, d.Error(), "error should be recorded") + }) + + t.Run("concurrent-fetch-blocks-until-complete", func(t *testing.T) { + d := &Dynamic{ + Secret: &Secret{ + Type: "BearerToken", + Domains: []string{"example.com"}, + Token: "initial", + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "Bearer token"}, + }, + } + + err := d.Validate() + require.NoError(t, err) + + var fetchStarted sync.WaitGroup + fetchStarted.Add(1) + fetchCompleted := make(chan struct{}) + + d.SetLazyFetchCallback(func(d *Dynamic) error { + fetchStarted.Done() // Signal that fetch has started + // Wait for signal to complete fetch (deterministic sync) + <-fetchCompleted + d.Extracted = map[string]interface{}{"token": "extracted"} + d.Token = "extracted" + return nil + }) + + const goroutines = 10 + var wg sync.WaitGroup + allGotResults := atomic.Int32{} + + // Start one goroutine first to trigger fetch + wg.Add(1) + go func() { + defer wg.Done() + result := d.GetStrategies() + if result != nil { + allGotResults.Add(1) + } + }() + + // Wait for fetch to start + fetchStarted.Wait() + + // Now start the rest of the goroutines + for i := 1; i < goroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + result := d.GetStrategies() + if result != nil { + allGotResults.Add(1) + } + }(i) + } + + // Let all goroutines block, then complete fetch + time.Sleep(10 * time.Millisecond) + close(fetchCompleted) + wg.Wait() + + // All goroutines should have received results after fetch completed + require.Equal(t, int32(goroutines), allGotResults.Load(), + "all goroutines should have received results after fetch completed") + require.Equal(t, "extracted", d.Token, "token should be updated after fetch") + }) +} + +// TestDynamicFetchAndHydrateIntegration tests that fetch and hydration are +// atomically combined under a single sync.Once guard. This ensures that +// template variables (e.g., {{token}}) are properly replaced with extracted +// values BEFORE any strategies are returned. +// This is the CRITICAL fix that prevents unhydrated secrets from being used. +func TestDynamicFetchAndHydrateIntegration(t *testing.T) { + t.Run("hydration-occurs-before-strategies-returned", func(t *testing.T) { + d := &Dynamic{ + Secret: &Secret{ + Type: "Header", + Domains: []string{"example.com"}, + Headers: []KV{ + {Key: "Authorization", Value: "Bearer {{token}}"}, + }, + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "placeholder"}, + }, + } + + require.NoError(t, d.Validate()) + + // Set fetch callback that extracts a token value + d.SetLazyFetchCallback(func(d *Dynamic) error { + d.Extracted = map[string]interface{}{"token": "actual-secret-token"} + return nil + }) + + // Get strategies should trigger fetch AND hydration + strategies := d.GetStrategies() + + require.NotNil(t, strategies, "strategies should not be nil") + require.Len(t, strategies, 1, "should have 1 strategy") + + // Verify the header value was hydrated (not still {{token}}) + secret := d.Secret + require.NotNil(t, secret) + require.Len(t, secret.Headers, 1) + require.Equal(t, "Bearer actual-secret-token", secret.Headers[0].Value, + "header value should be hydrated with actual token") + }) + + t.Run("fetch-then-getstrategies-returns-hydrated-secret", func(t *testing.T) { + d := &Dynamic{ + Secret: &Secret{ + Type: "Header", + Domains: []string{"example.com"}, + Headers: []KV{ + {Key: "Authorization", Value: "Bearer {{token}}"}, + }, + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "placeholder"}, + }, + } + + require.NoError(t, d.Validate()) + + // Set fetch callback + d.SetLazyFetchCallback(func(d *Dynamic) error { + d.Extracted = map[string]interface{}{"token": "from-fetch"} + return nil + }) + + // Call Fetch first (simulating pre-fetch scenario) + err := d.Fetch() + require.NoError(t, err) + + // Now call GetStrategies - should return hydrated secret + strategies := d.GetStrategies() + + require.NotNil(t, strategies, "strategies should not be nil") + + // Verify hydration occurred even though Fetch was called first + secret := d.Secret + require.NotNil(t, secret) + require.Len(t, secret.Headers, 1) + require.Equal(t, "Bearer from-fetch", secret.Headers[0].Value, + "header value should be hydrated even after Fetch() called first") + }) + + t.Run("getstrategies-then-fetch-both-return-hydrated", func(t *testing.T) { + d := &Dynamic{ + Secret: &Secret{ + Type: "Header", + Domains: []string{"example.com"}, + Headers: []KV{ + {Key: "Authorization", Value: "Bearer {{token}}"}, + }, + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "placeholder"}, + }, + } + + require.NoError(t, d.Validate()) + + callCount := atomic.Int32{} + d.SetLazyFetchCallback(func(d *Dynamic) error { + callCount.Add(1) + d.Extracted = map[string]interface{}{"token": "hydrated-value"} + return nil + }) + + // Call GetStrategies first + strategies1 := d.GetStrategies() + require.NotNil(t, strategies1) + + // Verify hydration + require.Equal(t, "Bearer hydrated-value", d.Headers[0].Value) + + // Call Fetch again - should NOT call callback again (sync.Once) + err := d.Fetch() + require.NoError(t, err) + + // Verify callback was only called once + require.Equal(t, int32(1), callCount.Load(), "fetch callback should only be called once") + + // Verify hydration still correct + require.Equal(t, "Bearer hydrated-value", d.Headers[0].Value) + }) + + t.Run("concurrent-fetch-and-getstrategies-all-hydrated", func(t *testing.T) { + d := &Dynamic{ + Secret: &Secret{ + Type: "Header", + Domains: []string{"example.com"}, + Headers: []KV{ + {Key: "Authorization", Value: "Bearer {{token}}"}, + }, + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "placeholder"}, + }, + } + + require.NoError(t, d.Validate()) + + fetchStarted := make(chan struct{}) + fetchCompleted := make(chan struct{}) + + d.SetLazyFetchCallback(func(d *Dynamic) error { + close(fetchStarted) + // Wait for deterministic sync signal instead of sleeping + <-fetchCompleted + d.Extracted = map[string]interface{}{"token": "concurrent-token"} + return nil + }) + + const goroutines = 15 + var wg sync.WaitGroup + results := make([]struct { + strategies []AuthStrategy + headerVal string + }, goroutines) + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx].strategies = d.GetStrategies() + if len(d.Headers) > 0 { + results[idx].headerVal = d.Headers[0].Value + } + }(i) + } + + // Wait for fetch to start, then let it complete + <-fetchStarted + time.Sleep(10 * time.Millisecond) // Let all goroutines block + close(fetchCompleted) + wg.Wait() + + // Verify ALL goroutines got hydrated values + for i, result := range results { + require.NotNil(t, result.strategies, "goroutine %d should have strategies", i) + require.Equal(t, "Bearer concurrent-token", result.headerVal, + "goroutine %d should have hydrated header value", i) + } + + // Final verification + require.Equal(t, "Bearer concurrent-token", d.Headers[0].Value) + }) +} + +// TestDynamicFetchRetryOnError tests that fetch can be retried after failure +// This verifies that sync.Once is reset on error, allowing retry +func TestDynamicFetchRetryOnError(t *testing.T) { + t.Run("retry-after-fetch-error", func(t *testing.T) { + d := &Dynamic{ + Secret: &Secret{ + Type: "Header", + Domains: []string{"example.com"}, + Headers: []KV{ + {Key: "Authorization", Value: "Bearer {{token}}"}, + }, + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "placeholder"}, + }, + } + + require.NoError(t, d.Validate()) + + callCount := atomic.Int32{} + fetchErr := errkit.New("temporary network error") + + // First call fails + d.SetLazyFetchCallback(func(d *Dynamic) error { + callCount.Add(1) + if callCount.Load() == 1 { + return fetchErr + } + // Second call succeeds + d.Extracted = map[string]interface{}{"token": "success-token"} + return nil + }) + + // First call should fail + err := d.Fetch() + require.Error(t, err) + require.ErrorIs(t, err, fetchErr) + require.Equal(t, int32(1), callCount.Load()) + + // Second call should succeed (retry allowed) + err = d.Fetch() + require.NoError(t, err) + require.Equal(t, int32(2), callCount.Load()) + + // Verify hydration occurred + require.Equal(t, "Bearer success-token", d.Headers[0].Value) + }) + + t.Run("manual-reset-allows-refetch", func(t *testing.T) { + // Note: Reset() clears fetch state but does not restore original template values + // This is expected behavior - after Reset(), you should re-initialize the Secret + // if you need to re-fetch with fresh template variables + d := &Dynamic{ + Secret: &Secret{ + Type: "Header", + Domains: []string{"example.com"}, + Headers: []KV{ + {Key: "Authorization", Value: "Bearer {{token}}"}, + }, + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "placeholder"}, + }, + } + + require.NoError(t, d.Validate()) + + callCount := atomic.Int32{} + d.SetLazyFetchCallback(func(d *Dynamic) error { + callCount.Add(1) + d.Extracted = map[string]interface{}{"token": fmt.Sprintf("token-%d", callCount.Load())} + return nil + }) + + // First fetch + strategies1 := d.GetStrategies() + require.NotNil(t, strategies1) + require.Equal(t, "Bearer token-1", d.Headers[0].Value) + require.Equal(t, int32(1), callCount.Load()) + + // Reset and fetch again - note that Headers[0].Value is now "Bearer token-1" + // so re-hydration won't change it unless we restore the template + d.Reset() + require.False(t, d.IsFetched(), "should not be fetched after reset") + + // For proper re-fetch, we need to restore the template value + d.Headers[0] = KV{Key: "Authorization", Value: "Bearer {{token}}"} + + strategies2 := d.GetStrategies() + require.NotNil(t, strategies2) + require.Equal(t, "Bearer token-2", d.Headers[0].Value) + require.Equal(t, int32(2), callCount.Load()) + }) + + t.Run("no-retry-after-success", func(t *testing.T) { + d := &Dynamic{ + Secret: &Secret{ + Type: "Header", + Domains: []string{"example.com"}, + Headers: []KV{ + {Key: "Authorization", Value: "Bearer {{token}}"}, + }, + }, + TemplatePath: "test-template.yaml", + Variables: []KV{ + {Key: "token", Value: "placeholder"}, + }, + } + + require.NoError(t, d.Validate()) + + callCount := atomic.Int32{} + d.SetLazyFetchCallback(func(d *Dynamic) error { + callCount.Add(1) + d.Extracted = map[string]interface{}{"token": "success-token"} + return nil + }) + + // First call succeeds + strategies1 := d.GetStrategies() + require.NotNil(t, strategies1) + require.Equal(t, int32(1), callCount.Load()) + + // Second call should NOT call callback again (sync.Once) + strategies2 := d.GetStrategies() + require.NotNil(t, strategies2) + require.Equal(t, int32(1), callCount.Load(), "callback should only be called once on success") + + // Verify same result + require.Equal(t, "Bearer success-token", d.Headers[0].Value) + }) +} diff --git a/pkg/authprovider/authx/file.go b/pkg/authprovider/authx/file.go index 05bd9dc5ec..93fbf48305 100644 --- a/pkg/authprovider/authx/file.go +++ b/pkg/authprovider/authx/file.go @@ -40,7 +40,7 @@ type Authx struct { ID string `json:"id" yaml:"id"` Info AuthFileInfo `json:"info" yaml:"info"` Secrets []Secret `json:"static" yaml:"static"` - Dynamic []Dynamic `json:"dynamic" yaml:"dynamic"` + Dynamic []*Dynamic `json:"dynamic" yaml:"dynamic"` } type AuthFileInfo struct { diff --git a/pkg/authprovider/authx/strategy.go b/pkg/authprovider/authx/strategy.go index 54ff8e81c4..bcd4195899 100644 --- a/pkg/authprovider/authx/strategy.go +++ b/pkg/authprovider/authx/strategy.go @@ -19,7 +19,7 @@ type AuthStrategy interface { // it implements the AuthStrategy interface type DynamicAuthStrategy struct { // Dynamic is the dynamic secret to use - Dynamic Dynamic + Dynamic *Dynamic } // Apply applies the strategy to the request diff --git a/pkg/authprovider/file.go b/pkg/authprovider/file.go index 40f4019062..bb6f062eed 100644 --- a/pkg/authprovider/file.go +++ b/pkg/authprovider/file.go @@ -175,7 +175,7 @@ func (f *FileAuthProvider) PreFetchSecrets() error { for _, ss := range f.domains { for _, s := range ss { if val, ok := s.(*authx.DynamicAuthStrategy); ok { - if err := val.Dynamic.Fetch(false); err != nil { + if err := val.Dynamic.Fetch(); err != nil { return err } } @@ -184,7 +184,7 @@ func (f *FileAuthProvider) PreFetchSecrets() error { for _, ss := range f.compiled { for _, s := range ss { if val, ok := s.(*authx.DynamicAuthStrategy); ok { - if err := val.Dynamic.Fetch(false); err != nil { + if err := val.Dynamic.Fetch(); err != nil { return err } }