Skip to content

Authserver DCR: credential store and resolver (Phase 2, Steps 2f/2c) #5038

Description

@tgrunnagle

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, and OAuth2Config.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:

  • 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.
  • NewInMemoryDCRCredentialStore() DCRCredentialStore constructor.
  • dcrStaleAgeThreshold = 90 * 24 * time.Hour constant (referenced by Step 2g logs).

New file pkg/authserver/runner/dcr_store_test.go:

  • Put/Get round-trip; distinct DCRKey values don't collide; ScopesHash is stable across permuted scope order.

Step 2c — resolveDCRCredentials (pkg/authserver/runner/dcr.go)

New file:

  • DCRResolution struct: ClientID, ClientSecret, AuthorizationEndpoint, TokenEndpoint, RegistrationAccessToken, RegistrationClientURI, TokenEndpointAuthMethod, CreatedAt time.Time.
  • needsDCR(rc *authserver.OAuth2UpstreamRunConfig) bool — returns rc.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.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:
    1. Defensive re-check of ClientID XOR DCRConfig.
    2. Cache lookup via DCRCredentialStore.Get — hit short-circuits immediately.
    3. Endpoint resolution: call FetchAuthorizationServerMetadata when DiscoveryURL set; use RegistrationEndpoint directly when set; synthesize {origin}/register when metadata omits registration_endpoint (nanobot/Hydra convention).
    4. Explicit rc.AuthorizationEndpoint / rc.TokenEndpoint override discovered values.
    5. Redirect URI: use rc.RedirectURI when set; else url.Parse(issuer) + ResolveReference("/oauth/callback"). Require HTTPS unless host is loopback.
    6. Auth-method selection via intersection helper.
    7. Scopes: rc.Scopes when set; else metadata.ScopesSupported; else empty + slog.Warn.
    8. Initial access token: read via resolveSecret(file, envVar) at pkg/authserver/runner/embeddedauthserver.go:409; attach as Authorization: Bearer {token}.
    9. Call oauthproto.RegisterClientDynamically() exactly once — no retry loop.
    10. Capture full RFC 7591 + RFC 7592 response into DCRResolution (including RegistrationAccessToken, RegistrationClientURI).
    11. 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.
  • Explicit AuthorizationEndpoint overrides discovered value.
  • Initial access token sent as Authorization: Bearer ….
  • client_secret_basic chosen over none when both advertised.
  • Missing registration_endpoint exercises synthesized /register path.
  • Teardown via t.Cleanup(server.Close) (not defer when t.Parallel() used).

Acceptance Criteria

Step 2f

  • pkg/authserver/runner/dcr_store.go defines DCRCredentialStore interface with Get(ctx, DCRKey) (*DCRResolution, bool, error) and Put(ctx, DCRKey, *DCRResolution) error.
  • DCRKey struct has Issuer, RedirectURI, ScopesHash fields; ScopesHash is SHA-256 hex of sorted scope list.
  • In-memory implementation: map[DCRKey]*DCRResolution guarded by sync.RWMutex, no cleanup goroutine.
  • dcrStaleAgeThreshold = 90 * 24 * time.Hour constant defined in runner package.
  • NewInMemoryDCRCredentialStore() DCRCredentialStore constructor exported.
  • Unit test: Put then Get returns the stored resolution.
  • Unit test: two distinct DCRKey values do not collide.
  • Unit test: ScopesHash is identical for ["openid", "profile"] and ["profile", "openid"].

Step 2c

  • DCRResolution struct has: ClientID, ClientSecret, AuthorizationEndpoint, TokenEndpoint, RegistrationAccessToken, RegistrationClientURI, TokenEndpointAuthMethod, CreatedAt.
  • needsDCR(rc) bool returns true iff rc.ClientID == "" and rc.DCRConfig != nil.
  • 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    authenhancementNew feature or requestgoPull requests that update go code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions