Skip to content

Commit 633ed9e

Browse files
committed
Address code review feedback
Fixed issues from review of df84256 (Extract DCR resolver into pkg/auth/dcr) for issue #5145 sub-issue 4a: - HIGH: Stale identifier references in buildUpstreamConfigs doc comment in pkg/authserver/runner/embeddedauthserver.go named the pre-rename symbols (consumeResolution, applyResolutionToOAuth2Config, resolveDCRCredentials, logDCRStepError). Updated the comment to use the new public names (dcr.ConsumeResolution, dcr.ApplyResolutionToOAuth2Config, dcr.ResolveCredentials, dcr.LogStepError). Re-grepped the runner package; no other survivors. - HIGH: pkg/auth/dcr's package doc claimed "profile-agnostic" while the public API takes embedded-authserver types directly. Revised the package doc comment in pkg/auth/dcr/resolver.go to call out the current coupling explicitly and labelled the profile-agnostic framing as a target state for sub-issue 4b rather than the current API shape. Designing a profile-neutral input type with only one consumer in hand would be speculative; the right design moment is when 4b lands the CLI flow as the second consumer. - MEDIUM: DCRStore() accessor doc comment in pkg/authserver/runner/embeddedauthserver.go referenced the dropped "runner-DCRCredentialStore adapter" name. Replaced with "dcr.CredentialStore adapter". - MEDIUM: resolveSecret is duplicated across pkg/authserver/runner and pkg/auth/dcr. Added a parallel TestResolveSecret / TestResolveSecretWithEnvVar suite in pkg/auth/dcr/secret_test.go that mirrors the runner-package twin's observable contract, so any future drift between the two copies will fail one of the two tests at CI time. Lifting into a shared helper is deferred until 4b lands a third call site. - MEDIUM: Process-global singleflight was undocumented at the package surface. Added a "Concurrency" section to the package doc comment in pkg/auth/dcr/resolver.go. Extended the dcrFlight var doc with a cross-consumer caveat that names the "third consumer with colliding redirect URI" failure mode. Extracted the flight-key construction into a new flightKeyOf(Key) string helper so the canonical key shape is inspectable rather than buried in a string-concatenation literal. - MEDIUM: endpointsFromMetadata dereferenced metadata without a defensive nil check. Added the guard immediately after the fetchErr check in pkg/auth/dcr/resolver.go::endpointsFromMetadata with an error message that names the cross-package contract being defended. Documented the oauthproto contract on the function's doc comment so the assumption is visible at the call site. Verified via task lint-fix (0 issues), task license-check (clean), and go test -count=1 -race ./pkg/auth/dcr/... ./pkg/authserver/... (all pass).
1 parent df84256 commit 633ed9e

3 files changed

Lines changed: 190 additions & 13 deletions

File tree

pkg/auth/dcr/resolver.go

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,33 @@
1111
// the registration body. Stateless RFC 7591 wire-shape primitives live in
1212
// pkg/oauthproto.
1313
//
14-
// Profile differences (public PKCE vs confidential client, redirect URI
15-
// policy, persistence backend) stay at the call site — the package is
16-
// profile-agnostic and lets each consumer plug a CredentialStore in.
14+
// # Concurrency
15+
//
16+
// The package maintains a process-global singleflight keyed on the tuple
17+
// (issuer, redirectURI, scopesHash) so concurrent ResolveCredentials calls
18+
// across all consumers in a single process coalesce when their cache keys
19+
// match. Consumers that share any of those three values will share a flight
20+
// — the deduplication is a feature for the embedded authserver but means
21+
// callers cannot assume per-call-site flight isolation. See the dcrFlight
22+
// doc comment in resolver.go for the rationale.
23+
//
24+
// # Current API coupling — sub-issue 4a only
25+
//
26+
// As of issue #5145 sub-issue 4a (the slice that lifted this code out of
27+
// pkg/authserver/runner), the public functions on this package take
28+
// embedded-authserver types — *authserver.OAuth2UpstreamRunConfig,
29+
// *authserver.DCRUpstreamConfig, *upstream.OAuth2Config — directly on
30+
// their signatures. This matches the embedded authserver's existing
31+
// internal shapes verbatim and was the cheapest move-only change.
32+
//
33+
// The CLI flow migration in sub-issue 4b will introduce the second
34+
// consumer (pkg/auth/discovery::PerformOAuthFlow) and is the right
35+
// trigger for replacing those parameters with a profile-neutral input
36+
// type — designing the neutral shape now, with only one consumer in
37+
// hand, would be speculative. Until 4b lands, callers outside the
38+
// embedded authserver MUST adapt their inputs to the authserver types
39+
// at the call site, and the "profile-agnostic" framing in this package's
40+
// charter is a target state, not the current state of the API.
1741
//
1842
// See issue #5145 for the design discussion that motivated lifting this out
1943
// of pkg/authserver/runner.
@@ -62,8 +86,30 @@ import (
6286
// decides whether the resolution is fresh enough to reuse. A future
6387
// Redis-backed store would still want this in-process gate so a single
6488
// replica does not double-register against itself.
89+
//
90+
// Cross-consumer caveat (matters once issue #5145 sub-issue 4b lands the
91+
// CLI flow as the second consumer): because dcrFlight is package-global,
92+
// two consumers that happen to construct identical Keys (same issuer, same
93+
// redirect URI, same scopes hash) will share a single in-flight
94+
// registration even if they semantically want different client profiles.
95+
// The current call sites do not collide — the embedded authserver's
96+
// redirect URI lives on the AS origin, the CLI flow's lives on a
97+
// loopback — but a future consumer that defaults its redirect URI into
98+
// either of those spaces would silently coalesce. Keep this in mind when
99+
// adding a third consumer.
65100
var dcrFlight singleflight.Group
66101

102+
// flightKeyOf canonicalises a Key into the singleflight string used by
103+
// dcrFlight. The "\n" separator is safe because newline is not a valid byte
104+
// in any of the three components: OAuth scope tokens (RFC 6749 §3.3), URI
105+
// reference characters (RFC 3986 §2), or hex digests (the form
106+
// storage.ScopesHash always emits). Exposed as a function so tests and
107+
// future inspection helpers can compute the exact key the resolver would
108+
// route through dcrFlight without re-implementing the concatenation.
109+
func flightKeyOf(key Key) string {
110+
return key.Issuer + "\n" + key.RedirectURI + "\n" + key.ScopesHash
111+
}
112+
67113
// defaultUpstreamRedirectPath is the redirect path derived from the issuer
68114
// origin when the caller's run-config does not supply an explicit RedirectURI.
69115
// Matches the authserver's public callback route.
@@ -369,9 +415,9 @@ func ResolveCredentials(
369415

370416
// Coalesce concurrent registrations for the same Key — see dcrFlight
371417
// doc comment. The leader runs the registerOnce closure; followers
372-
// receive the leader's *Resolution result. The flight key embeds the
373-
// Key fields with a separator that cannot appear in any of them
374-
// (newline is not valid in OAuth scope tokens, URLs, or hex digests).
418+
// receive the leader's *Resolution result. flightKeyOf canonicalises the
419+
// Key into a singleflight string with a separator that cannot appear in
420+
// any of the three Key components.
375421
//
376422
// A defer/recover inside the closure converts a panic in registerAndCache
377423
// (or anything it calls) into a normal error. Without this, singleflight
@@ -381,8 +427,7 @@ func ResolveCredentials(
381427
// dcrStepError and surfaces in the single boundary log emitted by
382428
// LogStepError, so the failure produces exactly one Error record (no
383429
// in-defer log here) and callers can react to it as a normal failure.
384-
flightKey := key.Issuer + "\n" + key.RedirectURI + "\n" + key.ScopesHash
385-
resolutionAny, err, _ := dcrFlight.Do(flightKey, func() (res any, err error) {
430+
resolutionAny, err, _ := dcrFlight.Do(flightKeyOf(key), func() (res any, err error) {
386431
defer func() {
387432
if r := recover(); r != nil {
388433
stepErr := newDCRStepError(dcrStepRegister, localIssuer, redirectURI,
@@ -948,6 +993,15 @@ func deriveExpectedIssuerFromDiscoveryURL(discoveryURL string) (string, error) {
948993
// document — possible if TLS to the metadata host is compromised, or if the
949994
// upstream is misconfigured — could otherwise plant http:// URLs that flow
950995
// through to the authorization-code and token-exchange call paths.
996+
//
997+
// Contract with oauthproto: FetchAuthorizationServerMetadata* guarantees a
998+
// non-nil *AuthorizationServerMetadata whenever fetchErr is nil OR
999+
// fetchErr is ErrRegistrationEndpointMissing (in the latter case the
1000+
// metadata is otherwise valid; only registration_endpoint is missing).
1001+
// The defensive nil guard below catches a future cross-package contract
1002+
// regression — e.g., a new oauthproto sentinel that returns nil metadata
1003+
// alongside a non-fatal error — and converts it into a clean validation
1004+
// error rather than a nil-pointer dereference at the field accesses.
9511005
func endpointsFromMetadata(
9521006
metadata *oauthproto.AuthorizationServerMetadata,
9531007
fetchErr error,
@@ -956,6 +1010,11 @@ func endpointsFromMetadata(
9561010
if fetchErr != nil && !errors.Is(fetchErr, oauthproto.ErrRegistrationEndpointMissing) {
9571011
return nil, fmt.Errorf("discover authorization server metadata: %w", fetchErr)
9581012
}
1013+
if metadata == nil {
1014+
return nil, fmt.Errorf(
1015+
"dcr: authorization server metadata is nil (oauthproto contract " +
1016+
"violation: nil metadata returned alongside a non-fatal fetch error)")
1017+
}
9591018

9601019
if err := validateUpstreamEndpointURL(metadata.AuthorizationEndpoint, "authorization_endpoint"); err != nil {
9611020
return nil, fmt.Errorf("dcr: discovered %w", err)

pkg/auth/dcr/secret_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dcr
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// TestResolveSecret pins the dcr-package copy of resolveSecret to the same
16+
// observable contract as the parallel runner-package copy
17+
// (pkg/authserver/runner/embeddedauthserver_test.go::TestResolveSecret*).
18+
// Two physically-distinct copies of this helper exist by design (the dcr
19+
// package must not reach back into pkg/authserver/runner per its
20+
// profile-agnostic charter); this test guards against silent drift between
21+
// them. If a future bug fix lands on one copy without being mirrored to the
22+
// other, this test or its runner-package twin will fail.
23+
//
24+
// Cases that take t.Setenv() are kept out of the parallel sub-suite because
25+
// t.Setenv requires a non-parallel test scope.
26+
func TestResolveSecret(t *testing.T) {
27+
t.Parallel()
28+
29+
tmpDir := t.TempDir()
30+
secretFile := filepath.Join(tmpDir, "secret")
31+
require.NoError(t, os.WriteFile(secretFile, []byte(" secret-value \n"), 0o600))
32+
33+
cases := []struct {
34+
name string
35+
file string
36+
envVar string
37+
want string
38+
wantErr bool
39+
wantErrSubs []string
40+
}{
41+
{
42+
name: "neither file nor env var set returns empty string and no error",
43+
file: "", envVar: "",
44+
want: "",
45+
},
46+
{
47+
name: "file content is read and surrounding whitespace trimmed",
48+
file: secretFile, envVar: "",
49+
want: "secret-value",
50+
},
51+
{
52+
name: "missing file returns wrapped read error",
53+
file: "/nonexistent/file", envVar: "",
54+
wantErr: true, wantErrSubs: []string{"failed to read secret file"},
55+
},
56+
{
57+
name: "env var name is set but env var is empty returns explanatory error",
58+
// Use a unique env var name that won't be set in the environment.
59+
file: "", envVar: "TEST_SECRET_NOT_SET_DCR_PKG_12345",
60+
wantErr: true, wantErrSubs: []string{"environment variable", "is not set"},
61+
},
62+
}
63+
64+
for _, tc := range cases {
65+
t.Run(tc.name, func(t *testing.T) {
66+
t.Parallel()
67+
68+
got, err := resolveSecret(tc.file, tc.envVar)
69+
if tc.wantErr {
70+
require.Error(t, err)
71+
for _, sub := range tc.wantErrSubs {
72+
assert.Contains(t, err.Error(), sub)
73+
}
74+
assert.Empty(t, got)
75+
return
76+
}
77+
require.NoError(t, err)
78+
assert.Equal(t, tc.want, got)
79+
})
80+
}
81+
}
82+
83+
// TestResolveSecretWithEnvVar covers the env-var paths separately because
84+
// t.Setenv requires a non-parallel test scope. Mirrors the runner-package
85+
// twin (TestResolveSecretWithEnvVar in embeddedauthserver_test.go).
86+
func TestResolveSecretWithEnvVar(t *testing.T) {
87+
tmpDir := t.TempDir()
88+
secretFile := filepath.Join(tmpDir, "secret")
89+
require.NoError(t, os.WriteFile(secretFile, []byte("secret-from-file"), 0o600))
90+
91+
t.Run("file takes precedence over env var when both are set", func(t *testing.T) {
92+
envVar := "TEST_SECRET_FILE_PRECEDENCE_DCR_PKG"
93+
t.Setenv(envVar, "secret-from-env")
94+
95+
got, err := resolveSecret(secretFile, envVar)
96+
require.NoError(t, err)
97+
assert.Equal(t, "secret-from-file", got)
98+
})
99+
100+
t.Run("env var is read when file is empty", func(t *testing.T) {
101+
envVar := "TEST_SECRET_ENV_ONLY_DCR_PKG"
102+
t.Setenv(envVar, "secret-from-env")
103+
104+
got, err := resolveSecret("", envVar)
105+
require.NoError(t, err)
106+
assert.Equal(t, "secret-from-env", got)
107+
})
108+
109+
t.Run("missing file does not silently fall back to env var", func(t *testing.T) {
110+
envVar := "TEST_SECRET_NO_FALLBACK_DCR_PKG"
111+
t.Setenv(envVar, "secret-from-env")
112+
113+
got, err := resolveSecret("/nonexistent/file", envVar)
114+
require.Error(t, err)
115+
assert.Contains(t, err.Error(), "failed to read secret file")
116+
assert.Empty(t, got)
117+
})
118+
}

pkg/authserver/runner/embeddedauthserver.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,8 @@ func (e *EmbeddedAuthServer) KeyProvider() keys.KeyProvider {
221221
// and is intended for admin / diagnostic code paths and integration tests
222222
// that need to verify that the DCR resolver is wired into the same backend
223223
// the authserver writes to. It does NOT expose any I/O the
224-
// runner-DCRCredentialStore adapter does not already provide; the returned
225-
// value is the same storage.DCRCredentialStore the constructor surfaced.
224+
// dcr.CredentialStore adapter does not already provide; the returned value
225+
// is the same storage.DCRCredentialStore the constructor surfaced.
226226
func (e *EmbeddedAuthServer) DCRStore() storage.DCRCredentialStore {
227227
return e.dcrStore
228228
}
@@ -352,14 +352,14 @@ func parseTokenLifespans(cfg *authserver.TokenLifespanRunConfig) (access, refres
352352
// RFC 7591 Dynamic Client Registration against the upstream authorization
353353
// server (hitting the network on first call, using dcrStore on subsequent
354354
// calls) and overlays the resulting ClientID / ClientSecret onto the output
355-
// config via consumeResolution + applyResolutionToOAuth2Config. The
355+
// config via dcr.ConsumeResolution + dcr.ApplyResolutionToOAuth2Config. The
356356
// caller's runConfigs slice is not mutated: in-place mutation of
357357
// caller-provided values surprises callers and can cause data races, so
358358
// each element is cloned before applying DCR resolution.
359359
//
360360
// Error logging: this function is the boundary for DCR errors — on any
361-
// failure from resolveDCRCredentials it emits exactly one structured
362-
// slog.Error via logDCRStepError and returns the wrapped error to the
361+
// failure from dcr.ResolveCredentials it emits exactly one structured
362+
// slog.Error via dcr.LogStepError and returns the wrapped error to the
363363
// caller without logging further. The resolver itself does not log
364364
// errors, which avoids the log-and-return double-reporting pattern.
365365
func buildUpstreamConfigs(

0 commit comments

Comments
 (0)