Skip to content

Commit d5c6b78

Browse files
spbsolubleclaude
andcommitted
feat: add client caching to reduce OAuth token requests
Previously, every certificate request reconciliation created a new Command API client, which meant a new OAuth token was fetched for each request. For customers with OAuth provider quotas, this caused rate limiting issues. This change introduces a ClientCache that: - Caches Command API clients by configuration hash - Reuses cached clients across reconciliations for the same issuer - Allows the underlying oauth2 library's token caching to work as intended - Is thread-safe for concurrent reconciliations The cache key is a SHA-256 hash of all configuration fields that affect the client connection (hostname, API path, credentials, scopes, etc.), ensuring different issuers get different clients while the same issuer reuses its client. Fixes: OAuth token re-authentication on every request Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3340f53 commit d5c6b78

File tree

3 files changed

+390
-4
lines changed

3 files changed

+390
-4
lines changed

cmd/main.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,18 +196,23 @@ func main() {
196196
os.Exit(1)
197197
}
198198

199-
if defaultHealthCheckInterval < time.Duration(30) * time.Second {
199+
if defaultHealthCheckInterval < time.Duration(30)*time.Second {
200200
setupLog.Error(errors.New(fmt.Sprintf("interval %s is invalid, must be greater than or equal to '30s'", healthCheckInterval)), "invalid health check interval")
201201
os.Exit(1)
202202
}
203203

204+
// Create a shared client cache to avoid re-authenticating (fetching new OAuth tokens)
205+
// for every certificate request. Clients are cached by configuration hash.
206+
clientCache := command.NewClientCache()
207+
setupLog.Info("initialized Command client cache for OAuth token reuse")
208+
204209
if err = (&controller.IssuerReconciler{
205210
Client: mgr.GetClient(),
206211
Kind: "Issuer",
207212
ClusterResourceNamespace: clusterResourceNamespace,
208213
SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel,
209214
Scheme: mgr.GetScheme(),
210-
HealthCheckerBuilder: command.NewHealthChecker,
215+
HealthCheckerBuilder: clientCache.GetOrCreateHealthChecker,
211216
DefaultHealthCheckInterval: defaultHealthCheckInterval,
212217
}).SetupWithManager(mgr); err != nil {
213218
setupLog.Error(err, "unable to create controller", "controller", "Issuer")
@@ -219,7 +224,7 @@ func main() {
219224
Kind: "ClusterIssuer",
220225
ClusterResourceNamespace: clusterResourceNamespace,
221226
SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel,
222-
HealthCheckerBuilder: command.NewHealthChecker,
227+
HealthCheckerBuilder: clientCache.GetOrCreateHealthChecker,
223228
DefaultHealthCheckInterval: defaultHealthCheckInterval,
224229
}).SetupWithManager(mgr); err != nil {
225230
setupLog.Error(err, "unable to create controller", "controller", "ClusterIssuer")
@@ -229,7 +234,7 @@ func main() {
229234
Client: mgr.GetClient(),
230235
Scheme: mgr.GetScheme(),
231236
ClusterResourceNamespace: clusterResourceNamespace,
232-
SignerBuilder: command.NewSignerBuilder,
237+
SignerBuilder: clientCache.GetOrCreateSigner,
233238
CheckApprovedCondition: !disableApprovedCheck,
234239
SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel,
235240
Clock: clock.RealClock{},

internal/command/client_cache.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
Copyright © 2025 Keyfactor
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package command
18+
19+
import (
20+
"context"
21+
"crypto/sha256"
22+
"encoding/hex"
23+
"fmt"
24+
"sync"
25+
26+
commandsdk "github.com/Keyfactor/keyfactor-go-client-sdk/v25"
27+
"sigs.k8s.io/controller-runtime/pkg/log"
28+
)
29+
30+
// ClientCache provides thread-safe caching of Command API clients to avoid
31+
// re-authenticating (and fetching new OAuth tokens) for every request.
32+
// Clients are cached by a hash of their configuration, so different issuers
33+
// with different configs get different clients, but the same issuer reuses
34+
// its client across reconciliations.
35+
type ClientCache struct {
36+
mu sync.RWMutex
37+
clients map[string]*cachedClient
38+
}
39+
40+
type cachedClient struct {
41+
signer *signer
42+
}
43+
44+
// NewClientCache creates a new ClientCache instance.
45+
func NewClientCache() *ClientCache {
46+
return &ClientCache{
47+
clients: make(map[string]*cachedClient),
48+
}
49+
}
50+
51+
// configHash generates a unique hash for a Config to use as a cache key.
52+
// This ensures that different configurations get different clients.
53+
func configHash(config *Config) string {
54+
h := sha256.New()
55+
56+
// Include all fields that affect the client connection
57+
h.Write([]byte(config.Hostname))
58+
h.Write([]byte(config.APIPath))
59+
h.Write(config.CaCertsBytes)
60+
61+
if config.BasicAuth != nil {
62+
h.Write([]byte("basic"))
63+
h.Write([]byte(config.BasicAuth.Username))
64+
h.Write([]byte(config.BasicAuth.Password))
65+
}
66+
67+
if config.OAuth != nil {
68+
h.Write([]byte("oauth"))
69+
h.Write([]byte(config.OAuth.TokenURL))
70+
h.Write([]byte(config.OAuth.ClientID))
71+
h.Write([]byte(config.OAuth.ClientSecret))
72+
h.Write([]byte(config.OAuth.Audience))
73+
for _, scope := range config.OAuth.Scopes {
74+
h.Write([]byte(scope))
75+
}
76+
}
77+
78+
// Include ambient credential config
79+
h.Write([]byte(config.AmbientCredentialAudience))
80+
for _, scope := range config.AmbientCredentialScopes {
81+
h.Write([]byte(scope))
82+
}
83+
84+
return hex.EncodeToString(h.Sum(nil))
85+
}
86+
87+
// GetOrCreateSigner returns a cached signer for the given config, or creates
88+
// a new one if none exists. This ensures OAuth tokens are reused across
89+
// requests to the same Command instance.
90+
func (c *ClientCache) GetOrCreateSigner(ctx context.Context, config *Config) (Signer, error) {
91+
key := configHash(config)
92+
logger := log.FromContext(ctx)
93+
94+
// Fast path: check if we have a cached client
95+
c.mu.RLock()
96+
if cached, ok := c.clients[key]; ok {
97+
c.mu.RUnlock()
98+
logger.V(1).Info("Reusing cached Command client", "cacheKey", key[:12])
99+
return cached.signer, nil
100+
}
101+
c.mu.RUnlock()
102+
103+
// Slow path: create a new client
104+
c.mu.Lock()
105+
defer c.mu.Unlock()
106+
107+
// Double-check after acquiring write lock
108+
if cached, ok := c.clients[key]; ok {
109+
logger.V(1).Info("Reusing cached Command client (after lock)", "cacheKey", key[:12])
110+
return cached.signer, nil
111+
}
112+
113+
logger.Info("Creating new Command client (will be cached for future requests)", "cacheKey", key[:12])
114+
115+
s, err := newInternalSigner(ctx, config, commandsdk.NewAPIClient)
116+
if err != nil {
117+
return nil, fmt.Errorf("failed to create signer: %w", err)
118+
}
119+
120+
c.clients[key] = &cachedClient{signer: s}
121+
return s, nil
122+
}
123+
124+
// GetOrCreateHealthChecker returns a cached health checker for the given config.
125+
// Since the signer type implements both Signer and HealthChecker interfaces,
126+
// this shares the same cache as GetOrCreateSigner.
127+
func (c *ClientCache) GetOrCreateHealthChecker(ctx context.Context, config *Config) (HealthChecker, error) {
128+
key := configHash(config)
129+
logger := log.FromContext(ctx)
130+
131+
// Fast path: check if we have a cached client
132+
c.mu.RLock()
133+
if cached, ok := c.clients[key]; ok {
134+
c.mu.RUnlock()
135+
logger.V(1).Info("Reusing cached Command client for health check", "cacheKey", key[:12])
136+
return cached.signer, nil
137+
}
138+
c.mu.RUnlock()
139+
140+
// Slow path: create a new client
141+
c.mu.Lock()
142+
defer c.mu.Unlock()
143+
144+
// Double-check after acquiring write lock
145+
if cached, ok := c.clients[key]; ok {
146+
logger.V(1).Info("Reusing cached Command client for health check (after lock)", "cacheKey", key[:12])
147+
return cached.signer, nil
148+
}
149+
150+
logger.Info("Creating new Command client for health check (will be cached)", "cacheKey", key[:12])
151+
152+
s, err := newInternalSigner(ctx, config, commandsdk.NewAPIClient)
153+
if err != nil {
154+
return nil, fmt.Errorf("failed to create health checker: %w", err)
155+
}
156+
157+
c.clients[key] = &cachedClient{signer: s}
158+
return s, nil
159+
}
160+
161+
// Invalidate removes a cached client for the given config.
162+
// This should be called when an issuer's credentials are updated.
163+
func (c *ClientCache) Invalidate(config *Config) {
164+
key := configHash(config)
165+
c.mu.Lock()
166+
defer c.mu.Unlock()
167+
delete(c.clients, key)
168+
}
169+
170+
// InvalidateAll removes all cached clients.
171+
// This can be used during shutdown or when a global credential refresh is needed.
172+
func (c *ClientCache) InvalidateAll() {
173+
c.mu.Lock()
174+
defer c.mu.Unlock()
175+
c.clients = make(map[string]*cachedClient)
176+
}
177+
178+
// Size returns the number of cached clients.
179+
func (c *ClientCache) Size() int {
180+
c.mu.RLock()
181+
defer c.mu.RUnlock()
182+
return len(c.clients)
183+
}

0 commit comments

Comments
 (0)