Skip to content

fix(control-plane): stop Keycloak event storms - #230

Merged
jsell-rh merged 7 commits into
mainfrom
fix/keycloak-event-storm
Aug 31, 2026
Merged

fix(control-plane): stop Keycloak event storms#230
jsell-rh merged 7 commits into
mainfrom
fix/keycloak-event-storm

Conversation

@jsell-rh

Copy link
Copy Markdown
Collaborator

Summary

  • Interpret OAuth expires_in values as seconds and reuse the control-plane token until the 80 percent refresh threshold.
  • Accept only the Keycloak-managed service_account default client scope as converged, while repair still rejects all extra default or optional scopes.
  • Add desired-state specifications, regression tests, and the completed reconciliation checkpoint.

This stops repeated CLIENT_LOGIN events and repeated service-account client repair writes.

Verification

  • go test ./... -count=1
  • go test -race ./internal/auth ./internal/serviceaccountkeycloak -count=1
  • go vet ./...
  • go build ./...
  • make lint-control-plane
  • repository pre-commit and pre-push policy checks
  • scoped alignment and independent review

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Team

Run ID: ba37b9b6-1d71-430b-b6cf-e6fd8388910e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@jsell-rh

jsell-rh commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Amber review: approve

Amber review

Status: Complete

Verdict

APPROVE. This is a tight, well-scoped fix for two real Keycloak event-storm root causes (token-lifetime unit bug and an over-strict service-account convergence predicate), backed by clear regression tests and matching spec updates. I found no blocking, critical, or major issues.

Summary

The expires_in fix is correct: the old time.Duration(float64(expiresIn) * 0.8) treated the seconds value as nanoseconds, so a 300s token "expired" after 240ns and the provider re-authenticated on essentially every call (the CLIENT_LOGIN storm). The new time.Duration(expiresIn) * time.Second * 8 / 10 multiplies before dividing, so no premature truncation and the 80% threshold is honored (240s for a 300s token). The service-account convergence change is equally sound: defaultClientScopesConverged accepts an empty list or exactly ["service_account"] (Keycloak's provider-managed built-in scope), while repair still sends empty scope lists and every other default/optional scope continues to fail closed to the repair path.

Strengths

  • Test coverage is genuinely additive, not a contract rewrite. New tests cover token reuse, concurrent sharing, and post-threshold refresh; the drift table gains service_account + rogue-scope, duplicated service_account, and injected optional-scope cases, all of which must still repair. No pre-existing assertion was flipped from "accepts" to "rejects," so the Test Diff Scrutiny checks pass cleanly.
  • The optional-to-accepted broadening (blank -> service_account) is safe for pre-existing data: an empty default-scope list is still treated as converged, so older Keycloak versions and existing clients need no backfill. The repair path re-emitting empty scopes is idempotent because Keycloak re-adds the built-in scope while service accounts are enabled.
  • Specs and the reconciliation checkpoint are updated alongside the code (OI-7, SA-14), including the Keycloak 26.1 rationale and the older-version fallback.

Minor

  1. [Minor] token_provider.go:91 - the expiry expression is correct but a little opaque. A short intermediate (e.g. ttl := time.Duration(expiresIn) * time.Second; tp.expiry = time.Now().Add(ttl * 8 / 10)) would make the "seconds, then 80%" intent self-evident and guard against a future edit reintroducing the unit bug. Non-blocking.

Cross-PR coordination

No material cross-PR coordination issue requires maintainer action.

Findings Summary (ordered by severity, highest first)

  1. [Minor] Expiry computation is correct but opaque; a named TTL intermediate would harden intent - Readability (token_provider.go:91)

Convention Checklist

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
No secrets in logs or responses Pass
Input validated Pass
Reconcile (not create-or-skip) pattern Pass
Test Diff Scrutiny (no silent contract flips) Pass
Optional -> required has fallback/backfill Pass (broadening is backward-compatible)
Conventional commit messages Pass
Spec updated with behavior change Pass

@jsell-rh jsell-rh left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

APPROVE. This is a tight, well-scoped fix for two real Keycloak event-storm root causes (token-lifetime unit bug and an over-strict service-account convergence predicate), backed by clear regression tests and matching spec updates. I found no blocking, critical, or major issues.

Summary

The expires_in fix is correct: the old time.Duration(float64(expiresIn) * 0.8) treated the seconds value as nanoseconds, so a 300s token "expired" after 240ns and the provider re-authenticated on essentially every call (the CLIENT_LOGIN storm). The new time.Duration(expiresIn) * time.Second * 8 / 10 multiplies before dividing, so no premature truncation and the 80% threshold is honored (240s for a 300s token). The service-account convergence change is equally sound: defaultClientScopesConverged accepts an empty list or exactly ["service_account"] (Keycloak's provider-managed built-in scope), while repair still sends empty scope lists and every other default/optional scope continues to fail closed to the repair path.

Strengths

  • Test coverage is genuinely additive, not a contract rewrite. New tests cover token reuse, concurrent sharing, and post-threshold refresh; the drift table gains service_account + rogue-scope, duplicated service_account, and injected optional-scope cases, all of which must still repair. No pre-existing assertion was flipped from "accepts" to "rejects," so the Test Diff Scrutiny checks pass cleanly.
  • The optional-to-accepted broadening (blank -> service_account) is safe for pre-existing data: an empty default-scope list is still treated as converged, so older Keycloak versions and existing clients need no backfill. The repair path re-emitting empty scopes is idempotent because Keycloak re-adds the built-in scope while service accounts are enabled.
  • Specs and the reconciliation checkpoint are updated alongside the code (OI-7, SA-14), including the Keycloak 26.1 rationale and the older-version fallback.

Minor

  1. [Minor] token_provider.go:91 - the expiry expression is correct but a little opaque. A short intermediate (e.g. ttl := time.Duration(expiresIn) * time.Second; tp.expiry = time.Now().Add(ttl * 8 / 10)) would make the "seconds, then 80%" intent self-evident and guard against a future edit reintroducing the unit bug. Non-blocking.

Cross-PR coordination

No material cross-PR coordination issue requires maintainer action.

Findings Summary (ordered by severity, highest first)

  1. [Minor] Expiry computation is correct but opaque; a named TTL intermediate would harden intent - Readability (token_provider.go:91)

Convention Checklist

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
No secrets in logs or responses Pass
Input validated Pass
Reconcile (not create-or-skip) pattern Pass
Test Diff Scrutiny (no silent contract flips) Pass
Optional -> required has fallback/backfill Pass (broadening is backward-compatible)
Conventional commit messages Pass
Spec updated with behavior change Pass

Comment thread components/control-plane/internal/auth/token_provider.go Outdated
// when service accounts are enabled. The repair payload stays empty because
// Keycloak owns this scope. All other scopes are drift.
func defaultClientScopesConverged(scopes []string) bool {
return len(scopes) == 0 || (len(scopes) == 1 && scopes[0] == "service_account")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads well and is backward-compatible: an empty default-scope list stays converged (older Keycloak / existing clients need no backfill), and only-service_account is accepted as the provider-managed built-in scope. Confirmed that repair still fails closed - service_account plus any other scope, a duplicated service_account, and any optional scope all continue to trigger repair, and the repair payload sends empty scope lists that Keycloak re-populates idempotently. No action needed.

@jsell-rh

jsell-rh commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Amber review: approve

Amber review

Status: Complete

Verdict

APPROVE. This is a tight, correctly-scoped bug fix that eliminates two Keycloak event storms: a token-lifetime unit error that expired the control-plane service token almost instantly, and a scope-convergence predicate that never reached a fixed point against Keycloak's provider-managed service_account scope. The change is well-tested (all test changes are additive, no existing guarantees were flipped) and the desired-state specs and reconciliation checkpoint were updated to match.

What I verified

Root cause 1 - token TTL unit bug (token_provider.go). The old line time.Duration(float64(expiresIn) * 0.8) treated expires_in (seconds) as raw time.Duration nanoseconds, so a 300s token expired in ~240ns and every Token() call minted a fresh grant - producing the CLIENT_LOGIN storm. The new ttl := time.Duration(expiresIn) * time.Second; expiry = now + ttl*8/10 interprets the value as seconds and refreshes at 80% (240s). Integer precedence ((ttl*8)/10) and overflow are both fine for realistic lifetimes. TestTokenRefreshesAfterThreshold asserts the ~240s threshold and TestTokenReusesCachedToken / TestConcurrentTokenCallsShareCachedToken prove exactly one grant is issued. The mutex is held across the network fetch, which correctly collapses the concurrent thundering-herd into a single grant.

Root cause 2 - scope convergence never fixed-points (serviceaccountkeycloak/client.go). Repair sends an empty defaultClientScopes, but Keycloak 26.1+ re-adds the built-in service_account scope whenever serviceAccountsEnabled is true, so the previous len(DefaultClientScopes) > 0 check always saw drift and re-repaired forever. defaultClientScopesConverged now accepts an empty list or exactly ["service_account"] and treats everything else as drift. Fail-closed security posture is preserved: the new drift cases (service_account+rogue, duplicated service_account, any optional scope) are covered and still route to repair.

Test Diff Scrutiny. All test hunks are additive - new test functions and new table cases. No pre-existing assertion was flipped from accept->reject or optional->required, so no silent guarantee was removed. No fallback/backfill concern applies (the empty-list branch preserves the older-Keycloak behavior).

Findings

  • [Minor] Maintainability (serviceaccountkeycloak/client.go:312): the built-in scope name "service_account" is a magic literal in the predicate. Promoting it to a named constant near the other Keycloak field constants would tie it to the spec (openshell-gateway-service-accounts.spec.md) and make the Keycloak-26.1 coupling greppable. Non-blocking.

Cross-PR coordination

Another open pull request hardens management-API JWT auth by enforcing the hypershell-frontend audience and failing closed on gRPC calls whose tokens are not minted for the management API. This PR's TokenProvider is exactly the token that GRPCCredentials attaches to every control-plane->API-server gRPC call, and both PRs also add requirements to specs/platform/oidc-integration.spec.md. Maintainers should coordinate so that the token this PR now caches and reuses carries the audience that the other PR will enforce; otherwise, after that PR merges, the control plane will efficiently reuse a token the API server rejects. This is a shared control-plane-token contract and a merge-order/assumption decision, not just file overlap - please align the two before merging both.

Findings Summary (ordered by severity, highest first):

  1. [Minor] Built-in scope name is a magic literal; prefer a named constant tied to the spec - Maintainability (client.go:312)

Convention Checklist:

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
Reconcile pattern (not create-or-skip) Pass
Proper context propagation Pass
Test Diff Scrutiny (no flipped assertions) Pass
Specs updated to match behavior Pass
Conventional commit messages Pass

@jsell-rh jsell-rh left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

APPROVE. This is a tight, correctly-scoped bug fix that eliminates two Keycloak event storms: a token-lifetime unit error that expired the control-plane service token almost instantly, and a scope-convergence predicate that never reached a fixed point against Keycloak's provider-managed service_account scope. The change is well-tested (all test changes are additive, no existing guarantees were flipped) and the desired-state specs and reconciliation checkpoint were updated to match.

What I verified

Root cause 1 - token TTL unit bug (token_provider.go). The old line time.Duration(float64(expiresIn) * 0.8) treated expires_in (seconds) as raw time.Duration nanoseconds, so a 300s token expired in ~240ns and every Token() call minted a fresh grant - producing the CLIENT_LOGIN storm. The new ttl := time.Duration(expiresIn) * time.Second; expiry = now + ttl*8/10 interprets the value as seconds and refreshes at 80% (240s). Integer precedence ((ttl*8)/10) and overflow are both fine for realistic lifetimes. TestTokenRefreshesAfterThreshold asserts the ~240s threshold and TestTokenReusesCachedToken / TestConcurrentTokenCallsShareCachedToken prove exactly one grant is issued. The mutex is held across the network fetch, which correctly collapses the concurrent thundering-herd into a single grant.

Root cause 2 - scope convergence never fixed-points (serviceaccountkeycloak/client.go). Repair sends an empty defaultClientScopes, but Keycloak 26.1+ re-adds the built-in service_account scope whenever serviceAccountsEnabled is true, so the previous len(DefaultClientScopes) > 0 check always saw drift and re-repaired forever. defaultClientScopesConverged now accepts an empty list or exactly ["service_account"] and treats everything else as drift. Fail-closed security posture is preserved: the new drift cases (service_account+rogue, duplicated service_account, any optional scope) are covered and still route to repair.

Test Diff Scrutiny. All test hunks are additive - new test functions and new table cases. No pre-existing assertion was flipped from accept->reject or optional->required, so no silent guarantee was removed. No fallback/backfill concern applies (the empty-list branch preserves the older-Keycloak behavior).

Findings

  • [Minor] Maintainability (serviceaccountkeycloak/client.go:312): the built-in scope name "service_account" is a magic literal in the predicate. Promoting it to a named constant near the other Keycloak field constants would tie it to the spec (openshell-gateway-service-accounts.spec.md) and make the Keycloak-26.1 coupling greppable. Non-blocking.

Cross-PR coordination

Another open pull request hardens management-API JWT auth by enforcing the hypershell-frontend audience and failing closed on gRPC calls whose tokens are not minted for the management API. This PR's TokenProvider is exactly the token that GRPCCredentials attaches to every control-plane->API-server gRPC call, and both PRs also add requirements to specs/platform/oidc-integration.spec.md. Maintainers should coordinate so that the token this PR now caches and reuses carries the audience that the other PR will enforce; otherwise, after that PR merges, the control plane will efficiently reuse a token the API server rejects. This is a shared control-plane-token contract and a merge-order/assumption decision, not just file overlap - please align the two before merging both.

Findings Summary (ordered by severity, highest first):

  1. [Minor] Built-in scope name is a magic literal; prefer a named constant tied to the spec - Maintainability (client.go:312)

Convention Checklist:

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
Reconcile pattern (not create-or-skip) Pass
Proper context propagation Pass
Test Diff Scrutiny (no flipped assertions) Pass
Specs updated to match behavior Pass
Conventional commit messages Pass

// Refresh at 80% of TTL to avoid using an expired token.
tp.expiry = time.Now().Add(time.Duration(float64(expiresIn) * 0.8))
ttl := time.Duration(expiresIn) * time.Second
tp.expiry = time.Now().Add(ttl * 8 / 10)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root-cause fix confirmed. The prior time.Duration(float64(expiresIn) * 0.8) interpreted expires_in (seconds) as nanoseconds, so a 300s token expired in ~240ns and every call re-authenticated - the CLIENT_LOGIN storm. time.Duration(expiresIn) * time.Second then ttl*8/10 correctly yields a 240s refresh threshold. Precedence (ttl*8)/10 and int64 range are both safe for realistic lifetimes. TestTokenRefreshesAfterThreshold locks in the ~240s expectation.

Comment thread components/control-plane/internal/serviceaccountkeycloak/client.go Outdated
@jsell-rh

Copy link
Copy Markdown
Collaborator Author

Cross-PR audience check for #182: confirmed. The repository-managed hypershell-control-plane Keycloak client has an OIDC audience mapper with included.client.audience set to hypershell-frontend. PR #182 configures the API server with --jwt-audience=hypershell-frontend. These contracts match, so the cached control-plane token will pass the new audience check. An external identity provider must configure its control-plane client with the same audience mapper.

@jsell-rh

jsell-rh commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Amber review: approve

Amber review

Status: Complete

Verdict

APPROVE. This is a tightly-scoped, correct fix for two real defects that together produced the Keycloak event storm: the token cache treated expires_in as nanoseconds (so every gRPC call re-ran the Client Credentials grant), and the service-account convergence predicate rejected Keycloak 26.1's provider-managed service_account scope (so every reconcile repaired the client). The change is well-tested, keeps the security-broadening drift checks intact, and updates the desired-state specs to match.

What I verified

Token lifetime fix (token_provider.go) - The old line time.Now().Add(time.Duration(float64(expiresIn) * 0.8)) interpreted expiresIn=300 as 240ns, so the cache expired almost immediately and every Token() call minted a fresh grant. The new ttl := time.Duration(expiresIn) * time.Second; tp.expiry = time.Now().Add(ttl * 8 / 10) correctly yields a 240s refresh window. Integer ordering (multiply before divide) preserves precision, and the values involved cannot overflow int64 for any realistic expires_in. Correct root-cause fix. Confidence: High.

Scope convergence fix (serviceaccountkeycloak/client.go) - defaultClientScopesConverged accepts an empty list (older Keycloak) or exactly [service_account] (Keycloak 26.1+), while !defaultClientScopesConverged(...) and len(OptionalClientScopes) > 0 still force repair for any other default scope, duplicates, or any optional scope. Repair sends empty scope lists and relies on Keycloak re-adding the built-in scope while service accounts are enabled, which converges on the next pass without looping. The reasoning is documented in the code comment and the spec. Confidence: High.

Test diff scrutiny - The changes to client_test.go are strictly additive: new no-write and update-payload tests plus three new security-broadening drift cases (built-in + rogue scope, duplicated built-in scope, injected optional scope). No pre-existing assertion was flipped from reject->accept; the loosening is confined to the exact provider-managed scope and is covered by paired drift tests. This is the correct way to widen a convergence rule. Confidence: High.

Specs & checkpoint - oidc-integration.spec.md (OI-7) and openshell-gateway-service-accounts.spec.md (SA-14) are updated with matching requirements and scenarios; RECONCILE.md records the wave. Config/behavior stay separate.

@jsell-rh jsell-rh left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

APPROVE. This is a tightly-scoped, correct fix for two real defects that together produced the Keycloak event storm: the token cache treated expires_in as nanoseconds (so every gRPC call re-ran the Client Credentials grant), and the service-account convergence predicate rejected Keycloak 26.1's provider-managed service_account scope (so every reconcile repaired the client). The change is well-tested, keeps the security-broadening drift checks intact, and updates the desired-state specs to match.

What I verified

Token lifetime fix (token_provider.go) - The old line time.Now().Add(time.Duration(float64(expiresIn) * 0.8)) interpreted expiresIn=300 as 240ns, so the cache expired almost immediately and every Token() call minted a fresh grant. The new ttl := time.Duration(expiresIn) * time.Second; tp.expiry = time.Now().Add(ttl * 8 / 10) correctly yields a 240s refresh window. Integer ordering (multiply before divide) preserves precision, and the values involved cannot overflow int64 for any realistic expires_in. Correct root-cause fix. Confidence: High.

Scope convergence fix (serviceaccountkeycloak/client.go) - defaultClientScopesConverged accepts an empty list (older Keycloak) or exactly [service_account] (Keycloak 26.1+), while !defaultClientScopesConverged(...) and len(OptionalClientScopes) > 0 still force repair for any other default scope, duplicates, or any optional scope. Repair sends empty scope lists and relies on Keycloak re-adding the built-in scope while service accounts are enabled, which converges on the next pass without looping. The reasoning is documented in the code comment and the spec. Confidence: High.

Test diff scrutiny - The changes to client_test.go are strictly additive: new no-write and update-payload tests plus three new security-broadening drift cases (built-in + rogue scope, duplicated built-in scope, injected optional scope). No pre-existing assertion was flipped from reject->accept; the loosening is confined to the exact provider-managed scope and is covered by paired drift tests. This is the correct way to widen a convergence rule. Confidence: High.

Specs & checkpoint - oidc-integration.spec.md (OI-7) and openshell-gateway-service-accounts.spec.md (SA-14) are updated with matching requirements and scenarios; RECONCILE.md records the wave. Config/behavior stay separate.

Cross-PR coordination

No material cross-PR coordination issue requires maintainer action.

Findings Summary (ordered by severity, highest first)

  1. [Minor] No log or metric is emitted when a new Client Credentials grant is obtained, which makes future grant-frequency regressions (the exact class of bug this PR fixes) hard to observe - Observability (token_provider.go L89-L92)

Convention Checklist

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
No secrets in logs or responses Pass
Reconcile (update-or-create), not create-or-skip Pass
Proper context propagation Pass
Test Diff Scrutiny (no silently-flipped assertions) Pass
Spec/desired-state updated to match code Pass
Conventional commit messages Pass

Comment thread components/control-plane/internal/auth/token_provider.go Outdated
@jsell-rh

jsell-rh commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Amber review: approve

Amber review

Status: Complete

View the submitted review.

@jsell-rh jsell-rh left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

APPROVE. This is a correct, well-scoped fix for a real production defect (a units bug that turned every control-plane gRPC call into a fresh Keycloak grant, plus a convergence predicate that repaired Keycloak's own built-in scope forever), and it lands with focused regression tests, a fallback for older Keycloak, and matching desired-state specs. No blocking issues; only minor observations.

What this fixes (root cause)

Two independent causes of the Keycloak event storm are addressed:

  1. Token TTL unit bug (token_provider.go): time.Duration(float64(expiresIn) * 0.8) interpreted expires_in seconds as nanoseconds. A 300s token was cached for ~240ns, so nearly every call minted a new client_credentials token → repeated CLIENT_LOGIN events. Now time.Duration(expiresIn) * time.Second with an integer 80% threshold. Verified correct; no overflow for realistic lifetimes.
  2. Service-account convergence loop (serviceaccountkeycloak/client.go): Keycloak 26.1+ auto-adds a built-in service_account default client scope when serviceAccountsEnabled is true. The old zero-write predicate treated any non-empty default scope as drift, so every reconcile "repaired" a scope Keycloak immediately re-added → repeated client-repair writes. defaultClientScopesConverged now accepts an empty list or exactly ["service_account"], while repair keeps sending empty scope lists.

Strengths

  • Security-conscious logging: only the client ID is logged (via %q, which neutralizes CR/LF log injection); no token or secret is emitted. A test asserts this.
  • Good drift coverage: extra default scope, duplicated built-in scope, and injected optional scope all still fail closed to repair.
  • Backward compatible: the empty-scope branch preserves behavior for pre-26.1 Keycloak, so no backfill/migration is required for existing clients.
  • Concurrency and refresh-threshold behavior are covered by sequential, concurrent, and threshold tests.

Test Diff Scrutiny

The changes to client_test.go are additive: new no-write/update-payload tests and new drift table cases. The pre-existing default client scope injected (rogue-scope) assertion is retained, and no existing assertion was flipped from accept→reject. The predicate change is a documented loosening (accept the provider-managed scope) with a preserved fallback (empty list), so it does not delete a prior guarantee.

Minor observations

  • The INFO/WARN string-prefix logging matches existing style in the file; if structured logging is adopted later, promote client/refresh to fields. Not blocking.

Cross-PR coordination

No material cross-PR coordination issue requires maintainer action.


Findings Summary (ordered by severity, highest first):

  1. [Minor] Grant log uses a string level prefix rather than structured fields; safe and consistent with existing code, no change required - Observability (token_provider.go L94)

Convention Checklist:

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
No secrets in logs or error messages Pass
Input validated / log injection prevented Pass
Reconcile pattern (fail-closed to repair) Pass
Test Diff Scrutiny (no silently flipped assertions) Pass
Conventional commit message Pass

// Refresh at 80% of TTL to avoid using an expired token.
tp.expiry = time.Now().Add(time.Duration(float64(expiresIn) * 0.8))
ttl := time.Duration(expiresIn) * time.Second
refreshAfter := ttl * 8 / 10

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root cause confirmed and correctly fixed. The prior time.Duration(float64(expiresIn) * 0.8) treated expiresIn (a value in seconds) as nanoseconds, so a 300s token was cached for ~240ns and effectively every Token() call re-ran the client_credentials grant — that is the Keycloak CLIENT_LOGIN event storm. time.Duration(expiresIn) * time.Second restores the intended unit and the 80% threshold. Confidence: High.

The integer form ttl * 8 / 10 avoids float rounding and cannot overflow for any realistic expires_in (max 900s), so this is a clean choice.

ttl := time.Duration(expiresIn) * time.Second
refreshAfter := ttl * 8 / 10
tp.expiry = time.Now().Add(refreshAfter)
log.Printf("INFO got OIDC access token for client %q; refresh in %s", tp.clientID, refreshAfter)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (observability / security). The grant log records only clientID via %q — which escapes embedded CR/LF and blocks log injection — and never logs the token or client secret, matching security.spec.md. The regression test asserts both properties, which is exactly right. No change required; if the control plane later adopts structured logging you may want to move client/refresh to fields.

// defaultClientScopesConverged accepts the built-in scope that Keycloak adds
// when service accounts are enabled. The repair payload stays empty because
// Keycloak owns this scope. All other scopes are drift.
func defaultClientScopesConverged(scopes []string) bool {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The predicate loosening is safe and self-terminating: defaultClientScopesConverged accepts only an empty list (older Keycloak) or exactly ["service_account"] (Keycloak 26.1+ built-in), while repair still PUTs empty scope lists and Keycloak re-adds only its own service_account scope — so the accept/repair cycle converges instead of looping. Drift cases (extra default scope, duplicated built-in, any optional scope) still fail closed to repair and are covered by the new table cases. This is additive to the existing rogue-scope guarantee, not a flipped assertion. Confidence: High.

@jsell-rh
jsell-rh enabled auto-merge August 31, 2026 20:35
@jsell-rh

jsell-rh commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Amber review: approve

Amber review

Status: Complete

View the submitted review.

@jsell-rh jsell-rh left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

This PR fixes two real production defects: the control-plane OAuth token cache treated expires_in as nanoseconds (so a 300s token "expired" after 240ns and was re-fetched on nearly every gRPC call), and the service-account convergence predicate rejected Keycloak 26.1+'s auto-added built-in service_account default scope (so every reconcile issued a repair write). Both fixes are correct, minimal, spec-backed, and covered by strong additive tests (sequential/concurrent/threshold reuse, provider-managed scope, and broadening-drift cases); I found no blocking issues.

What this fixes

  • token_provider.go: time.Duration(float64(expiresIn) * 0.8) interpreted the numeric TTL as nanoseconds. For expires_in=300 the cache window was 240ns, so the token was effectively never reused and each gRPC call triggered a fresh client_credentials grant — the CLIENT_LOGIN event storm. Multiplying by time.Second and computing ttl * 8 / 10 is the correct 80%-of-lifetime refresh window.
  • serviceaccountkeycloak/client.go: defaultClientScopesConverged now accepts an empty list or exactly ["service_account"] as converged, while any additional default scope, any duplicate, and any optional scope still fail closed to repair. This stops the repeated no-op repair writes without weakening drift detection.

Test Diff Scrutiny

No pre-existing assertion was flipped to erase a guarantee. The changes to client_test.go are additive and strengthen coverage: new drift cases assert that service_account + a rogue scope, a duplicated built-in scope, and an injected optional scope are all still repaired. The convergence predicate is loosened only for the exact provider-managed scope, and that loosening is proven safe by the accompanying no-write test plus the new drift tests. This is the intended fix, not a silently removed contract.

Findings

  • [Minor / Low confidence] The repair path (updateRepresentation) sends an explicit empty defaultClientScopes, relying on Keycloak to re-add the built-in service_account scope while serviceAccountsEnabled is true. This matches the spec's stated behavior and is exercised by TestUpdateRepresentationSendsEmptyClientScopeLists, but it is an assumption about Keycloak's server-side behavior (client-representation PUT vs. the default-client-scopes sub-resource). Worth a one-line confirmation against a live Keycloak 26.1+ if not already validated. Not blocking.

Convention Checklist

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
No secrets in logs or responses Pass (%q quotes client ID, escaping control chars; secret/token never logged, verified by TestTokenGrantLogContainsSafeRefreshDetails)
Log injection prevented Pass (%q on operator-supplied client ID)
Reconcile pattern (not create-or-skip) Pass
Spec updated to match behavior Pass
Tests additive, no flipped guarantees Pass

Cross-PR coordination

Another open pull request introduces mandatory JWT audience enforcement on the management API (requiring aud=hypershell-frontend on non-bypassed HTTP and gRPC endpoints) and assumes the control plane's service-account token already carries that audience via a dedicated Keycloak mapper. This PR makes the control plane cache and reuse that client_credentials token (attached to every outgoing gRPC call via PerRPCCredentials, including non-watch authenticated calls such as status write-backs, which are not in the JWT-bypass method set). Maintainers should decide and confirm that the hypershell-control-plane Keycloak client is provisioned with the hypershell-frontend audience mapper before the audience-enforcement change lands; otherwise the long-lived token this PR now reuses will be rejected as unauthenticated on those calls. This is a shared-assumption/ordering decision between the two changes, not a text merge conflict. See #182.

ttl := time.Duration(expiresIn) * time.Second
refreshAfter := ttl * 8 / 10
tp.expiry = time.Now().Add(refreshAfter)
log.Printf("INFO got OIDC access token for client %q; refresh in %s", tp.clientID, refreshAfter)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root-cause fix confirmed. The previous time.Duration(float64(expiresIn) * 0.8) treated the integer TTL as nanoseconds, so expires_in=300 produced a 240ns cache window and the token was re-fetched on nearly every gRPC call — the CLIENT_LOGIN storm. time.Duration(expiresIn) * time.Second then ttl * 8 / 10 is correct. The new INFO log is safe: %q quotes/escapes the operator-supplied client ID (log-injection safe) and neither the token nor secret is logged.

// when service accounts are enabled. The repair payload stays empty because
// Keycloak owns this scope. All other scopes are drift.
func defaultClientScopesConverged(scopes []string) bool {
return len(scopes) == 0 || (len(scopes) == 1 && scopes[0] == builtInServiceAccountScope)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fail-closed predicate: only an empty list or exactly ["service_account"] converges; extras, duplicates, and optional scopes still route to repair (covered by the new drift cases). One thing to confirm: updateRepresentation sends an empty defaultClientScopes and relies on Keycloak re-adding the built-in scope while serviceAccountsEnabled is true. That matches the spec, but since Keycloak also exposes a dedicated default-client-scopes sub-resource, a quick check against a live Keycloak 26.1+ that the client-representation PUT truly preserves service_account would remove the last assumption here. Non-blocking.

@jsell-rh
jsell-rh added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit e398aee Aug 31, 2026
17 checks passed
@jsell-rh
jsell-rh deleted the fix/keycloak-event-storm branch August 31, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant