From 16d43c59733244fa8e75ce0fa9976d280ae156e8 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 20 May 2026 11:10:05 +0900 Subject: [PATCH] =?UTF-8?q?headless=20JWKS=20=EC=9B=8C=EC=BB=A4=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20backoff=20=EB=B0=8F=20timeout=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/domain/headless_jwks_cache.go | 1 + .../internal/service/headless_jwks_cache.go | 71 +++++++++++++++---- .../service/headless_jwks_cache_test.go | 43 +++++++++++ common/package-lock.json | 26 +++---- 4 files changed, 116 insertions(+), 25 deletions(-) diff --git a/backend/internal/domain/headless_jwks_cache.go b/backend/internal/domain/headless_jwks_cache.go index 082b69d6..84d0a262 100644 --- a/backend/internal/domain/headless_jwks_cache.go +++ b/backend/internal/domain/headless_jwks_cache.go @@ -17,6 +17,7 @@ type HeadlessJWKSCacheState struct { CachedAt *time.Time `json:"cachedAt,omitempty"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"` + NextRetryAt *time.Time `json:"nextRetryAt,omitempty"` LastSuccessfulVerificationAt *time.Time `json:"lastSuccessfulVerificationAt,omitempty"` LastRefreshStatus string `json:"lastRefreshStatus,omitempty"` LastError string `json:"lastError,omitempty"` diff --git a/backend/internal/service/headless_jwks_cache.go b/backend/internal/service/headless_jwks_cache.go index 2413a662..4ca67471 100644 --- a/backend/internal/service/headless_jwks_cache.go +++ b/backend/internal/service/headless_jwks_cache.go @@ -20,11 +20,13 @@ const ( ) type HeadlessJWKSCacheService struct { - Redis domain.RedisRepository - HTTPClient *http.Client - TTL time.Duration - PrefetchWindow time.Duration - RequestTimeout time.Duration + Redis domain.RedisRepository + HTTPClient *http.Client + TTL time.Duration + PrefetchWindow time.Duration + RequestTimeout time.Duration + FailureThreshold int + FailureBackoff time.Duration } type headlessJWKSCacheStateStore struct { @@ -33,6 +35,7 @@ type headlessJWKSCacheStateStore struct { CachedAt *time.Time `json:"cachedAt,omitempty"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"` + NextRetryAt *time.Time `json:"nextRetryAt,omitempty"` LastSuccessfulVerificationAt *time.Time `json:"lastSuccessfulVerificationAt,omitempty"` LastRefreshStatus string `json:"lastRefreshStatus,omitempty"` LastError string `json:"lastError,omitempty"` @@ -61,17 +64,29 @@ func NewHeadlessJWKSCacheService(redis domain.RedisRepository, httpClient *http. prefetchSeconds = 600 } - timeoutSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FETCH_TIMEOUT_SECONDS", "5"))) + timeoutSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FETCH_TIMEOUT_SECONDS", "2"))) if timeoutSeconds <= 0 { - timeoutSeconds = 5 + timeoutSeconds = 2 + } + + failureThreshold, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FAILURE_THRESHOLD", "3"))) + if failureThreshold <= 0 { + failureThreshold = 3 + } + + backoffSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FAILURE_BACKOFF_SECONDS", "1800"))) + if backoffSeconds <= 0 { + backoffSeconds = 1800 } return &HeadlessJWKSCacheService{ - Redis: redis, - HTTPClient: httpClient, - TTL: time.Duration(ttlSeconds) * time.Second, - PrefetchWindow: time.Duration(prefetchSeconds) * time.Second, - RequestTimeout: time.Duration(timeoutSeconds) * time.Second, + Redis: redis, + HTTPClient: httpClient, + TTL: time.Duration(ttlSeconds) * time.Second, + PrefetchWindow: time.Duration(prefetchSeconds) * time.Second, + RequestTimeout: time.Duration(timeoutSeconds) * time.Second, + FailureThreshold: failureThreshold, + FailureBackoff: time.Duration(backoffSeconds) * time.Second, } } @@ -115,6 +130,7 @@ func (s *HeadlessJWKSCacheService) SaveState(clientID string, state domain.Headl CachedAt: state.CachedAt, ExpiresAt: state.ExpiresAt, LastCheckedAt: state.LastCheckedAt, + NextRetryAt: state.NextRetryAt, LastSuccessfulVerificationAt: state.LastSuccessfulVerificationAt, LastRefreshStatus: state.LastRefreshStatus, LastError: state.LastError, @@ -151,6 +167,7 @@ func (s *HeadlessJWKSCacheService) GetState(clientID string) (*domain.HeadlessJW CachedAt: stored.CachedAt, ExpiresAt: stored.ExpiresAt, LastCheckedAt: stored.LastCheckedAt, + NextRetryAt: stored.NextRetryAt, LastSuccessfulVerificationAt: stored.LastSuccessfulVerificationAt, LastRefreshStatus: stored.LastRefreshStatus, LastError: stored.LastError, @@ -193,6 +210,9 @@ func (s *HeadlessJWKSCacheService) ShouldPrefetch(state *domain.HeadlessJWKSCach if state == nil { return true } + if s.ShouldSkipRefresh(state, now) { + return false + } if strings.TrimSpace(state.RawJWKS) == "" { return true } @@ -202,6 +222,13 @@ func (s *HeadlessJWKSCacheService) ShouldPrefetch(state *domain.HeadlessJWKSCach return !state.ExpiresAt.After(now.Add(s.PrefetchWindow)) } +func (s *HeadlessJWKSCacheService) ShouldSkipRefresh(state *domain.HeadlessJWKSCacheState, now time.Time) bool { + if state == nil || state.NextRetryAt == nil { + return false + } + return state.NextRetryAt.After(now) +} + func (s *HeadlessJWKSCacheService) EnsureFreshKeySet(ctx context.Context, client domain.HydraClient, expectedKid string) (*jose.JSONWebKeySet, *domain.HeadlessJWKSCacheState, bool, error) { if s == nil { return nil, nil, false, fmt.Errorf("headless jwks cache service is not configured") @@ -283,6 +310,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom updated.JWKSURI = jwksURI updated.LastCheckedAt = &now updated.ExpiresAt = ptrTime(now.Add(s.TTL)) + updated.NextRetryAt = nil updated.LastRefreshStatus = "success" updated.LastError = "" updated.ConsecutiveFailures = 0 @@ -313,6 +341,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom CachedAt: &now, ExpiresAt: ptrTime(now.Add(s.TTL)), LastCheckedAt: &now, + NextRetryAt: nil, LastSuccessfulVerificationAt: previousLastVerification(previous), LastRefreshStatus: "success", LastError: "", @@ -349,10 +378,28 @@ func (s *HeadlessJWKSCacheService) persistRefreshFailure(client domain.HydraClie state.RawJWKS = previous.RawJWKS state.ConsecutiveFailures = previous.ConsecutiveFailures + 1 } + if s.shouldBackoff(state.ConsecutiveFailures) { + state.NextRetryAt = ptrTime(now.Add(s.failureBackoffDuration())) + } _ = s.SaveState(client.ClientID, state) return &state } +func (s *HeadlessJWKSCacheService) shouldBackoff(consecutiveFailures int) bool { + threshold := s.FailureThreshold + if threshold <= 0 { + threshold = 3 + } + return consecutiveFailures >= threshold +} + +func (s *HeadlessJWKSCacheService) failureBackoffDuration() time.Duration { + if s.FailureBackoff > 0 { + return s.FailureBackoff + } + return 30 * time.Minute +} + func decodeHeadlessJWKS(raw string) (*jose.JSONWebKeySet, error) { var keySet jose.JSONWebKeySet if err := json.Unmarshal([]byte(raw), &keySet); err != nil { diff --git a/backend/internal/service/headless_jwks_cache_test.go b/backend/internal/service/headless_jwks_cache_test.go index c4b650dd..8d835eef 100644 --- a/backend/internal/service/headless_jwks_cache_test.go +++ b/backend/internal/service/headless_jwks_cache_test.go @@ -143,6 +143,49 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_RefreshesWhenKidMissing(t *t assert.Equal(t, []string{"fresh-key"}, stored.CachedKids) } +func TestHeadlessJWKSCacheService_PersistRefreshFailure_SetsNextRetryAtAfterThreshold(t *testing.T) { + redisRepo := &headlessJWKSCacheTestRedis{} + cacheService := NewHeadlessJWKSCacheService(redisRepo, nil) + cacheService.FailureThreshold = 3 + cacheService.FailureBackoff = 15 * time.Minute + + client := domain.HydraClient{ + ClientID: "client-headless", + Metadata: map[string]any{ + domain.MetadataHeadlessLoginEnabled: true, + domain.MetadataHeadlessJWKSURI: "https://rp.example.com/.well-known/jwks.json", + }, + } + + previous := &domain.HeadlessJWKSCacheState{ + ClientID: client.ClientID, + JWKSURI: "https://rp.example.com/.well-known/jwks.json", + LastRefreshStatus: "failure", + ConsecutiveFailures: 2, + } + + state := cacheService.persistRefreshFailure(client, previous, assert.AnError) + require.NotNil(t, state) + assert.Equal(t, 3, state.ConsecutiveFailures) + require.NotNil(t, state.NextRetryAt) + assert.WithinDuration(t, time.Now().Add(15*time.Minute), *state.NextRetryAt, 3*time.Second) +} + +func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testing.T) { + cacheService := NewHeadlessJWKSCacheService(&headlessJWKSCacheTestRedis{}, nil) + now := time.Now() + + state := &domain.HeadlessJWKSCacheState{ + ClientID: "client-headless", + LastRefreshStatus: "failure", + ConsecutiveFailures: 3, + NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)), + } + + assert.False(t, cacheService.ShouldPrefetch(state, now)) + assert.True(t, cacheService.ShouldPrefetch(state, now.Add(11*time.Minute))) +} + func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.JSONWebKeySet) { t.Helper() diff --git a/common/package-lock.json b/common/package-lock.json index 04c0031f..c0ce14f5 100644 --- a/common/package-lock.json +++ b/common/package-lock.json @@ -2522,9 +2522,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3164,9 +3164,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.359", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.359.tgz", - "integrity": "sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==", + "version": "1.5.360", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz", + "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==", "dev": true, "license": "ISC" }, @@ -3999,9 +3999,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -4285,9 +4285,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4305,7 +4305,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" },