Skip to content

Commit c5b43e0

Browse files
IvMisticosclaude
andauthored
kcc-cache: default TTL for no-expiry credentials + debug mode (#11)
Some credential plugins (e.g. the passman krew plugin) emit an ExecCredential without a status.expirationTimestamp, which decodes to the zero time. Such credentials look permanently expired, so they were re-fetched on every call and never effectively cached despite leaving an entry in the OS secret store. When a refreshed credential's expiry is more than KUBE_CREDENTIAL_CACHE_NO_EXPIRY_THRESHOLD (default 24h) in the past, treat it as "no expiry provided" and cache it for KUBE_CREDENTIAL_CACHE_DEFAULT_TTL (default 1h). Genuinely recently-expired credentials still refresh. Add KUBE_CREDENTIAL_CACHE_DEBUG to log the cache key, backend, hit/miss, expiry and refresh decisions to stderr (never credential material). Claude-Session: https://claude.ai/code/session_01XP6Vo1jhL3S96g8hQ76umo Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4630083 commit c5b43e0

3 files changed

Lines changed: 159 additions & 0 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,21 @@ Clear the cache: delete the `kube-credential-cache` entries from your OS secret
162162
###### `...Corruption detected, recreate cache file`
163163
A broken cache file was detected. The cause is unknown; the cache is automatically recreated.
164164

165+
###### kubectl keeps re-running the credential plugin / prompting on every call
166+
The credential is being written to your secret store but never served from cache.
167+
The usual cause is a plugin that returns no `status.expirationTimestamp` (it shows
168+
up as `"expirationTimestamp":"0001-01-01T00:00:00Z"`), so kcc-cache considers it
169+
already expired every time. Set `KUBE_CREDENTIAL_CACHE_DEBUG=1` to see the cache
170+
key, hit/miss and expiry decisions on stderr. The default-TTL behaviour (above)
171+
handles this automatically; tune it with `KUBE_CREDENTIAL_CACHE_DEFAULT_TTL` and
172+
`KUBE_CREDENTIAL_CACHE_NO_EXPIRY_THRESHOLD`.
173+
174+
> :information_source: In the OS secret store each cached credential is a single
175+
> entry whose name is `kube-credential-cache:<cache-key>` (e.g.
176+
> `kube-credential-cache:user="..." server="..."`). The `kube-credential-cache:`
177+
> prefix is just the service name; the rest is the cache key — it is one entry,
178+
> not two separate keys.
179+
165180
## Configuration
166181

167182
### kcc-cache
@@ -171,7 +186,24 @@ A broken cache file was detected. The cause is unknown; the cache is automatical
171186
| KUBE_CREDENTIAL_CACHE_BACKEND | _auto_ (`keyring` if the OS secret store is reachable, otherwise `file`) | storage backend: `keyring` or `file` |
172187
| KUBE_CREDENTIAL_CACHE_FILE | macOS:</br>`~/Library/Caches/kube-credential-cache/cache.json`</br>Linux:</br>`$XDG_CACHE_HOME/kube-credential-cache/cache.json`</br>`~/.cache/kube-credential-cache/cache.json`</br>Windows:</br>`%AppData%\kube-credential-cache\cache.json` | path of Cache file (`file` backend only) |
173188
| KUBE_CREDENTIAL_CACHE_REFRESH_MARGIN | `30s` | margin of credential refresh |
189+
| KUBE_CREDENTIAL_CACHE_DEFAULT_TTL | `1h` | TTL applied to credentials that report no usable expiry (see below) |
190+
| KUBE_CREDENTIAL_CACHE_NO_EXPIRY_THRESHOLD | `24h` | how far in the past a credential's expiry must be to count as "no expiry" |
174191
| KUBE_CREDENTIAL_CACHE_CACHEKEY_ENV_LIST | `KUBE_CREDENTIAL_CACHE_USER,AWS_PROFILE,AWS_REGION,AWS_VAULT` | comma separated env names for additional cache-key |
192+
| KUBE_CREDENTIAL_CACHE_DEBUG | _unset_ | when set to a truthy value (`1`/`true`/`yes`/`on`), log cache key, hit/miss, expiry and refresh decisions to stderr |
193+
194+
#### Credentials without an expiry (default TTL)
195+
196+
Some credential plugins (for example the [passman](https://github.com/abenz1267/passman)
197+
krew plugin) emit an `ExecCredential` with no `status.expirationTimestamp`. That
198+
decodes to the zero time (`0001-01-01T00:00:00Z`), so the credential looks
199+
permanently expired and would be re-fetched on **every** call — defeating the
200+
cache while still leaving an entry in your secret store.
201+
202+
To handle this, when a refreshed credential's expiry is more than
203+
`KUBE_CREDENTIAL_CACHE_NO_EXPIRY_THRESHOLD` (default `24h`) in the past, kcc-cache
204+
treats it as "no expiry provided" and caches it for
205+
`KUBE_CREDENTIAL_CACHE_DEFAULT_TTL` (default `1h`) instead. Genuinely
206+
recently-expired credentials (within the threshold) still refresh as before.
175207

176208
#### Storage backends
177209

cmd/kcc-cache/main.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ func main() {
6868
// configuration
6969
var (
7070
refreshMargin = time.Second * 30
71+
defaultTTL = time.Hour
72+
noExpiryCutoff = time.Hour * 24
7173
cacheKeyEnvlist = []string{"KUBE_CREDENTIAL_CACHE_USER", "AWS_PROFILE", "AWS_REGION", "AWS_VAULT"}
7274
)
7375
if e := os.Getenv("KUBE_CREDENTIAL_CACHE_REFRESH_MARGIN"); e != "" {
@@ -77,9 +79,24 @@ func main() {
7779
}
7880
refreshMargin = d
7981
}
82+
if e := os.Getenv("KUBE_CREDENTIAL_CACHE_DEFAULT_TTL"); e != "" {
83+
d, err := time.ParseDuration(e)
84+
if err != nil {
85+
fatal("invalid environment variable 'KUBE_CREDENTIAL_CACHE_DEFAULT_TTL': %s", err.Error())
86+
}
87+
defaultTTL = d
88+
}
89+
if e := os.Getenv("KUBE_CREDENTIAL_CACHE_NO_EXPIRY_THRESHOLD"); e != "" {
90+
d, err := time.ParseDuration(e)
91+
if err != nil {
92+
fatal("invalid environment variable 'KUBE_CREDENTIAL_CACHE_NO_EXPIRY_THRESHOLD': %s", err.Error())
93+
}
94+
noExpiryCutoff = d
95+
}
8096
if e := os.Getenv("KUBE_CREDENTIAL_CACHE_CACHEKEY_ENV_LIST"); e != "" {
8197
cacheKeyEnvlist = strings.Split(e, ",")
8298
}
99+
debugEnabled = isTruthy(os.Getenv("KUBE_CREDENTIAL_CACHE_DEBUG"))
83100

84101
// cache key
85102
//
@@ -126,16 +143,25 @@ func main() {
126143
}
127144
}
128145

146+
debugf("cache key: %s", cacheKey)
147+
129148
// select storage backend
130149
backend := newBackend(os.Getenv("KUBE_CREDENTIAL_CACHE_BACKEND"))
150+
debugf("backend: %T (KUBE_CREDENTIAL_CACHE_BACKEND=%q)", backend, os.Getenv("KUBE_CREDENTIAL_CACHE_BACKEND"))
131151

132152
// check cache
133153
cache, ok, err := backend.Get(cacheKey)
134154
if err != nil {
135155
fatal("cache read failed: %s", err)
136156
}
157+
if ok {
158+
debugf("cache hit: expires %s (in %s)", cache.Status.ExpirationTimestamp.Format(time.RFC3339), time.Until(cache.Status.ExpirationTimestamp).Round(time.Second))
159+
} else {
160+
debugf("cache miss")
161+
}
137162
if !ok || time.Until(cache.Status.ExpirationTimestamp) < refreshMargin {
138163
// refresh (os.Args[1] is guaranteed present; checked at startup)
164+
debugf("refreshing: running %q", strings.Join(os.Args[1:], " "))
139165
cmd := exec.Command(os.Args[1], os.Args[2:]...)
140166
cmd.Stderr = os.Stderr
141167
bytes, err := cmd.Output()
@@ -156,9 +182,28 @@ func main() {
156182
fatal("json.Unmarshal() failed(read command output): %s\nactual stdout: %s", err, string(bytes))
157183
}
158184

185+
// default TTL for credentials without a usable expiry
186+
//
187+
// Some credential plugins (e.g. the passman krew plugin) emit an
188+
// ExecCredential without a status.expirationTimestamp, which decodes to
189+
// the zero time (0001-01-01T00:00:00Z). Such a credential is always
190+
// "expired", so it would be re-fetched on every single call and never
191+
// effectively cached. When the reported expiry is implausibly far in the
192+
// past (more than noExpiryCutoff ago) we treat it as "no expiry provided"
193+
// and substitute now+defaultTTL, while leaving genuinely-recently-expired
194+
// credentials to refresh as before.
195+
if adjusted, ok := withDefaultExpiry(cache, noExpiryCutoff, defaultTTL, time.Now()); ok {
196+
debugf("credential expiration %s is more than %s in the past; treating as no-expiry and applying default TTL %s (expires %s)",
197+
cache.Status.ExpirationTimestamp.Format(time.RFC3339), noExpiryCutoff, defaultTTL, adjusted.Status.ExpirationTimestamp.Format(time.RFC3339))
198+
cache = adjusted
199+
}
200+
159201
if err := backend.Set(cacheKey, cache); err != nil {
160202
fatal("cache write failed: %s", err)
161203
}
204+
debugf("stored credential (expires %s)", cache.Status.ExpirationTimestamp.Format(time.RFC3339))
205+
} else {
206+
debugf("serving from cache")
162207
}
163208

164209
// print
@@ -344,3 +389,39 @@ func log(format string, v ...any) {
344389
fmt.Fprintf(os.Stderr, "%s: ", path.Base(os.Args[0]))
345390
fmt.Fprintf(os.Stderr, format+"\n", v...)
346391
}
392+
393+
// debugEnabled gates verbose diagnostics, controlled by KUBE_CREDENTIAL_CACHE_DEBUG.
394+
var debugEnabled bool
395+
396+
// debugf logs a diagnostic line to stderr when debug output is enabled. It never
397+
// logs credential material (tokens / key data), only cache keys and metadata.
398+
func debugf(format string, v ...any) {
399+
if !debugEnabled {
400+
return
401+
}
402+
log("[debug] "+format, v...)
403+
}
404+
405+
// withDefaultExpiry substitutes a default expiry for credentials that report no
406+
// usable one. When the credential's expiration is more than cutoff before now
407+
// (e.g. the zero time emitted by plugins like passman), it returns a copy with
408+
// the expiration set to now+ttl and ok=true. Otherwise it returns the credential
409+
// unchanged with ok=false, so genuinely-recently-expired credentials still refresh.
410+
func withDefaultExpiry(cred ClientAuthentication, cutoff, ttl time.Duration, now time.Time) (ClientAuthentication, bool) {
411+
if now.Sub(cred.Status.ExpirationTimestamp) > cutoff {
412+
cred.Status.ExpirationTimestamp = now.Add(ttl)
413+
return cred, true
414+
}
415+
return cred, false
416+
}
417+
418+
// isTruthy reports whether an environment variable value should be treated as
419+
// "on". Anything set and not explicitly falsey enables the feature.
420+
func isTruthy(v string) bool {
421+
switch strings.ToLower(strings.TrimSpace(v)) {
422+
case "", "0", "false", "no", "off":
423+
return false
424+
default:
425+
return true
426+
}
427+
}

cmd/kcc-cache/main_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,52 @@ func testBackendRoundTrip(t *testing.T, b Backend) {
4848
}
4949
}
5050

51+
func TestWithDefaultExpiry(t *testing.T) {
52+
now := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
53+
cutoff := 24 * time.Hour
54+
ttl := time.Hour
55+
56+
tests := []struct {
57+
name string
58+
exp time.Time
59+
wantOK bool
60+
wantExp time.Time
61+
}{
62+
{"zero time (no expiry, e.g. passman)", time.Time{}, true, now.Add(ttl)},
63+
{"far past beyond cutoff", now.Add(-48 * time.Hour), true, now.Add(ttl)},
64+
{"recently expired within cutoff", now.Add(-time.Hour), false, now.Add(-time.Hour)},
65+
{"valid future expiry", now.Add(2 * time.Hour), false, now.Add(2 * time.Hour)},
66+
}
67+
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
cred := newCred("tok")
71+
cred.Status.ExpirationTimestamp = tt.exp
72+
73+
got, ok := withDefaultExpiry(cred, cutoff, ttl, now)
74+
if ok != tt.wantOK {
75+
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
76+
}
77+
if !got.Status.ExpirationTimestamp.Equal(tt.wantExp) {
78+
t.Fatalf("expiration = %s, want %s", got.Status.ExpirationTimestamp, tt.wantExp)
79+
}
80+
})
81+
}
82+
}
83+
84+
func TestIsTruthy(t *testing.T) {
85+
for _, v := range []string{"", "0", "false", "no", "off", " off ", "FALSE"} {
86+
if isTruthy(v) {
87+
t.Errorf("isTruthy(%q) = true, want false", v)
88+
}
89+
}
90+
for _, v := range []string{"1", "true", "yes", "on", "debug", "TRUE"} {
91+
if !isTruthy(v) {
92+
t.Errorf("isTruthy(%q) = false, want true", v)
93+
}
94+
}
95+
}
96+
5197
func TestKeyringBackend(t *testing.T) {
5298
keyring.MockInit()
5399
testBackendRoundTrip(t, keyringBackend{})

0 commit comments

Comments
 (0)