Skip to content

Commit fb6b5d6

Browse files
committed
Safeguard configured client reconciliation
1 parent 05b4beb commit fb6b5d6

9 files changed

Lines changed: 440 additions & 111 deletions

File tree

‎docs/arch/11-auth-server-storage.md‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -253,23 +253,29 @@ in-memory backend, which has always enforced the pinned method.
253253

254254
## Configured-client reconciliation
255255

256-
Operator-declared delegate and SPIFFE clients are reconciled as one desired set at
257-
embedded auth-server startup. Redis rows written by this path carry a
258-
`configured` ownership marker. Rows without that marker are legacy data and are
259-
never pruned; DCR-issued rows are also excluded.
260-
261-
Redis replicas publish their desired client IDs and logical fingerprints under a
262-
short-lived lease. A live lease protects its claimed IDs during rolling
263-
updates. Reconciliation uses cursor-based `SCAN` over the client namespace and
264-
rechecks candidate rows before deletion, so concurrent registration or
265-
reconciliation cannot be removed from a stale read. Conflicting fingerprints
266-
for the same client ID fail reconciliation instead of silently overwriting one
267-
replica's configuration.
268-
269-
The server performs an initial reconciliation before serving requests, renews
270-
its lease while running, and releases it before closing storage. Memory storage
271-
uses the same desired-set semantics under its existing mutex and treats lease
272-
operations as process-local no-ops.
256+
Operator-declared delegate clients are reconciled as one desired set at embedded
257+
auth-server startup. SPIFFE associations are not part of that delegate desired
258+
set: their durable rows are inert, reserved placeholders, not usable OAuth
259+
clients. Redis rows written by either configured path carry a `configured`
260+
ownership marker. Rows without that marker are legacy data and are never pruned;
261+
DCR-issued rows are also excluded.
262+
263+
Redis atomically validates every desired client collision and every live lease
264+
claim before publishing or renewing its lease and writing any configured rows.
265+
A live lease protects its claimed IDs during rolling updates, and stale-row
266+
sweeps make their final keep/delete decision atomically against the current
267+
lease index. `SCAN` only discovers candidates; it never decides ownership.
268+
The running server heartbeat renews only its existing lease and never rewrites
269+
client material. If a same-ID configured client changes its secret, token
270+
endpoint authentication material, or authorization configuration, the new
271+
reconcile fails closed while any old-material lease is live. Operators must
272+
drain/release the old replicas (or wait for the 30-second lease TTL) before
273+
starting a replica with the changed same-ID configuration. This smaller-scope
274+
coordination intentionally does not provide zero-downtime ordered generations.
275+
276+
Memory storage applies the same desired-set pruning under its mutex but has no
277+
distributed lease coordination, so it may atomically replace an already-owned
278+
configured client. DCR-issued and legacy/unowned rows remain protected.
273279

274280

275281
### CRD Configuration

‎pkg/authserver/server_impl.go‎

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ type server struct {
5151
// down its per-issuer JWKS refresh worker pools; nothing else releases them.
5252
trustedIssuerValidator *tokenexchange.MultiIssuerTokenValidator
5353
configuredReconciler storage.ConfiguredClientReconciler
54-
configuredClients []fosite.Client
5554
configuredLeaseID string
5655
configuredCancel context.CancelFunc
5756
configuredDone chan struct{}
@@ -178,7 +177,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage) (_ *server
178177
return nil, fmt.Errorf("storage backend %T does not implement storage.DCRCredentialStore", baseStore)
179178
}
180179

181-
stor, configuredReconciler, configuredLeaseID, desiredClients, err := setupConfiguredClients(ctx, cfg, stor)
180+
stor, configuredReconciler, configuredLeaseID, err := setupConfiguredClients(ctx, cfg, stor)
182181
if err != nil {
183182
return nil, err
184183
}
@@ -193,23 +192,23 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage) (_ *server
193192

194193
// Create OAuth2 config from authserver.Config
195194
oauthParams := &oauthserver.AuthorizationServerParams{
196-
Issuer: cfg.Issuer,
197-
AccessTokenLifespan: cfg.AccessTokenLifespan,
198-
RefreshTokenLifespan: cfg.RefreshTokenLifespan,
199-
AuthCodeLifespan: cfg.AuthCodeLifespan,
200-
HMACSecrets: cfg.HMACSecrets,
201-
SigningKeyID: signingKey.KeyID,
202-
SigningKeyAlgorithm: signingKey.Algorithm,
203-
SigningKey: signingKey.Key,
204-
ScopesSupported: cfg.ScopesSupported,
205-
BaselineClientScopes: cfg.BaselineClientScopes,
206-
AllowedAudiences: cfg.AllowedAudiences,
207-
AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL,
208-
CIMDEnabled: cfg.CIMDEnabled,
209-
AllowConfidentialClientRegistration: cfg.AllowConfidentialClientRegistration,
210-
AllowPrivateKeyJWTRegistration: cfg.AllowPrivateKeyJWTRegistration,
211-
HasStaticDelegateClients: len(cfg.DelegateClients) > 0,
212-
InsecureAllowHTTP: cfg.InsecureAllowHTTP,
195+
Issuer: cfg.Issuer,
196+
AccessTokenLifespan: cfg.AccessTokenLifespan,
197+
RefreshTokenLifespan: cfg.RefreshTokenLifespan,
198+
AuthCodeLifespan: cfg.AuthCodeLifespan,
199+
HMACSecrets: cfg.HMACSecrets,
200+
SigningKeyID: signingKey.KeyID,
201+
SigningKeyAlgorithm: signingKey.Algorithm,
202+
SigningKey: signingKey.Key,
203+
ScopesSupported: cfg.ScopesSupported,
204+
BaselineClientScopes: cfg.BaselineClientScopes,
205+
AllowedAudiences: cfg.AllowedAudiences,
206+
AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL,
207+
CIMDEnabled: cfg.CIMDEnabled,
208+
AllowConfidentialClientRegistration: cfg.AllowConfidentialClientRegistration,
209+
AllowPrivateKeyJWTRegistration: cfg.AllowPrivateKeyJWTRegistration,
210+
HasStaticDelegateClients: len(cfg.DelegateClients) > 0,
211+
InsecureAllowHTTP: cfg.InsecureAllowHTTP,
213212
InsecureAllowConfidentialOverLoopbackHTTP: cfg.InsecureAllowConfidentialOverLoopbackHTTP,
214213
ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs,
215214
DisableTokenExchange: cfg.DisableTokenExchange,
@@ -289,7 +288,6 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage) (_ *server
289288
upstreamRefresher: refresher,
290289
trustedIssuerValidator: trustedIssuerValidator,
291290
configuredReconciler: configuredReconciler,
292-
configuredClients: desiredClients,
293291
configuredLeaseID: configuredLeaseID,
294292
}
295293
if configuredReconciler != nil {
@@ -317,7 +315,7 @@ func (s *server) startConfiguredClientLeaseRenewal() {
317315
case <-renewCtx.Done():
318316
return
319317
case <-ticker.C:
320-
err := s.configuredReconciler.ReconcileConfiguredClients(renewCtx, s.configuredLeaseID, s.configuredClients)
318+
err := s.configuredReconciler.RenewConfiguredClientLease(renewCtx, s.configuredLeaseID)
321319
if err != nil {
322320
slog.Warn("configured client lease renewal failed", "error", err)
323321
}
@@ -332,33 +330,34 @@ func (s *server) startConfiguredClientLeaseRenewal() {
332330
// registration otherwise. It returns the (possibly wrapped) storage, the
333331
// reconciler the caller must use for lease renewal and release (nil if the
334332
// backend doesn't support reconciliation), the lease ID that reconciliation
335-
// claimed, and the desired client set for later renewal calls.
333+
// claimed. The desired set is used only for initial reconciliation; heartbeat
334+
// renewal mutates the lease only.
336335
func setupConfiguredClients(ctx context.Context, cfg Config, stor storage.Storage) (
337-
storage.Storage, storage.ConfiguredClientReconciler, string, []fosite.Client, error,
336+
storage.Storage, storage.ConfiguredClientReconciler, string, error,
338337
) {
339338
desiredClients, err := configuredClients(cfg)
340339
if err != nil {
341-
return nil, nil, "", nil, err
340+
return nil, nil, "", err
342341
}
343342
stor, err = decorateStorageForSPIFFE(ctx, cfg, stor)
344343
if err != nil {
345-
return nil, nil, "", nil, err
344+
return nil, nil, "", err
346345
}
347346

348347
baseStore := storage.Unwrap(stor)
349348
reconciler, ok := baseStore.(storage.ConfiguredClientReconciler)
350349
if !ok {
351350
if err := registerDelegateClients(ctx, stor, cfg.DelegateClients); err != nil {
352-
return nil, nil, "", nil, err
351+
return nil, nil, "", err
353352
}
354-
return stor, nil, "", desiredClients, nil
353+
return stor, nil, "", nil
355354
}
356355

357356
leaseID := uuid.NewString()
358357
if err := reconciler.ReconcileConfiguredClients(ctx, leaseID, desiredClients); err != nil {
359-
return nil, nil, "", nil, fmt.Errorf("reconcile configured clients: %w", err)
358+
return nil, nil, "", fmt.Errorf("reconcile configured clients: %w", err)
360359
}
361-
return stor, reconciler, leaseID, desiredClients, nil
360+
return stor, reconciler, leaseID, nil
362361
}
363362

364363
// configuredClients builds the desired set of operator-declared clients for
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package storage
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestConfiguredClientFingerprintCanonicalizesSemanticSets(t *testing.T) {
13+
t.Parallel()
14+
15+
first := storedClient{
16+
ID: "client", Secret: []byte("hashed-secret"),
17+
Scopes: []string{"profile", "openid", "openid"},
18+
Audience: []string{"https://b.example", "https://a.example"},
19+
GrantTypes: []string{"urn:ietf:params:oauth:grant-type:token-exchange"},
20+
ResponseTypes: []string{"code", "code"},
21+
}
22+
second := storedClient{
23+
ID: "client", Secret: []byte("hashed-secret"),
24+
Scopes: []string{"openid", "profile"},
25+
Audience: []string{"https://a.example", "https://b.example", "https://a.example"},
26+
GrantTypes: []string{"urn:ietf:params:oauth:grant-type:token-exchange"},
27+
ResponseTypes: []string{"code"},
28+
}
29+
30+
assert.Equal(t, configuredClientFingerprint(first), configuredClientFingerprint(second))
31+
second.Secret = []byte("rotated-secret")
32+
assert.NotEqual(t, configuredClientFingerprint(first), configuredClientFingerprint(second))
33+
second.Secret = first.Secret
34+
second.TokenEndpointAuthMethod = "client_secret_post"
35+
assert.NotEqual(t, configuredClientFingerprint(first), configuredClientFingerprint(second))
36+
}

‎pkg/authserver/storage/memory.go‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,9 +537,10 @@ func (s *MemoryStorage) ReconcileConfiguredClients(_ context.Context, _ string,
537537

538538
s.mu.Lock()
539539
defer s.mu.Unlock()
540-
for id, client := range desired {
540+
for id := range desired {
541541
if existing, exists := s.clients[id]; exists {
542-
if registration.DCRIssued(existing) || !fingerprintOfClient(existing).equal(fingerprintOfClient(client)) {
542+
_, configured := s.configuredClients[id]
543+
if registration.DCRIssued(existing) || !configured {
543544
return fmt.Errorf("%w: client %q is already registered", ErrAlreadyExists, id)
544545
}
545546
}
@@ -564,6 +565,9 @@ func (s *MemoryStorage) ReconcileConfiguredClients(_ context.Context, _ string,
564565
return nil
565566
}
566567

568+
// RenewConfiguredClientLease is a no-op for process-local storage.
569+
func (*MemoryStorage) RenewConfiguredClientLease(_ context.Context, _ string) error { return nil }
570+
567571
// ReleaseConfiguredClientLease is a no-op for process-local storage.
568572
func (*MemoryStorage) ReleaseConfiguredClientLease(_ context.Context, _ string) error { return nil }
569573

‎pkg/authserver/storage/memory_test.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,28 @@ func TestMemoryStorage_ReconcileConfiguredClientsPrunesStaleRows(t *testing.T) {
403403
require.NoError(t, err)
404404
}
405405

406+
func TestMemoryStorage_ReconcileConfiguredClientsReplacesOwnedClient(t *testing.T) {
407+
t.Parallel()
408+
409+
ctx := t.Context()
410+
s := NewMemoryStorage()
411+
defer s.Close()
412+
413+
first := &mockClient{id: "configured", scopes: []string{"openid"}, public: false}
414+
second := &mockClient{id: "configured", scopes: []string{"profile"}, public: false}
415+
require.NoError(t, s.ReconcileConfiguredClient(ctx, first))
416+
require.NoError(t, s.ReconcileConfiguredClients(ctx, "memory", []fosite.Client{second}))
417+
418+
retrieved, err := s.GetClient(ctx, "configured")
419+
require.NoError(t, err)
420+
assert.Equal(t, second, retrieved)
421+
422+
legacy := &mockClient{id: "legacy", public: false}
423+
require.NoError(t, s.RegisterClient(ctx, legacy))
424+
err = s.ReconcileConfiguredClients(ctx, "memory", []fosite.Client{&mockClient{id: "legacy", scopes: []string{"openid"}}})
425+
require.ErrorIs(t, err, ErrAlreadyExists)
426+
}
427+
406428
// matrix UpsertDCRIssuedClient must implement: create when absent, replace
407429
// (and renew the eviction position) when the existing record is itself
408430
// DCR-issued, refuse with ErrAlreadyExists when the existing record is NOT

‎pkg/authserver/storage/mocks/mock_storage.go‎

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)