Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion components/control-plane/internal/auth/token_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ func (tp *TokenProvider) Token() (string, error) {

tp.token = token
// 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.

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.

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.


return tp.token, nil
}
Expand Down
169 changes: 169 additions & 0 deletions components/control-plane/internal/auth/token_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package auth

import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)

func TestTokenGrantLogContainsSafeRefreshDetails(t *testing.T) {
var output bytes.Buffer
priorWriter := log.Writer()
priorFlags := log.Flags()
priorPrefix := log.Prefix()
log.SetOutput(&output)
log.SetFlags(0)
log.SetPrefix("")
t.Cleanup(func() {
log.SetOutput(priorWriter)
log.SetFlags(priorFlags)
log.SetPrefix(priorPrefix)
})

var grants atomic.Int32
server := newTokenServer(t, &grants)
provider := NewTokenProvider("https://issuer.invalid", "client-id\nforged-entry", "client-secret")
provider.SetTokenEndpoint(server.URL)

if _, err := provider.Token(); err != nil {
t.Fatalf("Token() failed: %v", err)
}

message := output.String()
if !strings.Contains(message, `client "client-id\nforged-entry"; refresh in 4m0s`) {
t.Fatalf("token grant log = %q, want quoted client ID and refresh interval", message)
}
for _, forbidden := range []string{"token-1", "client-secret", "\nforged-entry"} {
if strings.Contains(message, forbidden) {
t.Fatalf("token grant log contains unsafe value %q: %q", forbidden, message)
}
}
}

func TestTokenReusesCachedToken(t *testing.T) {
t.Parallel()

var grants atomic.Int32
server := newTokenServer(t, &grants)

provider := NewTokenProvider("https://issuer.invalid", "client-id", "client-secret")
provider.SetTokenEndpoint(server.URL)

first, err := provider.Token()
if err != nil {
t.Fatalf("first Token() call failed: %v", err)
}
second, err := provider.Token()
if err != nil {
t.Fatalf("second Token() call failed: %v", err)
}

if first != second {
t.Fatalf("Token() returned %q and %q; both calls must return the same token", first, second)
}
if got := grants.Load(); got != 1 {
t.Fatalf("token endpoint received %d grants; it must receive 1", got)
}
}

func TestConcurrentTokenCallsShareCachedToken(t *testing.T) {
t.Parallel()

var grants atomic.Int32
server := newTokenServer(t, &grants)

provider := NewTokenProvider("https://issuer.invalid", "client-id", "client-secret")
provider.SetTokenEndpoint(server.URL)

const callCount = 16
type result struct {
token string
err error
}

start := make(chan struct{})
results := make(chan result, callCount)
for range callCount {
go func() {
<-start
token, err := provider.Token()
results <- result{token: token, err: err}
}()
}
close(start)

for range callCount {
result := <-results
if result.err != nil {
t.Fatalf("Token() failed: %v", result.err)
}
if result.token != "token-1" {
t.Fatalf("Token() returned %q; it must return %q", result.token, "token-1")
}
}

if got := grants.Load(); got != 1 {
t.Fatalf("token endpoint received %d grants; it must receive 1", got)
}
}

func TestTokenRefreshesAfterThreshold(t *testing.T) {
t.Parallel()

var grants atomic.Int32
server := newTokenServer(t, &grants)

provider := NewTokenProvider("https://issuer.invalid", "client-id", "client-secret")
provider.SetTokenEndpoint(server.URL)

requestStarted := time.Now()
first, err := provider.Token()
requestFinished := time.Now()
if err != nil {
t.Fatalf("first Token() call failed: %v", err)
}

provider.mu.Lock()
refreshAt := provider.expiry
provider.expiry = time.Now().Add(-time.Second)
provider.mu.Unlock()

const refreshDelay = 4 * time.Minute
if refreshAt.Before(requestStarted.Add(refreshDelay)) || refreshAt.After(requestFinished.Add(refreshDelay)) {
t.Fatalf("refresh time = %v; want 80 percent of a 300-second lifetime", refreshAt)
}

second, err := provider.Token()
if err != nil {
t.Fatalf("second Token() call failed: %v", err)
}
if first != "token-1" || second != "token-2" {
t.Fatalf("Token() returned %q and %q; want %q and %q", first, second, "token-1", "token-2")
}
if got := grants.Load(); got != 2 {
t.Fatalf("token endpoint received %d grants; it must receive 2", got)
}
}

func newTokenServer(t *testing.T, grants *atomic.Int32) *httptest.Server {
t.Helper()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
grant := grants.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tokenResponse{
AccessToken: fmt.Sprintf("token-%d", grant),
ExpiresIn: 300,
TokenType: "Bearer",
})
}))
t.Cleanup(server.Close)
return server
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
clientRefreshTokenAttribute = "client_credentials.use_refresh_token"
deviceGrantAttribute = "oauth2.device.authorization.grant.enabled"
cibaGrantAttribute = "oidc.ciba.grant.enabled"
builtInServiceAccountScope = "service_account"
defaultAccessTokenLifetimeSecs = 300
)

Expand Down Expand Up @@ -276,7 +277,7 @@ func (c *Client) reconcileConverged(ctx context.Context, spec ServiceAccountSpec
return false, nil
}
if len(client.RedirectURIs) > 0 || len(client.WebOrigins) > 0 ||
len(client.DefaultClientScopes) > 0 || len(client.OptionalClientScopes) > 0 {
!defaultClientScopesConverged(client.DefaultClientScopes) || len(client.OptionalClientScopes) > 0 {
return false, nil
}
lifetime := spec.AccessTokenLifetimeSeconds
Expand Down Expand Up @@ -305,6 +306,13 @@ func (c *Client) reconcileConverged(ctx context.Context, spec ServiceAccountSpec
return c.protocolMappersConverged(ctx, client.ID, spec.GatewayClientID)
}

// 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.

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.

}

// roleMappingSet is the shape Keycloak returns for both user role-mappings and
// client scope-mappings.
type roleMappingSet struct {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,63 @@ func TestReconcileServiceAccountPerformsNoWritesWhenConverged(t *testing.T) {
}
}

func TestReconcileServiceAccountPerformsNoWritesWithBuiltInServiceAccountScope(t *testing.T) {
rep := convergedClientRepresentation()
rep["defaultClientScopes"] = []string{builtInServiceAccountScope}
var writes []string
server := httptest.NewServer(reconcileHandler(t, rep, convergedProtocolMappers(), &writes))
t.Cleanup(server.Close)
client := NewClient(server.URL, "realm", "provisioner", "admin-secret")
if err := client.ReconcileServiceAccount(t.Context(), reconcileSpec(), "service-uuid", "service-subject", true); err != nil {
t.Fatalf("ReconcileServiceAccount() error = %v", err)
}
if len(writes) != 0 {
t.Fatalf("converged reconciliation performed writes: %v", writes)
}
}

func TestUpdateRepresentationSendsEmptyClientScopeLists(t *testing.T) {
type updatePayload struct {
Enabled bool `json:"enabled"`
ServiceAccountsEnabled bool `json:"serviceAccountsEnabled"`
DefaultClientScopes []string `json:"defaultClientScopes"`
OptionalClientScopes []string `json:"optionalClientScopes"`
}
updates := make(chan updatePayload, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/realms/realm/protocol/openid-connect/token":
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "admin-token", "expires_in": 300})
case r.URL.Path == "/admin/realms/realm/clients/service-uuid" && r.Method == http.MethodPut:
var payload updatePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid update payload", http.StatusBadRequest)
return
}
updates <- payload
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, fmt.Sprintf("unexpected %s %s", r.Method, r.URL.Path), http.StatusNotFound)
}
}))
t.Cleanup(server.Close)

client := NewClient(server.URL, "realm", "provisioner", "admin-secret")
if err := client.updateRepresentation(t.Context(), "service-uuid", reconcileSpec(), false); err != nil {
t.Fatalf("updateRepresentation() error = %v", err)
}
payload := <-updates
if payload.Enabled || !payload.ServiceAccountsEnabled {
t.Fatalf("client state = enabled %t, service accounts enabled %t", payload.Enabled, payload.ServiceAccountsEnabled)
}
if payload.DefaultClientScopes == nil || len(payload.DefaultClientScopes) != 0 {
t.Fatalf("default client scopes = %v, want an explicit empty list", payload.DefaultClientScopes)
}
if payload.OptionalClientScopes == nil || len(payload.OptionalClientScopes) != 0 {
t.Fatalf("optional client scopes = %v, want an explicit empty list", payload.OptionalClientScopes)
}
}

func TestReconcileServiceAccountRepairsSecurityBroadeningDrift(t *testing.T) {
// Each case flips exactly one security-relevant field on an otherwise
// converged client. The zero-write predicate must reject every one of them so
Expand All @@ -489,6 +546,15 @@ func TestReconcileServiceAccountRepairsSecurityBroadeningDrift(t *testing.T) {
{name: "direct access grants enabled", mutate: func(rep map[string]any) { rep["directAccessGrantsEnabled"] = true }},
{name: "redirect origin added", mutate: func(rep map[string]any) { rep["redirectUris"] = []string{"https://attacker.example"} }},
{name: "default client scope injected", mutate: func(rep map[string]any) { rep["defaultClientScopes"] = []string{"rogue-scope"} }},
{name: "default client scope added to built-in scope", mutate: func(rep map[string]any) {
rep["defaultClientScopes"] = []string{builtInServiceAccountScope, "rogue-scope"}
}},
{name: "built-in default client scope duplicated", mutate: func(rep map[string]any) {
rep["defaultClientScopes"] = []string{builtInServiceAccountScope, builtInServiceAccountScope}
}},
{name: "optional client scope injected", mutate: func(rep map[string]any) {
rep["optionalClientScopes"] = []string{"rogue-scope"}
}},
{name: "device grant enabled", mutate: func(rep map[string]any) {
rep["attributes"].(map[string]string)[deviceGrantAttribute] = "true"
}},
Expand Down
29 changes: 24 additions & 5 deletions skills/RECONCILE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ skills/

## Reconciliation State

**Last analyzed**: 2026-08-27 (OpenShell Gateway Console GC-W1 complete)
**Last analyzed**: 2026-08-31 (Keycloak event-storm KC-ES-W1 complete)
**Spec corpus**: 40 spec files; the coverage table tracks 32 analyzed feature/spec groups after adding OpenShell Gateway Console
**Codebase commit**: 9984ed0 (fix/console OpenShift Route ingress)
**Codebase commit**: working tree (Keycloak event-storm KC-ES-W1 complete)

### Coverage Summary

Expand All @@ -68,11 +68,11 @@ skills/
| Platform - Sandbox Count | 1 | 6 | 6 | 0 | 0 | 0 | 100% |
| Platform - Local Development | 1 | 25 | 23 | 0 | 1 | 1 | 96% |
| Platform - E2E Testing | 1 | 8 | 8 | 0 | 0 | 0 | 100% |
| Platform - OIDC Integration | 1 | 6 | 5 | 1 | 0 | 0 | 92% |
| Platform - OIDC Integration | 1 | 7 | 6 | 1 | 0 | 0 | 93% |
| Web Console - Architecture | 1 | 28 | 21 | 5 | 2 | 0 | 86% |
| Security - RBAC Enforcement | 1 | 13 | 11 | 0 | 0 | 2 | 85% |
| Standards | 13 | 0 | 0 | 0 | 0 | 0 | N/A |
| **TOTAL** | **32** | **224** | **175** | **18** | **26** | **5** | **82%** |
| **TOTAL** | **32** | **225** | **176** | **18** | **26** | **5** | **82%** |

### Spec Dependency Order

Expand Down Expand Up @@ -133,13 +133,14 @@ Layer 7: web-console/architecture (depends on data-model, security, UI
| SA-11 | Workspace membership is a separate grant | Present | - | `plugins/serviceAccounts/presenter.go`, `components/cli/pkg/serviceaccount/`, `packages/gateway-management-ui/src/service-accounts/` | SA-W1, W4, W5 |
| SA-12 | Scopes are not configurable in version 1 | Present | - | `openapi.serviceAccounts.yaml`, `pkg/keycloak/service_accounts.go` | SA-W1, W3 |
| SA-13 | Auditability and secret redaction | Present | - | `plugins/serviceAccounts/`, `pkg/keycloak/`, generated SDKs, `components/web-console/bff/`, `packages/gateway-management-ui/src/service-accounts/` | SA-W3, W5 |
| SA-14 | Reconciliation and drift repair | Present | Structural reconciliation intentionally does not fetch a delivered secret or mint a token; this follows the stronger secret rule and records the spec contradiction | `plugins/serviceAccounts/service.go`, `pkg/keycloak/service_accounts.go` | SA-W3 |
| SA-14 | Reconciliation and drift repair | Present | The control-plane convergence predicate accepts Keycloak's built-in `service_account` scope and rejects all other client scopes. Structural reconciliation intentionally does not fetch a delivered secret or mint a token; this follows the stronger secret rule and records the spec contradiction. | `components/control-plane/internal/serviceaccountkeycloak/client.go`, `plugins/serviceAccounts/service.go`, `pkg/keycloak/service_accounts.go` | KC-ES-W1 |
| SA-15 | Verification coverage | Present | - | `pkg/keycloak/*_test.go`, `plugins/serviceAccounts/*_test.go`, `pkg/rbac/*_test.go`, `components/cli/pkg/serviceaccount/*_test.go`, `packages/gateway-management-ui/src/service-accounts/*_test.ts*`, `components/web-console/**/*test*` | SA-W1..W6 |

**Scoped analysis notes:**

- The nested API, generated SDKs, CLI, Keycloak lifecycle, reconciliation, cleanup barrier, and gateway-detail management UI now implement the resource's public behavior.
- The SDK and CLI generators now project the nested service-account collection. Generated clients and commands remain reproducible from the OpenAPI contract.
- Keycloak 26.1 and later adds the built-in `service_account` default client scope. The control-plane convergence predicate accepts this provider-managed scope without a write and rejects all additional scopes.
- The spec forbids retrieving or regenerating a delivered client secret during reconciliation, while the full-scope drift scenario asks reconciliation to issue and inspect another Client Credentials token. Creation can perform this token test because it still holds the new secret. Later reconciliation can verify and repair structural Keycloak state but cannot perform a new grant without violating the stronger one-time-secret rule. This remains a specification mismatch; reconciliation will not fetch the secret.
- The BFF forwards `Cache-Control` and `Pragma`, preserving the one-time response's no-store policy end to end.

Expand Down Expand Up @@ -331,6 +332,7 @@ Layer 7: web-console/architecture (depends on data-model, security, UI
| OI-4 | BFF Browser Session Contract | Present | GET /auth/session with identity, roles, expiry; no tokens | `bff/src/auth.ts` | OIDC ✅ |
| OI-5 | Kind OIDC Always-On | Present | OIDC enabled unconditionally in kind-up; KIND_ENABLE_OIDC removed | `scripts/kind/`, `Makefile` | OIDC ✅ |
| OI-6 | Identity Provider Client Security | Partial | redirectUris restricted but port wildcard pattern not supported by Keycloak; needs explicit port URIs | `keycloak.yaml` | Follow-up |
| OI-7 | Control Plane Service Token Reuse | Present | - | `components/control-plane/internal/auth/token_provider.go`, `components/control-plane/internal/auth/token_provider_test.go` | KC-ES-W1 |

### rbac-enforcement.spec.md

Expand Down Expand Up @@ -440,6 +442,21 @@ Layer 7: web-console/architecture (depends on data-model, security, UI

## Wave Plan

### KC-ES-W1: Stop the Keycloak event storm

**Scope:** OI-7, SA-14
**Dependency:** Existing control-plane token provider and service-account Keycloak reconciliation
**Status:** Complete

1. Interpret the token response's `expires_in` value as seconds and keep the 80 percent refresh threshold.
2. Add a regression test that proves that repeated calls reuse one token.
3. Accept an empty default-scope list or one built-in `service_account` scope as converged.
4. Reject every other default scope and every optional scope as drift.
5. Add regression tests for the provider-managed scope and additional-scope drift.
6. Run control-plane tests, race tests, vet, build, alignment, and review checks.

**KC-ES-W1 summary:** The token provider now interprets `expires_in` as seconds and refreshes after 80 percent of the token lifetime. Service-account reconciliation now accepts Keycloak's built-in `service_account` scope without a write and repairs every other client scope. Sequential, concurrent, threshold, no-write, drift, and update-payload tests cover the changes. The complete control-plane test suite, affected-package race tests, vet, lint, build, alignment scan, and independent review passed.

### GC-W1: OpenShift Route support for the Gateway Console

**Scope:** GC-1, GC-5, GC-7, GC-9
Expand Down Expand Up @@ -667,6 +684,8 @@ label-selected pod informer.

| Date | Commit | Action | Coverage | Notes |
|------|--------|--------|----------|-------|
| 2026-08-31 | working tree | Completed Keycloak event-storm KC-ES-W1 | 82% | Corrected the token lifetime unit, reused tokens until the 80 percent threshold, accepted the provider-managed service-account scope, rejected all other client scopes, and added regression tests. OI-7 and SA-14 are present. |
| 2026-08-31 | 9ac4354 | Keycloak event-storm scoped gap analysis | 82% | Found two partial requirements: the token cache uses nanoseconds for `expires_in`, and service-account convergence rejects Keycloak's built-in scope. Planned control-plane wave KC-ES-W1. |
| 2026-08-27 | 9984ed0 | Completed Gateway Console GC-W1 | 82% | Added mode-selected Route exposure, admission readiness, lifecycle cleanup, custom-host RBAC, and tests. All nine console requirements are present. |
| 2026-08-27 | 612b373 | Gateway Console scoped gap analysis | 81% | Added the console spec to the registry and found four partial requirements. Planned one control-plane wave for OpenShift Route exposure, readiness, cleanup, RBAC, and tests. |
| 2026-08-03 | initial | Initial setup | 100% | Baseline with 6 Kinds fully implemented |
Expand Down
Loading
Loading