Skip to content

Commit fbe4ff6

Browse files
committed
Make outbound DCR cache population race-safe across replicas
A reviewer found that two replicas racing on the same outbound DCR (RFC 7591) cache-miss could each independently register a different OAuth client with the upstream IdP — dynamic registration always mints a fresh client_id/secret — then whichever replica's write landed last in the shared Redis cache silently won. The losing replica keeps the client it registered baked into its own config for the rest of its process lifetime (DCR resolution runs once per upstream at startup, never re-resolved), so it no longer agrees with the durable cache about which client it holds credentials for. dcrFlight (a singleflight.Group) only coalesces concurrent callers within one process; it has no cross-replica reach. Change the cache-population contract from upsert to create-if-absent, returning the authoritative durable value either way: the caller's own resolution on a successful claim, or the concurrent winner's otherwise. CredentialStore.Put becomes PutIfAbsent, and DCRCredentialStore.StoreDCRCredentials becomes StoreDCRCredentialsIfAbsent; registerAndCache now returns whichever resolution the store says is authoritative instead of trusting its own local registration, and logs (at Debug, without ever including a secret) when this replica lost the race. Callers MUST use the returned value — RFC 7591 guarantees nothing about the two registrations converging. Redis claims the key with SET...NX (the same reservation-lock shape already used twice in this file for ClientAssertionJWTValid and ConsumeAssertionJWT), not WATCH/MULTI: unlike ReconcileConfiguredClient, this write has no read-then-decide step to protect, so a plain atomic NX claim is sufficient. On a lost claim it reads back the winner through the existing GetDCRCredentials path rather than a second, hand-rolled unmarshal, and retries the whole claim-or-read cycle (bounded) if the winner's row evicts between the failed NX and the read — its TTL can be as short as one second when the caller's ClientSecretExpiresAt was already in the past, so this is a real, reachable window, not a hypothetical one, and the alternative (a hard error) would turn a retryable race into a permanent startup failure. MemoryStorage's implementation treats an existing entry as absent only when its ClientSecretExpiresAt is non-zero and already past — otherwise it returns the existing entry unchanged rather than overwriting it. A single process's dcrFlight already prevents a live race there; this is contract symmetry with Redis, plus the correctness case Redis gets from TTL eviction: without the expiry check, a never-expiring entry can never be reclaimed, but a naive "any existing entry blocks re-registration" check would also permanently pin an already-expired one that should be re-registered. Refs #6200 Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
1 parent d92100e commit fbe4ff6

12 files changed

Lines changed: 686 additions & 134 deletions

File tree

pkg/auth/dcr/resolver.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,19 +470,39 @@ func registerAndCache(
470470
// failure leaves no in-memory state diverging from the cache: the
471471
// next call simply re-resolves rather than reading a value the cache
472472
// never saw.
473-
if err := cache.Put(ctx, key, resolution); err != nil {
473+
//
474+
// authoritative may differ from resolution: RFC 7591 dynamic
475+
// registration mints a brand-new, unique client_id/client_secret on
476+
// every call, so if another replica raced this one to register against
477+
// the same Key and won the durable claim first, cache.PutIfAbsent
478+
// returns THAT replica's credentials — the only ones the shared cache
479+
// (and hence any other replica or a future restart) will agree this
480+
// caller holds. Returning resolution here instead would leave this
481+
// process using a client_id the durable store does not recognize.
482+
authoritative, err := cache.PutIfAbsent(ctx, key, resolution)
483+
if err != nil {
474484
return nil, newDCRStepError(dcrStepCacheWrite, req.Issuer, redirectURI,
475485
fmt.Errorf("cache put: %w", err))
476486
}
487+
if authoritative.ClientID != resolution.ClientID {
488+
//nolint:gosec // G706: client_id is public metadata per RFC 7591.
489+
slog.Debug("dcr: registration superseded by concurrent winner",
490+
"local_issuer", req.Issuer,
491+
"upstream_id", key.UpstreamID,
492+
"redirect_uri", redirectURI,
493+
"registered_client_id", resolution.ClientID,
494+
"authoritative_client_id", authoritative.ClientID,
495+
)
496+
}
477497

478498
//nolint:gosec // G706: client_id is public metadata per RFC 7591.
479499
slog.Debug("dcr: registered new client",
480500
"local_issuer", req.Issuer,
481501
"upstream_id", key.UpstreamID,
482502
"redirect_uri", redirectURI,
483-
"client_id", resolution.ClientID,
503+
"client_id", authoritative.ClientID,
484504
)
485-
return resolution, nil
505+
return authoritative, nil
486506
}
487507

488508
// LogStepError emits the single boundary slog.Error record for a DCR

pkg/auth/dcr/resolver_test.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ func TestResolveDCRCredentials_CacheHitShortCircuits(t *testing.T) {
170170
AuthorizationEndpoint: "https://preloaded/authorize",
171171
TokenEndpoint: "https://preloaded/token",
172172
}
173-
require.NoError(t, cache.Put(context.Background(), key, preloaded))
173+
_, err := cache.PutIfAbsent(context.Background(), key, preloaded)
174+
require.NoError(t, err)
174175

175176
req := &Request{
176177
Issuer: issuer,
@@ -1510,8 +1511,8 @@ func (c *countingStore) Get(ctx context.Context, key Key) (*Resolution, bool, er
15101511
return res, ok, err
15111512
}
15121513

1513-
func (c *countingStore) Put(ctx context.Context, key Key, res *Resolution) error {
1514-
return c.inner.Put(ctx, key, res)
1514+
func (c *countingStore) PutIfAbsent(ctx context.Context, key Key, res *Resolution) (*Resolution, error) {
1515+
return c.inner.PutIfAbsent(ctx, key, res)
15151516
}
15161517

15171518
// TestResolveDCRCredentials_SingleflightCoalescesConcurrentCallers pins the
@@ -1790,8 +1791,11 @@ func (f failingDCRStore) Get(_ context.Context, _ Key) (*Resolution, bool, error
17901791
return nil, false, nil
17911792
}
17921793

1793-
func (f failingDCRStore) Put(_ context.Context, _ Key, _ *Resolution) error {
1794-
return f.putErr
1794+
func (f failingDCRStore) PutIfAbsent(_ context.Context, _ Key, res *Resolution) (*Resolution, error) {
1795+
if f.putErr != nil {
1796+
return nil, f.putErr
1797+
}
1798+
return res, nil
17951799
}
17961800

17971801
// TestResolveDCRCredentials_CacheGetFailureWrapped covers PR #5042 review
@@ -2017,7 +2021,7 @@ func (panickingPutDCRStore) Get(_ context.Context, _ Key) (*Resolution, bool, er
20172021
return nil, false, nil
20182022
}
20192023

2020-
func (s panickingPutDCRStore) Put(_ context.Context, _ Key, _ *Resolution) error {
2024+
func (s panickingPutDCRStore) PutIfAbsent(_ context.Context, _ Key, _ *Resolution) (*Resolution, error) {
20212025
panic(s.panicValue)
20222026
}
20232027

pkg/auth/dcr/store.go

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,20 @@ type CredentialStore interface {
5050
// key is not present. An error is returned only on backend failure.
5151
Get(ctx context.Context, key Key) (*Resolution, bool, error)
5252

53-
// Put stores the resolution for key, overwriting any existing entry.
53+
// PutIfAbsent claims key for resolution. Returns the authoritative
54+
// durable value: the caller's own resolution on a successful claim, the
55+
// concurrent winner's otherwise. Callers MUST use the returned value,
56+
// not their input resolution — RFC 7591 dynamic registration mints a
57+
// unique client_id/client_secret on every call, so a caller that lost
58+
// the race and kept using its own resolution would hold credentials the
59+
// durable store does not agree it owns.
60+
//
5461
// Implementations must reject a nil resolution with an error rather
5562
// than silently succeeding — a no-op would leave callers with no
5663
// debug trail for the subsequent Get miss.
57-
Put(ctx context.Context, key Key, resolution *Resolution) error
64+
//
65+
// The returned *Resolution is always non-nil when err is nil.
66+
PutIfAbsent(ctx context.Context, key Key, resolution *Resolution) (*Resolution, error)
5867
}
5968

6069
// NewStorageBackedStore returns a CredentialStore that delegates to a
@@ -154,15 +163,19 @@ func (s *inMemoryStore) Get(ctx context.Context, key Key) (*Resolution, bool, er
154163
return credentialsToResolution(creds), true, nil
155164
}
156165

157-
// Put implements CredentialStore by delegating to the embedded
166+
// PutIfAbsent implements CredentialStore by delegating to the embedded
158167
// *storage.MemoryStorage. The nil-resolution rejection matches
159-
// storageBackedStore.Put; see that method for the rationale.
160-
func (s *inMemoryStore) Put(ctx context.Context, key Key, resolution *Resolution) error {
168+
// storageBackedStore.PutIfAbsent; see that method for the rationale.
169+
func (s *inMemoryStore) PutIfAbsent(ctx context.Context, key Key, resolution *Resolution) (*Resolution, error) {
161170
if resolution == nil {
162-
return fmt.Errorf("dcr: resolution must not be nil")
171+
return nil, fmt.Errorf("dcr: resolution must not be nil")
163172
}
164173
creds := resolutionToCredentials(key, resolution)
165-
return s.mem.StoreDCRCredentials(ctx, creds)
174+
authoritative, err := s.mem.StoreDCRCredentialsIfAbsent(ctx, creds)
175+
if err != nil {
176+
return nil, err
177+
}
178+
return credentialsToResolution(authoritative), nil
166179
}
167180

168181
// Close releases the embedded MemoryStorage cleanup goroutine. Safe to
@@ -201,18 +214,27 @@ func (s *storageBackedStore) Get(ctx context.Context, key Key) (*Resolution, boo
201214
return credentialsToResolution(creds), true, nil
202215
}
203216

204-
// Put implements CredentialStore.
217+
// PutIfAbsent implements CredentialStore.
205218
//
206219
// A nil resolution is rejected rather than silently no-oped: a caller
207220
// passing nil would otherwise get a successful return, observe a miss on
208221
// the next Get, and have no error trail to debug from. Failing loudly at
209222
// the boundary makes such bugs visible at the first call.
210-
func (s *storageBackedStore) Put(ctx context.Context, key Key, resolution *Resolution) error {
223+
//
224+
// The returned *Resolution is the authoritative durable value —
225+
// s.backend.StoreDCRCredentialsIfAbsent's own contract — so a caller whose
226+
// registration lost a concurrent claim on this key gets back the winner's
227+
// credentials, not its own.
228+
func (s *storageBackedStore) PutIfAbsent(ctx context.Context, key Key, resolution *Resolution) (*Resolution, error) {
211229
if resolution == nil {
212-
return fmt.Errorf("dcr: resolution must not be nil")
230+
return nil, fmt.Errorf("dcr: resolution must not be nil")
213231
}
214232
creds := resolutionToCredentials(key, resolution)
215-
return s.backend.StoreDCRCredentials(ctx, creds)
233+
authoritative, err := s.backend.StoreDCRCredentialsIfAbsent(ctx, creds)
234+
if err != nil {
235+
return nil, err
236+
}
237+
return credentialsToResolution(authoritative), nil
216238
}
217239

218240
// resolutionToCredentials converts a resolver-side *Resolution into the

pkg/auth/dcr/store_test.go

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ func TestStorageBackedStore_PutGet_RoundTrip(t *testing.T) {
4040
CreatedAt: time.Now(),
4141
}
4242

43-
require.NoError(t, store.Put(ctx, key, resolution))
43+
authoritative, err := store.PutIfAbsent(ctx, key, resolution)
44+
require.NoError(t, err)
45+
assert.Equal(t, resolution.ClientID, authoritative.ClientID,
46+
"an uncontested claim must return the caller's own resolution")
4447

4548
got, ok, err := store.Get(ctx, key)
4649
require.NoError(t, err)
@@ -119,11 +122,15 @@ func TestStorageBackedStore_DistinctKeysDoNotCollide(t *testing.T) {
119122
}
120123
}
121124

122-
require.NoError(t, store.Put(ctx, keyA, resolution("a")))
123-
require.NoError(t, store.Put(ctx, keyB, resolution("b")))
124-
require.NoError(t, store.Put(ctx, keyC, resolution("c")))
125-
require.NoError(t, store.Put(ctx, keyD, resolution("d")))
126-
require.NoError(t, store.Put(ctx, keyE, resolution("e")))
125+
for _, put := range []struct {
126+
key Key
127+
clientID string
128+
}{
129+
{keyA, "a"}, {keyB, "b"}, {keyC, "c"}, {keyD, "d"}, {keyE, "e"},
130+
} {
131+
_, err := store.PutIfAbsent(ctx, put.key, resolution(put.clientID))
132+
require.NoError(t, err)
133+
}
127134

128135
for _, tc := range []struct {
129136
key Key
@@ -142,7 +149,15 @@ func TestStorageBackedStore_DistinctKeysDoNotCollide(t *testing.T) {
142149
}
143150
}
144151

145-
func TestStorageBackedStore_Put_OverwritesExisting(t *testing.T) {
152+
// TestStorageBackedStore_PutIfAbsent_FirstClaimWins pins the create-if-absent
153+
// contract that replaced unconditional overwrite (see PutIfAbsent doc): a
154+
// second PutIfAbsent for a key that already holds a value must NOT overwrite
155+
// it. Instead it returns the existing (first) resolution as the authoritative
156+
// value, and the store keeps holding the first entry. This is the fix for the
157+
// concurrent-replica bug where two callers independently register distinct
158+
// RFC 7591 clients for the same key — only one registration may ever become
159+
// the durable, agreed-upon value.
160+
func TestStorageBackedStore_PutIfAbsent_FirstClaimWins(t *testing.T) {
146161
t.Parallel()
147162

148163
store := newMemoryDCRStore(t)
@@ -161,21 +176,27 @@ func TestStorageBackedStore_Put_OverwritesExisting(t *testing.T) {
161176
Authorization: "https://idp.example.com/authorize",
162177
Token: "https://idp.example.com/token",
163178
}
164-
require.NoError(t, store.Put(ctx, key, &Resolution{
179+
first, err := store.PutIfAbsent(ctx, key, &Resolution{
165180
ClientID: "first",
166181
AuthorizationEndpoint: endpoints.Authorization,
167182
TokenEndpoint: endpoints.Token,
168-
}))
169-
require.NoError(t, store.Put(ctx, key, &Resolution{
183+
})
184+
require.NoError(t, err)
185+
assert.Equal(t, "first", first.ClientID, "the uncontested first claim returns its own resolution")
186+
187+
second, err := store.PutIfAbsent(ctx, key, &Resolution{
170188
ClientID: "second",
171189
AuthorizationEndpoint: endpoints.Authorization,
172190
TokenEndpoint: endpoints.Token,
173-
}))
191+
})
192+
require.NoError(t, err)
193+
assert.Equal(t, "first", second.ClientID,
194+
"the loser must get back the winner's resolution, not its own")
174195

175196
got, ok, err := store.Get(ctx, key)
176197
require.NoError(t, err)
177198
require.True(t, ok)
178-
assert.Equal(t, "second", got.ClientID)
199+
assert.Equal(t, "first", got.ClientID, "the store must keep the first-claimed entry, not overwrite it")
179200
}
180201

181202
// TestStorageBackedStore_Put_RejectsNilResolution pins the
@@ -189,7 +210,7 @@ func TestStorageBackedStore_Put_RejectsNilResolution(t *testing.T) {
189210
ctx := context.Background()
190211
key := Key{Issuer: "https://idp.example.com", RedirectURI: "https://x.example.com/cb"}
191212

192-
err := store.Put(ctx, key, nil)
213+
_, err := store.PutIfAbsent(ctx, key, nil)
193214
require.Error(t, err)
194215
assert.Contains(t, err.Error(), "must not be nil")
195216

@@ -211,11 +232,12 @@ func TestStorageBackedStore_GetReturnsDefensiveCopy(t *testing.T) {
211232
RedirectURI: "https://x.example.com/cb",
212233
ScopesHash: storage.ScopesHash([]string{"openid"}),
213234
}
214-
require.NoError(t, store.Put(ctx, key, &Resolution{
235+
_, err := store.PutIfAbsent(ctx, key, &Resolution{
215236
ClientID: "orig",
216237
AuthorizationEndpoint: "https://idp.example.com/authorize",
217238
TokenEndpoint: "https://idp.example.com/token",
218-
}))
239+
})
240+
require.NoError(t, err)
219241

220242
got, ok, err := store.Get(ctx, key)
221243
require.NoError(t, err)
@@ -289,14 +311,14 @@ func TestStorageBackedStore_ConcurrentAccess(t *testing.T) {
289311
CreatedAt: time.Now(),
290312
}
291313
if i%2 == 0 {
292-
if err := store.Put(ctx, overlappingKey(i), resolution); err != nil {
314+
if _, err := store.PutIfAbsent(ctx, overlappingKey(i), resolution); err != nil {
293315
atomic.AddInt32(&errCount, 1)
294316
}
295317
if _, _, err := store.Get(ctx, overlappingKey(i)); err != nil {
296318
atomic.AddInt32(&errCount, 1)
297319
}
298320
} else {
299-
if err := store.Put(ctx, disjointKey(worker, i), resolution); err != nil {
321+
if _, err := store.PutIfAbsent(ctx, disjointKey(worker, i), resolution); err != nil {
300322
atomic.AddInt32(&errCount, 1)
301323
}
302324
if _, _, err := store.Get(ctx, disjointKey(worker, i)); err != nil {
@@ -508,18 +530,19 @@ func TestInMemoryStore_PutGetCloseShareBackend(t *testing.T) {
508530
TokenEndpoint: "https://idp.example.com/token",
509531
}
510532

511-
require.NoError(t, store.Put(ctx, key, resolution))
533+
_, err := store.PutIfAbsent(ctx, key, resolution)
534+
require.NoError(t, err)
512535

513536
got, ok, err := store.Get(ctx, key)
514537
require.NoError(t, err)
515-
require.True(t, ok, "Get must see the value Put just wrote — confirms Put and Get share a backend")
538+
require.True(t, ok, "Get must see the value PutIfAbsent just wrote — confirms Put and Get share a backend")
516539
assert.Equal(t, "client-abc", got.ClientID)
517540
}
518541

519542
// TestInMemoryStore_PutRejectsNilResolution mirrors the contract pinned
520-
// for storageBackedStore.Put: a nil resolution is rejected at the
543+
// for storageBackedStore.PutIfAbsent: a nil resolution is rejected at the
521544
// adapter boundary rather than silently no-oped, so the next Get miss
522-
// surfaces with a debug trail. inMemoryStore implements Put directly
545+
// surfaces with a debug trail. inMemoryStore implements PutIfAbsent directly
523546
// (not via embedding) — this test guards against a delegation
524547
// regression that omitted the nil check.
525548
func TestInMemoryStore_PutRejectsNilResolution(t *testing.T) {
@@ -528,7 +551,7 @@ func TestInMemoryStore_PutRejectsNilResolution(t *testing.T) {
528551
store := NewInMemoryStore()
529552
t.Cleanup(func() { _ = store.Close() })
530553

531-
err := store.Put(context.Background(), Key{Issuer: "https://idp.example.com"}, nil)
554+
_, err := store.PutIfAbsent(context.Background(), Key{Issuer: "https://idp.example.com"}, nil)
532555
require.Error(t, err)
533556
assert.Contains(t, err.Error(), "resolution must not be nil")
534557
}

pkg/authserver/storage/memory.go

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ type MemoryStorage struct {
157157
// dcrCredentials maps DCRKey -> DCRCredentials for RFC 7591 Dynamic Client
158158
// Registration credentials. These entries come from OUTBOUND DCR: ToolHive
159159
// acting as a DCR client to register itself against a configured upstream
160-
// IdP (see pkg/auth/dcr/store.go, the only caller of StoreDCRCredentials).
160+
// IdP (see pkg/auth/dcr/store.go, the only caller of StoreDCRCredentialsIfAbsent).
161161
// This is not reachable from the inbound, unauthenticated /oauth/register
162162
// handler, so unlike clients (bounded by maxClients/clientOrder because
163163
// every inbound registration call mints an entry), growth here is bounded
@@ -1538,29 +1538,46 @@ func cloneDCRCredentials(c *DCRCredentials) *DCRCredentials {
15381538
return &cp
15391539
}
15401540

1541-
// StoreDCRCredentials persists DCR credentials for the given key.
1542-
// The credentials are stored under their own Key field; callers must populate
1543-
// it before calling. A defensive copy is made so subsequent caller mutations
1544-
// do not affect persisted state.
1541+
// StoreDCRCredentialsIfAbsent claims creds.Key for creds. The credentials
1542+
// are stored under their own Key field; callers must populate it before
1543+
// calling. A defensive copy is made so subsequent caller mutations do not
1544+
// affect persisted state.
15451545
//
1546-
// Overwrites any existing entry for the same Key. The in-memory backend
1547-
// applies no native TTL — DCR registrations are long-lived and bounded by
1548-
// the operator-configured upstream count, and ClientSecretExpiresAt is
1549-
// retained verbatim for callers to re-check on read (see the interface
1550-
// docstring's "TTL handling" section).
1546+
// Create-if-absent, not overwrite: a single process's dcrFlight singleflight
1547+
// (see pkg/auth/dcr) already prevents concurrent same-key writers within
1548+
// this process, so the check below is not closing a live race here — it is
1549+
// contract symmetry with RedisStorage.StoreDCRCredentialsIfAbsent, which
1550+
// DOES need it to prevent two replicas from independently registering RFC
1551+
// 7591 clients for the same Key and racing on which write wins.
1552+
//
1553+
// The in-memory backend has no native TTL, so "absent" cannot be a plain
1554+
// map-presence check: the Redis backend's rows self-evict via TTL derived
1555+
// from ClientSecretExpiresAt, so a claim there naturally succeeds again once
1556+
// the old row expires. To keep behaviour symmetric, an existing entry whose
1557+
// ClientSecretExpiresAt is non-zero and already in the past is treated as
1558+
// absent — the claim proceeds and overwrites it — so a secret's expiry does
1559+
// not permanently block re-registration on this backend the way a naive
1560+
// presence check would.
15511561
//
15521562
// Validation is delegated to validateDCRCredentialsForStore so the rejection
15531563
// set stays in sync with sibling backends.
1554-
func (s *MemoryStorage) StoreDCRCredentials(_ context.Context, creds *DCRCredentials) error {
1564+
func (s *MemoryStorage) StoreDCRCredentialsIfAbsent(_ context.Context, creds *DCRCredentials) (*DCRCredentials, error) {
15551565
if err := validateDCRCredentialsForStore(creds); err != nil {
1556-
return err
1566+
return nil, err
15571567
}
15581568

15591569
s.mu.Lock()
15601570
defer s.mu.Unlock()
15611571

1572+
if existing, ok := s.dcrCredentials[creds.Key]; ok {
1573+
expired := !existing.ClientSecretExpiresAt.IsZero() && time.Now().After(existing.ClientSecretExpiresAt)
1574+
if !expired {
1575+
return cloneDCRCredentials(existing), nil
1576+
}
1577+
}
1578+
15621579
s.dcrCredentials[creds.Key] = cloneDCRCredentials(creds)
1563-
return nil
1580+
return cloneDCRCredentials(creds), nil
15641581
}
15651582

15661583
// GetDCRCredentials retrieves DCR credentials by key.

0 commit comments

Comments
 (0)