Skip to content

Commit 73243ae

Browse files
committed
Add cache specialized for access tokens
Signed-off-by: Matheus Pimenta <[email protected]>
1 parent 00d1ceb commit 73243ae

File tree

2 files changed

+228
-0
lines changed

2 files changed

+228
-0
lines changed

cache/token.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
Copyright 2025 The Flux authors
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 cache
18+
19+
import (
20+
"sync"
21+
"time"
22+
)
23+
24+
// Token is an interface that represents an access token that can be used
25+
// to authenticate with a cloud provider. The only common method is to get the
26+
// duration of the token, because different providers may have different ways to
27+
// represent the token. For example, Azure and GCP use an opaque string token,
28+
// while AWS uses the pair of access key id and secret access key. Consumers of
29+
// this token should know what type to cast this interface to.
30+
type Token interface {
31+
// GetDuration returns the duration for which the token is valid relative to
32+
// approximately time.Now(). This is used to determine when the token should
33+
// be refreshed.
34+
GetDuration() time.Duration
35+
}
36+
37+
// TokenCache is a thread-safe cache specialized in storing and retrieving
38+
// access tokens. It uses an LRU cache as the underlying storage and takes
39+
// care of expiring tokens in a pessimistic way by storing both a timestamp
40+
// with a monotonic clock (the Go default) and an absolute timestamp created
41+
// from the Unix timestamp of when the token was created. The token is
42+
// considered expired when either timestamps are older than the current time.
43+
// This strategy ensures that expired tokens aren't kept in the cache for
44+
// longer than their expiration time. Also, tokens expire on 80% of their
45+
// lifetime, which is the same strategy used by kubelet for rotating
46+
// ServiceAccount tokens.
47+
type TokenCache struct {
48+
cache *LRU[*tokenItem]
49+
mu sync.Mutex
50+
}
51+
52+
type tokenItem struct {
53+
token Token
54+
mono time.Time
55+
unix time.Time
56+
}
57+
58+
func (ti *tokenItem) expired() bool {
59+
now := time.Now()
60+
return ti.mono.Before(now) || ti.unix.Before(now)
61+
}
62+
63+
// NewTokenCache returns a new TokenCache with the given capacity.
64+
func NewTokenCache(capacity int, opts ...Options) *TokenCache {
65+
cache, _ := NewLRU[*tokenItem](capacity, opts...)
66+
return &TokenCache{cache: cache}
67+
}
68+
69+
// Get returns the token for the given key, or nil if the key is not in the cache.
70+
func (c *TokenCache) Get(key string) Token {
71+
c.mu.Lock()
72+
defer c.mu.Unlock()
73+
74+
item, err := c.cache.Get(key)
75+
if err != nil {
76+
return nil
77+
}
78+
79+
if item.expired() {
80+
c.cache.Delete(key)
81+
return nil
82+
}
83+
84+
return item.token
85+
}
86+
87+
// Set adds a token to the cache with the given key.
88+
func (c *TokenCache) Set(key string, token Token) {
89+
item := c.newTokenItem(token)
90+
c.mu.Lock()
91+
c.cache.Set(key, item)
92+
c.mu.Unlock()
93+
}
94+
95+
// RecordCacheEvent records a cache event (cache_miss or cache_hit) with kind,
96+
// name and namespace of the associated object being reconciled.
97+
func (c *TokenCache) RecordCacheEvent(event, kind, name, namespace string) {
98+
c.cache.RecordCacheEvent(event, kind, name, namespace)
99+
}
100+
101+
// DeleteCacheEvent deletes the cache event (cache_miss or cache_hit) metric for
102+
// the associated object being reconciled, given their kind, name and namespace.
103+
func (c *TokenCache) DeleteCacheEvent(event, kind, name, namespace string) {
104+
c.cache.DeleteCacheEvent(event, kind, name, namespace)
105+
}
106+
107+
func (c *TokenCache) newTokenItem(token Token) *tokenItem {
108+
// Kubelet rotates ServiceAccount tokens when 80% of their lifetime has
109+
// passed, so we'll use the same threshold to consider tokens expired.
110+
//
111+
// Ref: https://github.com/kubernetes/kubernetes/blob/4032177faf21ae2f99a2012634167def2376b370/pkg/kubelet/token/token_manager.go#L172-L174
112+
d := (token.GetDuration() * 8) / 10
113+
114+
mono := time.Now().Add(d)
115+
unix := time.Unix(mono.Unix(), 0)
116+
117+
return &tokenItem{
118+
token: token,
119+
mono: mono,
120+
unix: unix,
121+
}
122+
}

cache/token_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
Copyright 2025 The Flux authors
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 cache_test
18+
19+
import (
20+
"testing"
21+
"time"
22+
23+
. "github.com/onsi/gomega"
24+
25+
"github.com/fluxcd/pkg/cache"
26+
)
27+
28+
type testToken struct {
29+
duration time.Duration
30+
}
31+
32+
func (t *testToken) GetDuration() time.Duration {
33+
return t.duration
34+
}
35+
36+
func TestTokenCache_Lifecycle(t *testing.T) {
37+
g := NewWithT(t)
38+
39+
tc := cache.NewTokenCache(1)
40+
41+
retrieved := tc.Get("test")
42+
g.Expect(retrieved).To(BeNil())
43+
44+
token := &testToken{duration: 100 * time.Second}
45+
tc.Set("test", token)
46+
retrieved = tc.Get("test")
47+
g.Expect(retrieved).To(Equal(token))
48+
49+
token2 := &testToken{duration: 3 * time.Second}
50+
tc.Set("test", token2)
51+
retrieved = tc.Get("test")
52+
g.Expect(retrieved).To(Equal(token2))
53+
g.Expect(retrieved).NotTo(Equal(token))
54+
55+
time.Sleep(3 * time.Second)
56+
retrieved = tc.Get("test")
57+
g.Expect(retrieved).To(BeNil())
58+
}
59+
60+
func TestTokenCache_Expiration(t *testing.T) {
61+
for _, tt := range []struct {
62+
name string
63+
opts []cache.Options
64+
tokenDuration time.Duration
65+
sleepDuration time.Duration
66+
expected bool
67+
}{
68+
{
69+
name: "token does not expire before 80 percent of its duration",
70+
tokenDuration: 5 * time.Second,
71+
sleepDuration: 3 * time.Second,
72+
expected: true,
73+
},
74+
{
75+
name: "token expires after 80 percent of its duration",
76+
tokenDuration: 1 * time.Second,
77+
sleepDuration: 810 * time.Millisecond,
78+
expected: false,
79+
},
80+
{
81+
name: "token with expiration longer than cache max duration expires on max duration",
82+
opts: []cache.Options{cache.WithMaxDuration(1 * time.Second)},
83+
tokenDuration: time.Hour,
84+
sleepDuration: 2 * time.Second,
85+
expected: false,
86+
},
87+
} {
88+
t.Run(tt.name, func(t *testing.T) {
89+
g := NewWithT(t)
90+
91+
tc := cache.NewTokenCache(1, tt.opts...)
92+
93+
token := &testToken{duration: tt.tokenDuration}
94+
tc.Set("test", token)
95+
96+
time.Sleep(tt.sleepDuration)
97+
98+
retrieved := tc.Get("test")
99+
if tt.expected {
100+
g.Expect(retrieved).NotTo(BeNil())
101+
} else {
102+
g.Expect(retrieved).To(BeNil())
103+
}
104+
})
105+
}
106+
}

0 commit comments

Comments
 (0)