Skip to content

Commit 1ddf8e9

Browse files
authored
fix(client): plumb Server.ClientTimeout into the rebuilt auth config (#59)
fix(client): plumb Server.ClientTimeout into the rebuilt auth config
2 parents 06609d7 + e9612c8 commit 1ddf8e9

10 files changed

Lines changed: 1186 additions & 58 deletions

v2/api/template_models.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,13 @@ type UpdateTemplateArg struct {
8181
AllowedRequesters *[]string `json:"AllowedRequesters,omitempty"`
8282
RFCEnforcement *bool `json:"RFCEnforcement,omitempty"`
8383
RequiresApproval *bool `json:"RequiresApproval,omitempty"`
84-
KeyUsage *bool `json:"KeyUsage,omitempty"`
84+
// KeyUsage is an int32 bitmask on Command's wire format (e.g. 160 =
85+
// digitalSignature|keyEncipherment), matching GetTemplateResponse.KeyUsage and
86+
// Command's TemplateUpdateRequest/TemplateRetrievalResponse swagger schema
87+
// (both typed "integer"/"int32"). A *bool here previously produced a live
88+
// HTTP 400 ("Unexpected character encountered while parsing value: t. Path
89+
// 'KeyUsage'") since Command rejects a JSON boolean for an integer field.
90+
KeyUsage *int `json:"KeyUsage,omitempty"`
8591
}
8692

8793
type UpdateTemplateResponse struct{ GetTemplateResponse }

v3/api/certificate.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,21 @@ func (c *Client) EnrollPFXV2(ea *EnrollPFXFctArgsV2) (*EnrollResponseV2, error)
142142
Payload: &ea,
143143
}
144144

145-
log.Println("[TRACE] Request: ", keyfactorAPIStruct)
145+
// Log a redacted copy of the enrollment args rather than ea/keyfactorAPIStruct
146+
// directly: ea.Password carries the PFX private-key protection password, and
147+
// %v-formatting the struct (as this TRACE log historically did) would dump it
148+
// in plaintext. redactedEA is a value copy (ea is *EnrollPFXFctArgsV2) so
149+
// mutating its Password field below never touches the real request's ea.
150+
redactedEA := *ea
151+
if redactedEA.Password != "" {
152+
redactedEA.Password = redactedLogValue
153+
}
154+
log.Println("[TRACE] Request: ", &request{
155+
Method: keyfactorAPIStruct.Method,
156+
Endpoint: keyfactorAPIStruct.Endpoint,
157+
Headers: keyfactorAPIStruct.Headers,
158+
Payload: &redactedEA,
159+
})
146160

147161
resp, err := c.sendRequest(keyfactorAPIStruct)
148162
if err != nil {
@@ -725,7 +739,18 @@ func (c *Client) RecoverCertificate(
725739
IncludeChain: true,
726740
}
727741

728-
log.Println("[DEBUG] RecoverCertificate: Recovering certificate with args:", rca)
742+
// Log a redacted copy: rca.Password is the private-key recovery password
743+
// supplied by the caller, and this DEBUG-level log (a common
744+
// troubleshooting verbosity, reachable on ordinary Read/Update/import
745+
// private-key-recovery paths) used to dump it in plaintext via %v-style
746+
// struct formatting. redactedRCA is a value copy (rca is
747+
// *recoverCertArgs) so mutating its Password field below never touches
748+
// the real rca used to build the outgoing request below.
749+
redactedRCA := *rca
750+
if redactedRCA.Password != "" {
751+
redactedRCA.Password = redactedLogValue
752+
}
753+
log.Println("[DEBUG] RecoverCertificate: Recovering certificate with args:", &redactedRCA)
729754
// Set Keyfactor-specific headers
730755
headers := &apiHeaders{
731756
Headers: []StringTuple{

v3/api/client.go

Lines changed: 83 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"net/url"
2929
"path"
3030
"strings"
31+
"sync"
3132
"time"
3233

3334
"github.com/Keyfactor/keyfactor-auth-client-go/auth_providers"
@@ -69,6 +70,47 @@ var (
6970
type Client struct {
7071
AuthClient AuthConfig
7172
LoggerType string
73+
74+
// httpClient caches the *http.Client returned by AuthClient.GetHttpClient()
75+
// so that sendRequest reuses a single underlying transport/connection pool
76+
// across requests instead of asking AuthClient to build a brand new one on
77+
// every call. Both CommandConfigOauth.GetHttpClient() and
78+
// CommandAuthConfigBasic.GetHttpClient() (in keyfactor-auth-client-go)
79+
// construct a fresh http.Transport per invocation, and that transport's
80+
// IdleConnTimeout is derived from the configured HttpClientTimeout - so
81+
// without this cache, every request opens its own connection pool whose
82+
// sockets linger for up to HttpClientTimeout before being reclaimed. This
83+
// was already true at the old fixed 60s default; plumbing a caller-supplied
84+
// ClientTimeout (see NewKeyfactorClient) just widens the window, so caching
85+
// here keeps that fix from amplifying a pre-existing resource leak.
86+
httpClient *http.Client
87+
httpClientMu sync.Mutex
88+
}
89+
90+
// getHttpClient returns the cached *http.Client if one has already been
91+
// resolved for this Client, populating the cache on first use otherwise.
92+
// This guarantees AuthClient.GetHttpClient() is invoked at most once per
93+
// Client instance, so the transport (and its connection pool) is reused
94+
// across requests. It is safe for concurrent use.
95+
//
96+
// Note this does not affect OAuth token refresh: the cached *http.Client's
97+
// transport wraps an oauth2 TokenSource that is consulted (and refreshed as
98+
// needed) on every RoundTrip, independent of how many times the *http.Client
99+
// itself is reused.
100+
func (c *Client) getHttpClient() (*http.Client, error) {
101+
c.httpClientMu.Lock()
102+
defer c.httpClientMu.Unlock()
103+
104+
if c.httpClient != nil {
105+
return c.httpClient, nil
106+
}
107+
108+
httpClient, err := c.AuthClient.GetHttpClient()
109+
if err != nil {
110+
return nil, err
111+
}
112+
c.httpClient = httpClient
113+
return httpClient, nil
72114
}
73115

74116
// TerraformLogger wraps the tflog logging to handle Go's log messages with log level mapping.
@@ -142,11 +184,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie
142184
clientAuthType := cfg.GetAuthType()
143185

144186
baseConfig := auth_providers.CommandAuthConfig{
145-
CommandHostName: cfg.Host,
146-
CommandPort: cfg.Port,
147-
CommandAPIPath: cfg.APIPath,
148-
CommandCACert: cfg.CACertPath,
149-
SkipVerify: cfg.SkipTLSVerify,
187+
CommandHostName: cfg.Host,
188+
CommandPort: cfg.Port,
189+
CommandAPIPath: cfg.APIPath,
190+
CommandCACert: cfg.CACertPath,
191+
SkipVerify: cfg.SkipTLSVerify,
192+
HttpClientTimeout: cfg.ClientTimeout,
150193
}
151194

152195
if clientAuthType == "basic" {
@@ -160,11 +203,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie
160203
if aErr != nil {
161204
return nil, aErr
162205
}
163-
_, cErr := basicCfg.GetHttpClient()
206+
httpClient, cErr := basicCfg.GetHttpClient()
164207
if cErr != nil {
165208
return nil, cErr
166209
}
167210
client.AuthClient = &basicCfg
211+
client.httpClient = httpClient
168212
return &client, nil
169213
} else if clientAuthType == "oauth" {
170214
oauthCfg := auth_providers.CommandConfigOauth{
@@ -180,11 +224,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie
180224
if aErr != nil {
181225
return nil, aErr
182226
}
183-
_, cErr := oauthCfg.GetHttpClient()
227+
httpClient, cErr := oauthCfg.GetHttpClient()
184228
if cErr != nil {
185229
return nil, cErr
186230
}
187231
client.AuthClient = &oauthCfg
232+
client.httpClient = httpClient
188233
return &client, nil
189234
} else {
190235
return nil, fmt.Errorf("unsupported auth type or authentication cfg: '%s'", clientAuthType)
@@ -204,7 +249,12 @@ func logRequest(req *http.Request) error {
204249
// Restore the request body so it can be read later
205250
req.Body = io.NopCloser(bytes.NewBuffer(body))
206251

207-
// Create a struct to hold request data
252+
// Create a struct to hold request data. The body is redacted before
253+
// logging (see redactSensitiveJSONForLogging) since it's always the same
254+
// JSON-marshaled request.Payload passed into sendRequest, which may carry
255+
// a certificate/PFX recovery password or other secret - this must not be
256+
// dumped verbatim into TRACE-level logs.
257+
redactedBody := redactSensitiveJSONForLogging(body)
208258
requestData := struct {
209259
Method string `json:"method"`
210260
URL string `json:"url"`
@@ -214,7 +264,7 @@ func logRequest(req *http.Request) error {
214264
Method: req.Method,
215265
URL: req.URL.String(),
216266
Headers: req.Header,
217-
Body: string(body),
267+
Body: string(redactedBody),
218268
}
219269

220270
// Convert struct to JSON
@@ -251,15 +301,18 @@ func requestToCurl(req *http.Request) (string, error) {
251301
}
252302
}
253303

254-
// Add the body if it exists
304+
// Add the body if it exists. The body is redacted before being embedded
305+
// in the logged cURL command (see redactSensitiveJSONForLogging) since a
306+
// TRACE-level cURL command containing a raw password is directly
307+
// replayable by anyone who reads the log, not just informational.
255308
if req.Method == http.MethodPost || req.Method == http.MethodPut {
256309
body, err := io.ReadAll(req.Body)
257310
if err != nil {
258311
return "", err
259312
}
260313
req.Body = io.NopCloser(bytes.NewBuffer(body)) // Restore the request body
261314

262-
curlCommand.WriteString(fmt.Sprintf("--data %q ", string(body)))
315+
curlCommand.WriteString(fmt.Sprintf("--data %q ", string(redactSensitiveJSONForLogging(body))))
263316
}
264317

265318
return curlCommand.String(), nil
@@ -317,7 +370,7 @@ func (c *Client) sendRequest(request *request) (*http.Response, error) {
317370
if mErr != nil {
318371
return nil, mErr
319372
}
320-
log.Printf("[TRACE] Request body: %s", jsonByes)
373+
log.Printf("[TRACE] Request body: %s", redactSensitiveJSONForLogging(jsonByes))
321374

322375
req, reqErr := http.NewRequest(request.Method, keyfactorPath, bytes.NewBuffer(jsonByes))
323376
if reqErr != nil {
@@ -342,49 +395,30 @@ func (c *Client) sendRequest(request *request) (*http.Response, error) {
342395

343396
// Log the request
344397
logRequest(req)
345-
httpClient, cErr := c.AuthClient.GetHttpClient()
398+
httpClient, cErr := c.getHttpClient()
346399
if cErr != nil {
347400
return nil, cErr
348401
}
349402
resp, respErr := httpClient.Do(req)
350403

351-
// check if context deadline exceeded
404+
// NOTE: this used to silently retry on "context deadline exceeded" (up to
405+
// MAX_CONTEXT_DEADLINE_RETRIES times) without ever surfacing that a retry
406+
// happened. That's unsafe for two reasons:
407+
// 1. Retrying a non-idempotent request (e.g. a POST enrollment) after a
408+
// client-side timeout risks creating a second server-side resource
409+
// if the original request actually succeeded after the client gave
410+
// up on it -- exactly the scenario callers need to detect via the
411+
// returned error, not have hidden from them by a "successful" retry.
412+
// 2. If every retry also failed, `resp` was never reassigned from its
413+
// original nil value and there was no `return` for this case, so
414+
// control fell through to `resp.StatusCode` below on a nil
415+
// *http.Response, panicking the caller (e.g. crashing `terraform
416+
// apply` outright).
417+
// Callers that need retry-with-backoff semantics around a timeout (and
418+
// that know their request is safe to repeat) should implement that at
419+
// their own call site, where they have the context to decide; this layer
420+
// now always returns the transport error untouched.
352421
switch {
353-
case respErr != nil && (strings.Contains(respErr.Error(), "context deadline exceeded")):
354-
sleepDuration := time.Duration(1) * time.Second
355-
for i := 0; i < MAX_CONTEXT_DEADLINE_RETRIES; i++ {
356-
// sleep for exponential backoff
357-
if i > 0 {
358-
sleepDuration *= 2
359-
if sleepDuration > time.Duration(MAX_WAIT_SECONDS)*time.Second {
360-
sleepDuration = time.Duration(MAX_WAIT_SECONDS) * time.Second
361-
}
362-
log.Printf(
363-
"[DEBUG] %s request to %s failed with error %s, retrying in %s seconds...",
364-
request.Method,
365-
keyfactorPath,
366-
respErr.Error(),
367-
sleepDuration,
368-
)
369-
time.Sleep(sleepDuration)
370-
}
371-
372-
log.Printf(
373-
"[DEBUG] %s request to %s failed with error %s, retrying...",
374-
request.Method,
375-
keyfactorPath,
376-
respErr.Error(),
377-
)
378-
req, reqErr = http.NewRequest(request.Method, keyfactorPath, bytes.NewBuffer(jsonByes))
379-
if reqErr != nil {
380-
return nil, reqErr
381-
}
382-
resp2, respErr2 := httpClient.Do(req)
383-
if respErr2 == nil && resp2 != nil {
384-
resp = resp2
385-
break
386-
}
387-
}
388422
case respErr != nil:
389423
log.Printf("[ERROR] Error sending '%s' request to '%s': %s", request.Method, request.Endpoint, respErr)
390424
return nil, respErr

0 commit comments

Comments
 (0)