Skip to content

Commit f180fa9

Browse files
authored
Migrate CLI OAuth flow to pkg/auth/dcr resolver (#5250)
* Migrate CLI OAuth flow to pkg/auth/dcr resolver 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. * Address code review feedback 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. * Address iteration-2 code review feedback 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. * Drop CI guard against direct RegisterClientDynamically calls 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. * Tighten DCR resolver defense-in-depth from PR review Addresses #5250 review comments: - MEDIUM pkg/auth/dcr/resolver.go (3221902433): include PublicClient in storage.DCRKey, flightKeyOf, and the Redis key serialisation so public and confidential registrations cannot share a cache entry under any Issuer/RedirectURI/ScopesHash collision. - MEDIUM pkg/auth/dcr/resolver.go (3221902453): apply validateUpstreamEndpointURL to explicit authorization_endpoint / token_endpoint overrides; propagate the error via dcrStepMetadata. - MEDIUM pkg/authserver/runner/dcr_adapter.go (3221902473): add a tripwire test that pins the two-call invariant — calling only consumeResolution must leave the built upstream.OAuth2Config's ClientSecret empty. - MEDIUM pkg/authserver/storage/types.go (body): rewrite DCRKey.Issuer doc to describe both consumer profiles (embedded authserver's own issuer vs CLI's upstream issuer) and the cache-key non-collision invariant. - MEDIUM pkg/auth/dcr/resolver.go (body): extend queryStrippingPattern / SanitizeErrorForLog to also match redis(s):// URLs; covers the persistent CredentialStore error-chain surface on the embedded-authserver path. - LOW pkg/auth/discovery/discovery.go (3221902485): log the store.Close() error at debug instead of dropping it; rationale in the deferred-Close block. - LOW pkg/authserver/runner/dcr_adapter_test.go (3221902504): add an env-var case to TestNewDCRRequest pinning the InitialAccessTokenEnvVar wiring end-to-end. * Fix spell-check failure on "Get's" in store_test.go The doc comment for TestInMemoryStore_PutRejectsNilResolution used a possessive apostrophe where the plain noun is correct. * Drop PublicClient from DCR cache and flight keys The original PublicClient-in-keys fix was defense-in-depth against a collision that today's two consumers cannot reach: the embedded authserver registers on AS-origin redirect URIs and the CLI registers on RFC 8252 loopback redirect URIs, and the two address spaces are disjoint. RedirectURI alone separates the public-client and confidential-client profiles at both the persistent-cache and singleflight layers. Encoding PublicClient additionally would invalidate every existing Redis-cached entry across a deployment without buying additional protection. Drop it from DCRKey and flightKeyOf and document the RedirectURI-disjointness invariant alongside the migration condition for a hypothetical future consumer that brings the two address spaces into collision. * Correct stale DCR doc comments from PR review Addresses #5250 review comments: - LOW pkg/auth/discovery/discovery.go (3233521790): handleDynamicRegistration claimed HasCachedClientCredentials enforces cross-invocation expiry, but the gate only checks CachedClientID != "" and does not consult CachedSecretExpiry. Rewrite the option-(b) doc block to acknowledge that cross-invocation expiry is unhandled today and that tightening the gate is open follow-up work. - LOW pkg/auth/dcr/resolver.go (3233521799): replace two stale "Step 2g" forward-references with prose describing today's reality — staleness observability already lives in lookupCachedResolution (no separate sub- issue tracks it), and the "no retry loop" comment now spells out that any future retry/backoff belongs above ResolveCredentials.
1 parent 88667f7 commit f180fa9

17 files changed

Lines changed: 1873 additions & 837 deletions

pkg/auth/dcr/request.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dcr
5+
6+
// Request is the profile-neutral input to ResolveCredentials. Each consumer
7+
// (embedded authserver, CLI OAuth flow) translates its domain types into a
8+
// Request at the call site so the resolver does not import any consumer's
9+
// shapes.
10+
//
11+
// The struct collects exactly the fields the resolver reads:
12+
//
13+
// - Identity: Issuer (the caller's logical scope — see the Issuer field
14+
// doc for what each consumer puts there), Scopes, and RedirectURI.
15+
// - Endpoint discovery: DiscoveryURL or RegistrationEndpoint, with optional
16+
// explicit AuthorizationEndpoint / TokenEndpoint overrides.
17+
// - Registration metadata: InitialAccessToken, ClientName. The caller is
18+
// responsible for resolving any file-or-env reference (e.g. the embedded
19+
// authserver's InitialAccessTokenFile / InitialAccessTokenEnvVar) into
20+
// InitialAccessToken before constructing a Request.
21+
//
22+
// Mutually exclusive constraints are enforced at validation time:
23+
//
24+
// - Exactly one of DiscoveryURL / RegistrationEndpoint must be non-empty.
25+
// - Issuer must be non-empty.
26+
//
27+
// Constructing a Request is the caller's responsibility; the resolver does
28+
// not clone or mutate it.
29+
type Request struct {
30+
// Issuer is the caller's logical scope for cache keying and (when
31+
// RedirectURI is empty) the basis for the default redirect URI
32+
// ({Issuer}/oauth/callback). Required.
33+
//
34+
// What each consumer puts here:
35+
//
36+
// - Embedded authserver: its own issuer identifier. The authserver
37+
// owns a distinct logical identity from the upstream IdP, and
38+
// defaulting RedirectURI to {Issuer}/oauth/callback lands the
39+
// callback on the authserver's origin (correct).
40+
// - CLI OAuth flow: the upstream OAuth provider's issuer URL,
41+
// because the CLI has no separate logical issuer of its own. The
42+
// CLI always supplies an explicit loopback RedirectURI per
43+
// RFC 8252 §7.3, so the redirect-URI defaulting is never
44+
// exercised on this path.
45+
//
46+
// The resolver's cache key is (Issuer, RedirectURI, ScopesHash); the
47+
// two consumers do not collide on the key because the embedded
48+
// authserver's RedirectURI lives on the authserver's origin and the
49+
// CLI's lives on a loopback (RFC 8252 §7.3) — even when Issuer
50+
// happens to match between the two profiles, the RedirectURI
51+
// component keeps the cache key distinct. See the cross-consumer
52+
// caveat on dcrFlight in resolver.go for the wider invariant.
53+
//
54+
// Issuer is *not* used for RFC 8414 §3.3 metadata verification —
55+
// that uses an issuer recovered from DiscoveryURL inside the
56+
// resolver.
57+
Issuer string
58+
59+
// RedirectURI is the redirect URI to register with the upstream. When
60+
// empty, the resolver derives {Issuer}/oauth/callback. HTTPS is required
61+
// except for loopback hosts (RFC 8252 §7.3 — the CLI flow uses
62+
// http://localhost:{port}/callback).
63+
RedirectURI string
64+
65+
// Scopes are the OAuth scopes to request in the registration body.
66+
// May be empty; the resolver will fall back to discovered scopes if the
67+
// upstream advertises any.
68+
Scopes []string
69+
70+
// DiscoveryURL points at the upstream's RFC 8414 / OIDC Discovery
71+
// document. The resolver fetches this URL exactly once and reads
72+
// registration_endpoint, authorization_endpoint, token_endpoint,
73+
// token_endpoint_auth_methods_supported, scopes_supported, and
74+
// code_challenge_methods_supported from it.
75+
//
76+
// Mutually exclusive with RegistrationEndpoint.
77+
DiscoveryURL string
78+
79+
// RegistrationEndpoint is used directly when set, bypassing discovery.
80+
// On this branch the caller is expected to also supply AuthorizationEndpoint
81+
// and TokenEndpoint explicitly; the resolver's auth-method selection
82+
// defaults to client_secret_basic because no server-capability fields
83+
// are available.
84+
//
85+
// Mutually exclusive with DiscoveryURL.
86+
RegistrationEndpoint string
87+
88+
// AuthorizationEndpoint, when non-empty, overrides any value discovered
89+
// via DiscoveryURL. Explicit caller configuration always wins.
90+
AuthorizationEndpoint string
91+
92+
// TokenEndpoint, when non-empty, overrides any value discovered via
93+
// DiscoveryURL. Explicit caller configuration always wins.
94+
TokenEndpoint string
95+
96+
// InitialAccessToken is the RFC 7591 initial access token presented to
97+
// the registration endpoint as a Bearer header. The caller resolves any
98+
// file-or-env reference before populating this field.
99+
InitialAccessToken string
100+
101+
// ClientName is sent as the RFC 7591 "client_name" registration
102+
// metadata. When empty, the resolver uses
103+
// oauthproto.ToolHiveMCPClientName.
104+
ClientName string
105+
106+
// PublicClient, when true, instructs the resolver to register as a
107+
// public PKCE client (token_endpoint_auth_method=none) regardless of
108+
// what other methods the upstream advertises. This matches the CLI
109+
// flow's intent (RFC 8252 §8.4 native public clients) and the
110+
// resolver still enforces the RFC 7636 / OAuth 2.1 S256 PKCE gate:
111+
// when the upstream's discovery metadata does not advertise S256 in
112+
// code_challenge_methods_supported, the registration is refused with
113+
// a clear error rather than silently downgrading.
114+
//
115+
// When false (the embedded-authserver default), the resolver picks
116+
// the strongest auth method the upstream advertises (private_key_jwt
117+
// > client_secret_basic > client_secret_post > none, with the same
118+
// S256 gate on "none").
119+
//
120+
// Has no effect on the RegistrationEndpoint-direct branch when the
121+
// caller has not also supplied a DiscoveryURL: without
122+
// code_challenge_methods_supported the S256 gate cannot be evaluated,
123+
// so the resolver refuses to register as a public client.
124+
PublicClient bool
125+
}

0 commit comments

Comments
 (0)