Skip to content

Commit 81b9e02

Browse files
committed
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).
1 parent 9211a36 commit 81b9e02

9 files changed

Lines changed: 491 additions & 216 deletions

File tree

Lines changed: 172 additions & 64 deletions
Large diffs are not rendered by default.
Lines changed: 62 additions & 62 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: 45 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,78 @@ 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+
// NewInMemoryStore returns a thread-safe in-memory CredentialStore intended
59+
// for tests and single-replica development deployments. It is a thin adapter
60+
// over storage.NewMemoryStorage so the resolver-side cache and the
61+
// authserver's main storage backend share a single in-memory implementation.
62+
//
63+
// Production deployments should use a Redis-backed
64+
// storage.DCRCredentialStore (instantiated via storage.NewRedisStorage and
65+
// passed through this package's storage-backed adapter), which addresses
66+
// cross-replica sharing, durability, and cross-process coordination.
67+
func NewInMemoryStore() CredentialStore {
68+
return NewStorageBackedStore(storage.NewMemoryStorage())
69+
}
70+
71+
// NewStorageBackedStore returns a CredentialStore that delegates to a
5872
// storage.DCRCredentialStore for durable persistence and translates
59-
// DCRResolution values into DCRCredentials at the boundary. The returned
73+
// Resolution values into DCRCredentials at the boundary. The returned
6074
// store is safe for concurrent use because the underlying
6175
// storage.DCRCredentialStore must be (per its interface contract).
62-
func newStorageBackedStore(backend storage.DCRCredentialStore) dcrResolutionCache {
76+
func NewStorageBackedStore(backend storage.DCRCredentialStore) CredentialStore {
6377
return &storageBackedStore{backend: backend}
6478
}
6579

66-
// storageBackedStore is the runner-side dcrResolutionCache wrapping a
80+
// storageBackedStore is the dcr-package CredentialStore wrapping a
6781
// storage.DCRCredentialStore. Its methods are the only place that converts
68-
// between the resolver's *DCRResolution and the persisted
82+
// between the resolver's *Resolution and the persisted
6983
// *storage.DCRCredentials shapes.
7084
type storageBackedStore struct {
7185
backend storage.DCRCredentialStore
7286
}
7387

74-
// Get implements dcrResolutionCache.
88+
// Get implements CredentialStore.
7589
//
7690
// A storage-level ErrNotFound is translated into the (nil, false, nil)
7791
// miss-tuple advertised by the interface. Other errors propagate as-is.
78-
func (s *storageBackedStore) Get(ctx context.Context, key DCRKey) (*DCRResolution, bool, error) {
92+
func (s *storageBackedStore) Get(ctx context.Context, key Key) (*Resolution, bool, error) {
7993
creds, err := s.backend.GetDCRCredentials(ctx, key)
8094
if err != nil {
8195
if errors.Is(err, storage.ErrNotFound) {
@@ -86,27 +100,27 @@ func (s *storageBackedStore) Get(ctx context.Context, key DCRKey) (*DCRResolutio
86100
return credentialsToResolution(creds), true, nil
87101
}
88102

89-
// Put implements dcrResolutionCache.
103+
// Put implements CredentialStore.
90104
//
91105
// A nil resolution is rejected rather than silently no-oped: a caller
92106
// passing nil would otherwise get a successful return, observe a miss on
93107
// the next Get, and have no error trail to debug from. Failing loudly at
94108
// the boundary makes such bugs visible at the first call.
95-
func (s *storageBackedStore) Put(ctx context.Context, key DCRKey, resolution *DCRResolution) error {
109+
func (s *storageBackedStore) Put(ctx context.Context, key Key, resolution *Resolution) error {
96110
if resolution == nil {
97111
return fmt.Errorf("dcr: resolution must not be nil")
98112
}
99113
creds := resolutionToCredentials(key, resolution)
100114
return s.backend.StoreDCRCredentials(ctx, creds)
101115
}
102116

103-
// resolutionToCredentials converts a resolver-side *DCRResolution into the
104-
// persisted *storage.DCRCredentials shape. The DCRKey is supplied separately
117+
// resolutionToCredentials converts a resolver-side *Resolution into the
118+
// persisted *storage.DCRCredentials shape. The Key is supplied separately
105119
// because storage.DCRCredentials carries the key as a struct field rather
106120
// than implicitly via a map key, so the persistence layer can round-trip it
107121
// across processes and backends.
108122
//
109-
// Fields that exist on DCRResolution but not on DCRCredentials are dropped:
123+
// Fields that exist on Resolution but not on DCRCredentials are dropped:
110124
// - ClientIDIssuedAt: informational only per RFC 7591 §3.2.1; the resolver
111125
// does not consult it for cache invalidation, so it does not need to
112126
// survive a process restart.
@@ -116,7 +130,7 @@ func (s *storageBackedStore) Put(ctx context.Context, key DCRKey, resolution *DC
116130
// CreatedAt and ClientSecretExpiresAt are preserved so cache observers
117131
// (e.g. lookupCachedResolution's staleness Warn) and TTL-aware backends
118132
// (Redis) keep their existing behaviour after a restart.
119-
func resolutionToCredentials(key DCRKey, res *DCRResolution) *storage.DCRCredentials {
133+
func resolutionToCredentials(key Key, res *Resolution) *storage.DCRCredentials {
120134
if res == nil {
121135
return nil
122136
}
@@ -136,17 +150,17 @@ func resolutionToCredentials(key DCRKey, res *DCRResolution) *storage.DCRCredent
136150

137151
// credentialsToResolution is the inverse of resolutionToCredentials. The
138152
// 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
153+
// off the resolution (e.g. ConsumeResolution, which writes it back onto a
140154
// run-config copy when the caller left it empty) see the canonical value.
141155
//
142156
// ClientIDIssuedAt is left zero because it is not persisted. Callers that
143157
// care about it (none today) must read it directly from the live RFC 7591
144158
// response, not from a cached resolution.
145-
func credentialsToResolution(creds *storage.DCRCredentials) *DCRResolution {
159+
func credentialsToResolution(creds *storage.DCRCredentials) *Resolution {
146160
if creds == nil {
147161
return nil
148162
}
149-
return &DCRResolution{
163+
return &Resolution{
150164
ClientID: creds.ClientID,
151165
ClientSecret: creds.ClientSecret,
152166
AuthorizationEndpoint: creds.AuthorizationEndpoint,

0 commit comments

Comments
 (0)