Skip to content

Commit b4c8fed

Browse files
committed
Fix CIMD client renewal broken by create-only registration
Rebasing this stack onto main picked up CIMD's write-through client persistence (main), which relies on RegisterClient acting as an upsert to renew a resolved client's row on every document re-fetch. This stack's own registration hardening made RegisterClient create-only for DCR-issued clients, so every renewal after the first fetch for a given CIMD client_id would silently fail, leaving stale client data (and, absent the token-exchange-triggered RenewClientTTL path, a stale TTL) in storage. Add UpsertDCRIssuedClient, a narrow fourth ClientRegistry operation distinct from both RegisterClient (unauthenticated DCR, stays create-only) and ReconcileConfiguredClient (fingerprint-locked, the wrong shape since a CIMD document can legitimately change between fetches). It creates the row if absent, replaces and renews it only when the existing row is itself DCR-issued, and refuses with ErrAlreadyExists otherwise -- protecting a configured or SPIFFE client from being clobbered. Wire CIMDStorageDecorator.fetch to call it instead of RegisterClient, and give SPIFFEStorageDecorator the same reserved-ID guard its other overrides already enforce. Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
1 parent 25b7abf commit b4c8fed

10 files changed

Lines changed: 425 additions & 7 deletions

File tree

pkg/authserver/storage/cimd_decorator.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,14 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
276276
// issue #6187). The client carries the DCR-issued marker (above) so the
277277
// row gets the same anti-bloat TTL as DCR registrations: unauthenticated
278278
// /oauth/authorize traffic can mint these rows, so they must never be
279-
// permanent. Re-persisting on every fresh fetch keeps the snapshot
280-
// current with the document. A persistence failure only degrades
281-
// token-path rehydration, so it must not fail the resolution itself.
282-
if err := d.RegisterClient(ctx, client); err != nil {
279+
// permanent. UpsertDCRIssuedClient (not RegisterClient) is used because
280+
// RegisterClient is strictly create-only and would fail every fetch after
281+
// the first for the same client_id -- this method re-persists on every
282+
// fresh fetch, keeping the stored snapshot current with the document,
283+
// while still refusing to clobber a configured/SPIFFE-reconciled client
284+
// at the same ID. A persistence failure only degrades token-path
285+
// rehydration, so it must not fail the resolution itself.
286+
if err := d.UpsertDCRIssuedClient(ctx, client); err != nil {
283287
slog.WarnContext(ctx, "failed to persist resolved CIMD client",
284288
"client_id", id, "error", err)
285289
}

pkg/authserver/storage/cimd_decorator_test.go

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -950,14 +950,15 @@ func TestCIMDStorageDecorator_PersistsLoopbackResolvedClient(t *testing.T) {
950950
require.NoError(t, err)
951951
}
952952

953-
// registerFailingStorage wraps a Storage and fails every RegisterClient call,
954-
// for testing that a write-through persistence failure does not fail the
953+
// registerFailingStorage wraps a Storage and fails every UpsertDCRIssuedClient
954+
// call -- the write-through persistence path fetch() actually calls -- for
955+
// testing that a write-through persistence failure does not fail the
955956
// resolution itself.
956957
type registerFailingStorage struct {
957958
Storage
958959
}
959960

960-
func (*registerFailingStorage) RegisterClient(context.Context, fosite.Client) error {
961+
func (*registerFailingStorage) UpsertDCRIssuedClient(context.Context, fosite.Client) error {
961962
return errors.New("register failed")
962963
}
963964

@@ -977,3 +978,50 @@ func TestCIMDStorageDecorator_PersistFailureDoesNotFailResolution(t *testing.T)
977978
require.NoError(t, err, "a write-through persistence failure must not fail the resolution")
978979
assert.NotNil(t, client)
979980
}
981+
982+
// TestCIMDStorageDecorator_RepeatFetchRenewsPersistedClient closes the gap
983+
// that let the RegisterClient/UpsertDCRIssuedClient bug go undetected:
984+
// nothing previously asserted the persisted row's state after a repeat
985+
// fetch of the same client_id. Two fetches of the same CIMD document, whose
986+
// served content changes between them, must both succeed, and the second
987+
// fetch's write-through must actually replace the stored row's data rather
988+
// than silently failing with ErrAlreadyExists (as it would have with the old
989+
// RegisterClient-only call, whose failure fetch() only logs).
990+
func TestCIMDStorageDecorator_RepeatFetchRenewsPersistedClient(t *testing.T) {
991+
t.Parallel()
992+
993+
var callCount atomic.Int32
994+
srv := serveCIMDDocWithFields(t, func(doc *cimd.ClientMetadataDocument) {
995+
// Change the served document between the first and second fetch so a
996+
// real re-persist is distinguishable from a no-op.
997+
if callCount.Add(1) == 1 {
998+
doc.RedirectURIs = []string{"https://example.com/callback-v1"}
999+
} else {
1000+
doc.RedirectURIs = []string{"https://example.com/callback-v2"}
1001+
}
1002+
})
1003+
base := newTestBase(t)
1004+
dec := newEnabledDecorator(t, base, 10, time.Minute)
1005+
id := srv.URL + "/meta.json"
1006+
ctx := context.Background()
1007+
1008+
// Call fetch() directly (bypassing the in-process LRU cache) to force two
1009+
// real write-through persistence attempts, exactly as two independent
1010+
// process replicas resolving the same client_id would.
1011+
first, err := dec.fetch(ctx, id)
1012+
require.NoError(t, err)
1013+
assert.Equal(t, []string{"https://example.com/callback-v1"}, first.GetRedirectURIs())
1014+
1015+
stored, err := base.GetClient(ctx, id)
1016+
require.NoError(t, err, "first fetch must persist the row")
1017+
assert.Equal(t, []string{"https://example.com/callback-v1"}, stored.GetRedirectURIs())
1018+
1019+
second, err := dec.fetch(ctx, id)
1020+
require.NoError(t, err, "second fetch for the same client_id must not fail")
1021+
assert.Equal(t, []string{"https://example.com/callback-v2"}, second.GetRedirectURIs())
1022+
1023+
stored, err = base.GetClient(ctx, id)
1024+
require.NoError(t, err)
1025+
assert.Equal(t, []string{"https://example.com/callback-v2"}, stored.GetRedirectURIs(),
1026+
"the persisted row must be renewed with the newly-fetched document, not left stale")
1027+
}

pkg/authserver/storage/memory.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,38 @@ func (s *MemoryStorage) RegisterClient(_ context.Context, client fosite.Client)
476476
return s.insertClientLocked(id, client)
477477
}
478478

479+
// UpsertDCRIssuedClient creates or replaces a DCR-issued client at
480+
// client.GetID(). Unlike RegisterClient, an existing row is replaced (and its
481+
// eviction position refreshed) rather than rejected -- but only when the
482+
// existing row is itself DCR-issued; a configured/SPIFFE-reconciled row at
483+
// the same ID is protected and refuses with ErrAlreadyExists. See the
484+
// ClientRegistry interface doc for the full contract.
485+
func (s *MemoryStorage) UpsertDCRIssuedClient(_ context.Context, client fosite.Client) error {
486+
if !registration.DCRIssued(client) {
487+
return fmt.Errorf("client %q must carry the DCR-issued marker to use UpsertDCRIssuedClient", client.GetID())
488+
}
489+
id := client.GetID()
490+
if err := ValidateRegisterableClientID(id); err != nil {
491+
return err
492+
}
493+
494+
s.mu.Lock()
495+
defer s.mu.Unlock()
496+
497+
existing, exists := s.clients[id]
498+
if !exists {
499+
return s.insertClientLocked(id, client)
500+
}
501+
if !registration.DCRIssued(existing) {
502+
return fmt.Errorf("%w: client %q", ErrAlreadyExists, id)
503+
}
504+
505+
s.clientOrder = slices.DeleteFunc(s.clientOrder, func(e clientOrderEntry) bool { return e.id == id })
506+
s.clientOrder = append(s.clientOrder, clientOrderEntry{id: id, touchedAt: time.Now()})
507+
s.clients[id] = client
508+
return nil
509+
}
510+
479511
// ReconcileConfiguredClient applies an operator-declared client: creates it
480512
// if absent, idempotently replaces a matching-fingerprint configured client
481513
// (the restart-with-unchanged-config case), or refuses with ErrAlreadyExists

pkg/authserver/storage/memory_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,99 @@ func TestMemoryStorage_ReconcileConfiguredClient(t *testing.T) {
380380
})
381381
}
382382

383+
// TestMemoryStorage_UpsertDCRIssuedClient covers the create/replace/reject
384+
// matrix UpsertDCRIssuedClient must implement: create when absent, replace
385+
// (and renew the eviction position) when the existing record is itself
386+
// DCR-issued, refuse with ErrAlreadyExists when the existing record is NOT
387+
// DCR-issued (the critical protection: a configured/SPIFFE client must never
388+
// be clobbered by this path), and refuse when the incoming client itself does
389+
// not carry the DCR-issued marker (misuse guard).
390+
func TestMemoryStorage_UpsertDCRIssuedClient(t *testing.T) {
391+
t.Parallel()
392+
393+
t.Run("creates when absent", func(t *testing.T) {
394+
t.Parallel()
395+
ctx := t.Context()
396+
s := NewMemoryStorage()
397+
defer s.Close()
398+
399+
client := dcrClient(t, "cimd-client")
400+
require.NoError(t, s.UpsertDCRIssuedClient(ctx, client))
401+
402+
retrieved, err := s.GetClient(ctx, "cimd-client")
403+
require.NoError(t, err)
404+
assert.Equal(t, client, retrieved)
405+
})
406+
407+
t.Run("replaces and renews when existing row is DCR-issued", func(t *testing.T) {
408+
t.Parallel()
409+
ctx := t.Context()
410+
// MinClientAge(0) isolates this test to the replace/renew behaviour;
411+
// the age-floor grace window has its own tests above.
412+
s := NewMemoryStorage(WithMaxClients(2), WithMinClientAge(0))
413+
defer s.Close()
414+
415+
first := dcrClient(t, "cimd-client")
416+
require.NoError(t, s.UpsertDCRIssuedClient(ctx, first))
417+
require.NoError(t, s.RegisterClient(ctx, dcrClient(t, "client-b")))
418+
419+
// A distinguishing field on the replacement proves the replace branch
420+
// actually overwrote the stored data rather than being a silent no-op.
421+
second, err := registration.New(registration.Config{
422+
ID: "cimd-client",
423+
TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodNone,
424+
RedirectURIs: []string{"https://app.example/cb-v2"},
425+
})
426+
require.NoError(t, err)
427+
require.NoError(t, s.UpsertDCRIssuedClient(ctx, second))
428+
429+
retrieved, err := s.GetClient(ctx, "cimd-client")
430+
require.NoError(t, err)
431+
assert.Equal(t, second, retrieved, "second call must replace the stored row")
432+
433+
// Renewed eviction position: cimd-client was registered first (oldest)
434+
// but the upsert must have moved it to the back, so overflow evicts
435+
// client-b instead.
436+
require.NoError(t, s.RegisterClient(ctx, dcrClient(t, "client-c")))
437+
_, err = s.GetClient(ctx, "cimd-client")
438+
require.NoError(t, err, "renewed client must survive eviction")
439+
_, err = s.GetClient(ctx, "client-b")
440+
requireNotFoundError(t, err)
441+
})
442+
443+
t.Run("refuses to overwrite a non-DCR-issued client", func(t *testing.T) {
444+
t.Parallel()
445+
ctx := t.Context()
446+
s := NewMemoryStorage()
447+
defer s.Close()
448+
449+
configured := &mockClient{id: "configured", public: false}
450+
require.NoError(t, s.ReconcileConfiguredClient(ctx, configured))
451+
452+
err := s.UpsertDCRIssuedClient(ctx, dcrClient(t, "configured"))
453+
require.ErrorIs(t, err, ErrAlreadyExists)
454+
455+
// The original registration must be untouched.
456+
retrieved, err := s.GetClient(ctx, "configured")
457+
require.NoError(t, err)
458+
assert.Equal(t, configured, retrieved)
459+
})
460+
461+
t.Run("rejects a client not carrying the DCR-issued marker", func(t *testing.T) {
462+
t.Parallel()
463+
ctx := t.Context()
464+
s := NewMemoryStorage()
465+
defer s.Close()
466+
467+
err := s.UpsertDCRIssuedClient(ctx, &mockClient{id: "not-dcr"})
468+
require.Error(t, err)
469+
assert.Contains(t, err.Error(), "must carry the DCR-issued marker")
470+
471+
_, err = s.GetClient(ctx, "not-dcr")
472+
require.ErrorIs(t, err, ErrNotFound)
473+
})
474+
}
475+
383476
// TestMemoryStorage_RegisterClient_Bounded pins the anti-DoS cap: the client
384477
// map is bounded by maxClients with oldest-first eviction among DCR-issued
385478
// clients only, and duplicate registrations fail without changing stored state.

pkg/authserver/storage/mocks/mock_storage.go

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

pkg/authserver/storage/redis.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,81 @@ func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client)
421421
return nil
422422
}
423423

424+
// UpsertDCRIssuedClient creates or replaces a DCR-issued client at
425+
// client.GetID(). Unlike RegisterClient (create-only via SetNX), an existing
426+
// row is replaced -- and its TTL refreshed to DefaultDCRClientTTL -- when the
427+
// existing row is itself DCR-issued; it refuses with ErrAlreadyExists when the
428+
// existing row is NOT DCR-issued, protecting a configured/SPIFFE-reconciled
429+
// client from being clobbered. See the ClientRegistry interface doc for the
430+
// full contract.
431+
//
432+
// The read-check-write sequence for an existing key runs inside a Redis
433+
// WATCH/MULTI transaction, mirroring ReconcileConfiguredClient, so a
434+
// concurrent writer cannot interleave between the DCR-issued check and the
435+
// write; see maxConfiguredClientReconcileRetries for why this method retries
436+
// redis.TxFailedErr itself, up to the same bounded count. Unlike
437+
// ReconcileConfiguredClient (ttl=0, permanent), the write here always uses
438+
// DefaultDCRClientTTL: this row is TTL-bounded like any other DCR-issued
439+
// client.
440+
func (s *RedisStorage) UpsertDCRIssuedClient(ctx context.Context, client fosite.Client) error {
441+
if !registration.DCRIssued(client) {
442+
return fmt.Errorf("client %q must carry the DCR-issued marker to use UpsertDCRIssuedClient", client.GetID())
443+
}
444+
if err := ValidateRegisterableClientID(client.GetID()); err != nil {
445+
return err
446+
}
447+
448+
key := redisKey(s.keyPrefix, KeyTypeClient, client.GetID())
449+
stored := buildStoredClient(client)
450+
data, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users
451+
if err != nil {
452+
return fmt.Errorf("failed to marshal client: %w", err)
453+
}
454+
455+
setPipelined := func(tx *redis.Tx) error {
456+
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
457+
pipe.Set(ctx, key, data, DefaultDCRClientTTL)
458+
return nil
459+
})
460+
return err
461+
}
462+
463+
txFn := func(tx *redis.Tx) error {
464+
existingData, getErr := tx.Get(ctx, key).Bytes()
465+
if errors.Is(getErr, redis.Nil) {
466+
return setPipelined(tx)
467+
}
468+
if getErr != nil {
469+
return fmt.Errorf("failed to get existing client: %w", getErr)
470+
}
471+
472+
ttl, ttlErr := tx.TTL(ctx, key).Result()
473+
if ttlErr != nil {
474+
return fmt.Errorf("failed to get existing client TTL: %w", ttlErr)
475+
}
476+
477+
var existingStored storedClient
478+
if unmarshalErr := json.Unmarshal(existingData, &existingStored); unmarshalErr != nil {
479+
return fmt.Errorf("failed to unmarshal existing client: %w", unmarshalErr)
480+
}
481+
482+
if !registration.DCRIssued(clientFromStored(existingStored, ttl >= 0)) {
483+
return fmt.Errorf("%w: client %q", ErrAlreadyExists, client.GetID())
484+
}
485+
486+
return setPipelined(tx)
487+
}
488+
489+
var watchErr error
490+
for attempt := 0; attempt < maxConfiguredClientReconcileRetries; attempt++ {
491+
watchErr = s.client.Watch(ctx, txFn, key)
492+
if !errors.Is(watchErr, redis.TxFailedErr) {
493+
return watchErr
494+
}
495+
}
496+
return watchErr
497+
}
498+
424499
// storedClientFingerprintsEqual mirrors clientFingerprintsEqual but compares
425500
// the raw serialized fields directly rather than through the fosite.Client
426501
// interface returned by clientFromStored. This matters because

0 commit comments

Comments
 (0)