Skip to content

Commit 17bd2ab

Browse files
committed
Address code review feedback
Fixed issues from code review of 94fe0dc: - HIGH: Add ClientSecretExpiresAt to DCRCredentials so the Redis backend (sub-issue 2) can satisfy the TTL contract without re-touching the value type. Round-trip test asserts the new field is preserved. - MEDIUM: Reject creds with empty Key.Issuer or empty Key.RedirectURI in StoreDCRCredentials, matching the validation pattern used by StoreUpstreamTokens. Empty ScopesHash is intentionally accepted — ScopesHash(nil) is a non-empty digest, so the empty-scope-registration path stays valid. - MEDIUM: Drop runner-side TestScopesHash_* tests; the storage-side suite already exercises the canonical implementation. Replaced with a one-line comment pointing readers to the canonical tests. - MEDIUM: Add DCRCredentials count to MemoryStorage.Stats for parity with every other in-memory map. - MEDIUM: Reconcile the interface contract — couple the TTL clause to the new ClientSecretExpiresAt field, weaken MUST to SHOULD, and document that backends without native TTL retain the field verbatim for caller-side re-check.
1 parent 94fe0dc commit 17bd2ab

4 files changed

Lines changed: 92 additions & 81 deletions

File tree

pkg/authserver/runner/dcr_store_test.go

Lines changed: 5 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -168,75 +168,11 @@ func TestInMemoryDCRCredentialStore_GetReturnsDefensiveCopy(t *testing.T) {
168168
assert.Equal(t, "orig", refetched.ClientID)
169169
}
170170

171-
func TestScopesHash_StableAcrossPermutation(t *testing.T) {
172-
t.Parallel()
173-
174-
tests := []struct {
175-
name string
176-
a, b []string
177-
}{
178-
{
179-
name: "two-element permutation",
180-
a: []string{"openid", "profile"},
181-
b: []string{"profile", "openid"},
182-
},
183-
{
184-
name: "three-element permutation",
185-
a: []string{"openid", "profile", "email"},
186-
b: []string{"email", "openid", "profile"},
187-
},
188-
{
189-
// OAuth scope sets are sets, not multisets (RFC 6749 §3.3).
190-
// scopesHash deduplicates before hashing so a caller who
191-
// accidentally repeats a scope still hits the cache entry
192-
// keyed under the canonical set.
193-
name: "single element equals double element duplicate",
194-
a: []string{"openid"},
195-
b: []string{"openid", "openid"},
196-
},
197-
{
198-
name: "three-element with duplicate equals two-element unique",
199-
a: []string{"openid", "profile", "openid"},
200-
b: []string{"openid", "profile"},
201-
},
202-
}
203-
for _, tc := range tests {
204-
t.Run(tc.name, func(t *testing.T) {
205-
t.Parallel()
206-
assert.Equal(t, scopesHash(tc.a), scopesHash(tc.b))
207-
})
208-
}
209-
}
210-
211-
func TestScopesHash_DistinctForDistinctScopes(t *testing.T) {
212-
t.Parallel()
213-
214-
a := scopesHash([]string{"openid"})
215-
b := scopesHash([]string{"openid", "profile"})
216-
c := scopesHash([]string{"profile"})
217-
d := scopesHash(nil)
218-
e := scopesHash([]string{})
219-
220-
// Non-empty distinct sets produce distinct hashes.
221-
assert.NotEqual(t, a, b)
222-
assert.NotEqual(t, a, c)
223-
assert.NotEqual(t, b, c)
224-
assert.NotEqual(t, a, d)
225-
// nil and empty slice canonicalise to the same hash (both sort-then-join
226-
// to the empty canonical form).
227-
assert.Equal(t, d, e)
228-
}
229-
230-
func TestScopesHash_NoCollisionFromBoundaryJoin(t *testing.T) {
231-
t.Parallel()
232-
233-
// Without a delimiter that cannot appear inside a scope value,
234-
// ["ab", "c"] and ["a", "bc"] would collide. This test exists to
235-
// prevent a regression if the canonical form is ever simplified.
236-
h1 := scopesHash([]string{"ab", "c"})
237-
h2 := scopesHash([]string{"a", "bc"})
238-
assert.NotEqual(t, h1, h2)
239-
}
171+
// Tests for the canonical scopes-hash form live next to the canonical
172+
// implementation in pkg/authserver/storage/memory_test.go (TestScopesHash_*).
173+
// The runner-package binding `scopesHash = storage.ScopesHash` would only
174+
// re-exercise the same code, so duplicating the suite here would be redundant
175+
// per .claude/rules/testing.md.
240176

241177
// TestInMemoryDCRCredentialStore_ConcurrentAccess fans out N goroutines
242178
// performing alternating Put / Get against overlapping and disjoint keys,

pkg/authserver/storage/memory.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,12 +1219,25 @@ func cloneDCRCredentials(c *DCRCredentials) *DCRCredentials {
12191219
// do not affect persisted state.
12201220
//
12211221
// Overwrites any existing entry for the same Key. The in-memory backend
1222-
// applies no TTL — DCR registrations are long-lived and bounded by the
1223-
// operator-configured upstream count.
1222+
// applies no native TTL — DCR registrations are long-lived and bounded by
1223+
// the operator-configured upstream count, and ClientSecretExpiresAt is
1224+
// retained verbatim for callers to re-check on read (see the interface
1225+
// docstring's "TTL handling" section).
1226+
//
1227+
// Rejects nil creds and an unpopulated Key (empty Issuer or empty
1228+
// RedirectURI). An empty ScopesHash is permitted because ScopesHash("")
1229+
// produces a non-empty canonical digest, and the empty-scope case
1230+
// (no scopes configured or discovered) is a real, valid registration.
12241231
func (s *MemoryStorage) StoreDCRCredentials(_ context.Context, creds *DCRCredentials) error {
12251232
if creds == nil {
12261233
return fosite.ErrInvalidRequest.WithHint("dcr credentials cannot be nil")
12271234
}
1235+
if creds.Key.Issuer == "" {
1236+
return fosite.ErrInvalidRequest.WithHint("dcr credentials key issuer cannot be empty")
1237+
}
1238+
if creds.Key.RedirectURI == "" {
1239+
return fosite.ErrInvalidRequest.WithHint("dcr credentials key redirect_uri cannot be empty")
1240+
}
12281241

12291242
s.mu.Lock()
12301243
defer s.mu.Unlock()
@@ -1268,6 +1281,7 @@ type Stats struct {
12681281
ClientAssertionJWTs int
12691282
Users int
12701283
ProviderIdentities int
1284+
DCRCredentials int
12711285
}
12721286

12731287
// Stats returns current statistics about storage contents.
@@ -1288,6 +1302,7 @@ func (s *MemoryStorage) Stats() Stats {
12881302
ClientAssertionJWTs: len(s.clientAssertionJWTs),
12891303
Users: len(s.users),
12901304
ProviderIdentities: len(s.providerIdentities),
1305+
DCRCredentials: len(s.dcrCredentials),
12911306
}
12921307
}
12931308

pkg/authserver/storage/memory_test.go

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,7 @@ func TestMemoryStorage_DCRCredentials_RoundTrip(t *testing.T) {
17361736
AuthorizationEndpoint: "https://idp.example.com/authorize",
17371737
TokenEndpoint: "https://idp.example.com/token",
17381738
CreatedAt: time.Date(2025, 5, 1, 12, 0, 0, 0, time.UTC),
1739+
ClientSecretExpiresAt: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC),
17391740
}
17401741

17411742
require.NoError(t, s.StoreDCRCredentials(ctx, creds))
@@ -1797,12 +1798,48 @@ func TestMemoryStorage_DCRCredentials_NotFound(t *testing.T) {
17971798
})
17981799
}
17991800

1800-
func TestMemoryStorage_DCRCredentials_StoreNilRejected(t *testing.T) {
1801-
withStorage(t, func(ctx context.Context, s *MemoryStorage) {
1802-
err := s.StoreDCRCredentials(ctx, nil)
1803-
require.Error(t, err)
1804-
assert.ErrorIs(t, err, fosite.ErrInvalidRequest)
1805-
})
1801+
// TestMemoryStorage_DCRCredentials_StoreInvalidInputRejected pins the
1802+
// fail-loud-on-invalid-input contract: nil creds, an empty Key.Issuer, or
1803+
// an empty Key.RedirectURI must be rejected with fosite.ErrInvalidRequest
1804+
// rather than producing a working-looking write under a partially-populated
1805+
// key.
1806+
func TestMemoryStorage_DCRCredentials_StoreInvalidInputRejected(t *testing.T) {
1807+
t.Parallel()
1808+
1809+
tests := []struct {
1810+
name string
1811+
creds *DCRCredentials
1812+
}{
1813+
{
1814+
name: "nil creds",
1815+
creds: nil,
1816+
},
1817+
{
1818+
name: "empty issuer",
1819+
creds: &DCRCredentials{
1820+
Key: DCRKey{Issuer: "", RedirectURI: "https://x/cb"},
1821+
},
1822+
},
1823+
{
1824+
name: "empty redirect_uri",
1825+
creds: &DCRCredentials{
1826+
Key: DCRKey{Issuer: "https://idp.example.com", RedirectURI: ""},
1827+
},
1828+
},
1829+
}
1830+
for _, tc := range tests {
1831+
tc := tc
1832+
t.Run(tc.name, func(t *testing.T) {
1833+
withStorage(t, func(ctx context.Context, s *MemoryStorage) {
1834+
err := s.StoreDCRCredentials(ctx, tc.creds)
1835+
require.Error(t, err)
1836+
assert.ErrorIs(t, err, fosite.ErrInvalidRequest)
1837+
// Confirm the rejection did not partially populate the store.
1838+
assert.Equal(t, 0, s.Stats().DCRCredentials,
1839+
"rejected Store must not leave any entry behind")
1840+
})
1841+
})
1842+
}
18061843
}
18071844

18081845
// TestMemoryStorage_DCRCredentials_GetReturnsDefensiveCopy pins the

pkg/authserver/storage/types.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,20 @@ type DCRCredentials struct {
217217
// not expire entries based on age (see ClientSecretExpiresAt for the
218218
// authoritative expiry signal).
219219
CreatedAt time.Time
220+
221+
// ClientSecretExpiresAt is the RFC 7591 §3.2.1 client_secret_expires_at
222+
// value converted from int64 epoch seconds to time.Time. The wire
223+
// convention is that 0 means "the secret does not expire"; this struct
224+
// represents that as the zero time.Time so callers can use IsZero()
225+
// rather than special-casing 0.
226+
//
227+
// When non-zero, this is the authoritative signal a backend uses to TTL
228+
// the persisted entry: the Redis backend (sub-issue 2) plumbs it through
229+
// SetEX so the row evicts before the upstream rejects the secret at the
230+
// token endpoint. The in-memory backend ignores this field — entries
231+
// persist for the process lifetime and the resolver re-checks the
232+
// expiry on read.
233+
ClientSecretExpiresAt time.Time
220234
}
221235

222236
// DCRCredentialStore is a narrow, segregated interface for persisting
@@ -235,17 +249,26 @@ type DCRCredentials struct {
235249
// Implementations MUST defensively copy on both Store and Get so caller
236250
// mutations cannot reach persisted state and vice versa, mirroring the
237251
// UpstreamTokens contract.
252+
//
253+
// # TTL handling
254+
//
255+
// Implementations SHOULD honor a non-zero DCRCredentials.ClientSecretExpiresAt
256+
// as a backend-level TTL when the underlying store supports one (e.g. Redis
257+
// SetEX) so an entry evicts before the upstream rejects the secret at the
258+
// token endpoint. Backends without a native TTL (e.g. the in-memory backend)
259+
// retain the field verbatim and rely on the caller — typically the runner's
260+
// resolver — to re-check expiry on read; see MemoryStorage.GetDCRCredentials.
261+
// A zero ClientSecretExpiresAt means the upstream did not assert an expiry
262+
// and no TTL is applied.
238263
type DCRCredentialStore interface {
239264
// GetDCRCredentials returns the credentials for the given key.
240265
// Returns ErrNotFound (wrapped) if no entry exists for the key.
241266
// The returned value is a defensive copy.
242267
GetDCRCredentials(ctx context.Context, key DCRKey) (*DCRCredentials, error)
243268

244269
// StoreDCRCredentials persists the credentials, overwriting any existing
245-
// entry for the same Key. Implementations MUST honor RFC 7591
246-
// client_secret_expires_at as a TTL when the source DCR response
247-
// provided it (e.g. propagated through a higher-level resolution type).
248-
// When absent or zero, no TTL is applied.
270+
// entry for the same Key. See the interface-level "TTL handling" section
271+
// for the contract on ClientSecretExpiresAt.
249272
StoreDCRCredentials(ctx context.Context, creds *DCRCredentials) error
250273
}
251274

0 commit comments

Comments
 (0)