forked from baron/baron-sso
headless JWKS 워커 실패 backoff 및 timeout 단축
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
26
common/package-lock.json
generated
26
common/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user