Skip to content

Add persistent DCRCredentialStore types and memory backend#5186

Draft
tgrunnagle wants to merge 1 commit intoissue_5040_dcr-2dfrom
dcr-3a_issue_5183
Draft

Add persistent DCRCredentialStore types and memory backend#5186
tgrunnagle wants to merge 1 commit intoissue_5040_dcr-2dfrom
dcr-3a_issue_5183

Conversation

@tgrunnagle
Copy link
Copy Markdown
Contributor

@tgrunnagle tgrunnagle commented May 4, 2026

DRAFT - not ready for review

Summary

  • Why: Phase 3 of the authserver-driven DCR story (Persistent DCRCredentialStore backends (memory + Redis) #4979 / parent Authserver-driven DCR for upstream OAuth 2.1 MCP servers #4976) needs the persisted credential store moved to pkg/authserver/storage/ so both MemoryStorage and the future RedisStorage (sub-issue 2) can implement the same interface. Until that happens, every authserver replica re-registers its own DCR client on boot, no client_secret_expires_at TTL is honored, and the runner-side stub from Authserver DCR integration (Phase 2, Steps 2a-2g) #4978 has no production-shaped sibling. This PR ships the storage-level interface, the value type with full RFC 7591 + 7592 fields, and the in-memory backend; the Redis backend and the wiring swap follow as separate PRs.
  • What (top of stack — issue Persistent DCRCredentialStore: types, interface, and in-memory backend #5183):
    • New DCRCredentials value type in pkg/authserver/storage/types.go carrying the canonical DCRKey, ClientID/ClientSecret, TokenEndpointAuthMethod, RFC 7592 management fields (RegistrationAccessToken, RegistrationClientURI), AuthorizationEndpoint/TokenEndpoint, CreatedAt, and ClientSecretExpiresAt (added in the review-feedback commit so the Redis backend can satisfy the TTL contract without re-touching the value type).
    • New segregated DCRCredentialStore interface (GetDCRCredentials, StoreDCRCredentials) added to the package's mockgen directive; mocks regenerated.
    • DCRKey and its canonical ScopesHash constructor moved from pkg/authserver/runner/ into pkg/authserver/storage/. The runner keeps a package-local type DCRKey = storage.DCRKey alias and a var scopesHash = storage.ScopesHash binding so existing call sites compile unchanged and the canonical hashing has a single source of truth.
    • MemoryStorage gains a dcrCredentials map[DCRKey]*DCRCredentials guarded by the existing sync.RWMutex; StoreDCRCredentials defensively copies on write and rejects creds with empty Key.Issuer or Key.RedirectURI; GetDCRCredentials defensively copies on read and returns wrapped ErrNotFound on miss. DCR entries are intentionally excluded from cleanupExpired (registrations are long-lived; Redis will use SetEX for the TTL case). Stats exposes the new map's count for parity with the other in-memory maps.
  • What (folded-in preparatory commits):

Closes #5183

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Specifically:

  • pkg/authserver/storage/memory_test.go — round-trip on all DCRCredentials fields including ClientSecretExpiresAt; two distinct DCRKey values do not collide; overwrite semantics replace the prior entry; Get on an absent key returns wrapped ErrNotFound via errors.Is; defensive-copy assertions on both the read and write paths; StoreDCRCredentials rejects empty Key.Issuer and empty Key.RedirectURI; Stats reports the DCRCredentials count.
  • pkg/authserver/storage/types_test.go — canonical ScopesHash exercised here as the single source of truth (the runner-side TestScopesHash_* cases were dropped in the review-feedback commit).
  • pkg/authserver/runner/dcr_test.go, embeddedauthserver_test.go — full DCR boot path against a mock AS, cache-hit short-circuit issues zero additional HTTP requests, caller's RunConfig.Upstreams slice element is unchanged across both calls, sanitiser test cases for userinfo/fragment/case-insensitive scheme/trailing punctuation, single-emission of the panic-recovery log.
  • cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go, controllerutil/authserver_test.go — table-driven CEL/Go validation for DCR + ClientID conflict, both endpoints set, neither endpoint set, valid single-endpoint cases, and InitialAccessTokenRef env-var wiring.
  • task gen rerun; pkg/authserver/storage/mocks/mock_storage.go regenerated.

API Compatibility

The CRD changes add new optional fields on OAuth2UpstreamConfig (dcrConfig) and add a CEL validation that requires exactly one of clientId or dcrConfig. Existing manifests that set clientId continue to validate; the new dcrConfig path is additive. clientId is now optional at the CRD level (it was previously required), which is a relaxation, not a break.

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Changes

File Change
pkg/authserver/storage/types.go Add DCRCredentials, DCRCredentialStore, move DCRKey + ScopesHash here, extend mockgen directive
pkg/authserver/storage/memory.go Add dcrCredentials map, StoreDCRCredentials/GetDCRCredentials, defensive-copy helpers, exclude from cleanupExpired, surface in Stats
pkg/authserver/storage/memory_test.go Round-trip / overwrite / not-found / defensive-copy / validation tests
pkg/authserver/storage/mocks/mock_storage.go Regenerated mock for new interface
pkg/authserver/runner/dcr_store.go DCRKey becomes a type alias to storage.DCRKey; scopesHash rebound to storage.ScopesHash
pkg/authserver/runner/dcr.go Resolver structured logs, dcrStepError boundary logging, applyResolution/applyResolutionToOAuth2Config pairing, sanitiser hardening
pkg/authserver/runner/dcr_store_test.go Drop runner-local ScopesHash tests; the canonical suite lives in the storage package
pkg/authserver/runner/embeddedauthserver.go Construct in-memory DCR store; resolve DCR before building OAuth2 configs
pkg/authserver/server/handlers/handler.go Validate AuthorizationServerConfig in NewHandler; simplify issuer()
pkg/authserver/server/handlers/dcr.go Demote success log to Debug; thread software_id
pkg/authserver/server/registration/dcr.go Cap software_id length, require printable ASCII
pkg/auth/monitored_token_source.go Promote upstream/client_id to constructor parameters; emit DCR remediation Warn once per state transition from markAsUnauthenticated
pkg/oauthproto/dcr.go Bound /register error body read at 8 KiB
pkg/runner/runner.go Wire upstream/client_id from RemoteAuthConfig (CIMD precedence) into the MonitoredTokenSource
cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go New DCRUpstreamConfig type, CEL validation, ValidateOAuth2DCRConfig
cmd/thv-operator/pkg/controllerutil/authserver.go Convert DCRConfig to authserver.DCRUpstreamConfig; resolve InitialAccessTokenRef to env var
deploy/charts/operator-crds/{files,templates}/crds/*.yaml, docs/operator/crd-api.md Regenerated CRDs and docs

Does this introduce a user-facing change?

Yes. Kubernetes operators can now configure RFC 7591 Dynamic Client Registration on OAuth2UpstreamConfig via a new dcrConfig field (discoveryUrl/registrationEndpoint, initialAccessTokenRef, softwareId, softwareStatement). Until the Redis backend (Phase 3 sub-issue 2) and the wiring swap (sub-issue 3) land, the storage-level DCRCredentialStore introduced here is implemented only by MemoryStorage, so each authserver replica still holds an independent map; cross-replica sharing arrives with the Redis backend.

Special notes for reviewers

  • PR scope is wider than the headline issue. The two commits at the top of the stack (94fe0dc7, 17bd2ab5) implement Persistent DCRCredentialStore: types, interface, and in-memory backend #5183 and are the primary review target. The earlier six commits are Phase 2 follow-ups (Authserver DCR: wire resolver into authserver and add structured logs (Phase 2, Steps 2d/2g) #5039, Authserver DCR: expose DCR config in operator CRD (Phase 2, CRD surface) #5040, Wire authserver DCR resolver and add structured logs #5044 review feedback) that landed on the same branch because they touch the same DCR call-graph; reviewers may find it easier to read commit-by-commit than diff-by-diff against main.
  • DCRKey consolidation choice. Issue Persistent DCRCredentialStore: types, interface, and in-memory backend #5183 offered two options: move DCRKey to storage/ (option a) or keep it in runner/ with a storage-side alias (option b). This PR takes option (a) — canonical definition in storage/, package-local alias in runner/ — so any future Redis backend hashes keys identically. The runner-side scopesHash is a var binding (var scopesHash = storage.ScopesHash) rather than a re-export, which keeps the indirection visible to callers.
  • Runner-side DCRCredentialStore is intentionally NOT removed. Sub-issue 1 lands the new storage-level interface and implementation; the runner-side adapter (Get/Put on *DCRResolution) is left in place as the thin abstraction the wiring PR (sub-issue 3) will swap. Two competing implementations exist on main after this lands — that is expected and called out in the issue.
  • ClientSecretExpiresAt on the in-memory backend is informational. The in-memory backend retains the field verbatim and does not act on it; the resolver re-checks expiry on read. The Redis backend will plumb it through SetEX. The interface contract uses SHOULD (not MUST) for TTL handling so backends without a native TTL remain conformant.
  • Defensive-copy semantics match UpstreamTokens. StoreDCRCredentials and GetDCRCredentials both do field-by-field copies; DCRCredentials is a flat value type with no nested maps/slices/pointers so a shallow copy is a deep copy here. If a future field introduces nested mutable state, cloneDCRCredentials is the single point to update.
  • No new logging of secrets. client_secret, registration_access_token, initial_access_token and refresh tokens are never arguments to slog.* calls; the grep assertion from Authserver DCR integration (Phase 2, Steps 2a-2g) #4978 still passes.

Implementation plan

Approved implementation plan (issue #5183)
  1. Add DCRCredentials value type to pkg/authserver/storage/types.go adjacent to UpstreamTokens. Mirror the defensive-copy contract documented for UpstreamTokens. Capture RFC 7591 fields plus the RFC 7592 management fields verbatim.
  2. Move DCRKey and its ScopesHash constructor from pkg/authserver/runner/ to pkg/authserver/storage/ so any backend hashes keys identically. Keep a package-local type alias in the runner so existing call sites compile unchanged.
  3. Add DCRCredentialStore interface (GetDCRCredentials, StoreDCRCredentials) to types.go. Wire it into the existing mockgen directive. Keep it segregated — do NOT add it to the Storage umbrella interface.
  4. Extend MemoryStorage with a dcrCredentials map[DCRKey]*DCRCredentials guarded by the existing sync.RWMutex. Defensive copy on read and write; reject empty Key.Issuer/Key.RedirectURI; return wrapped ErrNotFound on miss; exclude from cleanupExpired; surface in Stats. Add the compile-time interface assertion at the bottom of the file.
  5. Run task gen to regenerate the DCRCredentialStore mock.
  6. Verify with task build, task test, task lint-fix, task license-check. Run the secret-grep assertion from Authserver DCR integration (Phase 2, Steps 2a-2g) #4978.

Out of scope (deferred to sub-issues 2 and 3):

  • Redis backend implementation.
  • Wiring EmbeddedAuthServer to use the storage-level interface and removing the Phase 2 standalone in-memory stub.

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label May 4, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 transformation

Alternative:

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.

@tgrunnagle tgrunnagle changed the base branch from main to issue_5040_dcr-2d May 4, 2026 19:46
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/XL Extra large PR: 1000+ lines changed labels May 4, 2026
@github-actions github-actions Bot dismissed their stale review May 4, 2026 19:47

PR size has been reduced below the XL threshold. Thank you for splitting this up!

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

✅ 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!

@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels May 4, 2026
@tgrunnagle tgrunnagle force-pushed the dcr-3a_issue_5183 branch from 17bd2ab to c0fed52 Compare May 4, 2026 19:47
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels May 4, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 4, 2026

Codecov Report

❌ Patch coverage is 94.87179% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.71%. Comparing base (1b0e781) to head (cc92472).

Files with missing lines Patch % Lines
pkg/authserver/storage/memory.go 93.10% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@                  Coverage Diff                  @@
##           issue_5040_dcr-2d    #5186      +/-   ##
=====================================================
- Coverage              67.74%   67.71%   -0.03%     
=====================================================
  Files                    607      607              
  Lines                  62049    62078      +29     
=====================================================
+ Hits                   42033    42039       +6     
- Misses                 16854    16876      +22     
- Partials                3162     3163       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tgrunnagle tgrunnagle force-pushed the issue_5040_dcr-2d branch from 64feb73 to ee7d4cc Compare May 5, 2026 14:32
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.
@tgrunnagle tgrunnagle force-pushed the dcr-3a_issue_5183 branch from c0fed52 to cc92472 Compare May 5, 2026 15:50
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels May 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Persistent DCRCredentialStore: types, interface, and in-memory backend

1 participant