Skip to content

Commit 3fa65b0

Browse files
authored
Extract DCR resolver into pkg/auth/dcr (#5198)
* Extract DCR resolver into pkg/auth/dcr Sub-issue 4a of #5145. Creates the shared pkg/auth/dcr package and migrates the embedded authserver to consume it. The CLI flow migration (pkg/auth/discovery::PerformOAuthFlow) is left to sub-issue 4b (#5219) so this slice stays under the project's PR-size limit. Files moved (renamed via git mv so the diff shows as renames, not deletions + additions): pkg/authserver/runner/dcr.go -> pkg/auth/dcr/resolver.go pkg/authserver/runner/dcr_store.go -> pkg/auth/dcr/store.go pkg/authserver/runner/dcr_test.go -> pkg/auth/dcr/resolver_test.go pkg/authserver/runner/dcr_store_test.go -> pkg/auth/dcr/store_test.go pkg/authserver/runner/dcr_testhelpers_test.go -> pkg/auth/dcr/testhelpers_test.go Public API surface (renamed from runner-package internals because they now cross a package boundary): Resolution (was DCRResolution) Key (was DCRKey, alias to storage.DCRKey unchanged) CredentialStore (was the package-private dcrResolutionCache) ResolveCredentials (was resolveDCRCredentials) NeedsDCR (was needsDCR) ConsumeResolution (was consumeResolution) ApplyResolutionToOAuth2Config (was applyResolutionToOAuth2Config) LogStepError (was logDCRStepError) NewInMemoryStore (new convenience constructor wrapping storage.NewMemoryStorage; mirrors the pattern the per-test newMemoryDCRStore helpers use) NewStorageBackedStore (was the package-private newStorageBackedStore) SanitizeErrorForLog (was sanitizeErrorForLog; promoted to exported because the deferred-cleanup log path in pkg/authserver/runner needs it) Names follow the pkg/auth/dcr.* form so the linter's stuttering check (revive: dcr.DCRResolution would stutter) passes and the surface reads as ordinary package API. resolveSecret was duplicated into the dcr package because pkg/auth/dcr must stay profile-agnostic and cannot reach back into pkg/authserver/runner. The duplication is intentional and called out in a comment; future consolidation can move it into a shared helper if a third caller appears. A parallel TestResolveSecret / TestResolveSecretWithEnvVar suite in pkg/auth/dcr/secret_test.go mirrors the runner-package twin's observable contract so any future drift between the two copies fails CI on at least one side. Review-feedback bits applied alongside the move: - Package doc comment in pkg/auth/dcr/resolver.go now documents the process-global singleflight in a Concurrency section, and labels the profile-agnostic framing as a target state for sub-issue 4b rather than as a description of the current API shape (the package's public API still takes embedded-authserver types). - dcrFlight var doc gains a cross-consumer caveat naming the "third consumer with colliding redirect URI" failure mode. - Flight-key construction extracted into flightKeyOf(Key) string so the canonical key shape is inspectable rather than buried in a string-concatenation literal. - endpointsFromMetadata gains a defensive nil check; the function's doc comment names the oauthproto contract being defended. - newMemoryDCRStore test helper is duplicated across the two packages because Go test helpers cannot be shared without exporting them; the duplication is documented at both sites. Acceptance criteria status (#5145): AC#1 (pkg/auth/dcr exists, exports a stateful resolver, consumed by both call sites): partially met — package exists and is consumed by EmbeddedAuthServer. CLI flow migration is sub-issue 4b (#5219). AC#2 (stateless RFC 7591 helpers in pkg/oauthproto): met (already satisfied before this PR). AC#3 (no direct oauthproto.RegisterClientDynamically calls outside pkg/auth/dcr): not yet met — pkg/auth/discovery still calls it. Resolved by sub-issue 4b (#5219). AC#4 (review-property behaviours apply to CLI flow): not yet met — same dependency on 4b (#5219). Closes #5220. Verified via task lint-fix (0 issues), task license-check (clean), and go test -count=1 -race ./pkg/auth/dcr/... ./pkg/authserver/... (all pass). * Harden pkg/auth/dcr/store.go constructors Addresses #5198 review comments: - MEDIUM pkg/auth/dcr/store.go (3219717154): remove NewInMemoryStore — its CredentialStore return type has no Close method, so the storage.NewMemoryStorage cleanup goroutine the constructor spawns cannot be stopped through the returned interface. With no production caller in this PR (the test helpers wire their own *MemoryStorage with t.Cleanup), removing it is the lightest fix and resolves the "public API ahead of caller" smell. A real consumer with lifecycle management can reintroduce the appropriate shape (e.g. returning a paired shutdown func, or adding Close to CredentialStore) when 4b lands. - MEDIUM pkg/auth/dcr/store.go (3219717166): panic on nil backend in NewStorageBackedStore. Per .claude/rules/go-style.md "Constructor Validation: Fail Loudly on Invalid Input" — a nil backend is unambiguously a programming error and silent acceptance would only delay the nil-pointer dereference to the first Get/Put call, far from the constructor site. * Sweep stale doc references and tighten advisory comments Addresses #5198 review comments: - MEDIUM pkg/authserver/storage/types.go (review body finding #2): DCRCredentials "Converter contract" doc named the now-deleted pkg/authserver/runner/dcr_store.go path and the *DCRResolution type. Updated to pkg/auth/dcr/store.go, *dcr.Resolution, and pkg/auth/dcr/store_test.go (the rename sweep had missed this out-of-diff file). - MEDIUM pkg/auth/dcr/resolver.go (3219717202): dcrFlight doc named EmbeddedAuthServer.dcrStore, a field #5196 removed. Reframed the lifetime contrast in terms of the public injection point — the CredentialStore the embedded authserver constructs and injects into ResolveCredentials — rather than a private field across a package boundary. - MEDIUM pkg/authserver/runner/embeddedauthserver_test.go (3219717294): drift-guard cross-reference was one-way. Added reciprocal doc comments on TestResolveSecret and TestResolveSecretWithEnvVar naming the dcr-side twin at pkg/auth/dcr/secret_test.go, mirroring the dcr-side comment that already names the runner-side twin. - LOW pkg/auth/dcr/resolver.go (3219717277): SanitizeErrorForLog reads as a generic URL sanitiser but only handles http(s); tightened the doc with an "IMPORTANT — caller responsibility" warning that callers receiving non-http(s) URLs with credential-bearing components (redis://user:pass@host, postgres://, etc.) MUST verify those separately. Long-term consolidation into a shared helper appropriate for sub-issue 4b. - LOW pkg/authserver/server/handlers/dcr.go (review body finding #7): log-correlation comment named pkg/authserver/runner/dcr.go; updated to pkg/auth/dcr/resolver.go (also missed by the rename sweep). - LOW pkg/auth/dcr/resolver.go (3219717222): package doc listed *authserver.DCRUpstreamConfig as a parameter type — no exported function takes it; it is reached transitively via OAuth2UpstreamRunConfig.DCRConfig. Applied the reviewer's suggested wording. - LOW pkg/auth/dcr/resolver.go (3219717247): flightKeyOf doc cited RFC 6749 §3.3 (scope tokens) for the ScopesHash field, which is a hex digest, not a scope token. Removed the misaligned citation. - LOW pkg/authserver/runner/embeddedauthserver_test.go (3219717303): qualified four bare "DCRKey" references to "storage.DCRKey" in comments and an assertion message; also fixed a sibling staleness in the same test-doc block where the comment named the deleted dcrStore field. - LOW pkg/auth/dcr/resolver.go (3219717270): added a TODO(#5219) on dcrFlight noting that the flight key must gain a consumer-identifier component when sub-issue 4b wires the CLI flow as the second consumer. The colliding-Key risk is theoretical today; once two profiles share this group it becomes a correctness hazard. Cross-consumer caveat above already describes the failure mode; this TODO ties it to the resolution PR. - LOW pkg/auth/dcr/resolver.go (3219717285): resolveSecret has no production caller in this PR. Tightened the duplication doc to state that the helper is staged for sub-issue 4b (#5219) to wire as part of the CLI flow migration, and that at that point this copy SHOULD be promoted to a shared helper with both consumers migrated in the same PR — i.e., the threshold for consolidation is "second caller wires up", not "third caller appears". * 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 110b9a7 commit 3fa65b0

11 files changed

Lines changed: 597 additions & 275 deletions

File tree

Lines changed: 242 additions & 100 deletions
Large diffs are not rendered by default.
Lines changed: 66 additions & 66 deletions
Large diffs are not rendered by default.

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+
}
Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
package runner
4+
package dcr
55

66
import (
77
"context"
@@ -18,64 +18,72 @@ import (
1818
// long-lived and are only purged by explicit RFC 7592 deregistration.
1919
const dcrStaleAgeThreshold = 90 * 24 * time.Hour
2020

21-
// DCRKey is a re-export of storage.DCRKey, kept as a package-local alias so
22-
// existing runner-side callers continue to compile against runner.DCRKey
23-
// while the canonical definition lives in pkg/authserver/storage. The
24-
// canonical form (and its ScopesHash constructor) MUST live in a single place
25-
// so any future Redis backend hashes keys identically to the in-memory
26-
// backend; see storage.DCRKey for the field documentation.
27-
type DCRKey = storage.DCRKey
21+
// Key is a re-export of storage.DCRKey, kept as a package-local alias so
22+
// callers in this package can reference the canonical cache key without an
23+
// explicit storage. qualifier on every call site, while the canonical
24+
// definition lives in pkg/authserver/storage. The canonical form (and its
25+
// ScopesHash constructor) MUST live in a single place so any future Redis
26+
// backend hashes keys identically to the in-memory backend; see
27+
// storage.DCRKey for the field documentation.
28+
type Key = storage.DCRKey
2829

29-
// dcrResolutionCache caches RFC 7591 Dynamic Client Registration resolutions
30+
// CredentialStore caches RFC 7591 Dynamic Client Registration resolutions
3031
// keyed by the (Issuer, RedirectURI, ScopesHash) tuple. Implementations must
3132
// be safe for concurrent use.
3233
//
3334
// This is the runner-facing interface used by the DCR resolver. It is a
3435
// narrow re-projection of storage.DCRCredentialStore that exchanges
35-
// *DCRResolution values (the resolver's working type) instead of
36+
// *Resolution values (the resolver's working type) instead of
3637
// *storage.DCRCredentials so the resolver internals stay agnostic to the
3738
// persistence layer's exact field shape.
3839
//
3940
// Implementations in this package are thin adapters around a
4041
// storage.DCRCredentialStore — the durable map / Redis hash lives over
41-
// there, and this interface adds a per-call DCRResolution <-> DCRCredentials
42+
// there, and this interface adds a per-call Resolution <-> DCRCredentials
4243
// translation. There is exactly one persistence implementation per backend:
43-
// storage.MemoryStorage and storage.RedisStorage. See newStorageBackedStore
44+
// storage.MemoryStorage and storage.RedisStorage. See NewStorageBackedStore
4445
// for the adapter.
45-
type dcrResolutionCache interface {
46+
type CredentialStore interface {
4647
// Get returns the cached resolution for key, or (nil, false, nil) if the
4748
// key is not present. An error is returned only on backend failure.
48-
Get(ctx context.Context, key DCRKey) (*DCRResolution, bool, error)
49+
Get(ctx context.Context, key Key) (*Resolution, bool, error)
4950

5051
// Put stores the resolution for key, overwriting any existing entry.
5152
// Implementations must reject a nil resolution with an error rather
5253
// than silently succeeding — a no-op would leave callers with no
5354
// debug trail for the subsequent Get miss.
54-
Put(ctx context.Context, key DCRKey, resolution *DCRResolution) error
55+
Put(ctx context.Context, key Key, resolution *Resolution) error
5556
}
5657

57-
// newStorageBackedStore returns a dcrResolutionCache that delegates to a
58+
// NewStorageBackedStore returns a CredentialStore that delegates to a
5859
// storage.DCRCredentialStore for durable persistence and translates
59-
// DCRResolution values into DCRCredentials at the boundary. The returned
60+
// Resolution values into DCRCredentials at the boundary. The returned
6061
// store is safe for concurrent use because the underlying
6162
// storage.DCRCredentialStore must be (per its interface contract).
62-
func newStorageBackedStore(backend storage.DCRCredentialStore) dcrResolutionCache {
63+
//
64+
// Panics if backend is nil — a nil backend is unambiguously a programming
65+
// error and silent acceptance would only delay the eventual nil-pointer
66+
// dereference to the first Get/Put call, far from the constructor site.
67+
func NewStorageBackedStore(backend storage.DCRCredentialStore) CredentialStore {
68+
if backend == nil {
69+
panic("dcr: NewStorageBackedStore: backend must not be nil")
70+
}
6371
return &storageBackedStore{backend: backend}
6472
}
6573

66-
// storageBackedStore is the runner-side dcrResolutionCache wrapping a
74+
// storageBackedStore is the dcr-package CredentialStore wrapping a
6775
// storage.DCRCredentialStore. Its methods are the only place that converts
68-
// between the resolver's *DCRResolution and the persisted
76+
// between the resolver's *Resolution and the persisted
6977
// *storage.DCRCredentials shapes.
7078
type storageBackedStore struct {
7179
backend storage.DCRCredentialStore
7280
}
7381

74-
// Get implements dcrResolutionCache.
82+
// Get implements CredentialStore.
7583
//
7684
// A storage-level ErrNotFound is translated into the (nil, false, nil)
7785
// miss-tuple advertised by the interface. Other errors propagate as-is.
78-
func (s *storageBackedStore) Get(ctx context.Context, key DCRKey) (*DCRResolution, bool, error) {
86+
func (s *storageBackedStore) Get(ctx context.Context, key Key) (*Resolution, bool, error) {
7987
creds, err := s.backend.GetDCRCredentials(ctx, key)
8088
if err != nil {
8189
if errors.Is(err, storage.ErrNotFound) {
@@ -86,27 +94,27 @@ func (s *storageBackedStore) Get(ctx context.Context, key DCRKey) (*DCRResolutio
8694
return credentialsToResolution(creds), true, nil
8795
}
8896

89-
// Put implements dcrResolutionCache.
97+
// Put implements CredentialStore.
9098
//
9199
// A nil resolution is rejected rather than silently no-oped: a caller
92100
// passing nil would otherwise get a successful return, observe a miss on
93101
// the next Get, and have no error trail to debug from. Failing loudly at
94102
// the boundary makes such bugs visible at the first call.
95-
func (s *storageBackedStore) Put(ctx context.Context, key DCRKey, resolution *DCRResolution) error {
103+
func (s *storageBackedStore) Put(ctx context.Context, key Key, resolution *Resolution) error {
96104
if resolution == nil {
97105
return fmt.Errorf("dcr: resolution must not be nil")
98106
}
99107
creds := resolutionToCredentials(key, resolution)
100108
return s.backend.StoreDCRCredentials(ctx, creds)
101109
}
102110

103-
// resolutionToCredentials converts a resolver-side *DCRResolution into the
104-
// persisted *storage.DCRCredentials shape. The DCRKey is supplied separately
111+
// resolutionToCredentials converts a resolver-side *Resolution into the
112+
// persisted *storage.DCRCredentials shape. The Key is supplied separately
105113
// because storage.DCRCredentials carries the key as a struct field rather
106114
// than implicitly via a map key, so the persistence layer can round-trip it
107115
// across processes and backends.
108116
//
109-
// Fields that exist on DCRResolution but not on DCRCredentials are dropped:
117+
// Fields that exist on Resolution but not on DCRCredentials are dropped:
110118
// - ClientIDIssuedAt: informational only per RFC 7591 §3.2.1; the resolver
111119
// does not consult it for cache invalidation, so it does not need to
112120
// survive a process restart.
@@ -116,7 +124,7 @@ func (s *storageBackedStore) Put(ctx context.Context, key DCRKey, resolution *DC
116124
// CreatedAt and ClientSecretExpiresAt are preserved so cache observers
117125
// (e.g. lookupCachedResolution's staleness Warn) and TTL-aware backends
118126
// (Redis) keep their existing behaviour after a restart.
119-
func resolutionToCredentials(key DCRKey, res *DCRResolution) *storage.DCRCredentials {
127+
func resolutionToCredentials(key Key, res *Resolution) *storage.DCRCredentials {
120128
if res == nil {
121129
return nil
122130
}
@@ -136,17 +144,17 @@ func resolutionToCredentials(key DCRKey, res *DCRResolution) *storage.DCRCredent
136144

137145
// credentialsToResolution is the inverse of resolutionToCredentials. The
138146
// RedirectURI is recovered from the persisted Key so consumers that read it
139-
// off the resolution (e.g. consumeResolution, which writes it back onto a
147+
// off the resolution (e.g. ConsumeResolution, which writes it back onto a
140148
// run-config copy when the caller left it empty) see the canonical value.
141149
//
142150
// ClientIDIssuedAt is left zero because it is not persisted. Callers that
143151
// care about it (none today) must read it directly from the live RFC 7591
144152
// response, not from a cached resolution.
145-
func credentialsToResolution(creds *storage.DCRCredentials) *DCRResolution {
153+
func credentialsToResolution(creds *storage.DCRCredentials) *Resolution {
146154
if creds == nil {
147155
return nil
148156
}
149-
return &DCRResolution{
157+
return &Resolution{
150158
ClientID: creds.ClientID,
151159
ClientSecret: creds.ClientSecret,
152160
AuthorizationEndpoint: creds.AuthorizationEndpoint,

0 commit comments

Comments
 (0)