Skip to content

Commit cc54275

Browse files
authored
Merge branch 'main' into worktree-followup-redis-cluster-mode
2 parents f31fb22 + 6b39256 commit cc54275

10 files changed

Lines changed: 858 additions & 217 deletions

File tree

pkg/authserver/runner/dcr.go

Lines changed: 7 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ package runner
55

66
import (
77
"context"
8-
"crypto/sha256"
9-
"encoding/hex"
108
"errors"
119
"fmt"
1210
"log/slog"
@@ -15,13 +13,13 @@ import (
1513
"regexp"
1614
"runtime/debug"
1715
"slices"
18-
"sort"
1916
"strings"
2017
"time"
2118

2219
"golang.org/x/sync/singleflight"
2320

2421
"github.com/stacklok/toolhive/pkg/authserver"
22+
"github.com/stacklok/toolhive/pkg/authserver/storage"
2523
"github.com/stacklok/toolhive/pkg/authserver/upstream"
2624
"github.com/stacklok/toolhive/pkg/networking"
2725
"github.com/stacklok/toolhive/pkg/oauthproto"
@@ -73,7 +71,7 @@ var authMethodPreference = []string{
7371
// successful Dynamic Client Registration, together with the endpoints the
7472
// upstream advertises so the caller need not re-discover them.
7573
//
76-
// The struct is the unit of storage in DCRCredentialStore and the unit of
74+
// The struct is the unit of storage in dcrResolutionCache and the unit of
7775
// application via consumeResolution.
7876
type DCRResolution struct {
7977
// ClientID is the RFC 7591 "client_id" returned by the authorization
@@ -229,35 +227,6 @@ func applyResolutionToOAuth2Config(cfg *upstream.OAuth2Config, res *DCRResolutio
229227
cfg.ClientSecret = res.ClientSecret
230228
}
231229

232-
// scopesHash returns the SHA-256 hex digest of the canonical scope set.
233-
//
234-
// Canonicalisation:
235-
// 1. Sort ascending so the digest is order-insensitive — e.g.
236-
// []string{"openid", "profile"} and []string{"profile", "openid"} hash to
237-
// the same value.
238-
// 2. Deduplicate so that []string{"openid"} and []string{"openid", "openid"}
239-
// hash to the same value. An OAuth scope set is a set, not a multiset
240-
// (RFC 6749 §3.3), and without deduplication a caller that accidentally
241-
// duplicated a scope would miss cache entries and trigger redundant
242-
// RFC 7591 registrations.
243-
// 3. Join with newlines (a character not valid in OAuth scope tokens per
244-
// RFC 6749 §3.3) to avoid collision between e.g. ["ab", "c"] and
245-
// ["a", "bc"].
246-
func scopesHash(scopes []string) string {
247-
sorted := slices.Clone(scopes)
248-
sort.Strings(sorted)
249-
sorted = slices.Compact(sorted)
250-
251-
h := sha256.New()
252-
for i, s := range sorted {
253-
if i > 0 {
254-
_, _ = h.Write([]byte("\n"))
255-
}
256-
_, _ = h.Write([]byte(s))
257-
}
258-
return hex.EncodeToString(h.Sum(nil))
259-
}
260-
261230
// Step identifiers for structured error logs emitted by the caller of
262231
// resolveDCRCredentials. These values flow through the "step" attribute so
263232
// operators can narrow failures to a specific phase without parsing error
@@ -347,7 +316,7 @@ func resolveDCRCredentials(
347316
ctx context.Context,
348317
rc *authserver.OAuth2UpstreamRunConfig,
349318
localIssuer string,
350-
cache DCRCredentialStore,
319+
cache dcrResolutionCache,
351320
) (*DCRResolution, error) {
352321
if err := validateResolveInputs(rc, localIssuer, cache); err != nil {
353322
return nil, newDCRStepError(dcrStepValidate, localIssuer, "", err)
@@ -363,7 +332,7 @@ func resolveDCRCredentials(
363332
key := DCRKey{
364333
Issuer: localIssuer,
365334
RedirectURI: redirectURI,
366-
ScopesHash: scopesHash(scopes),
335+
ScopesHash: storage.ScopesHash(scopes),
367336
}
368337

369338
// Cache lookup short-circuits before any network I/O.
@@ -417,7 +386,7 @@ func registerAndCache(
417386
localIssuer, redirectURI string,
418387
scopes []string,
419388
key DCRKey,
420-
cache DCRCredentialStore,
389+
cache dcrResolutionCache,
421390
) (*DCRResolution, error) {
422391
// Recheck cache: another flight that just finished may have populated
423392
// it between our initial lookup and our singleflight entry.
@@ -614,7 +583,7 @@ var queryStrippingPattern = regexp.MustCompile(`(?i)https?://[^\s"']+`)
614583
func validateResolveInputs(
615584
rc *authserver.OAuth2UpstreamRunConfig,
616585
localIssuer string,
617-
cache DCRCredentialStore,
586+
cache dcrResolutionCache,
618587
) error {
619588
if rc == nil {
620589
return fmt.Errorf("oauth2 upstream run-config is required")
@@ -658,7 +627,7 @@ func validateResolveInputs(
658627
// trigger.
659628
func lookupCachedResolution(
660629
ctx context.Context,
661-
cache DCRCredentialStore,
630+
cache dcrResolutionCache,
662631
key DCRKey,
663632
localIssuer, redirectURI string,
664633
) (*DCRResolution, bool, error) {

pkg/authserver/runner/dcr_store.go

Lines changed: 40 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,53 +8,39 @@ import (
88
"fmt"
99
"sync"
1010
"time"
11+
12+
"github.com/stacklok/toolhive/pkg/authserver/storage"
1113
)
1214

1315
// dcrStaleAgeThreshold is the age beyond which a cached DCR resolution is
1416
// considered stale and logged as such by higher-level wiring. The store itself
1517
// does not expire or evict entries — RFC 7591 client registrations are
16-
// long-lived and are only purged by explicit RFC 7592 deregistration. This
17-
// threshold is consumed by Step 2g observability logs introduced in the next
18-
// PR in the DCR stack (sub-issue C, #5039); 5042 only defines the constant
19-
// so the consumer can land without a cross-PR cycle.
20-
//
21-
//nolint:unused // consumed by lookupCachedResolution in #5039
18+
// long-lived and are only purged by explicit RFC 7592 deregistration.
2219
const dcrStaleAgeThreshold = 90 * 24 * time.Hour
2320

24-
// DCRKey is the canonical lookup key for a DCR resolution. The tuple is
25-
// designed so a future Redis-backed store can serialise it into a single key
26-
// segment (Phase 3) without redefining the canonical form. ScopesHash rather
27-
// than the raw scope slice is used so the key is comparable and order-
28-
// insensitive.
29-
type DCRKey struct {
30-
// Issuer is *this* auth server's issuer identifier — the local issuer
31-
// of the embedded authorization server that performed the registration,
32-
// NOT the upstream's. The cache is keyed by this value because two
33-
// different local issuers registering against the same upstream are
34-
// distinct OAuth clients and must not share credentials. The upstream's
35-
// issuer is used only for RFC 8414 §3.3 metadata verification inside
36-
// the resolver and is not part of the cache key.
37-
Issuer string
38-
39-
// RedirectURI is the redirect URI registered with the upstream
40-
// authorization server. Lives on the local issuer's origin since it is
41-
// where the upstream sends the user back to us after authentication.
42-
RedirectURI string
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
4328

44-
// ScopesHash is the SHA-256 hex digest of the sorted scope list.
45-
// See scopesHash in dcr.go for the canonical form.
46-
ScopesHash string
47-
}
48-
49-
// DCRCredentialStore caches RFC 7591 Dynamic Client Registration resolutions
29+
// dcrResolutionCache caches RFC 7591 Dynamic Client Registration resolutions
5030
// keyed by the (Issuer, RedirectURI, ScopesHash) tuple. Implementations must
5131
// be safe for concurrent use.
5232
//
53-
// The store is an in-memory cache of long-lived registrations — it is not a
54-
// durable store, and entries are never expired or evicted by the store
55-
// itself. Callers are responsible for invalidating entries when the
56-
// underlying registration is revoked (e.g., via RFC 7592 deregistration).
57-
type DCRCredentialStore interface {
33+
// This is a runner-internal cache of *DCRResolution values; it is distinct
34+
// from the persistent storage.DCRCredentialStore (which holds *DCRCredentials
35+
// and is the durable contract sub-issue 3 wires the resolver to use). Naming
36+
// them differently keeps the two interfaces unambiguous to readers and grep
37+
// tooling while both exist during the Phase 3 migration.
38+
//
39+
// The cache is in-memory and holds long-lived registrations — entries are
40+
// never expired or evicted by the cache itself. Callers are responsible for
41+
// invalidating entries when the underlying registration is revoked (e.g.,
42+
// via RFC 7592 deregistration).
43+
type dcrResolutionCache interface {
5844
// Get returns the cached resolution for key, or (nil, false, nil) if the
5945
// key is not present. An error is returned only on backend failure.
6046
Get(ctx context.Context, key DCRKey) (*DCRResolution, bool, error)
@@ -66,17 +52,21 @@ type DCRCredentialStore interface {
6652
Put(ctx context.Context, key DCRKey, resolution *DCRResolution) error
6753
}
6854

69-
// NewInMemoryDCRCredentialStore returns a thread-safe in-memory
70-
// DCRCredentialStore intended for tests and single-replica development
55+
// newInMemoryDCRResolutionCache returns a thread-safe in-memory
56+
// dcrResolutionCache intended for tests and single-replica development
7157
// deployments. Production deployments should use the Redis-backed store
7258
// introduced in Phase 3, which addresses the cross-replica sharing,
7359
// durability, and cross-process coordination gaps documented below.
7460
//
7561
// Entries are retained for the process lifetime; there is no TTL and no
76-
// background cleanup goroutine. The usual concern about an unbounded
77-
// cache leaking memory does not apply here because the key space is
78-
// bounded by the operator-configured upstream count, and this
79-
// implementation is not the production answer.
62+
// background cleanup goroutine. Growth is bounded by upstream count ×
63+
// distinct scope sets ever registered for each upstream during the
64+
// process lifetime — for a stable configuration this collapses to the
65+
// upstream count, but rotating scope sets (operator-driven scope
66+
// changes, or upstream scopes_supported rotations re-derived by the
67+
// resolver) accumulate stale entries that survive until restart. This
68+
// implementation is not the production answer; the Redis backend
69+
// introduced in Phase 3 mitigates the rotation case via SetEX TTL.
8070
//
8171
// What this enables: serialises Get/Put against a single in-process map so
8272
// concurrent callers within one authserver process see a consistent view of
@@ -97,23 +87,23 @@ type DCRCredentialStore interface {
9787
// that side, the loser becomes orphaned. The
9888
// resolveDCRCredentials-level singleflight in dcr.go only deduplicates
9989
// within one process.
100-
func NewInMemoryDCRCredentialStore() DCRCredentialStore {
101-
return &inMemoryDCRCredentialStore{
90+
func newInMemoryDCRResolutionCache() dcrResolutionCache {
91+
return &inMemoryDCRResolutionCache{
10292
entries: make(map[DCRKey]*DCRResolution),
10393
}
10494
}
10595

106-
// inMemoryDCRCredentialStore is the default DCRCredentialStore backed by a
96+
// inMemoryDCRResolutionCache is the default dcrResolutionCache backed by a
10797
// plain map guarded by sync.RWMutex. Modelled on
10898
// pkg/authserver/storage/memory.go but stripped of TTL bookkeeping — DCR
10999
// resolutions are long-lived.
110-
type inMemoryDCRCredentialStore struct {
100+
type inMemoryDCRResolutionCache struct {
111101
mu sync.RWMutex
112102
entries map[DCRKey]*DCRResolution
113103
}
114104

115-
// Get implements DCRCredentialStore.
116-
func (s *inMemoryDCRCredentialStore) Get(_ context.Context, key DCRKey) (*DCRResolution, bool, error) {
105+
// Get implements dcrResolutionCache.
106+
func (s *inMemoryDCRResolutionCache) Get(_ context.Context, key DCRKey) (*DCRResolution, bool, error) {
117107
s.mu.RLock()
118108
defer s.mu.RUnlock()
119109

@@ -128,13 +118,13 @@ func (s *inMemoryDCRCredentialStore) Get(_ context.Context, key DCRKey) (*DCRRes
128118
return &cp, true, nil
129119
}
130120

131-
// Put implements DCRCredentialStore.
121+
// Put implements dcrResolutionCache.
132122
//
133123
// A nil resolution is rejected rather than silently no-oped: a caller
134124
// passing nil would otherwise get a successful return, observe a miss on
135125
// the next Get, and have no error trail to debug from. Failing loudly at
136126
// the boundary makes such bugs visible at the first call.
137-
func (s *inMemoryDCRCredentialStore) Put(_ context.Context, key DCRKey, resolution *DCRResolution) error {
127+
func (s *inMemoryDCRResolutionCache) Put(_ context.Context, key DCRKey, resolution *DCRResolution) error {
138128
if resolution == nil {
139129
return fmt.Errorf("dcr: resolution must not be nil")
140130
}

0 commit comments

Comments
 (0)