Migrate CLI OAuth flow to pkg/auth/dcr resolver#5250
Conversation
Sub-issue 4b of #5145. The CLI OAuth flow at pkg/auth/discovery::PerformOAuthFlow used to call oauthproto.RegisterClientDynamically directly, so it did not inherit the review-property behaviours added during #5042 (S256 PKCE gating, RFC 7591 §3.2.1 expiry-driven refetch, bearer-token transport with redirect refusal, panic recovery, singleflight deduplication). This commit routes that call site through the shared pkg/auth/dcr resolver introduced in sub-issue 4a (PR #5198) and pins the invariant with a CI grep guard. Profile-neutral resolver input: pkg/auth/dcr now exposes a Request struct that carries exactly the fields the resolver reads (issuer, redirect URI, scopes, discovery URL or registration endpoint, optional explicit endpoint overrides, initial access token, client name, public-client flag). ResolveCredentials takes a Request and no longer imports authserver / upstream domain types. The embedded-authserver adapter helpers (needsDCR, consumeResolution, applyResolutionToOAuth2Config) move to pkg/authserver/runner/dcr_adapter.go where they belong by ownership. CLI persistence model: option (b) from the issue. The resolver runs against an in-memory dcr.CredentialStore scoped to one PerformOAuthFlow invocation. Cross-invocation persistence is handled outside the resolver by pkg/auth/remote/handler.go's existing CachedClientID / CachedClientSecretRef fields, which already preserved cross-invocation reuse and continue to do so unchanged. Wrapping the secretProvider into a CredentialStore adapter (option (a)) was rejected as out-of-scope churn — the existing remote-handler caching is sufficient. PublicClient flag: a new bool on dcr.Request tells the resolver to register as a public PKCE client (token_endpoint_auth_method=none). The S256 gate still fires — the CLI surfaces a clear resolver error rather than silently downgrading when upstream advertises only "plain". Invariant guard: Taskfile target check-dcr-isolation (wired into task lint) and a matching CI step in .github/workflows/lint.yml fail if oauthproto.RegisterClientDynamically is referenced anywhere outside pkg/auth/dcr or pkg/oauthproto. Tests added for the CLI's inherited properties (S256 gating, redirect refusal, singleflight deduplication) in pkg/auth/discovery/dcr_resolver_test.go. The fallback error message for upstreams that omit registration_endpoint is preserved verbatim and pinned by TestHandleDynamicRegistration_MissingRegistrationEndpoint. Closes #5145.
Fixed issues from code review: - MEDIUM: Rewrote TestResolveSecret / TestResolveSecretWithEnvVar doc comments so they no longer point at the deleted dcr-package twin; the runner-side resolveSecret is now described as the single authoritative implementation. - MEDIUM: Documented the redundant discovery fetch in resolveDCRCredentials as an acknowledged trade-off (the S256 PKCE gate needs code_challenge_methods_supported, which AuthServerInfo does not carry). Threading that field through AuthServerInfo is the natural follow-up. - MEDIUM: Rewrote the dcr.Request.Issuer field doc to spell out what each consumer puts there and why the cache key cannot collide between the embedded authserver and CLI consumers. Added a matching call-site comment in resolveDCRCredentials. - MEDIUM: Introduced dcr.CloseableCredentialStore (embeds CredentialStore + io.Closer). NewInMemoryStore now returns it, so the CLI's defer store.Close() is compile-time safe — no more anonymous-interface type-assertion that would silently no-op on a future refactor. - MEDIUM: Reconciled the CLI flow's "expiry refetch inheritance" wording with what option (b) actually delivers. The handleDynamicRegistration doc and the dcr_resolver_test.go file-level comment now spell out that cross-invocation expiry handling lives in the remote handler, not the resolver, and that option (a) would close the loop as a follow-up. - LOW: The unreachable "fall back to RegistrationEndpoint-direct path" comment is replaced by an accurate description of when each branch fires.
Fixed issues from second-iteration review:
- MEDIUM: Made dcr.inMemoryStore.Close() idempotent via sync.Once.
storage.MemoryStorage.Close() closes a channel and is NOT itself
idempotent — the previous wrapper inherited that defect through an
incorrect doc comment ("Safe to call multiple times"). The wrapper now
delivers what the comment claims: a second Close returns the captured
error from the first call rather than panicking on
"close of closed channel". Pinned by TestInMemoryStore_CloseIsIdempotent
and TestInMemoryStore_CloseIsIdempotentUnderRace (8 concurrent callers).
- MEDIUM: Dropped the embedded storageBackedStore from inMemoryStore so
the type holds a single *storage.MemoryStorage handle instead of two
parallel handles (one via embedded interface, one concrete) that
required a manual "keep these two in sync" invariant. Get and Put are
implemented directly on inMemoryStore, delegating to s.mem — three
lines each, structurally guaranteed to share a backend with Close.
Pinned by TestInMemoryStore_PutGetCloseShareBackend,
TestInMemoryStore_PutRejectsNilResolution, and
TestInMemoryStore_GetMissingKeyReturnsMissTuple.
The Taskfile check-dcr-isolation grep guard and its workflow counterpart added extra surface area to enforce an architectural invariant. Removing both per reviewer preference; the resolver boundary remains enforced by code review alone.
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.
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: oauth-security, go-correctness, architecture, concurrency, test-coverage, general-code-quality (codex cross-review skipped — CLI not installed)
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | PublicClient absent from singleflight + cache keys |
9/10 | MEDIUM | Discuss |
| 2 | applyExplicitEndpointOverrides bypasses URL validation |
7/10 | MEDIUM | Fix |
| 3 | Two-call consumeResolution/applyResolutionToOAuth2Config invariant not type-enforced |
8/10 | MEDIUM | Discuss |
| 4 | SanitizeErrorForLog is http(s)-only — redis-go error chain |
7/10 | MEDIUM | Discuss |
| 5 | DCRKey.Issuer doc contradicts new CLI usage |
8/10 | MEDIUM | Fix |
| 6 | _ = store.Close() swallows error without comment/log |
7/10 | LOW | Fix |
| 7 | TestNewDCRRequest missing env-var IAT branch |
7/10 | LOW | Fix |
| 8 | TestConsumeResolution does not pin nil-input no-op |
6/10 | LOW | Optional |
| 9 | LogStepError "upstream" attribute shape varies per consumer |
7/10 | LOW | Discuss |
| 10 | Direct-registration + PublicClient=true error msg is misleading |
6/10 | LOW | Optional |
| 11 | Wall-clock ClientSecretExpiresAt comparison lacks skew tolerance |
6/10 | LOW | Optional |
| 12 | PR body still reads "DRAFT - not ready for review" | 7/10 | LOW | Fix |
| 13 | Option-(b) rationale duplicated across two function docs | 6/10 | LOW | Optional |
Overall
This is a well-executed migration of the CLI OAuth flow onto the shared pkg/auth/dcr resolver. The package boundary is structurally clean (resolver no longer imports any authserver/discovery types — verified), the new dcr.Request envelope is genuinely profile-neutral, and the new inherited-property tests (S256 gate, redirect refusal, singleflight dedup) pin exactly the security-relevant behaviours that motivated the migration. The PR description, doc comments, and orientation prose are unusually good — a cold reviewer can pick up the architectural picture in a single pass.
The consensus findings cluster around one theme: correctness invariants encoded in comments rather than types. The singleflight + cache keys do not include PublicClient, the two-call resolution invariant relies on review, the sanitiser is scheme-specific despite the generic name, and DCRKey.Issuer's field doc still claims "local issuer ... NOT the upstream's" even though the CLI now puts the upstream issuer there. Each one is a small refactor away from being structural. Current call sites do not exhibit the failure modes by construction; the risk is forward-looking, not active.
None of the findings rise to a hard blocker. Recommended priorities are #1 (PublicClient in keys), #3 (two-call consolidation), and #5 (DCRKey doc fix) — all cheap, all close defense-in-depth gaps the author already calls out in inline comments.
For consistency, please note: the authenticated reviewer and PR author are the same user, so this review uses COMMENT rather than REQUEST_CHANGES — this is not a draft-blocker assessment, it's an artefact of GitHub's policy.
Additional findings (file not in this PR's diff, or line outside diff hunks)
- [MEDIUM]
DCRKey.Issuerdoc drift —pkg/authserver/storage/types.go:122-130documentsIssueras "the local issuer of the embedded authorization server that performed the registration, NOT the upstream's". The new CLI call site atpkg/auth/discovery/discovery.go:712-713puts the upstream's issuer URL there. The newdcr.Request.Issuerdoc accommodates both consumers, but the underlying storage type's field doc still claims the old single-consumer semantic. UpdateDCRKey.Issuerto reflect the dual semantic, e.g. "For the embedded authserver this is its own (local) issuer; for the CLI flow this is the upstream's issuer because the CLI has no separate logical issuer of its own. The cache key (Issuer, RedirectURI, ScopesHash) keeps the two consumers' entries apart via the RedirectURI component." - [MEDIUM]
SanitizeErrorForLogis http(s)-only —pkg/auth/dcr/resolver.go:481-538(outside this PR's diff hunks). The function name reads generic, but the implementation only strips userinfo/query/fragment fromhttp(s)://URLs. On the embedded-authserver path,cache.Get/Puterrors fromstorage/redis.goflow intoLogStepError. If a future operator (or redis-go release) puts credentials in a sentinel/cluster URL, those credentials hit the slog.Error attribute. Either rename tosanitizeHTTPURLsso the scope is unambiguous, or extend the regex to also matchredis(s)?://. Add a small test asserting a redis-style error string with credentials is sanitised. - [LOW]
LogStepError"upstream" attribute is inconsistent per consumer —pkg/auth/dcr/resolver.go:452-479(outside this PR's diff hunks). Embedded-authserver path passesrc.Name(operator-defined short name); CLI path passesissuer(upstream issuer URL). Operators reading mixed logs see two different shapes under the same key. Either rename the parameter toupstreamIDand document it as opaque, or acceptslog.Attrpairs so callers can label ("upstream_name"vs"upstream_issuer"). - [LOW]
ClientSecretExpiresAtwall-clock comparison lacks skew tolerance —pkg/auth/dcr/resolver.go:641(outside this PR's diff hunks).time.Now().After(cached.ClientSecretExpiresAt)is exact. NTP step on resume or AS-clock-ahead skew leads to either serving an expired secret (already broken at the AS) or refetching too early. Considertime.Now().Add(30 * time.Second).After(cached.ClientSecretExpiresAt)as defense-in-depth. - [LOW] PR body still reads "DRAFT - not ready for review" — The PR is in draft state and the body header says "DRAFT - not ready for review". Since you are explicitly requesting a review now, either flip out of draft or remove the header so other reviewers landing on this PR aren't confused about its status.
Dropped findings appendix (consensus < 7)
The following observations were raised by individual agents but did not meet the consensus threshold. Listed for auditability only — no action requested:
- Regex
(?i)https?://[^\s"']+can match across a comma (security LOW, score 5). Failure mode is "did not sanitise", not corrupted output. selectTokenEndpointAuthMethodwith emptyserverSupported+publicClient=truetrusts upstream to accept "none" (security LOW, score 5).CloseableCredentialStoreloses Close capability when widened to base interface (architecture LOW, score 6). No occurrence in this PR.- Hardcoded
CallbackPort: 8765in CLI tests (test INFO, score 5). Tests don't bind the port — acceptable today. dcr.Keyis generically named (quality INFO, score 5).flightKeyOf(key)is a free function where a method would read more idiomatically (quality LOW, score 5).- Long doc comments on
Request/Resolutionpartially restate code (quality LOW, score 5). handleDynamicRegistrationdoc preamble is 26 lines for a 45-line body (quality LOW, score 5).ResolveCredentialstakes*Requestby pointer; could take by value (architecture LOW, score 5).PublicClient booltri-state suggestion to fail-loud on unset (security INFO, score 5).- CLI cross-invocation expiry refetch not in resolver's loop (architecture INFO, score 6) — explicitly documented as option-(b) trade-off.
Generated with Claude Code
| // either of those spaces would silently coalesce; if a third profile is | ||
| // added the flight key MUST gain a consumer-identifier component so a | ||
| // collision is impossible rather than improbable. | ||
| var dcrFlight singleflight.Group |
There was a problem hiding this comment.
[MEDIUM] PublicClient absent from singleflight and cache keys (Consensus: 9/10)
The flight key (and storage.DCRKey) is (Issuer, RedirectURI, ScopesHash). req.PublicClient is not part of either. The singleflight closure captures the leader's req and returns the leader's *Resolution to every follower — so two callers with different PublicClient values whose keys happen to match would receive the same registration. Today's two consumers don't collide by construction (CLI loopback redirect vs AS-origin redirect), but the comment two lines down already calls this out: "if a third profile is added the flight key MUST gain a consumer-identifier component so a collision is impossible rather than improbable."
The same gap also affects the persisted Redis-backed cache for the embedded authserver: if option-(a) (wrap the CLI's secret provider as a CredentialStore) ever lands, a CLI invocation could read a confidential-client resolution the AS persisted.
Recommendation: include PublicClient (or a typed ConsumerKind enum tag on dcr.Request) in both storage.DCRKey and flightKeyOf. Cost: one extra field + one extra concat. Benefit: the safety invariant becomes structural rather than review-only.
Raised by: oauth-security (HIGH), concurrency (MEDIUM), architecture (MEDIUM), go-correctness (LOW), code-quality (INFO)
| endpoints.authorizationEndpoint = rc.AuthorizationEndpoint | ||
| // from req when req specifies them. Explicit caller configuration always | ||
| // wins over discovery. | ||
| func applyExplicitEndpointOverrides(endpoints *dcrEndpoints, req *Request) { |
There was a problem hiding this comment.
[MEDIUM] applyExplicitEndpointOverrides bypasses validateUpstreamEndpointURL (Consensus: 7/10)
endpointsFromMetadata runs validateUpstreamEndpointURL against discovered authorization_endpoint / token_endpoint. This function then overwrites them with req.AuthorizationEndpoint / req.TokenEndpoint without re-validating. The validation contract documented on validateUpstreamEndpointURL claims "every point where an endpoint URL enters the resolver from outside" — the override path violates that promise.
Today's two callers (newDCRRequest, resolveDCRCredentials) populate these from already-validated sources, so this is defense-in-depth, not an active bug. But the cost to close the gap is trivial.
| func applyExplicitEndpointOverrides(endpoints *dcrEndpoints, req *Request) { | |
| func applyExplicitEndpointOverrides(endpoints *dcrEndpoints, req *Request) error { | |
| if req.AuthorizationEndpoint != "" { | |
| if err := validateUpstreamEndpointURL(req.AuthorizationEndpoint, "authorization_endpoint"); err != nil { | |
| return fmt.Errorf("dcr: explicit %w", err) | |
| } | |
| endpoints.authorizationEndpoint = req.AuthorizationEndpoint | |
| } | |
| if req.TokenEndpoint != "" { | |
| if err := validateUpstreamEndpointURL(req.TokenEndpoint, "token_endpoint"); err != nil { | |
| return fmt.Errorf("dcr: explicit %w", err) | |
| } | |
| endpoints.tokenEndpoint = req.TokenEndpoint | |
| } | |
| return nil | |
| } |
(Signature change requires updating the one caller at registerAndCache to propagate the error via newDCRStepError(dcrStepMetadata, …).)
Raised by: oauth-security
|
|
||
| if publicClient { | ||
| if !pkceS256Advertised { | ||
| return "", fmt.Errorf( |
There was a problem hiding this comment.
[LOW] Misleading error for direct-registration + PublicClient=true (Consensus: 6/10)
When a caller passes RegistrationEndpoint (no DiscoveryURL) and PublicClient=true, the resolver correctly refuses — but via this branch with the message "public client requested but upstream does not advertise S256 in code_challenge_methods_supported (got [])". Operators who never asked for discovery will look for a metadata document that doesn't exist in their config.
Recommendation: detect the PublicClient=true && RegistrationEndpoint != "" && DiscoveryURL == "" case earlier (in validateResolveInputs) and return: "PublicClient=true requires DiscoveryURL so the S256 PKCE gate can be evaluated; got RegistrationEndpoint only". The behaviour is correct today; only the diagnostic needs sharpening.
Raised by: oauth-security
| // fully-resolved DCR client. Forgetting the second call leaves | ||
| // ClientSecret empty and produces silent auth failures at request time — | ||
| // the type system does not enforce the pair, so the invariant lives here. | ||
| func applyResolutionToOAuth2Config(cfg *upstream.OAuth2Config, res *dcr.Resolution) { |
There was a problem hiding this comment.
[MEDIUM] Two-call invariant not type-enforced (Consensus: 8/10)
The doc on this function acknowledges the gap: "Forgetting the second call leaves ClientSecret empty and produces silent auth failures at request time — the type system does not enforce the pair, so the invariant lives here." The only call site (buildUpstreamConfigs) is correct, but a future call site that copies the consumeResolution pattern would silently produce a no-secret config — exactly the "silent no-op that breaks callers" pattern .claude/rules/go-style.md warns against.
Recommendations (pick one):
- Collapse into a single
applyDCRResolution(rc *OAuth2UpstreamRunConfig, cfg *upstream.OAuth2Config, res *dcr.Resolution)that writes everything atomically. - Keep the split but add a regression test that exercises only
consumeResolutionand assertscfg.ClientSecret == ""— a tripwire so any future code-shaped refactor has an explicit failure mode.
Raised by: architecture (MEDIUM), go-correctness (LOW)
| } | ||
|
|
||
| // getDiscoveryDocument retrieves the OIDC discovery document | ||
| // resolveDCRCredentials routes the CLI-flow DCR registration through the |
There was a problem hiding this comment.
[LOW] Option-(b) rationale duplicated across handleDynamicRegistration and resolveDCRCredentials (Consensus: 6/10)
Both function docs carry multi-paragraph explanations of the option-(b) persistence trade-off in overlapping vocabulary. They are not duplicates (one is about persistence, the other about the redundant discovery fetch), but they share substantial conceptual ground and reference the same sub-issue numbering. A future maintainer modifying option-(b) behaviour must update both.
Recommendation: pick this function (where the trade-off actually manifests) as the canonical site. In handleDynamicRegistration, summarise in 2-3 lines and link here.
Raised by: code-quality
| } | ||
|
|
||
| store := dcr.NewInMemoryStore() | ||
| defer func() { _ = store.Close() }() |
There was a problem hiding this comment.
[LOW] _ = store.Close() silently drops the error (Consensus: 7/10)
.claude/rules/go-style.md: "Comment ignored errors — explain why and typically log them." inMemoryStore.Close returns nil today, but a future change to storage.MemoryStorage.Close (e.g. timeout on cleanup goroutine teardown) would surface a real error and the CLI would drop it without a trace.
| defer func() { _ = store.Close() }() | |
| defer func() { | |
| if err := store.Close(); err != nil { | |
| slog.Debug("dcr: in-memory store close failed", "error", err) | |
| } | |
| }() |
Raised by: concurrency
| } | ||
| } | ||
|
|
||
| func TestConsumeResolution_RespectsExplicitEndpoints(t *testing.T) { |
There was a problem hiding this comment.
[LOW] TestConsumeResolution does not pin the nil-input no-op contract (Consensus: 6/10)
consumeResolution returns early on rc == nil || res == nil, but no test exercises that early-return. TestApplyResolutionToOAuth2Config does cover nil cfg and nil res (lines 145-147), so the asymmetry is conspicuous. A regression that dereferenced rc before the nil check would crash the embedded-authserver boot in a hard-to-diagnose way.
Recommendation: add a one-line subtest that calls consumeResolution(nil, &dcr.Resolution{}) and consumeResolution(&authserver.OAuth2UpstreamRunConfig{}, nil) and asserts no panic. Cheap, high-signal.
Raised by: test-coverage
| // TestNewDCRRequest covers the OAuth2UpstreamRunConfig → dcr.Request | ||
| // translation, including the file-based InitialAccessToken resolution that | ||
| // previously lived inside the resolver. | ||
| func TestNewDCRRequest(t *testing.T) { |
There was a problem hiding this comment.
[LOW] TestNewDCRRequest missing env-var InitialAccessToken branch (Consensus: 7/10)
The four cases cover file-based IAT, no IAT, nil run-config, and missing dcr_config. The env-var sibling (InitialAccessTokenEnvVar) is not exercised end-to-end through newDCRRequest → resolveSecret. The underlying resolveSecret is independently covered in embeddedauthserver_test.go::TestResolveSecretWithEnvVar, but the adapter-level wiring boundary is not pinned — a refactor that swapped the argument order or accidentally read the wrong field on DCRConfig would not be caught.
Recommendation: add a fifth table case that sets DCRConfig.InitialAccessTokenEnvVar via t.Setenv on a unique env-var name (drop t.Parallel() for that subcase, matching the TestResolveSecretWithEnvVar pattern) and asserts the resulting req.InitialAccessToken.
Raised by: test-coverage
DRAFT - not ready for review
Summary
Sub-issue 4b of #5145. The CLI OAuth flow in
pkg/auth/discovery::PerformOAuthFlowused to calloauthproto.RegisterClientDynamicallydirectly, so it bypassed the review-property behaviours added during #5042 (S256 PKCE gating, RFC 7591 §3.2.1 expiry-driven refetch, bearer-token transport with redirect refusal, panic recovery, singleflight deduplication). This PR routes that call site through the sharedpkg/auth/dcrresolver introduced in sub-issue 4a (PR #5198) so both consumers — the embedded authserver and the CLI — share the same hardened DCR path.To make the resolver genuinely profile-agnostic,
pkg/auth/dcrnow exposes aRequeststruct carrying exactly the fields the resolver reads. The embedded-authserver adapter helpers move intopkg/authserver/runner/dcr_adapter.gowhere they belong by ownership, andResolveCredentialsno longer imports authserver or upstream domain types.Closes #5145.
Type of change
Test plan
task test)task lint-fix)New tests in
pkg/auth/discovery/dcr_resolver_test.gocover the CLI's inherited properties:plain.(issuer, scopes, redirectURI)produce one/registercall.TestHandleDynamicRegistration_MissingRegistrationEndpointpins the verbatim "configure with--remote-auth-client-id/--remote-auth-client-secret" message.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.No operator API surface is touched. The
pkg/auth/dcrpackage was added in 4a (#5198) and has no external consumers; theResolveCredentialssignature change is internal.Changes
pkg/auth/dcr/request.goRequestinput type forResolveCredentials.pkg/auth/dcr/resolver.goResolveCredentialsnow takes*dcr.Request; no longer imports authserver / upstream.pkg/auth/dcr/store.goCloseableCredentialStoreinterface;NewInMemoryStorereturns it sodefer Close()is compile-time safe.pkg/auth/discovery/discovery.gohandleDynamicRegistrationbuilds adcr.Requestand callsdcr.ResolveCredentialsinstead ofoauthproto.RegisterClientDynamically. Loopback redirect URI (RFC 8252 §7.3) and CLI-flag fallback error preserved.pkg/auth/discovery/dcr_request.gopkg/auth/dcr.Request.pkg/auth/discovery/dcr_resolver_test.gopkg/authserver/runner/dcr_adapter.goneedsDCR,newDCRRequest,consumeResolution,applyResolutionToOAuth2Config) moved here frompkg/auth/dcr.pkg/authserver/runner/embeddedauthserver.go*dcr.Requestintodcr.ResolveCredentials.pkg/authserver/runner/dcr_store.gopkg/auth/dcr/store.go(4a follow-through).Does this introduce a user-facing change?
No. The CLI OAuth flow's observable behaviour is unchanged on the happy path. The new error paths (S256 gating, redirect refusal) are inherited security improvements that surface as clearer error messages when an upstream provider misbehaves.
Special notes for reviewers
Persistence model — option (b) from the issue. The resolver runs against an in-memory
dcr.CredentialStorescoped to onePerformOAuthFlowinvocation. Cross-invocation persistence is handled outside the resolver bypkg/auth/remote/handler.go's existingCachedClientID/CachedClientSecretReffields, which already preserved cross-invocation reuse and continue to do so unchanged. Option (a) — wrapping the secret provider as aCredentialStoreadapter — was rejected as out-of-scope churn; the trade-off is documented at the wiring site inhandleDynamicRegistration. A consequence worth flagging: cross-invocation expiry refetch lives in the remote handler, not in the resolver, on the CLI path. Closing that loop is a natural follow-up once option (a) is wired.PublicClient flag. A new bool on
dcr.Requesttells the resolver to register as a public PKCE client (token_endpoint_auth_method=none). The S256 gate still fires — the CLI surfaces a clear resolver error rather than silently downgrading when the upstream advertises onlyplainPKCE.Resolver invariant — code review only. The issue's acceptance criterion for a CI grep guard against direct
oauthproto.RegisterClientDynamicallycalls outsidepkg/auth/dcris intentionally out of scope for this PR. Atask check-dcr-isolationTaskfile target and matching workflow step were prototyped and then removed (commit fd66a1f) per reviewer preference. The resolver boundary is enforced by code review alone going forward.