Skip to content

Commit 9572f6a

Browse files
tgrunnagleclaude
andauthored
Wire authserver DCR resolver and add structured logs (#5044)
* Wire authserver DCR resolver and add structured logs Implements Phase 2 steps 2d/2g of the DCR story (#5039): - EmbeddedAuthServer now owns an in-memory DCRCredentialStore and calls resolveDCRCredentials for any OAuth2 upstream with DCRConfig. The resolved ClientSecret is overlaid on the built upstream.OAuth2Config after buildPureOAuth2Config (whose signature and body remain intentionally unchanged) so that RFC 7591-obtained credentials flow through the same execution path as file/env-resolved secrets. - Each UpstreamRunConfig element is shallow-copied and its OAuth2 sub-config is deep-copied before DCR resolution, preserving the caller's RunConfig.Upstreams slice per .claude/rules/go-style.md "Copy Before Mutating Caller Input". - resolveDCRCredentials emits structured logs: Debug on cache hit with dcr_age_days, an additional Warn when the cached registration exceeds dcrStaleAgeThreshold (90 days), and Error with a "step" attribute identifying which phase failed on every error path. - The /oauth/register handler upgrades its success log to Info with upstream, issuer, client_id, software_id, token_endpoint_auth_method, and scopes. SoftwareID is threaded through DCRRequest validation so incoming "software_id" is captured. A small helper guards against a nil embedded *fosite.Config (a legitimate test-only condition). - isTransientNetworkError's permanent-4xx branch now emits a Warn with a DCR remediation hint before returning false unchanged. The MonitoredTokenSource gains an optional SetUpstreamContext setter so the upstream and client_id fields can be threaded into the log without breaking the existing NewMonitoredTokenSource contract. - Integration tests exercise the full DCR boot path against a mock AS, verify the cache-hit short-circuit issues zero additional HTTP requests, and assert the caller's original RunConfig.Upstreams slice element is unchanged across both calls. Address authserver DCR review feedback Fixes from the CODE_REVIEW_ISSUES.md review of commit 71c4f43: Critical - Wire upstream/client_id into MonitoredTokenSource so the DCR remediation warning carries meaningful correlation fields. Promote the fields to constructor parameters (replacing SetUpstreamContext) to remove the unsynchronized writer and force callers to supply them at construction. - Runner populates the new fields from the RemoteAuthConfig, preferring the DCR-cached client_id over the statically configured one. High - /oauth/register handler drops the redundant upstream attribute that mirrored issuer, and omits issuer when empty rather than emitting a bare issuer="". - Resolver no longer logs each error branch and then returns; it now wraps failures in a DCRStepError and the boundary caller (buildUpstreamConfigs) emits a single slog.Error via LogDCRStepError. - The DCR-resolved ClientSecret is applied through a new applyResolutionToOAuth2Config helper paired with applyResolution, so the DCR application sites live side-by-side and future call sites cannot silently drop the secret. Medium - Remove the Type==OAuth2 guard that duplicated needsDCR's nil check. - Cap software_id to 256 characters and require printable ASCII in ValidateDCRRequest; expose MaxSoftwareIDLength. - Add TestNewEmbeddedAuthServer_DCRBoot to drive the full constructor and assert EmbeddedAuthServer.dcrStore is populated after boot. - Remove the nil-guard in Handler.issuer() and add TestHandler_issuer so a real wiring bug fails loudly instead of logging issuer="". - Sanitize error strings before logging to strip URL query parameters that could plausibly carry tokens in a future refactor. Preserve URL-trailing punctuation in DCR log sanitiser The query-stripping regex in sanitizeErrorForLog matches `[^\s"']+`, so a URL ending with sentence punctuation (`.`, `,`, `)`, `]`, etc.) pulls that punctuation into the URL match. url.Parse then absorbs it into the raw query, and the Strip + Reassemble step drops it along with the rest of the query — mangling the surrounding prose. Split the trailing run of terminal punctuation off the match before parsing, and re-append it verbatim after the query is stripped. URL matches without a query are returned untouched so the pass is idempotent for URLs that are already clean. New test cases cover commas, periods, closing parens, mixed runs, and a Go http.Client-style quoted URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Bound upstream DCR /register error body before logging A misbehaving or malicious authorization server could echo arbitrary content into the upstream's /register error response. handleHTTPResponse read that body via io.ReadAll with no LimitReader, then embedded it verbatim in the returned error — which downstream callers log. Cap the read at 8 KiB (far larger than any conformant RFC 7591 error response) so operator log volume cannot be inflated by a non-conformant upstream. Addresses #5044 review finding F2 (HIGH). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Mirror CIMD precedence in remoteAuthLogContext The doc comment claimed remoteAuthLogContext mirrored the precedence of remote.Handler.resolveClientCredentials, but the implementation skipped the CachedCIMDClientID check entirely. For CIMD-authenticated workloads the new DCR remediation Warn would have reported a stale or empty client_id rather than the CIMD URL actually being sent on token refresh, defeating the operator-correlation the field exists for. Restore the documented precedence (CachedCIMDClientID > CachedClientID > ClientID) and add a TestRemoteAuthLogContext case covering the CIMD-wins path. Addresses #5044 review findings F3 (HIGH) and F26 (MEDIUM, closed by the new test case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Hoist DCR remediation Warn out of network-error classifier isTransientNetworkError previously emitted the cached-DCR-client remediation Warn from inside its classifier on every permanent 4xx. A tight Token() loop hitting the same condition would spam the same record on every call before the workload's Unauthenticated status propagated. The same branch also fired for non-DCR workloads, which saw a remediation telling them to "delete cached credentials" they never had. Strip the side effect from the classifier and emit the Warn from markAsUnauthenticated, which already gates the close-monitoring transition through stopOnce. The Warn now fires at most once per state transition, is suppressed when no client_id context is available, and reads honestly about the variability ("if this workload uses cached DCR or CIMD credentials they may be stale"). Addresses #5044 review finding F5 (MEDIUM). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Validate handler config eagerly and demote DCR success log Two related polish items in the authserver handlers package: NewHandler did not validate that AuthorizationServerConfig (or its embedded *fosite.Config) was non-nil. issuer() reached into the config at request time, so a misconfigured caller would panic deep inside an HTTP handler instead of failing at startup. Add the nil check to the constructor and simplify issuer() to rely on the constructor invariant. Pin the new invariant with TestNewHandler_ErrorsOnNilConfig. The /oauth/register success log was promoted to Info even though the operation is neither long-running nor exceptional. Demote back to Debug; an audit-log path is the right home for the audit signal if that becomes a requirement. Addresses #5044 review findings F6 and F7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Tighten DCR resolver internals and clarify cache lifetimes Bundles ten review-feedback items in pkg/authserver/runner; each addresses internal-API hygiene or doc-comment drift in the DCR resolver, no behaviour change for callers. Sanitizer hardening (F1, F19, F18, F23): sanitizeErrorForLog now strips userinfo and fragment in addition to the query, since either can carry credentials or tokens (https://user:pass@host, implicit-flow #access_token=...). queryStrippingPattern matches http/https case-insensitively per RFC 3986 §3.1 so HTTPS://... cannot escape sanitisation. trimURLTrailingPunctuation switches to strings.IndexByte to match the ASCII-only terminator set without the rune-decoding overhead. Test cases added for each. Resolver error API (F4, F13, F20): the dcrStepRegister panic-recovery branch no longer emits a duplicate slog.Error; the captured stack travels with the wrapped *dcrStepError to the boundary log so a single panic produces a single record. DCRStepError / LogDCRStepError lowercased to dcrStepError / logDCRStepError since no caller lives outside the package. logDCRStepError now no-ops on nil err so the unknown-step branch cannot fire on a missing failure. Resolution helpers (F11, F12, F25): applyResolution renamed to consumeResolution to communicate that it is a one-shot state transition (clearing DCRConfig is unconditional), not an idempotent defaulting step. The applyResolutionToOAuth2Config doc now states the paired-call invariant explicitly without referencing a specific test. Lifecycle docs (F21, F22): the per-instance dcrStore vs. process-wide dcrFlight asymmetry is now stated on both sides, and EmbeddedAuthServer.Close documents the future-Close hook for a backend with handles. Inline rules-file rationale (F24): production comments no longer cite .claude/rules/... by path; the principle is inlined. Addresses #5044 review findings F1, F4, F11, F12, F13, F18, F19, F20, F21, F22, F23, F24, F25. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Tighten DCR remediation hint and drain capped error body Three follow-up items from review: isPermanentTokenEndpointError used to treat every non-5xx RetrieveError as permanent, which meant the DCR remediation Warn fired on transient back-pressure (408 Request Timeout, 429 Too Many Requests). Narrow the classifier to an explicit 400 / 401 / 403 allowlist so the "delete the cached credentials and restart" hint fires only on responses where stale cached client credentials are a plausible cause. Retry behaviour is unaffected — the retry decision is gated entirely by isTransientNetworkError, which already returns false for the whole RetrieveError branch. handleHTTPResponse in pkg/oauthproto/dcr.go now drains the response body after the LimitReader-bounded ReadAll. Without this, an upstream returning more than 8 KiB on /register error left the connection mid- stream when the deferred Close fired, preventing TCP connection reuse. Mirrors what the non-JSON content-type branch a few lines down already does. remoteAuthLogContext moved out of pkg/runner into pkg/auth/remote as *Config.LogContext(), collocating it with resolveClientCredentials so the cached-CIMD > cached-DCR > static precedence has one home. Adding a fourth cached field in future updates both call sites in one place. Tests moved to pkg/auth/remote/config_test.go. Addresses #5044 review #4212656411 comments 3174540081, 3174540082, and 3174540086. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 11fa6fb commit 9572f6a

19 files changed

Lines changed: 1363 additions & 104 deletions

pkg/auth/monitored_token_source.go

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"log/slog"
1111
"net"
12+
"net/http"
1213
"os"
1314
"strconv"
1415
"strings"
@@ -129,6 +130,15 @@ type transientRefresher struct {
129130
source oauth2.TokenSource
130131
workload string
131132

133+
// upstream identifies the upstream authorization server that issued the
134+
// token, and clientID is the OAuth 2.0 client_id used by this workload.
135+
// Both are optional and are only surfaced in structured logs (notably the
136+
// DCR/CIMD remediation warning emitted from MonitoredTokenSource on the
137+
// transition to Unauthenticated). Empty strings are acceptable and are
138+
// omitted from log output by the slog handler.
139+
upstream string
140+
clientID string
141+
132142
// newBackOff is a factory for the backoff used during retries.
133143
// Nil in production; overridable in tests for fast execution.
134144
newBackOff func() backoff.BackOff
@@ -212,6 +222,8 @@ func (r *transientRefresher) getBackOff() backoff.BackOff {
212222
type MonitoredTokenSource struct {
213223
tokenSource oauth2.TokenSource
214224
workloadName string
225+
upstream string
226+
clientID string
215227
statusUpdater StatusUpdater
216228
monitoringCtx context.Context
217229
stopMonitoring chan struct{}
@@ -226,20 +238,42 @@ type MonitoredTokenSource struct {
226238

227239
// NewMonitoredTokenSource creates a new MonitoredTokenSource that wraps the provided
228240
// oauth2.TokenSource and monitors it for authentication failures.
241+
//
242+
// upstream and clientID annotate structured logs emitted by the token source,
243+
// most importantly the DCR/CIMD remediation warning fired on the transition
244+
// to Unauthenticated when the token endpoint returns a permanent 4xx (which
245+
// frequently indicates stale cached credentials). Pass empty strings when
246+
// the workload does not use DCR/CIMD or the upstream issuer is unknown; the
247+
// remediation log will be suppressed when clientID is empty since its
248+
// operator-correlation field would be blank.
249+
//
250+
// The fields are fixed at construction time rather than exposed via a setter
251+
// so there is no data race between a late writer and the readers in Token()
252+
// / transientRefresher.retry() — both of which may run on the background
253+
// monitor goroutine started by StartBackgroundMonitoring.
229254
func NewMonitoredTokenSource(
230255
ctx context.Context,
231256
tokenSource oauth2.TokenSource,
232257
workloadName string,
258+
upstream string,
259+
clientID string,
233260
statusUpdater StatusUpdater,
234261
) *MonitoredTokenSource {
235262
return &MonitoredTokenSource{
236263
tokenSource: tokenSource,
237264
workloadName: workloadName,
265+
upstream: upstream,
266+
clientID: clientID,
238267
statusUpdater: statusUpdater,
239268
monitoringCtx: ctx,
240269
stopMonitoring: make(chan struct{}),
241270
stopped: make(chan struct{}),
242-
refresher: &transientRefresher{source: tokenSource, workload: workloadName},
271+
refresher: &transientRefresher{
272+
source: tokenSource,
273+
workload: workloadName,
274+
upstream: upstream,
275+
clientID: clientID,
276+
},
243277
}
244278
}
245279

@@ -264,7 +298,10 @@ func (mts *MonitoredTokenSource) Token() (*oauth2.Token, error) {
264298
}
265299

266300
if !isTransientNetworkError(err) {
267-
mts.markAsUnauthenticated(fmt.Sprintf("Token retrieval failed: %v", err))
301+
mts.markAsUnauthenticated(
302+
fmt.Sprintf("Token retrieval failed: %v", err),
303+
isPermanentTokenEndpointError(err),
304+
)
268305
return nil, err
269306
}
270307

@@ -273,7 +310,10 @@ func (mts *MonitoredTokenSource) Token() (*oauth2.Token, error) {
273310
tok, err = mts.refresher.Refresh(mts.monitoringCtx, err)
274311
if err != nil {
275312
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
276-
mts.markAsUnauthenticated(fmt.Sprintf("Token refresh failed after retries: %v", err))
313+
mts.markAsUnauthenticated(
314+
fmt.Sprintf("Token refresh failed after retries: %v", err),
315+
isPermanentTokenEndpointError(err),
316+
)
277317
}
278318
return nil, err
279319
}
@@ -349,6 +389,11 @@ func (mts *MonitoredTokenSource) onTick() (bool, time.Duration) {
349389
// OAuth2 client-level auth failures (invalid_grant, 401, 400) and TLS errors
350390
// (certificate verification, handshake failure) are NOT considered transient and
351391
// return false so the workload is marked unauthenticated immediately.
392+
//
393+
// The function is side-effect free; callers that want to emit a DCR
394+
// remediation hint on a permanent 4xx must do so themselves at the
395+
// state-transition boundary using isPermanentTokenEndpointError to
396+
// classify, so a tight Token() loop does not spam the same record.
352397
func isTransientNetworkError(err error) bool {
353398
if err == nil ||
354399
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
@@ -399,6 +444,36 @@ func isTransientNetworkError(err error) bool {
399444
return false
400445
}
401446

447+
// isPermanentTokenEndpointError reports whether err is an *oauth2.RetrieveError
448+
// whose status implies the cached client credentials are themselves the
449+
// problem — specifically 400 (invalid_grant / invalid_client), 401, or
450+
// 403. Used at state-transition boundaries to decide whether to emit a
451+
// DCR/CIMD remediation hint alongside the unauthentication.
452+
//
453+
// Other 4xx codes are intentionally NOT treated as permanent here even
454+
// though isTransientNetworkError classifies the whole RetrieveError
455+
// branch as non-transient. 408 (Request Timeout) and 429 (Too Many
456+
// Requests) are typically transient back-pressure that the operator
457+
// cannot remediate by deleting cached credentials; firing the
458+
// "delete the cached credentials and restart" Warn on those would
459+
// mislead operators chasing a transient hiccup. The narrower allowlist
460+
// keeps the remediation hint truthful.
461+
func isPermanentTokenEndpointError(err error) bool {
462+
retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err)
463+
if !ok {
464+
return false
465+
}
466+
if retrieveErr.Response == nil {
467+
return false
468+
}
469+
switch retrieveErr.Response.StatusCode {
470+
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden:
471+
return true
472+
default:
473+
return false
474+
}
475+
}
476+
402477
// isOAuthParseError detects errors from the oauth2 library that indicate the
403478
// token endpoint returned an unparsable response body on a 2xx status. This
404479
// typically happens when a load balancer, CDN, or reverse proxy intercepts the
@@ -414,13 +489,39 @@ func isOAuthParseError(err error) bool {
414489
strings.Contains(msg, "oauth2: cannot parse response")
415490
}
416491

417-
// markAsUnauthenticated marks the workload as unauthenticated and stops background monitoring.
418-
func (mts *MonitoredTokenSource) markAsUnauthenticated(reason string) {
492+
// markAsUnauthenticated marks the workload as unauthenticated and stops
493+
// background monitoring. If permanent4xx is true and the workload was
494+
// constructed with a non-empty client_id, a one-shot DCR/CIMD remediation
495+
// hint is emitted alongside the stop transition. The hint and the close
496+
// of stopMonitoring share stopOnce, so a caller (e.g. a tight Token()
497+
// loop) cannot spam the record on every call after the workload has
498+
// already transitioned to Unauthenticated.
499+
func (mts *MonitoredTokenSource) markAsUnauthenticated(reason string, permanent4xx bool) {
419500
_ = mts.statusUpdater.SetWorkloadStatus(
420501
context.Background(),
421502
mts.workloadName,
422503
runtime.WorkloadStatusUnauthenticated,
423504
reason,
424505
)
425-
mts.stopOnce.Do(func() { close(mts.stopMonitoring) })
506+
mts.stopOnce.Do(func() {
507+
// A permanent 4xx from the token endpoint commonly indicates the
508+
// cached client (DCR or CIMD) is no longer recognised — but the
509+
// same branch fires for revoked consent, disabled accounts, and
510+
// statically configured clients, so the message has to be honest
511+
// about the variability. Gating on clientID != "" suppresses the
512+
// log entirely for workloads where no client_id context is
513+
// available; the operator-correlation it provides would be empty.
514+
if permanent4xx && mts.clientID != "" {
515+
//nolint:gosec // G706: client_id is public metadata per RFC 7591.
516+
slog.Warn(
517+
"token endpoint returned a permanent error; if this workload uses "+
518+
"cached DCR or CIMD credentials they may be stale — delete the "+
519+
"cached credentials and restart to re-register.",
520+
"workload", mts.workloadName,
521+
"upstream", mts.upstream,
522+
"client_id", mts.clientID,
523+
)
524+
}
525+
close(mts.stopMonitoring)
526+
})
426527
}

pkg/auth/monitored_token_source_test.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func TestMonitoredTokenSource_SuccessfulTokenRetrieval(t *testing.T) {
118118
ctx, cancel := context.WithCancel(context.Background())
119119
defer cancel()
120120

121-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
121+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
122122

123123
// Test successful token retrieval
124124
token, err := ats.Token()
@@ -151,7 +151,7 @@ func TestMonitoredTokenSource_AuthenticationErrorMarksUnauthenticated(t *testing
151151
ctx, cancel := context.WithCancel(context.Background())
152152
defer cancel()
153153

154-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
154+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
155155

156156
// Expect SetWorkloadStatus to be called with unauthenticated status
157157
statusManager.EXPECT().
@@ -195,7 +195,7 @@ func TestMonitoredTokenSource_ErrorMarksUnauthenticated(t *testing.T) {
195195
ctx, cancel := context.WithCancel(context.Background())
196196
defer cancel()
197197

198-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
198+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
199199

200200
// Expect SetWorkloadStatus to be called for any error
201201
statusManager.EXPECT().
@@ -245,7 +245,7 @@ func TestMonitoredTokenSource_BackgroundMonitoring(t *testing.T) {
245245
ctx, cancel := context.WithCancel(context.Background())
246246
defer cancel()
247247

248-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
248+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
249249

250250
// Expect SetWorkloadStatus to be called when auth error occurs
251251
statusManager.EXPECT().
@@ -297,7 +297,7 @@ func TestMonitoredTokenSource_BackgroundMonitoringStopsOnAnyError(t *testing.T)
297297
ctx, cancel := context.WithCancel(context.Background())
298298
defer cancel()
299299

300-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
300+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
301301

302302
// Expect SetWorkloadStatus to be called when any error occurs
303303
statusManager.EXPECT().
@@ -341,7 +341,7 @@ func TestMonitoredTokenSource_ExpiredTokenHandling(t *testing.T) {
341341
ctx, cancel := context.WithCancel(context.Background())
342342
defer cancel()
343343

344-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
344+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
345345

346346
// Should not mark as unauthenticated just for expired token
347347
// (oauth2 library should handle refresh; we only mark on actual auth errors)
@@ -374,7 +374,7 @@ func TestMonitoredTokenSource_StopMonitoring(t *testing.T) {
374374
ctx, cancel := context.WithCancel(context.Background())
375375
defer cancel()
376376

377-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
377+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
378378
ats.StartBackgroundMonitoring()
379379

380380
// Wait a bit to ensure monitoring started
@@ -407,7 +407,7 @@ func TestMonitoredTokenSource_MultipleCallsToToken(t *testing.T) {
407407
ctx, cancel := context.WithCancel(context.Background())
408408
defer cancel()
409409

410-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
410+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
411411

412412
statusManager.EXPECT().
413413
SetWorkloadStatus(
@@ -627,7 +627,7 @@ func TestMonitoredTokenSource_BackgroundMonitor_ErrorClassification(t *testing.T
627627
statusUpdater, _ := newMockStatusUpdater(ctrl)
628628
retrying := tokenSource.notifyOnCall(2)
629629

630-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
630+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
631631
ats.refresher.newBackOff = fastBackOff
632632
ats.StartBackgroundMonitoring()
633633

@@ -647,7 +647,7 @@ func TestMonitoredTokenSource_BackgroundMonitor_ErrorClassification(t *testing.T
647647
Return(nil).
648648
Times(1)
649649

650-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
650+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
651651
ats.refresher.newBackOff = fastBackOff
652652
ats.StartBackgroundMonitoring()
653653

@@ -698,7 +698,7 @@ func TestMonitoredTokenSource_TransientErrorRetriesAndSucceeds(t *testing.T) {
698698
ctx, cancel := context.WithCancel(context.Background())
699699
defer cancel()
700700

701-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
701+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
702702
ats.refresher.newBackOff = fastBackOff
703703
ats.StartBackgroundMonitoring()
704704

@@ -738,7 +738,7 @@ func TestMonitoredTokenSource_TransientErrorContextCancellation(t *testing.T) {
738738

739739
ctx, cancel := context.WithCancel(context.Background())
740740

741-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
741+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
742742
ats.refresher.newBackOff = fastBackOff
743743
ats.StartBackgroundMonitoring()
744744

@@ -793,7 +793,7 @@ func TestMonitoredTokenSource_TransientThenNonTransientMarksUnauthenticated(t *t
793793
ctx, cancel := context.WithCancel(context.Background())
794794
defer cancel()
795795

796-
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater)
796+
ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater)
797797
ats.refresher.newBackOff = fastBackOff
798798
ats.StartBackgroundMonitoring()
799799

pkg/auth/remote/config.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,30 @@ func (c *Config) ClearCachedClientCredentials() {
186186
c.CachedRegTokenRef = ""
187187
}
188188

189+
// LogContext returns the upstream issuer and resolved client_id for use as
190+
// log-correlation fields on the MonitoredTokenSource. Returns ("", "")
191+
// when c is nil so callers do not need to guard the call. Mirrors the
192+
// precedence applied at runtime when sending the client_id on token
193+
// refresh: cached CIMD URL > cached DCR client_id > statically configured
194+
// client_id. Lives next to resolveClientCredentials so the precedence has
195+
// a single home — adding a fourth cached field updates both call sites
196+
// in one place.
197+
func (c *Config) LogContext() (upstream, clientID string) {
198+
if c == nil {
199+
return "", ""
200+
}
201+
clientID = func() string {
202+
if c.CachedCIMDClientID != "" {
203+
return c.CachedCIMDClientID
204+
}
205+
if c.CachedClientID != "" {
206+
return c.CachedClientID
207+
}
208+
return c.ClientID
209+
}()
210+
return c.Issuer, clientID
211+
}
212+
189213
// DefaultResourceIndicator derives the resource indicator (RFC 8707) from the remote server URL.
190214
// This function should only be called when the user has not explicitly provided a resource indicator.
191215
// If the resource indicator cannot be derived, it returns an empty string.

0 commit comments

Comments
 (0)