You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of #4978 (Phase 2 of the DCR story, #4976). This sub-issue implements the in-memory credential store and the DCR resolver that performs discovery and registration at startup.
Depends on: #5037 (Sub-issue A — DCRUpstreamConfig, FetchAuthorizationServerMetadata, and OAuth2Config.Validate() must land first). Followed by: Sub-issue C (wiring + observability + integration tests).
DCRCredentialStore interface with Get(ctx context.Context, key DCRKey) (*DCRResolution, bool, error) and Put(ctx context.Context, key DCRKey, resolution *DCRResolution) error.
DCRKey struct: Issuer, RedirectURI, ScopesHash (SHA-256 hex of sorted scope list for canonical form shared by future Redis backend).
In-memory implementation: plain map[DCRKey]*DCRResolution guarded by sync.RWMutex. No TTL, no cleanup goroutine — entries are long-lived. Reference shape: pkg/authserver/storage/memory.go.
applyResolution(rc *authserver.OAuth2UpstreamRunConfig, res *DCRResolution) — copies resolved fields into the caller's run-config COPY (per .claude/rules/go-style.md copy-before-mutate rule).
scopesHash(scopes []string) string — SHA-256 hex of sorted scope list.
Auth-method intersection helper: preference order private_key_jwt > client_secret_basic > client_secret_post > none; empty intersection returns a clear error.
resolveDCRCredentials(ctx, rc, issuer, cache) full flow:
Defensive re-check of ClientID XOR DCRConfig.
Cache lookup via DCRCredentialStore.Get — hit short-circuits immediately.
Endpoint resolution: call FetchAuthorizationServerMetadata when DiscoveryURL set; use RegistrationEndpoint directly when set; synthesize {origin}/register when metadata omits registration_endpoint (nanobot/Hydra convention).
Redirect URI: use rc.RedirectURI when set; else url.Parse(issuer) + ResolveReference("/oauth/callback"). Require HTTPS unless host is loopback.
Auth-method selection via intersection helper.
Scopes: rc.Scopes when set; else metadata.ScopesSupported; else empty + slog.Warn.
Initial access token: read via resolveSecret(file, envVar) at pkg/authserver/runner/embeddedauthserver.go:409; attach as Authorization: Bearer {token}.
Call oauthproto.RegisterClientDynamically() exactly once — no retry loop.
Capture full RFC 7591 + RFC 7592 response into DCRResolution (including RegistrationAccessToken, RegistrationClientURI).
Store via DCRCredentialStore.Put before returning.
New file pkg/authserver/runner/dcr_test.go:
Resolver flow via a single httptest.NewServer mounting AS metadata + DCR endpoint.
Cache-hit short-circuits: pre-populate store via Put, assert zero HTTP requests to mock server.
applyResolution(rc, res) copies resolved credentials and endpoints into the caller's run-config copy.
Cache hit short-circuits before any network I/O.
On cache miss: calls FetchAuthorizationServerMetadata when DiscoveryURL set; uses RegistrationEndpoint directly when set; synthesizes {origin}/register when metadata omits registration_endpoint.
Explicit rc.AuthorizationEndpoint / rc.TokenEndpoint win over discovered values.
Redirect URI: uses rc.RedirectURI when set; otherwise derives from issuer + /oauth/callback. HTTPS required unless host is loopback.
TokenEndpointAuthMethod: defaults to client_secret_basic; intersects token_endpoint_auth_methods_supported against preference order private_key_jwt > client_secret_basic > client_secret_post > none; empty intersection returns an error.
Initial access token (from file or env, via resolveSecret) sent as Authorization: Bearer {token}.
oauthproto.RegisterClientDynamically() called exactly once per resolution.
RFC 7592 fields (RegistrationAccessToken, RegistrationClientURI) captured in DCRResolution.
Resolution stored via DCRCredentialStore.Put before returning.
Unit test: pre-populated store → resolveDCRCredentials → zero HTTP requests to mock server.
Unit test: explicit AuthorizationEndpoint overrides discovered value.
Unit test: initial access token forwarded as Authorization: Bearer ….
Unit test: client_secret_basic chosen over none when both advertised.
Unit test: metadata missing registration_endpoint triggers synthesized /register path.
Secret grep: grep -nE '(client_secret|registration_access_token|initial_access_token|refresh_token)' pkg/authserver/runner/dcr.go returns only struct-field / JSON-tag hits, never slog.* call arguments.
Cross-cutting
task build, task test, task lint-fix, task license-check all pass.
All new .go files have SPDX 2-line header.
Patterns & References
resolveSecret(file, envVar) at pkg/authserver/runner/embeddedauthserver.go:409 — reuse for initial access token, do not duplicate secret-loading logic.
pkg/authserver/storage/memory.go — reference shape for sync.RWMutex + map in-memory store.
pkg/authserver/storage/redis_keys.go:72-80 — reference for future Redis DCR key scheme; the DCRKey.Issuer/RedirectURI/ScopesHash tuple is designed to compose the <id> segment in Phase 3 without redefining the canonical form.
Plain testing + testify (not Ginkgo). require.NoError over t.Fatal. Table-driven tests. t.Cleanup(server.Close) — never defer when t.Parallel() is used.
Write to durable storage before updating in-memory state (.claude/rules/go-style.md) — Put before returning to caller.
SPDX header required on all new files (task license-fix catches omissions).
Context
Part of #4978 (Phase 2 of the DCR story, #4976). This sub-issue implements the in-memory credential store and the DCR resolver that performs discovery and registration at startup.
Depends on: #5037 (Sub-issue A —
DCRUpstreamConfig,FetchAuthorizationServerMetadata, andOAuth2Config.Validate()must land first).Followed by: Sub-issue C (wiring + observability + integration tests).
Scope
Step 2f —
DCRCredentialStore(pkg/authserver/runner/dcr_store.go)New file:
DCRCredentialStoreinterface withGet(ctx context.Context, key DCRKey) (*DCRResolution, bool, error)andPut(ctx context.Context, key DCRKey, resolution *DCRResolution) error.DCRKeystruct:Issuer,RedirectURI,ScopesHash(SHA-256 hex of sorted scope list for canonical form shared by future Redis backend).map[DCRKey]*DCRResolutionguarded bysync.RWMutex. No TTL, no cleanup goroutine — entries are long-lived. Reference shape:pkg/authserver/storage/memory.go.NewInMemoryDCRCredentialStore() DCRCredentialStoreconstructor.dcrStaleAgeThreshold = 90 * 24 * time.Hourconstant (referenced by Step 2g logs).New file
pkg/authserver/runner/dcr_store_test.go:ScopesHashis stable across permuted scope order.Step 2c —
resolveDCRCredentials(pkg/authserver/runner/dcr.go)New file:
DCRResolutionstruct:ClientID,ClientSecret,AuthorizationEndpoint,TokenEndpoint,RegistrationAccessToken,RegistrationClientURI,TokenEndpointAuthMethod,CreatedAt time.Time.needsDCR(rc *authserver.OAuth2UpstreamRunConfig) bool— returnsrc.ClientID == "" && rc.DCRConfig != nil.applyResolution(rc *authserver.OAuth2UpstreamRunConfig, res *DCRResolution)— copies resolved fields into the caller's run-config COPY (per.claude/rules/go-style.mdcopy-before-mutate rule).scopesHash(scopes []string) string— SHA-256 hex of sorted scope list.private_key_jwt > client_secret_basic > client_secret_post > none; empty intersection returns a clear error.resolveDCRCredentials(ctx, rc, issuer, cache)full flow:ClientIDXORDCRConfig.DCRCredentialStore.Get— hit short-circuits immediately.FetchAuthorizationServerMetadatawhenDiscoveryURLset; useRegistrationEndpointdirectly when set; synthesize{origin}/registerwhen metadata omitsregistration_endpoint(nanobot/Hydra convention).rc.AuthorizationEndpoint/rc.TokenEndpointoverride discovered values.rc.RedirectURIwhen set; elseurl.Parse(issuer)+ResolveReference("/oauth/callback"). Require HTTPS unless host is loopback.rc.Scopeswhen set; elsemetadata.ScopesSupported; else empty +slog.Warn.resolveSecret(file, envVar)atpkg/authserver/runner/embeddedauthserver.go:409; attach asAuthorization: Bearer {token}.oauthproto.RegisterClientDynamically()exactly once — no retry loop.DCRResolution(includingRegistrationAccessToken,RegistrationClientURI).DCRCredentialStore.Putbefore returning.New file
pkg/authserver/runner/dcr_test.go:httptest.NewServermounting AS metadata + DCR endpoint.Put, assert zero HTTP requests to mock server.AuthorizationEndpointoverrides discovered value.Authorization: Bearer ….client_secret_basicchosen overnonewhen both advertised.registration_endpointexercises synthesized/registerpath.t.Cleanup(server.Close)(notdeferwhent.Parallel()used).Acceptance Criteria
Step 2f
pkg/authserver/runner/dcr_store.godefinesDCRCredentialStoreinterface withGet(ctx, DCRKey) (*DCRResolution, bool, error)andPut(ctx, DCRKey, *DCRResolution) error.DCRKeystruct hasIssuer,RedirectURI,ScopesHashfields;ScopesHashis SHA-256 hex of sorted scope list.map[DCRKey]*DCRResolutionguarded bysync.RWMutex, no cleanup goroutine.dcrStaleAgeThreshold = 90 * 24 * time.Hourconstant defined in runner package.NewInMemoryDCRCredentialStore() DCRCredentialStoreconstructor exported.PutthenGetreturns the stored resolution.DCRKeyvalues do not collide.ScopesHashis identical for["openid", "profile"]and["profile", "openid"].Step 2c
DCRResolutionstruct has:ClientID,ClientSecret,AuthorizationEndpoint,TokenEndpoint,RegistrationAccessToken,RegistrationClientURI,TokenEndpointAuthMethod,CreatedAt.needsDCR(rc) boolreturns true iffrc.ClientID == ""andrc.DCRConfig != nil.applyResolution(rc, res)copies resolved credentials and endpoints into the caller's run-config copy.FetchAuthorizationServerMetadatawhenDiscoveryURLset; usesRegistrationEndpointdirectly when set; synthesizes{origin}/registerwhen metadata omitsregistration_endpoint.rc.AuthorizationEndpoint/rc.TokenEndpointwin over discovered values.rc.RedirectURIwhen set; otherwise derives from issuer +/oauth/callback. HTTPS required unless host is loopback.TokenEndpointAuthMethod: defaults toclient_secret_basic; intersectstoken_endpoint_auth_methods_supportedagainst preference orderprivate_key_jwt > client_secret_basic > client_secret_post > none; empty intersection returns an error.resolveSecret) sent asAuthorization: Bearer {token}.oauthproto.RegisterClientDynamically()called exactly once per resolution.RegistrationAccessToken,RegistrationClientURI) captured inDCRResolution.DCRCredentialStore.Putbefore returning.resolveDCRCredentials→ zero HTTP requests to mock server.AuthorizationEndpointoverrides discovered value.Authorization: Bearer ….client_secret_basicchosen overnonewhen both advertised.registration_endpointtriggers synthesized/registerpath.grep -nE '(client_secret|registration_access_token|initial_access_token|refresh_token)' pkg/authserver/runner/dcr.goreturns only struct-field / JSON-tag hits, neverslog.*call arguments.Cross-cutting
task build,task test,task lint-fix,task license-checkall pass..gofiles have SPDX 2-line header.Patterns & References
resolveSecret(file, envVar)atpkg/authserver/runner/embeddedauthserver.go:409— reuse for initial access token, do not duplicate secret-loading logic.pkg/authserver/storage/memory.go— reference shape forsync.RWMutex+ map in-memory store.pkg/authserver/storage/redis_keys.go:72-80— reference for future Redis DCR key scheme; theDCRKey.Issuer/RedirectURI/ScopesHashtuple is designed to compose the<id>segment in Phase 3 without redefining the canonical form.testing+testify(not Ginkgo).require.NoErrorovert.Fatal. Table-driven tests.t.Cleanup(server.Close)— neverdeferwhent.Parallel()is used..claude/rules/go-style.md) —Putbefore returning to caller.task license-fixcatches omissions).