Skip to content

Commit cf6e17c

Browse files
committed
Make DCR consume helpers value-in / value-out
Addresses #5198 review comments: - INFO pkg/auth/dcr/resolver.go (3227004701): ConsumeResolution and ApplyResolutionToOAuth2Config mutated caller-supplied pointers and documented the "pass a copy" invariant only in prose. Converted both to value-in / value-out signatures so the no-caller-mutation contract is compile-time enforced rather than a prose discipline the second consumer (CLI flow in sub-issue 4b, #5219) would have to remember. The change is structural but small: two signature changes (ConsumeResolution / ApplyResolutionToOAuth2Config), two function bodies returning the modified value, and call-site updates in embeddedauthserver.go (1 production call site per function) and resolver_test.go (4 test call sites for ConsumeResolution; zero for ApplyResolutionToOAuth2Config). Doc comments updated to describe the new value-in / value-out shape; the "pass a copy of the run-config" prose-discipline paragraph is removed because the type system now enforces it. Pointer-typed fields inside the struct (DCRConfig) still share storage with the caller via the shallow copy, but the only mutation here is nil-assignment to the copy's DCRConfig field, which does not reach back through the caller's pointer; this nuance is now called out in the doc comment. ResolveCredentials is left as *authserver.OAuth2UpstreamRunConfig deliberately: its rc input is read-only (no mutation), so the pointer is just for avoiding a struct copy on the hot path. Satisfies .claude/rules/go-style.md "Copy Before Mutating Caller Input".
1 parent 29c751a commit cf6e17c

3 files changed

Lines changed: 61 additions & 48 deletions

File tree

pkg/auth/dcr/resolver.go

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -229,23 +229,26 @@ func NeedsDCR(rc *authserver.OAuth2UpstreamRunConfig) bool {
229229
return rc.ClientID == "" && rc.DCRConfig != nil
230230
}
231231

232-
// ConsumeResolution copies resolved credentials and endpoints from res into
233-
// rc and consumes rc.DCRConfig (sets it to nil), transitioning the run-
234-
// config copy from "DCR-pending" (ClientID == "" && DCRConfig != nil) to
235-
// "DCR-resolved" (ClientID populated && DCRConfig == nil). The "consume"
236-
// name is deliberate: a second call after the first is a no-op only
237-
// because the first cleared DCRConfig — this is a one-shot state
238-
// transition, not an idempotent default-fill.
232+
// ConsumeResolution returns a copy of rc with the resolved credentials and
233+
// endpoints from res copied in and DCRConfig consumed (set to nil),
234+
// transitioning the run-config from "DCR-pending" (ClientID == "" &&
235+
// DCRConfig != nil) to "DCR-resolved" (ClientID populated && DCRConfig
236+
// == nil). The "consume" name is deliberate: a second call on the
237+
// returned value is a no-op only because the first cleared DCRConfig —
238+
// this is a one-shot state transition, not an idempotent default-fill.
239239
//
240-
// Callers must pass a COPY of the upstream run-config so the caller's
241-
// original is unaffected; ConsumeResolution does not clone rc internally.
240+
// rc is taken by value and the modified copy is returned. The caller's
241+
// original is never observably mutated; the value-in / value-out shape
242+
// makes the no-mutation contract compile-time enforced rather than a
243+
// prose discipline the caller is required to remember. Pointer-typed
244+
// fields (DCRConfig) share storage with the caller's copy via the struct
245+
// shallow-copy, but the only mutation here is to assign nil to the
246+
// copy's DCRConfig — nil-assignment to the local field does not reach
247+
// back through the original pointer.
242248
//
243249
// Why DCRConfig is cleared: OAuth2UpstreamRunConfig.Validate enforces
244250
// ClientID xor DCRConfig — a resolved copy that left DCRConfig set would
245-
// fail the validator that runs downstream in buildPureOAuth2Config. The
246-
// caller's *original* OAuth2Config is unaffected because
247-
// buildUpstreamConfigs deep-copies before resolution; only the post-
248-
// resolution copy is mutated here.
251+
// fail the validator that runs downstream in buildPureOAuth2Config.
249252
//
250253
// ClientID, the endpoints, and RedirectURI are written only when rc leaves
251254
// them empty — explicit caller configuration always wins. The conditional
@@ -261,15 +264,15 @@ func NeedsDCR(rc *authserver.OAuth2UpstreamRunConfig) bool {
261264
// RedirectURI, which authserver.Config validation requires.
262265
//
263266
// Note on ClientSecret: ConsumeResolution does NOT write the resolved
264-
// secret to rc because OAuth2UpstreamRunConfig models secrets as file-or-
265-
// env references only. To propagate the DCR-resolved secret into the
266-
// final upstream.OAuth2Config, callers must pair this call with
267+
// secret because OAuth2UpstreamRunConfig models secrets as file-or-env
268+
// references only. To propagate the DCR-resolved secret into the final
269+
// upstream.OAuth2Config, callers must pair this call with
267270
// ApplyResolutionToOAuth2Config once the config has been built. Keeping
268271
// the two helpers side-by-side localises the DCR-specific application
269272
// logic.
270-
func ConsumeResolution(rc *authserver.OAuth2UpstreamRunConfig, res *Resolution) {
271-
if rc == nil || res == nil {
272-
return
273+
func ConsumeResolution(rc authserver.OAuth2UpstreamRunConfig, res *Resolution) authserver.OAuth2UpstreamRunConfig {
274+
if res == nil {
275+
return rc
273276
}
274277
if rc.ClientID == "" {
275278
rc.ClientID = res.ClientID
@@ -284,28 +287,35 @@ func ConsumeResolution(rc *authserver.OAuth2UpstreamRunConfig, res *Resolution)
284287
if rc.RedirectURI == "" {
285288
rc.RedirectURI = res.RedirectURI
286289
}
290+
return rc
287291
}
288292

289-
// ApplyResolutionToOAuth2Config overlays the DCR-resolved ClientSecret onto
290-
// a built *upstream.OAuth2Config. This is the companion to
293+
// ApplyResolutionToOAuth2Config returns a copy of cfg with the DCR-
294+
// resolved ClientSecret overlaid onto it. This is the companion to
291295
// ConsumeResolution: where that function writes fields representable in
292296
// the file-or-env run-config model, this one writes the inline-only
293297
// ClientSecret directly on the runtime config.
294298
//
295-
// The split exists because buildPureOAuth2Config intentionally retains a
296-
// narrow file-or-env contract (no DCR awareness) and because OAuth2's
297-
// ClientSecret on the run-config is modelled as a reference rather than
298-
// an inline string. Any future output path from OAuth2UpstreamRunConfig
299-
// to upstream.OAuth2Config must call BOTH ConsumeResolution (run-config
300-
// side) AND ApplyResolutionToOAuth2Config (built-config side) to get a
301-
// fully-resolved DCR client. Forgetting the second call leaves
302-
// ClientSecret empty and produces silent auth failures at request time —
303-
// the type system does not enforce the pair, so the invariant lives here.
304-
func ApplyResolutionToOAuth2Config(cfg *upstream.OAuth2Config, res *Resolution) {
305-
if cfg == nil || res == nil {
306-
return
299+
// cfg is taken by value and the modified copy is returned, mirroring
300+
// ConsumeResolution. The no-mutation contract is compile-time enforced
301+
// by the signature rather than a prose discipline.
302+
//
303+
// The split between these two helpers exists because buildPureOAuth2Config
304+
// intentionally retains a narrow file-or-env contract (no DCR awareness)
305+
// and because OAuth2's ClientSecret on the run-config is modelled as a
306+
// reference rather than an inline string. Any future output path from
307+
// OAuth2UpstreamRunConfig to upstream.OAuth2Config must call BOTH
308+
// ConsumeResolution (run-config side) AND ApplyResolutionToOAuth2Config
309+
// (built-config side) to get a fully-resolved DCR client. Forgetting the
310+
// second call leaves ClientSecret empty and produces silent auth failures
311+
// at request time — the type system does not enforce the pair, so the
312+
// invariant lives here.
313+
func ApplyResolutionToOAuth2Config(cfg upstream.OAuth2Config, res *Resolution) upstream.OAuth2Config {
314+
if res == nil {
315+
return cfg
307316
}
308317
cfg.ClientSecret = res.ClientSecret
318+
return cfg
309319
}
310320

311321
// Step identifiers for structured error logs emitted by the caller of

pkg/auth/dcr/resolver_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,7 @@ func TestNeedsDCR(t *testing.T) {
635635
func TestConsumeResolution_RespectsExplicitEndpoints(t *testing.T) {
636636
t.Parallel()
637637

638-
rc := &authserver.OAuth2UpstreamRunConfig{
638+
rc := authserver.OAuth2UpstreamRunConfig{
639639
AuthorizationEndpoint: "https://explicit/authorize",
640640
TokenEndpoint: "https://explicit/token",
641641
}
@@ -644,7 +644,7 @@ func TestConsumeResolution_RespectsExplicitEndpoints(t *testing.T) {
644644
AuthorizationEndpoint: "https://discovered/authorize",
645645
TokenEndpoint: "https://discovered/token",
646646
}
647-
ConsumeResolution(rc, res)
647+
rc = ConsumeResolution(rc, res)
648648
assert.Equal(t, "got-client", rc.ClientID)
649649
assert.Equal(t, "https://explicit/authorize", rc.AuthorizationEndpoint)
650650
assert.Equal(t, "https://explicit/token", rc.TokenEndpoint)
@@ -653,13 +653,13 @@ func TestConsumeResolution_RespectsExplicitEndpoints(t *testing.T) {
653653
func TestConsumeResolution_FillsMissingEndpoints(t *testing.T) {
654654
t.Parallel()
655655

656-
rc := &authserver.OAuth2UpstreamRunConfig{}
656+
rc := authserver.OAuth2UpstreamRunConfig{}
657657
res := &Resolution{
658658
ClientID: "got-client",
659659
AuthorizationEndpoint: "https://discovered/authorize",
660660
TokenEndpoint: "https://discovered/token",
661661
}
662-
ConsumeResolution(rc, res)
662+
rc = ConsumeResolution(rc, res)
663663
assert.Equal(t, "got-client", rc.ClientID)
664664
assert.Equal(t, "https://discovered/authorize", rc.AuthorizationEndpoint)
665665
assert.Equal(t, "https://discovered/token", rc.TokenEndpoint)
@@ -673,7 +673,7 @@ func TestConsumeResolution_FillsMissingEndpoints(t *testing.T) {
673673
func TestConsumeResolution_ClearsDCRConfig(t *testing.T) {
674674
t.Parallel()
675675

676-
rc := &authserver.OAuth2UpstreamRunConfig{
676+
rc := authserver.OAuth2UpstreamRunConfig{
677677
DCRConfig: &authserver.DCRUpstreamConfig{
678678
RegistrationEndpoint: "https://idp.example.com/register",
679679
},
@@ -682,7 +682,7 @@ func TestConsumeResolution_ClearsDCRConfig(t *testing.T) {
682682
ClientID: "dcr-issued-client",
683683
}
684684

685-
ConsumeResolution(rc, res)
685+
rc = ConsumeResolution(rc, res)
686686

687687
assert.Equal(t, "dcr-issued-client", rc.ClientID)
688688
assert.Nil(t, rc.DCRConfig,
@@ -1205,13 +1205,13 @@ func TestResolveUpstreamRedirectURI_PreservesIssuerPath(t *testing.T) {
12051205
func TestConsumeResolution_DoesNotOverwritePreProvisionedClientID(t *testing.T) {
12061206
t.Parallel()
12071207

1208-
rc := &authserver.OAuth2UpstreamRunConfig{
1208+
rc := authserver.OAuth2UpstreamRunConfig{
12091209
ClientID: "pre-provisioned",
12101210
}
12111211
res := &Resolution{
12121212
ClientID: "would-be-overwrite",
12131213
}
1214-
ConsumeResolution(rc, res)
1214+
rc = ConsumeResolution(rc, res)
12151215
assert.Equal(t, "pre-provisioned", rc.ClientID,
12161216
"ConsumeResolution must not overwrite a non-empty ClientID")
12171217
}

pkg/authserver/runner/embeddedauthserver.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -385,20 +385,22 @@ func buildUpstreamConfigs(
385385
// "does this upstream require DCR" avoids drift if the condition
386386
// ever needs to be extended (e.g., to support OIDC DCR).
387387
if dcr.NeedsDCR(rcCopy.OAuth2Config) {
388-
// Deep-copy the OAuth2 sub-config so dcr.ConsumeResolution writes to the
389-
// copy, not the caller's OAuth2UpstreamRunConfig pointer.
390-
o2Copy := *rcCopy.OAuth2Config
391-
rcCopy.OAuth2Config = &o2Copy
388+
// Take a local copy of the OAuth2 sub-config. dcr.ResolveCredentials
389+
// reads it but does not mutate; dcr.ConsumeResolution is value-in /
390+
// value-out, so the caller's original OAuth2Config pointer target
391+
// is never reached by either call.
392+
o2 := *rcCopy.OAuth2Config
392393

393-
resolution, err := dcr.ResolveCredentials(ctx, &o2Copy, issuer, dcrStore)
394+
resolution, err := dcr.ResolveCredentials(ctx, &o2, issuer, dcrStore)
394395
if err != nil {
395396
// Emit the single boundary Error record with enough context to
396397
// correlate the failure back to this upstream; then return the
397398
// wrapped error without further logging.
398399
dcr.LogStepError(rc.Name, err)
399400
return nil, fmt.Errorf("upstream %q: %w", rc.Name, err)
400401
}
401-
dcr.ConsumeResolution(&o2Copy, resolution)
402+
o2 = dcr.ConsumeResolution(o2, resolution)
403+
rcCopy.OAuth2Config = &o2
402404
dcrResolution = resolution
403405
}
404406

@@ -413,7 +415,8 @@ func buildUpstreamConfigs(
413415
// documented in pkg/auth/dcr/resolver.go — both calls must be
414416
// paired to produce a fully-resolved DCR client.
415417
if dcrResolution != nil && cfg.OAuth2Config != nil {
416-
dcr.ApplyResolutionToOAuth2Config(cfg.OAuth2Config, dcrResolution)
418+
applied := dcr.ApplyResolutionToOAuth2Config(*cfg.OAuth2Config, dcrResolution)
419+
cfg.OAuth2Config = &applied
417420
}
418421

419422
configs = append(configs, *cfg)

0 commit comments

Comments
 (0)