You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
0 commit comments