dcr

package
v0.28.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 19, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package dcr is the shared RFC 7591 Dynamic Client Registration client used by every consumer in the codebase that needs to register a downstream OAuth 2.x client at runtime. The package owns the stateful concerns of the flow — credential cache, in-process singleflight deduplication, scope-set canonicalisation, token-endpoint auth-method selection (with the RFC 7636 / OAuth 2.1 S256 PKCE gate), RFC 7591 §3.2.1 expiry-driven cache invalidation, the bearer-token transport with redirect refusal, and panic recovery around the registration body. Stateless RFC 7591 wire-shape primitives live in pkg/oauthproto.

API surface

ResolveCredentials takes a profile-neutral Request and a CredentialStore and returns a Resolution. The Request carries only the fields the resolver actually reads (issuer, redirect URI, scopes, discovery URL or registration endpoint, optional explicit endpoint overrides, optional initial access token, optional registration metadata); each consumer translates its domain types into a Request at the call site so the resolver does not import any consumer's shapes.

Two consumers exist today:

  • pkg/authserver/runner constructs a Request from *authserver.OAuth2UpstreamRunConfig and uses its own adapter helpers (needsDCR / consumeResolution / applyResolutionToOAuth2Config in runner/dcr_adapter.go) to fold the resolution back into its run-config and built upstream.OAuth2Config.
  • pkg/auth/discovery constructs a Request from *OAuthFlowConfig for the CLI OAuth flow and copies the returned Resolution onto OAuthFlowResult.

Concurrency

The package maintains a process-global singleflight keyed on the tuple (issuer, redirectURI, scopesHash) so concurrent ResolveCredentials calls across all consumers in a single process coalesce when their cache keys match. Consumers that share any of those three values will share a flight — the deduplication is a feature for the embedded authserver but means callers cannot assume per-call-site flight isolation. See the dcrFlight doc comment below for the rationale.

See issue #5145 for the design discussion that motivated lifting this out of pkg/authserver/runner.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func LogStepError

func LogStepError(upstreamName string, err error)

LogStepError emits the single boundary slog.Error record for a DCR resolver failure, carrying the step / issuer / redirect_uri attributes extracted from err. If err is not a *dcrStepError, it is logged with a generic "unknown" step — ResolveCredentials always wraps its errors, so this branch indicates a programming error in a future caller rather than a runtime condition. err == nil is a no-op so this function is safe to call without an outer guard.

Every wrapped error is passed through SanitizeErrorForLog to strip URL query parameters that could plausibly contain sensitive tokens (defense in depth — the current DCR flow sends the initial access token as an Authorization header, not a query parameter, but nothing in the type system prevents a future refactor from doing otherwise).

func SanitizeErrorForLog

func SanitizeErrorForLog(err error) string

SanitizeErrorForLog strips secret-bearing components from any URLs embedded in err's message. The Go HTTP client, url.Error, and other net/* wrappers embed the full request URL — including userinfo, query, and fragment — in their error strings. Any of those can carry credentials or tokens (e.g. https://user:pass@host, ?token=…, implicit-flow callbacks #access_token=…); the current DCR flow does not embed any of them today, but stripping them here is defense in depth that protects the log regardless of future changes.

Scheme, host, and path are preserved so operators retain enough context to correlate with upstream server logs. Trailing sentence punctuation adjacent to the URL (e.g. the comma in "reaching URL?q=1, retrying") is preserved — see trimURLTrailingPunctuation for the list of characters considered terminators.

Scope: the regex matches http://, https://, redis://, and rediss:// schemes (case-insensitively per RFC 3986 §3.1). The redis schemes are included because the embedded-authserver DCR path persists through pkg/authserver/storage/redis.go and a redis-go error chain on the Get/Put critical path can embed a sentinel/cluster URL with credentials. Other schemes (file://, postgres://, smtp://, raw host:port) are NOT sanitised; if a future call site flows error strings from one of those into LogStepError, extend the regex here rather than re-implementing the sanitiser at the call site.

IMPORTANT — caller responsibility: this function strips credentials only from the schemes listed above. Callers that may receive errors containing other credential-bearing URL schemes MUST verify those URLs are not credential-bearing before logging, or sanitise them separately.

Types

type CloseableCredentialStore

type CloseableCredentialStore interface {
	CredentialStore
	io.Closer
}

CloseableCredentialStore is a CredentialStore that also releases an underlying resource (typically a background goroutine) when closed. NewInMemoryStore returns this interface so callers can call Close() directly without a runtime type assertion — Close() being part of the returned type at compile time prevents the silent-no-op failure mode that .claude/rules/go-style.md warns about ("Never define a local anonymous interface inside an option and type-assert against it to check capability — a silent no-op results if the target doesn't implement it.").

NewStorageBackedStore returns the base CredentialStore because its backends (storage.MemoryStorage shared across the authserver, storage.RedisStorage) are owned by the authserver runner and closed through that lifecycle, not by the dcr package.

func NewInMemoryStore

func NewInMemoryStore() CloseableCredentialStore

NewInMemoryStore returns a CloseableCredentialStore whose entries live only for the lifetime of the returned value. This is the constructor consumers reach for when they have no shared durable backend — most notably the CLI OAuth flow, which manages cross-invocation credential persistence outside the resolver (in pkg/auth/remote/handler.go's CachedClientID / CachedClientSecretRef fields) and only needs the resolver's intra-call singleflight + S256 PKCE / expiry-refetch behaviour for one PerformOAuthFlow call.

The implementation delegates to storage.NewMemoryStorage to share the same Get/Put/scope-hash semantics as the durable backends, including the background cleanup goroutine. Callers that need to release that goroutine before process exit MUST call Close on the returned value when finished — the return type is CloseableCredentialStore precisely so the call site can `defer store.Close()` without a runtime type-assertion. (CLI flows that complete in a single invocation can also rely on process exit.)

type CredentialStore

type CredentialStore interface {
	// Get returns the cached resolution for key, or (nil, false, nil) if the
	// key is not present. An error is returned only on backend failure.
	Get(ctx context.Context, key Key) (*Resolution, bool, error)

	// Put stores the resolution for key, overwriting any existing entry.
	// Implementations must reject a nil resolution with an error rather
	// than silently succeeding — a no-op would leave callers with no
	// debug trail for the subsequent Get miss.
	Put(ctx context.Context, key Key, resolution *Resolution) error
}

CredentialStore caches RFC 7591 Dynamic Client Registration resolutions keyed by the (Issuer, RedirectURI, ScopesHash) tuple. Implementations must be safe for concurrent use.

This is the runner-facing interface used by the DCR resolver. It is a narrow re-projection of storage.DCRCredentialStore that exchanges *Resolution values (the resolver's working type) instead of *storage.DCRCredentials so the resolver internals stay agnostic to the persistence layer's exact field shape.

Implementations in this package are thin adapters around a storage.DCRCredentialStore — the durable map / Redis hash lives over there, and this interface adds a per-call Resolution <-> DCRCredentials translation. There is exactly one persistence implementation per backend: storage.MemoryStorage and storage.RedisStorage. See NewStorageBackedStore for the adapter.

func NewStorageBackedStore

func NewStorageBackedStore(backend storage.DCRCredentialStore) CredentialStore

NewStorageBackedStore returns a CredentialStore that delegates to a storage.DCRCredentialStore for durable persistence and translates Resolution values into DCRCredentials at the boundary. The returned store is safe for concurrent use because the underlying storage.DCRCredentialStore must be (per its interface contract).

Panics if backend is nil — a nil backend is unambiguously a programming error and silent acceptance would only delay the eventual nil-pointer dereference to the first Get/Put call, far from the constructor site.

type Key

type Key = storage.DCRKey

Key is a re-export of storage.DCRKey, kept as a package-local alias so callers in this package can reference the canonical cache key without an explicit storage. qualifier on every call site, while the canonical definition lives in pkg/authserver/storage. The canonical form (and its ScopesHash constructor) MUST live in a single place so any future Redis backend hashes keys identically to the in-memory backend; see storage.DCRKey for the field documentation.

type Request

type Request struct {
	// Issuer is the caller's logical scope for cache keying and (when
	// RedirectURI is empty) the basis for the default redirect URI
	// ({Issuer}/oauth/callback). Required.
	//
	// What each consumer puts here:
	//
	//   - Embedded authserver: its own issuer identifier. The authserver
	//     owns a distinct logical identity from the upstream IdP, and
	//     defaulting RedirectURI to {Issuer}/oauth/callback lands the
	//     callback on the authserver's origin (correct).
	//   - CLI OAuth flow: the upstream OAuth provider's issuer URL,
	//     because the CLI has no separate logical issuer of its own. The
	//     CLI always supplies an explicit loopback RedirectURI per
	//     RFC 8252 §7.3, so the redirect-URI defaulting is never
	//     exercised on this path.
	//
	// The resolver's cache key is (Issuer, RedirectURI, ScopesHash); the
	// two consumers do not collide on the key because the embedded
	// authserver's RedirectURI lives on the authserver's origin and the
	// CLI's lives on a loopback (RFC 8252 §7.3) — even when Issuer
	// happens to match between the two profiles, the RedirectURI
	// component keeps the cache key distinct. See the cross-consumer
	// caveat on dcrFlight in resolver.go for the wider invariant.
	//
	// Issuer is *not* used for RFC 8414 §3.3 metadata verification —
	// that uses an issuer recovered from DiscoveryURL inside the
	// resolver.
	Issuer string

	// RedirectURI is the redirect URI to register with the upstream. When
	// empty, the resolver derives {Issuer}/oauth/callback. HTTPS is required
	// except for loopback hosts (RFC 8252 §7.3 — the CLI flow uses
	// http://localhost:{port}/callback).
	RedirectURI string

	// Scopes are the OAuth scopes to request in the registration body.
	// May be empty; the resolver will fall back to discovered scopes if the
	// upstream advertises any.
	Scopes []string

	// DiscoveryURL points at the upstream's RFC 8414 / OIDC Discovery
	// document. The resolver fetches this URL exactly once and reads
	// registration_endpoint, authorization_endpoint, token_endpoint,
	// token_endpoint_auth_methods_supported, scopes_supported, and
	// code_challenge_methods_supported from it.
	//
	// Mutually exclusive with RegistrationEndpoint.
	DiscoveryURL string

	// RegistrationEndpoint is used directly when set, bypassing discovery.
	// On this branch the caller is expected to also supply AuthorizationEndpoint
	// and TokenEndpoint explicitly; the resolver's auth-method selection
	// defaults to client_secret_basic because no server-capability fields
	// are available.
	//
	// Mutually exclusive with DiscoveryURL.
	RegistrationEndpoint string

	// AuthorizationEndpoint, when non-empty, overrides any value discovered
	// via DiscoveryURL. Explicit caller configuration always wins.
	AuthorizationEndpoint string

	// TokenEndpoint, when non-empty, overrides any value discovered via
	// DiscoveryURL. Explicit caller configuration always wins.
	TokenEndpoint string

	// InitialAccessToken is the RFC 7591 initial access token presented to
	// the registration endpoint as a Bearer header. The caller resolves any
	// file-or-env reference before populating this field.
	InitialAccessToken string

	// ClientName is sent as the RFC 7591 "client_name" registration
	// metadata. When empty, the resolver uses
	// oauthproto.ToolHiveMCPClientName.
	ClientName string

	// PublicClient, when true, instructs the resolver to register as a
	// public PKCE client (token_endpoint_auth_method=none) regardless of
	// what other methods the upstream advertises. This matches the CLI
	// flow's intent (RFC 8252 §8.4 native public clients) and the
	// resolver still enforces the RFC 7636 / OAuth 2.1 S256 PKCE gate:
	// when the upstream's discovery metadata does not advertise S256 in
	// code_challenge_methods_supported, the registration is refused with
	// a clear error rather than silently downgrading.
	//
	// When false (the embedded-authserver default), the resolver picks
	// the strongest auth method the upstream advertises (private_key_jwt
	// > client_secret_basic > client_secret_post > none, with the same
	// S256 gate on "none").
	//
	// Has no effect on the RegistrationEndpoint-direct branch when the
	// caller has not also supplied a DiscoveryURL: without
	// code_challenge_methods_supported the S256 gate cannot be evaluated,
	// so the resolver refuses to register as a public client.
	PublicClient bool
}

Request is the profile-neutral input to ResolveCredentials. Each consumer (embedded authserver, CLI OAuth flow) translates its domain types into a Request at the call site so the resolver does not import any consumer's shapes.

The struct collects exactly the fields the resolver reads:

  • Identity: Issuer (the caller's logical scope — see the Issuer field doc for what each consumer puts there), Scopes, and RedirectURI.
  • Endpoint discovery: DiscoveryURL or RegistrationEndpoint, with optional explicit AuthorizationEndpoint / TokenEndpoint overrides.
  • Registration metadata: InitialAccessToken, ClientName. The caller is responsible for resolving any file-or-env reference (e.g. the embedded authserver's InitialAccessTokenFile / InitialAccessTokenEnvVar) into InitialAccessToken before constructing a Request.

Mutually exclusive constraints are enforced at validation time:

  • Exactly one of DiscoveryURL / RegistrationEndpoint must be non-empty.
  • Issuer must be non-empty.

Constructing a Request is the caller's responsibility; the resolver does not clone or mutate it.

type Resolution

type Resolution struct {
	// ClientID is the RFC 7591 "client_id" returned by the authorization
	// server.
	ClientID string

	// ClientSecret is the RFC 7591 "client_secret" returned by the
	// authorization server. Empty for public PKCE clients.
	ClientSecret string

	// AuthorizationEndpoint is the discovered (or configured) authorization
	// endpoint for this upstream.
	AuthorizationEndpoint string

	// TokenEndpoint is the discovered (or configured) token endpoint for this
	// upstream.
	TokenEndpoint string

	// RegistrationAccessToken is the RFC 7592 "registration_access_token"
	// required for subsequent registration management operations (update,
	// read, delete).
	RegistrationAccessToken string

	// RegistrationClientURI is the RFC 7592 "registration_client_uri" for
	// registration management operations.
	RegistrationClientURI string

	// TokenEndpointAuthMethod is the authentication method negotiated at the
	// token endpoint for this client.
	TokenEndpointAuthMethod string

	// RedirectURI is the redirect URI presented to the authorization server
	// during registration. When the caller's Request did not specify one,
	// this holds the defaulted value derived from the issuer + /oauth/callback
	// (via resolveUpstreamRedirectURI). Persisting it on the resolution lets
	// consumers (pkg/authserver/runner.consumeResolution) write it back onto
	// the run-config COPY so that downstream code (buildPureOAuth2Config,
	// upstream.OAuth2Config validation) sees a non-empty RedirectURI.
	RedirectURI string

	// ClientIDIssuedAt is the RFC 7591 §3.2.1 "client_id_issued_at" value
	// converted to a Go time.Time. Zero when the upstream omitted the field
	// (the field is OPTIONAL per RFC 7591). Informational; not used to
	// invalidate the cache.
	ClientIDIssuedAt time.Time

	// ClientSecretExpiresAt is the RFC 7591 §3.2.1 "client_secret_expires_at"
	// value converted to a Go time.Time. The wire convention is that 0 means
	// "the secret does not expire"; in this struct that is represented by
	// the zero time.Time so callers can use IsZero() rather than special-
	// casing 0.
	//
	// When non-zero, this field is the authoritative signal that
	// lookupCachedResolution uses to refetch credentials before the upstream
	// rejects them at the token endpoint. The 90-day dcrStaleAgeThreshold
	// is a heuristic for "consider rotating"; this is a hard expiry asserted
	// by the upstream itself.
	ClientSecretExpiresAt time.Time

	// CreatedAt is the wall-clock time at which the resolution was completed.
	// Used by the staleness observability in lookupCachedResolution to
	// compute age against dcrStaleAgeThreshold and emit a warn log when a
	// cached registration exceeds the threshold.
	CreatedAt time.Time
}

Resolution captures the full RFC 7591 + RFC 7592 response for a successful Dynamic Client Registration, together with the endpoints the upstream advertises so the caller need not re-discover them.

The struct is the unit of storage in CredentialStore and the unit of application that consumers project back into their own domain types (e.g. consumeResolution / applyResolutionToOAuth2Config in pkg/authserver/runner/dcr_adapter.go, or direct OAuthFlowResult field writes in pkg/auth/discovery).

MUST update both converters (resolutionToCredentials and credentialsToResolution in store.go) when adding, renaming, or removing a field here. The two converters are the seam between this dcr-package type and the persisted *storage.DCRCredentials shape; a field added here without a paired converter update will silently fail to round-trip across an authserver restart, the exact "parallel types drift" failure mode .claude/rules/go-style.md warns about. The round-trip behaviour is pinned by TestResolutionCredentialsRoundTrip in store_test.go.

func ResolveCredentials

func ResolveCredentials(
	ctx context.Context,
	req *Request,
	cache CredentialStore,
) (*Resolution, error)

ResolveCredentials performs Dynamic Client Registration for req against the upstream authorization server identified by req.DiscoveryURL or req.RegistrationEndpoint, caching the resulting credentials in cache. On cache hit the resolver returns immediately without any network I/O.

req.Issuer is *this caller's* logical issuer identifier, NOT the upstream's. It is used to key the cache and to default the redirect URI to {req.Issuer}/oauth/callback when req.RedirectURI is empty. The upstream's issuer is recovered separately from req.DiscoveryURL inside the resolver and is used solely for RFC 8414 §3.3 metadata verification. Passing the upstream's issuer in req.Issuer would produce a wrong-origin default redirect and a cache key that does not identify the caller context.

The resolver does not mutate req or the cache on failure.

Observability: this function never calls slog.Error directly — all failures are annotated with a *dcrStepError and returned to the caller, which is expected to emit the boundary Error record. This avoids the double-logging pattern where both the resolver and the outer frame report the same failure. Cache-hit Debug / stale-Warn logs and the successful-registration Debug log are emitted here because they have no outer-frame equivalent. No secret values (client_secret, registration_access_token, initial_access_token) are ever logged — only public metadata such as client_id and redirect_uri.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL