Skip to content

Commit 6ac521f

Browse files
Merge pull request #123642 from aramase/aramase/f/kep_3331_jwks_metrics
Add JWKS fetch metrics for jwt authenticator Kubernetes-commit: b4d4cc93840fa30a305b013acd1b1060ed3f8ee2
2 parents 9251159 + 096ce03 commit 6ac521f

File tree

7 files changed

+861
-10
lines changed

7 files changed

+861
-10
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ require (
4848
gopkg.in/evanphx/json-patch.v4 v4.13.0
4949
gopkg.in/go-jose/go-jose.v2 v2.6.3
5050
gopkg.in/natefinch/lumberjack.v2 v2.2.1
51-
k8s.io/api v0.0.0-20251030032104-67e96f7d5671
51+
k8s.io/api v0.0.0-20251031002201-cc5ff10557fc
5252
k8s.io/apimachinery v0.0.0-20251029233601-5a85a549ac04
5353
k8s.io/client-go v0.0.0-20251030152624-63b5f5942509
5454
k8s.io/component-base v0.0.0-20251030233029-3a1cbfb75a20

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,8 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
295295
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
296296
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
297297
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
298-
k8s.io/api v0.0.0-20251030032104-67e96f7d5671 h1:144MaWZ/WJxhNAP2D+m1n80DpqGDVvC+i3n2a+NIubQ=
299-
k8s.io/api v0.0.0-20251030032104-67e96f7d5671/go.mod h1:t9ALtSJ/Lz32mvweSmEQ4A7Ixha3Bt7kzKKL/gNqQYI=
298+
k8s.io/api v0.0.0-20251031002201-cc5ff10557fc h1:c+B4s9hnmRzTTvUejC44aiqhPfuJCszorD8IN37j3W0=
299+
k8s.io/api v0.0.0-20251031002201-cc5ff10557fc/go.mod h1:t9ALtSJ/Lz32mvweSmEQ4A7Ixha3Bt7kzKKL/gNqQYI=
300300
k8s.io/apimachinery v0.0.0-20251029233601-5a85a549ac04 h1:cT+E5ifXtFO6HabSviCJKBWqZyRqjBesgYc95Bg1UuQ=
301301
k8s.io/apimachinery v0.0.0-20251029233601-5a85a549ac04/go.mod h1:khYq6ZZ3qxhyKXYGU64a438RVSfpfZZ4Xept0x/H3Qw=
302302
k8s.io/client-go v0.0.0-20251030152624-63b5f5942509 h1:K1IfKcFkYj6EP00JJJ6mW64i2iiJliaFBpQreDwY024=

pkg/features/kube_features.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ const (
245245
// Enables Egress Selector in Structured Authentication Configuration
246246
StructuredAuthenticationConfigurationEgressSelector featuregate.Feature = "StructuredAuthenticationConfigurationEgressSelector"
247247

248+
// owner: @aramase, @enj, @nabokihms
249+
// kep: https://kep.k8s.io/3331
250+
//
251+
// Enables JWKs metrics for Structured Authentication Configuration
252+
StructuredAuthenticationConfigurationJWKSMetrics featuregate.Feature = "StructuredAuthenticationConfigurationJWKSMetrics"
253+
248254
// owner: @palnabarun
249255
// kep: https://kep.k8s.io/3221
250256
//
@@ -472,6 +478,10 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate
472478
{Version: version.MustParse("1.34"), Default: true, PreRelease: featuregate.Beta},
473479
},
474480

481+
StructuredAuthenticationConfigurationJWKSMetrics: {
482+
{Version: version.MustParse("1.35"), Default: true, PreRelease: featuregate.Beta},
483+
},
484+
475485
StructuredAuthorizationConfiguration: {
476486
{Version: version.MustParse("1.29"), Default: false, PreRelease: featuregate.Alpha},
477487
{Version: version.MustParse("1.30"), Default: true, PreRelease: featuregate.Beta},

plugin/pkg/authenticator/token/oidc/metrics.go

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,160 @@ var (
4747
},
4848
[]string{"result", "jwt_issuer_hash"},
4949
)
50+
51+
jwksFetchLastTimestampSeconds = metrics.NewGaugeVec(
52+
&metrics.GaugeOpts{
53+
Namespace: namespace,
54+
Subsystem: subsystem,
55+
Name: "jwt_authenticator_jwks_fetch_last_timestamp_seconds",
56+
Help: "Timestamp of the last successful or failed JWKS fetch split by result, api server identity " +
57+
"and jwt issuer for the JWT authenticator.",
58+
StabilityLevel: metrics.ALPHA,
59+
},
60+
[]string{"result", "jwt_issuer_hash", "apiserver_id_hash"},
61+
)
62+
63+
jwksFetchLastKeySetInfo = metrics.NewDesc(
64+
metrics.BuildFQName(namespace, subsystem, "jwt_authenticator_jwks_fetch_last_key_set_info"),
65+
"Information about the last JWKS fetched by the JWT authenticator with hash as label, split by api server identity and jwt issuer.",
66+
[]string{"jwt_issuer_hash", "apiserver_id_hash", "hash"},
67+
nil,
68+
metrics.ALPHA,
69+
"",
70+
)
5071
)
5172

73+
// jwksHashKey uniquely identifies a JWKS by issuer and API server ID
74+
type jwksHashKey struct {
75+
jwtIssuerHash string
76+
apiServerIDHash string
77+
}
78+
79+
// jwksHashProvider manages JWKS hashes for all authenticators
80+
type jwksHashProvider struct {
81+
hashes sync.Map // map[jwksHashKey]string
82+
}
83+
84+
func newJWKSHashProvider() *jwksHashProvider {
85+
return &jwksHashProvider{}
86+
}
87+
88+
func (p *jwksHashProvider) setHash(jwtIssuer, apiServerID, keySet string) {
89+
key := jwksHashKey{
90+
jwtIssuerHash: getHash(jwtIssuer),
91+
apiServerIDHash: getHash(apiServerID),
92+
}
93+
jwksHash := getHash(keySet)
94+
p.hashes.Store(key, jwksHash)
95+
}
96+
97+
func (p *jwksHashProvider) getHashes() map[jwksHashKey]string {
98+
result := make(map[jwksHashKey]string)
99+
p.hashes.Range(func(k, v interface{}) bool {
100+
result[k.(jwksHashKey)] = v.(string)
101+
return true
102+
})
103+
return result
104+
}
105+
106+
func (p *jwksHashProvider) reset() {
107+
p.hashes.Range(func(k, v interface{}) bool {
108+
p.hashes.Delete(k)
109+
return true
110+
})
111+
}
112+
113+
func (p *jwksHashProvider) deleteHash(jwtIssuer, apiServerID string) {
114+
key := jwksHashKey{
115+
jwtIssuerHash: getHash(jwtIssuer),
116+
apiServerIDHash: getHash(apiServerID),
117+
}
118+
p.hashes.Delete(key)
119+
}
120+
121+
// jwksHashCollector is a custom collector that emits JWKS hash metrics
122+
type jwksHashCollector struct {
123+
metrics.BaseStableCollector
124+
desc *metrics.Desc
125+
hashProvider *jwksHashProvider
126+
}
127+
128+
func newJWKSHashCollector(desc *metrics.Desc, hashProvider *jwksHashProvider) metrics.StableCollector {
129+
return &jwksHashCollector{
130+
desc: desc,
131+
hashProvider: hashProvider,
132+
}
133+
}
134+
135+
func (c *jwksHashCollector) DescribeWithStability(ch chan<- *metrics.Desc) {
136+
ch <- c.desc
137+
}
138+
139+
func (c *jwksHashCollector) CollectWithStability(ch chan<- metrics.Metric) {
140+
hashes := c.hashProvider.getHashes()
141+
for key, hash := range hashes {
142+
ch <- metrics.NewLazyConstMetric(
143+
c.desc,
144+
metrics.GaugeValue,
145+
1,
146+
key.jwtIssuerHash,
147+
key.apiServerIDHash,
148+
hash,
149+
)
150+
}
151+
}
152+
52153
var registerMetrics sync.Once
154+
var hashProvider = newJWKSHashProvider()
53155

54156
func RegisterMetrics() {
55157
registerMetrics.Do(func() {
56158
legacyregistry.MustRegister(jwtAuthenticatorLatencyMetric)
159+
legacyregistry.MustRegister(jwksFetchLastTimestampSeconds)
160+
legacyregistry.CustomMustRegister(newJWKSHashCollector(jwksFetchLastKeySetInfo, hashProvider))
57161
})
58162
}
59163

60164
func recordAuthenticationLatency(result, jwtIssuerHash string, duration time.Duration) {
61165
jwtAuthenticatorLatencyMetric.WithLabelValues(result, jwtIssuerHash).Observe(duration.Seconds())
62166
}
63167

168+
func recordJWKSFetchTimestamp(result, jwtIssuer, apiServerID string) {
169+
jwksFetchLastTimestampSeconds.WithLabelValues(result, getHash(jwtIssuer), getHash(apiServerID)).SetToCurrentTime()
170+
}
171+
172+
func recordJWKSFetchKeySetSuccess(jwtIssuer, apiServerID, keySet string) {
173+
recordJWKSFetchKeySetHash(jwtIssuer, apiServerID, keySet)
174+
recordJWKSFetchTimestamp("success", jwtIssuer, apiServerID)
175+
}
176+
177+
func recordJWKSFetchKeySetFailure(jwtIssuer, apiServerID string) {
178+
recordJWKSFetchTimestamp("failure", jwtIssuer, apiServerID)
179+
}
180+
181+
func recordJWKSFetchKeySetHash(jwtIssuer, apiServerID, keySet string) {
182+
hashProvider.setHash(jwtIssuer, apiServerID, keySet)
183+
}
184+
185+
// DeleteJWKSFetchMetrics deletes all JWKS-related metrics for a specific issuer and API server.
186+
// This includes the hash metric and timestamp metrics (both success and failure).
187+
// This should be called when an issuer is removed from the configuration to clean up stale metrics.
188+
func DeleteJWKSFetchMetrics(jwtIssuer, apiServerID string) {
189+
jwtIssuerHash := getHash(jwtIssuer)
190+
apiServerIDHash := getHash(apiServerID)
191+
192+
hashProvider.deleteHash(jwtIssuer, apiServerID)
193+
194+
jwksFetchLastTimestampSeconds.DeleteLabelValues("success", jwtIssuerHash, apiServerIDHash)
195+
jwksFetchLastTimestampSeconds.DeleteLabelValues("failure", jwtIssuerHash, apiServerIDHash)
196+
}
197+
198+
func ResetMetrics() {
199+
jwtAuthenticatorLatencyMetric.Reset()
200+
jwksFetchLastTimestampSeconds.Reset()
201+
hashProvider.reset()
202+
}
203+
64204
func getHash(data string) string {
65205
if len(data) > 0 {
66206
return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(data)))
@@ -73,7 +213,6 @@ func newInstrumentedAuthenticator(jwtIssuer string, delegate AuthenticatorTokenW
73213
}
74214

75215
func newInstrumentedAuthenticatorWithClock(jwtIssuer string, delegate AuthenticatorTokenWithHealthCheck, clock clock.PassiveClock) *instrumentedAuthenticator {
76-
RegisterMetrics()
77216
return &instrumentedAuthenticator{
78217
jwtIssuerHash: getHash(jwtIssuer),
79218
delegate: delegate,

plugin/pkg/authenticator/token/oidc/metrics_test.go

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ import (
2929
)
3030

3131
const (
32-
testIssuer = "testIssuer"
32+
testIssuer = "testIssuer"
33+
testAPIServerID = "testAPIServerID"
34+
testKeySet = `{"keys":[{"kty":"RSA","use":"sig","kid":"test"}]}`
3335
)
3436

3537
func TestRecordAuthenticationLatency(t *testing.T) {
@@ -131,3 +133,125 @@ func (d dummyClock) Now() time.Time {
131133
func (d dummyClock) Since(t time.Time) time.Duration {
132134
return time.Duration(1)
133135
}
136+
137+
func TestRecordJWKSFetchKeySetSuccess(t *testing.T) {
138+
expectedValue := `
139+
# HELP apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info [ALPHA] Information about the last JWKS fetched by the JWT authenticator with hash as label, split by api server identity and jwt issuer.
140+
# TYPE apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info gauge
141+
apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info{apiserver_id_hash="sha256:14f9d63e669337ac6bfda2e2162915ee6a6067743eddd4e5c374b572f951ff37",hash="sha256:d132d414ef2da3d863abd7bf0165c00403ef1d3510faf8fdf1d7cf335c888e53",jwt_issuer_hash="sha256:29b34beedc55b972f2428f21bc588f9d38e5e8f7a7af825486e7bb4fd9caa2ad"} 1
142+
`
143+
144+
metrics := []string{
145+
namespace + "_" + subsystem + "_jwt_authenticator_jwks_fetch_last_key_set_info",
146+
}
147+
148+
ResetMetrics()
149+
RegisterMetrics()
150+
151+
recordJWKSFetchKeySetSuccess(testIssuer, testAPIServerID, testKeySet)
152+
153+
if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(expectedValue), metrics...); err != nil {
154+
t.Fatal(err)
155+
}
156+
}
157+
158+
func TestRecordJWKSFetchKeySetFailure(t *testing.T) {
159+
ResetMetrics()
160+
RegisterMetrics()
161+
162+
recordJWKSFetchKeySetFailure(testIssuer, testAPIServerID)
163+
164+
metrics, err := legacyregistry.DefaultGatherer.Gather()
165+
if err != nil {
166+
t.Fatal(err)
167+
}
168+
169+
found := false
170+
for _, m := range metrics {
171+
if m.GetName() == namespace+"_"+subsystem+"_jwt_authenticator_jwks_fetch_last_timestamp_seconds" {
172+
found = true
173+
break
174+
}
175+
}
176+
if !found {
177+
t.Fatal("Expected jwt_authenticator_jwks_fetch_last_timestamp_seconds metric to be present")
178+
}
179+
}
180+
181+
func TestJWKSHashCollector_MultipleAuthenticators(t *testing.T) {
182+
expectedValue := `
183+
# HELP apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info [ALPHA] Information about the last JWKS fetched by the JWT authenticator with hash as label, split by api server identity and jwt issuer.
184+
# TYPE apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info gauge
185+
apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info{apiserver_id_hash="sha256:14f9d63e669337ac6bfda2e2162915ee6a6067743eddd4e5c374b572f951ff37",hash="sha256:d132d414ef2da3d863abd7bf0165c00403ef1d3510faf8fdf1d7cf335c888e53",jwt_issuer_hash="sha256:29b34beedc55b972f2428f21bc588f9d38e5e8f7a7af825486e7bb4fd9caa2ad"} 1
186+
apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info{apiserver_id_hash="sha256:14f9d63e669337ac6bfda2e2162915ee6a6067743eddd4e5c374b572f951ff37",hash="sha256:1b5293c65ffc96e13f2d6fefae782190aec8cfb89957a3109419d4f47b80e3e8",jwt_issuer_hash="sha256:f10ab1bafaa1a8628d0fae41ee554948912b01957e4a2db1698fc1c3e4451682"} 1
187+
`
188+
189+
metrics := []string{
190+
namespace + "_" + subsystem + "_jwt_authenticator_jwks_fetch_last_key_set_info",
191+
}
192+
193+
ResetMetrics()
194+
RegisterMetrics()
195+
196+
recordJWKSFetchKeySetSuccess(testIssuer, testAPIServerID, testKeySet)
197+
198+
secondIssuer := "https://another-issuer.example.com"
199+
secondKeySet := `{"keys":[{"kty":"EC","use":"sig","kid":"test2"}]}`
200+
recordJWKSFetchKeySetSuccess(secondIssuer, testAPIServerID, secondKeySet)
201+
202+
if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(expectedValue), metrics...); err != nil {
203+
t.Fatal(err)
204+
}
205+
}
206+
207+
func TestJWKSHashCollector_UpdateExistingHash(t *testing.T) {
208+
expectedValue := `
209+
# HELP apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info [ALPHA] Information about the last JWKS fetched by the JWT authenticator with hash as label, split by api server identity and jwt issuer.
210+
# TYPE apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info gauge
211+
apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info{apiserver_id_hash="sha256:14f9d63e669337ac6bfda2e2162915ee6a6067743eddd4e5c374b572f951ff37",hash="sha256:1b5293c65ffc96e13f2d6fefae782190aec8cfb89957a3109419d4f47b80e3e8",jwt_issuer_hash="sha256:29b34beedc55b972f2428f21bc588f9d38e5e8f7a7af825486e7bb4fd9caa2ad"} 1
212+
`
213+
214+
metrics := []string{
215+
namespace + "_" + subsystem + "_jwt_authenticator_jwks_fetch_last_key_set_info",
216+
}
217+
218+
ResetMetrics()
219+
RegisterMetrics()
220+
221+
recordJWKSFetchKeySetSuccess(testIssuer, testAPIServerID, testKeySet)
222+
223+
// Update with new JWKS - should replace old hash with new one
224+
updatedKeySet := `{"keys":[{"kty":"EC","use":"sig","kid":"test2"}]}`
225+
recordJWKSFetchKeySetSuccess(testIssuer, testAPIServerID, updatedKeySet)
226+
227+
if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(expectedValue), metrics...); err != nil {
228+
t.Fatal(err)
229+
}
230+
}
231+
232+
func TestJWKSHashCollector_DeleteHash(t *testing.T) {
233+
ResetMetrics()
234+
RegisterMetrics()
235+
236+
recordJWKSFetchKeySetSuccess(testIssuer, testAPIServerID, testKeySet)
237+
secondIssuer := "https://another-issuer.example.com"
238+
secondKeySet := `{"keys":[{"kty":"EC","use":"sig","kid":"test2"}]}`
239+
recordJWKSFetchKeySetSuccess(secondIssuer, testAPIServerID, secondKeySet)
240+
241+
// Delete first authenticator's metrics and verify only second authenticator's hash remains
242+
DeleteJWKSFetchMetrics(testIssuer, testAPIServerID)
243+
244+
expectedValue := `
245+
# HELP apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info [ALPHA] Information about the last JWKS fetched by the JWT authenticator with hash as label, split by api server identity and jwt issuer.
246+
# TYPE apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info gauge
247+
apiserver_authentication_jwt_authenticator_jwks_fetch_last_key_set_info{apiserver_id_hash="sha256:14f9d63e669337ac6bfda2e2162915ee6a6067743eddd4e5c374b572f951ff37",hash="sha256:1b5293c65ffc96e13f2d6fefae782190aec8cfb89957a3109419d4f47b80e3e8",jwt_issuer_hash="sha256:f10ab1bafaa1a8628d0fae41ee554948912b01957e4a2db1698fc1c3e4451682"} 1
248+
`
249+
250+
metrics := []string{
251+
namespace + "_" + subsystem + "_jwt_authenticator_jwks_fetch_last_key_set_info",
252+
}
253+
254+
if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(expectedValue), metrics...); err != nil {
255+
t.Fatal(err)
256+
}
257+
}

0 commit comments

Comments
 (0)