Add persistent DCRCredentialStore types and memory backend#5186
Add persistent DCRCredentialStore types and memory backend#5186tgrunnagle wants to merge 7 commits intomainfrom
Conversation
There was a problem hiding this comment.
Large PR Detected
This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.
How to unblock this PR:
Add a section to your PR description with the following format:
## Large PR Justification
[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformationAlternative:
Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.
See our Contributing Guidelines for more details.
This review will be automatically dismissed once you add the justification section.
PR size has been reduced below the XL threshold. Thank you for splitting this up!
|
✅ PR size has been reduced below the XL threshold. The size review has been dismissed and this PR can now proceed with normal review. Thank you for splitting this up! |
17bd2ab to
c0fed52
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5186 +/- ##
==========================================
- Coverage 67.75% 67.72% -0.04%
==========================================
Files 607 607
Lines 62053 62090 +37
==========================================
+ Hits 42044 42049 +5
- Misses 16848 16878 +30
- Partials 3161 3163 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
64feb73 to
ee7d4cc
Compare
c0fed52 to
cc92472
Compare
There was a problem hiding this comment.
Large PR Detected
This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.
How to unblock this PR:
Add a section to your PR description with the following format:
## Large PR Justification
[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformationAlternative:
Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.
See our Contributing Guidelines for more details.
This review will be automatically dismissed once you add the justification section.
Phase 3 sub-issue 1 of #5183. Define the persisted DCRCredentials value type and the storage-level DCRCredentialStore interface in pkg/authserver/storage/, and ship the in-process memory implementation that single-replica deployments and unit tests use. The Redis backend (sub-issue 2) and the wiring change (sub-issue 3) build on this. DCRKey consolidation: chose option (a) from the issue — DCRKey and its ScopesHash constructor move to pkg/authserver/storage/ so any future backend hashes keys identically. The runner package keeps a package-local type alias (type DCRKey = storage.DCRKey) and a var binding for scopesHash so existing call sites compile unchanged; the canonical form has a single source of truth. DCRCredentials carries ClientSecretExpiresAt so the Redis backend can drive a SetEX TTL without re-touching the value type or regenerating mocks. The interface contract documents this as SHOULD honor when backend-supported; MemoryStorage retains entries verbatim for the process lifetime. StoreDCRCredentials rejects nil creds and zero-valued Key.Issuer or Key.RedirectURI with fosite.ErrInvalidRequest, matching the StoreUpstreamTokens validation pattern. Stats reports dcrCredentials count for parity with the other in-memory maps. The runner-side DCRCredentialStore (Get/Put *DCRResolution) is left in place as the thin adapter sub-issue 3 will swap. This sub-issue lands the new storage-level interface, MemoryStorage implementation, and regenerated mock without touching the wire-up. DCR credentials are intentionally excluded from cleanupExpired: RFC 7591 client registrations are long-lived and the authoritative expiry signal is client_secret_expires_at, which the Redis backend will honor as a SetEX TTL.
cc92472 to
7aa7366
Compare
PR size has been reduced below the XL threshold. Thank you for splitting this up!
|
✅ PR size has been reduced below the XL threshold. The size review has been dismissed and this PR can now proceed with normal review. Thank you for splitting this up! |
There was a problem hiding this comment.
Multi-agent review summary
6 specialist agents reviewed the diff (Go style/API design, concurrency/locking, OAuth/RFC compliance & security, test coverage, review-friendliness, general quality). 8 findings reached consensus (score ≥ 7); 13 dropped to the appendix below.
Author note: I'm the PR author, so this review is posted as COMMENT (GitHub rejects APPROVE / REQUEST_CHANGES on your own PR). If a co-reviewer agrees with the HIGH/MEDIUM findings, a formal REQUEST_CHANGES is warranted.
Consensus summary
| ID | Finding | Severity | Score |
|---|---|---|---|
| F1 | PR body advertises scope not present in the diff | HIGH (process) | 7 |
| F2 | Two DCRCredentialStore interfaces with the same name in sibling packages |
HIGH | 9 |
| F3 | var scopesHash = storage.ScopesHash is a mutable runtime binding, not an alias |
MEDIUM | 8 |
| F4 | StoreDCRCredentials validation is narrower than the doc contract claims |
MEDIUM | 9 |
| F5 | "Bounded by upstream count" claim is incorrect when scopes change | MEDIUM | 8 |
| F6 | StoreDCRCredentials signature/naming inconsistent with sibling Storage methods |
MEDIUM | 7 |
| F7 | Missing concurrent-access test for new DCR Store/Get methods | LOW-MEDIUM | 7 |
| F8 | PR is marked DRAFT but a review was requested | MEDIUM (process) | 7 |
F2–F6 are inline below. F1, F7, F8 are PR-level and addressed here:
F1 — PR body / diff mismatch (HIGH, process)
The Summary enumerates changes in cmd/thv-operator/api/v1beta1/, pkg/auth/monitored_token_source.go, pkg/oauthproto/dcr.go, pkg/runner/runner.go, and the v1beta1 CRD/operator paths. None of those files appear in the diff: the actual change is 7 files confined to pkg/authserver/storage/ and pkg/authserver/runner/. Reviewers will read the body, expect operator/runner-side wiring, and either think the diff is incomplete or burn time hunting for code that isn't here.
Recommendation: Rewrite the Summary so it describes only what is in the diff (the storage-package DCR types + memory backend + runner-side type aliases). Move the "follow-up stack" narrative to a forward-looking note in Special notes for reviewers, not to the Summary.
F7 — Missing concurrent-access test for new DCR Store/Get methods (LOW-MEDIUM)
TestMemoryStorage_ConcurrentAccess doesn't include the new methods, and all the new DCR tests run single-goroutine. A regression that, e.g., dropped the lock or returned an internal pointer instead of a clone would not be caught by the current suite under -race. The runner-side TestInMemoryDCRCredentialStore_ConcurrentAccess covers a different implementation.
Recommendation: Add a fan-out test in pkg/authserver/storage/memory_test.go — N goroutines doing StoreDCRCredentials with distinct keys, M doing GetDCRCredentials, with a timed wg.Wait() barrier per .claude/rules/testing.md. Pattern after the existing concurrent tests in this package or the runner-side TestInMemoryDCRCredentialStore_ConcurrentAccess.
F8 — PR is marked DRAFT (process)
Body opens with "# DRAFT - not ready for review". Combined with F1, this suggests the body was written before commits were dropped or rebased. The 555-line / 7-file diff is well within the 400-line / 10-file PR-size limit when generated/test files are excluded, so size isn't a blocker.
Recommendation: Once F1–F6 are addressed, take the PR out of draft and request reviewers.
Holistic assessment
Architecturally the change is sound: the storage interface is narrowly segregated (Get/Store only), the defensive-copy contract mirrors the existing UpstreamTokens pattern, the cross-replica/TTL contract is documented honestly (the in-memory backend's TTL ignorance is offset by the resolver re-checking ClientSecretExpiresAt on read at dcr.go:672), and the compile-time interface assertion (memory.go:1316) catches drift. The PR is well-scoped against the 400-line/10-file limit when generated and test files are excluded (~250 LOC of production code across 4 files).
The main holistic concern is the transitional state: after this PR lands there are two DCRCredentialStore interfaces with the same name in sibling packages (runner.DCRCredentialStore and storage.DCRCredentialStore). The PR body acknowledges this is intentional during sub-issue 2/3, but the chosen name collision is the most reviewer-hostile option — it makes every subsequent review of intermediate PRs require mental disambiguation. F2 below proposes the cheapest fix.
Dropped findings appendix (score < 7)
| Title | Severity | Reason dropped |
|---|---|---|
cloneDCRCredentials shallow copy brittle to future fields |
LOW | Correct today; doc warns; reflection-test mitigation is optional |
| Silent overwrite when two upstreams collide on Key but differ in ProviderName | LOW | Improbable; a slog.Warn on collision would suffice |
ExcludedFromCleanupExpired test uses misleading stale CreatedAt |
LOW | Cosmetic: assertion is correct, just the comment misleads |
Stats().DCRCredentials lacks positive-count assertion |
LOW | One-line test addition; nice-to-have |
ScopesHash empty-input non-emptiness not asserted |
LOW | One assert.NotEmpty; nice-to-have |
| Inconsistent "DCR"/"dcr" casing in error hints | LOW | Cosmetic |
| Comment-only placeholder where deleted tests used to live | LOW | Style preference |
| Doc comment redundancy between canonical type and runner-side alias | LOW | Style preference |
DCRCredentials.ClientSecret no String()/Marshal redaction |
LOW | Defense-in-depth; today's code doesn't need it |
DCRCredentials.ProviderName stored but never read |
LOW | YAGNI; would be consumed by a future PR |
Stats struct addition compat |
INFO | Project uses keyed struct literals — non-issue |
| G117 nolint cites wrong upstream rule | INFO | Resolved — follows established repo convention per .golangci.yml |
slog.Debug call inside RLock on miss path |
LOW | Resolved — matches existing pattern (memory.go:345/490/545/...) |
File-by-file findings (would-be inline comments)
GitHub's inline-comment API rejected the batched and per-comment posts with a pull_request_review_thread.base internal-error, so the inline findings are folded into the main body below. Each block names the exact file and line.
pkg/authserver/storage/types.go:263
F2 — Naming collision with runner.DCRCredentialStore (HIGH, score 9)
After this PR lands there are two interfaces sharing this name in sibling packages:
runner.DCRCredentialStore(pkg/authserver/runner/dcr_store.go:57) —Get/Putreturning*DCRResolution, the in-process resolver cache.storage.DCRCredentialStore(here) —GetDCRCredentials/StoreDCRCredentialsreturning*DCRCredentials, the persistent contract.
The runner package imports pkg/authserver/storage, so unqualified references inside runner/ are ambiguous to readers and grep tooling. The PR body documents this is intentional during sub-issue 2/3, but the same-name collision is the worst possible outcome for review-friendliness — it prevents the compiler/reader from telling them apart at a glance, and the two will inevitably drift before sub-issue 3 swaps them.
This is the cross-cutting .claude/rules/go-style.md "Avoid parallel types that drift" failure mode.
Recommendation: Rename the runner-side interface (the cheap path) to dcrResolutionCache (lowercase — only the resolver consumes it). Reserve the canonical DCRCredentialStore name for the storage package the future Redis backend will implement. Doing this in this PR costs one rename and removes ambiguity from every subsequent review.
pkg/authserver/storage/types.go:272
F6 — Method signature & naming inconsistent with sibling Storage methods (MEDIUM, score 7)
Two related drifts from the package's local conventions:
-
Signature shape: Sibling methods take
(ctx, key, value):StorePendingAuthorization(ctx, state, pending)StoreUpstreamTokens(ctx, sessionID, providerName, tokens)StoreAccessTokenSession(ctx, signature, request)
This method takes
(ctx, creds)with the key embedded ascreds.Key. The embeddedKeydoubles storage and creates a structural opportunity for caller/key inconsistency — a caller could plausibly construct&DCRCredentials{Key: keyA, ...}and expect Store to use a separate key parameter; the implementation insists oncreds.Key, but only because the parameter list doesn't expose the alternative. -
Method names: Sibling methods omit the type suffix because the verb implies the subject —
StoreAuthCode/GetAuthCode,StoreUpstreamTokens/GetUpstreamTokens,GetUser,GetClient.StoreDCRCredentials/GetDCRCredentialsover-specifies.
Recommendation:
// Suggested
type DCRCredentialStore interface {
GetDCR(ctx context.Context, key DCRKey) (*DCRCredentials, error)
StoreDCR(ctx context.Context, key DCRKey, creds *DCRCredentials) error
}...and drop DCRCredentials.Key accordingly. Worth fixing before the interface is exported into the wiring PR (sub-issue 3) since renaming after merge churns more files. If retaining the embedded Key is intentional for self-describing Redis serialisation, document the rationale on the interface so future readers see the reason for the inconsistency.
pkg/authserver/storage/memory.go:97
F5 — "Bounded by upstream count" claim is incorrect when scopes change (MEDIUM, score 8)
The comment asserts: "The map is bounded by the operator-configured upstream count, so unbounded growth is not a concern."
This is only true if (Issuer, RedirectURI, ScopesHash) is invariant per upstream over the process lifetime. ScopesHash changes whenever:
- the operator widens/narrows the scope set on
MCPServer.Spec.OAuthConfig.Scopes - the upstream's RFC 8414
scopes_supportedrotates and the resolver re-derives the scope set
Each distinct ScopesHash produces a fresh entry; nothing evicts the prior entry (cleanupExpired deliberately skips this map per the comment just below). In a long-lived process where scope sets rotate, growth is upstream count × distinct-scope-sets-ever-registered, not upstream count. Each retained entry holds a ClientSecret, so this is also a (slow) sensitive-data accumulation.
Same false claim is also at pkg/authserver/runner/dcr_store.go:71.
Recommendation: Tighten the comment:
// dcrCredentials maps DCRKey -> DCRCredentials for RFC 7591 Dynamic Client
// Registration credentials. Entries are intentionally excluded from the
// periodic cleanupExpired loop: DCR registrations are long-lived and the
// authoritative expiry signal is RFC 7591 client_secret_expires_at, which
// is honored at read time by callers (and by the future Redis backend's
// SetEX TTL). Growth is bounded by the operator-configured upstream count
// times the number of distinct scope sets ever registered against each
// upstream during the process lifetime; for a stable configuration this
// is the upstream count, but rotating scope sets accumulate stale entries
// that survive until process restart. The Redis backend's SetEX TTL
// mitigates this in production deployments.
pkg/authserver/storage/memory.go:1231
F4 — Validation is narrower than the documented contract (MEDIUM, score 9)
The doc says "callers must populate Key" but the implementation only validates Key.Issuer != "" and Key.RedirectURI != "".
Key.ScopesHashis silently accepted as empty even though the field doc onDCRKey(intypes.go) says "Use ScopesHash() to compute this value — do NOT hash scopes by hand at call sites." A caller that forgets to populateScopesHashgets a successful Store under the empty-string slot; a sibling that did computeScopesHash(nil)(a non-empty digest) lands on a different slot. The two records refer to the same logical scope set but live in different cache cells.ClientID,AuthorizationEndpoint,TokenEndpointare RFC 7591 mandatory response fields. The struct doc says "All fields are populated from the upstream's DCR response." An empty value is a caller bug, not a real registration — but Store accepts it. A subsequent Get returns a working-looking record that fails downstream at the upstream's token endpoint, with no audit trail tying the failure back to the bad Store.
Violates .claude/rules/go-style.md "Validate Parsed Results" / "Constructor Validation: Fail Loudly on Invalid Input".
Recommendation: Tighten validation — reject empty Key.ScopesHash, ClientID, AuthorizationEndpoint, and TokenEndpoint with fosite.ErrInvalidRequest.WithHint(...) matching the existing pattern at lines 1235–1243. Leave ClientSecret permissive (RFC 7591 §2 public clients with none auth method legitimately have no secret).
pkg/authserver/runner/dcr.go:235
F3 — var scopesHash = storage.ScopesHash is a mutable runtime binding, not an alias (MEDIUM, score 8)
Unlike type DCRKey = storage.DCRKey in dcr_store.go:27 (a true compile-time type alias — sound, identity-preserving), this is a package-level variable whose value happens to be a function. Three concrete drawbacks:
- Mutable: any code in the runner package can reassign it at runtime. No compile-time guard.
- Indirection: extra hop on every call; tempts future contributors to add behaviour by reassigning "the local scopesHash".
- Tooling:
goplsfind-references, refactoring tools, andgo docall see the variable, notstorage.ScopesHash. The canonical-source-of-truth motivation argues for visible qualification, not against it.
The alias semantics are also asymmetric with the type alias in the same package — readers see two different aliasing strategies for two related symbols.
Recommendation (pick one):
Option A — qualify call sites:
// at every call site in runner/dcr.go and runner/dcr_store_test.go
storage.ScopesHash(scopes)Mechanical edit, removes the indirection, makes the storage dependency visible.
Option B — forwarding func (immutable, gets inlined):
func scopesHash(s []string) string { return storage.ScopesHash(s) }Option A preferred — the entire reason ScopesHash moved to storage/ was to make it the single source of truth, so the qualifier is informational, not noise.
Eliminates the post-merge name collision between runner.DCRCredentialStore and storage.DCRCredentialStore. The two interfaces serve different purposes (runtime resolution cache vs persistent credential store), and the same-name collision in sibling packages forces every reader and grep search to mentally disambiguate during the Phase 3 migration. Renaming the runner-side interface to lowercase dcrResolutionCache keeps the canonical DCRCredentialStore name reserved for the storage package, which is where the future Redis backend will plug in. The rename is internal to pkg/authserver/runner — no external callers exist. Renamed symbols: - DCRCredentialStore -> dcrResolutionCache - inMemoryDCRCredentialStore -> inMemoryDCRResolutionCache - NewInMemoryDCRCredentialStore -> newInMemoryDCRResolutionCache - TestInMemoryDCRCredentialStore_* -> TestInMemoryDCRResolutionCache_* Addresses #5186 review F2 (HIGH, score 9): naming collision with runner.DCRCredentialStore.
Replace `var scopesHash = storage.ScopesHash` with direct calls to storage.ScopesHash. The variable was a mutable runtime binding (any package code could reassign it, no compile-time guard) and an extra indirection layer for tooling and refactors. Qualifying call sites makes the storage dependency visible and matches the type-alias strategy already used for DCRKey in dcr_store.go. Addresses #5186 review F3 (MEDIUM, score 8): mutable runtime binding, not a true alias.
Reject empty Key.ScopesHash, ClientID, AuthorizationEndpoint, and TokenEndpoint with fosite.ErrInvalidRequest, matching the broader "required field" contract documented on the DCRCredentials struct. The previous validation only checked Key.Issuer and Key.RedirectURI, which silently accepted partial registrations: a caller forgetting to populate ScopesHash would Store under the empty-string slot while a sibling that did compute ScopesHash(nil) (a non-empty digest) would land on a different cache cell, splitting one logical record across two slots. Likewise, accepting an empty ClientID, AuthorizationEndpoint, or TokenEndpoint would Store a working-looking record that fails downstream at the upstream's token endpoint with no audit trail. ClientSecret is left permissive because RFC 7591 §2 public clients (token_endpoint_auth_method = "none") legitimately have no secret. Tests: replaced the three-case nil/issuer/redirect_uri table with a seven-case table that constructs a fully valid DCRCredentials and isolates one missing field per subtest; updated the existing RoundTrip / DistinctKeys / Overwrite / DefensiveCopy / StoreCopyIsolates / ExcludedFromCleanupExpired tests to populate the newly-required fields so they exercise the success path under the tightened contract. Addresses #5186 review F4 (MEDIUM, score 9): validation narrower than the documented contract.
The previous comments asserted the cache is "bounded by the operator-configured upstream count". This holds only when (Issuer, RedirectURI, ScopesHash) is invariant per upstream over the process lifetime. ScopesHash changes whenever the operator widens or narrows the scope set on MCPServer.Spec.OAuthConfig.Scopes, or when an upstream's RFC 8414 scopes_supported rotates and the resolver re-derives the scope set. Each distinct ScopesHash produces a fresh entry; nothing evicts the prior one (cleanupExpired deliberately skips this map), so in a long-lived process with rotating scope sets growth is upstream count × distinct-scope-sets-ever-registered, not upstream count alone. Each retained entry holds a ClientSecret, so this is also a (slow) sensitive-data accumulation. The Redis backend's SetEX TTL mitigates this in production. Updated parallel comments at memory.go:97 and runner/dcr_store.go:61 to state the actual bound and the production mitigation. Addresses #5186 review F5 (MEDIUM, score 8): "bounded by upstream count" claim incorrect when scopes change.
The DCRCredentialStore interface takes (ctx, creds) on Store rather than the (ctx, key, value) shape used by sibling methods on Storage. The asymmetry is intentional — embedding DCRKey in DCRCredentials.Key makes the persisted blob self-describing, so a Redis SCAN, an admin dump, or a cross-replica reconciliation path can identify a record's logical cache slot from the value alone — but it diverges from the package's "key + value" convention enough that future readers will ask why. Add a "Why the key is embedded" section to the interface doc that states the rationale and points at the Memory backend's validation contract for the populate-Key requirement. Addresses #5186 review F6 (MEDIUM, score 7): signature/naming inconsistent with sibling Storage methods. The reviewer offered two paths — rename to GetDCR/StoreDCR + drop Key, or document the rationale. Documenting was selected because the embedded-Key shape is load-bearing for the Phase 3 Redis backend.
The existing TestMemoryStorage_ConcurrentAccess fan-out covers authcodes, tokens, and clients but not the new DCRCredentialStore methods, and every other DCR test runs single-goroutine. A regression that dropped the RWMutex or returned an internal pointer instead of a defensive copy would not be caught by the suite under -race. Add a dedicated TestMemoryStorage_DCRCredentials_ConcurrentAccess that fans out 16 workers × 200 ops, alternating Store / Get against an overlapping key space (lock must serialise writes) and a disjoint key space (reads must always hit). Bounded by a 10s fail-fast deadline so a deadlock regression surfaces with a clear message rather than the global test timeout. Mirrors the runner-side TestInMemoryDCRResolutionCache_ConcurrentAccess pattern. Addresses #5186 review F7 (LOW-MEDIUM, score 7): missing concurrent-access test for new DCR Store/Get methods.
|
Addressed all 8 findings from the multi-agent review (
|
There was a problem hiding this comment.
Large PR Detected
This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.
How to unblock this PR:
Add a section to your PR description with the following format:
## Large PR Justification
[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformationAlternative:
Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.
See our Contributing Guidelines for more details.
This review will be automatically dismissed once you add the justification section.
Summary
pkg/authserver/storage/so bothMemoryStorageand the future Redis backend (sub-issue 2) can implement the same interface, and so the wiring change (sub-issue 3) has a single canonical type to swap to. Until that lands, every authserver replica re-registers its own DCR client on boot, noclient_secret_expires_atTTL is honored at the storage layer, and the runner-side cache from Authserver DCR integration (Phase 2, Steps 2a-2g) #4978 has no production-shaped sibling.DCRCredentialsvalue type, the segregatedDCRCredentialStoreinterface, and aMemoryStorageimplementation. Production deployments will use the Redis backend introduced in sub-issue 2; the in-memory implementation lands here for tests and single-replica development. The wiring swap is intentionally out of scope.DCRCredentialsvalue type inpkg/authserver/storage/types.gocarrying the canonicalDCRKey,ProviderName, the RFC 7591 client fields (ClientID,ClientSecret,TokenEndpointAuthMethod), the RFC 7592 management fields (RegistrationAccessToken,RegistrationClientURI), the upstream-resolved endpoints (AuthorizationEndpoint,TokenEndpoint), andCreatedAt/ClientSecretExpiresAtfor the staleness and TTL signals.DCRCredentialStoreinterface (GetDCRCredentials,StoreDCRCredentials) added to the package'smockgendirective; mocks regenerated. The interface doc states the defensive-copy contract, the SHOULD-honor-TTL contract for backends with native TTL support, and the rationale for embeddingDCRKeyin the value type (self-describing blobs for Redis SCAN / admin tooling).DCRKeyand itsScopesHashconstructor moved frompkg/authserver/runner/intopkg/authserver/storage/so any future backend hashes keys identically. The runner keeps a package-localtype DCRKey = storage.DCRKeyalias and call sites usestorage.ScopesHashdirectly.MemoryStoragegains adcrCredentials map[DCRKey]*DCRCredentialsguarded by the existingsync.RWMutex.StoreDCRCredentialsdefensively copies on write and rejects nil creds, an unpopulatedKey(emptyIssuer,RedirectURI, orScopesHash), and missing RFC 7591 mandatory response fields (ClientID,AuthorizationEndpoint,TokenEndpoint);GetDCRCredentialsdefensively copies on read and returns wrappedErrNotFoundon miss. DCR entries are intentionally excluded fromcleanupExpired(registrations are long-lived; Redis will TTL viaSetEX).Statsexposes the new map's count for parity with the other in-memory maps.DCRCredentialStore(a runtime resolution cache for*DCRResolution) to lowercasedcrResolutionCacheso it does not collide with the newstorage.DCRCredentialStoreduring the Phase 3 migration. The runner-side cache is the thin adapter sub-issue 3 will swap.Closes #5183
Type of change
Test plan
task test)task test-e2e)task lint-fix)Specifically:
pkg/authserver/storage/memory_test.go— round-trip on everyDCRCredentialsfield includingClientSecretExpiresAt; distinctDCRKeyvalues do not collide along the Issuer / RedirectURI / Scopes axes; overwrite semantics replace the prior entry;Geton an absent key returns wrappedErrNotFoundviaerrors.Is; defensive-copy assertions on both read and write paths;StoreDCRCredentialsrejects nil creds and the seven invalid-input cases (empty Issuer / RedirectURI / ScopesHash / ClientID / AuthorizationEndpoint / TokenEndpoint, plus nil);Statsreports theDCRCredentialscount; DCR entries survivecleanupExpired; concurrent fan-out (16 workers × 200 ops,-race) over overlapping and disjoint key spaces with a fail-fast deadline.pkg/authserver/storage/memory_test.go(TestScopesHash_*) — canonical scopes-hash form exercised here as the single source of truth (permutation, duplicates, boundary-join collision, distinct-scopes inequality).task genrerun;pkg/authserver/storage/mocks/mock_storage.goregenerated for the new interface.Special notes for reviewers
The branch is one commit on top of
main. The runner-sidedcrResolutionCacheis renamed but functionally unchanged — it still holds*DCRResolutionvalues and is consumed only byresolveDCRCredentials. Sub-issue 3 will swap the resolver to read/writestorage.DCRCredentialStoreand delete the runner-side cache; this PR keeps both side by side under non-colliding names so the intermediate state is unambiguous to reviewers and tooling.The interface contract documents
ClientSecretExpiresAtas SHOULD-honor (not MUST), with the in-memory backend retaining the field verbatim and the resolver re-checking on read. The Redis backend (sub-issue 2) plumbs the field intoSetEXso an entry evicts before the upstream rejects the secret at the token endpoint.🤖 Generated with Claude Code