From cf2e665ae35376d48d90a7a31185d71eff2911ca Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 11 Sep 2026 14:08:07 -0700 Subject: [PATCH 1/3] feat: gate the OAuth2 provider on CODER_OAUTH2_PROVIDER_ENABLE Move every OAuth2 provider gate off the oauth2 experiment and onto the deployment flag added in the previous commit. The flag is off by default, so a deployment that relied on CODER_EXPERIMENTS=oauth2 must set it or its OAuth2 clients receive 404 responses after upgrading. - Add httpmw.RequireOAuth2Provider, which answers 404 while the provider is disabled. A disabled provider looks like a deployment that never had one. There is no bypass for development builds. - Use it on both discovery documents, the /oauth2 tree, the admin apps API, and the MCP HTTP endpoint, which keeps its mcp-server-http experiment check. The AI Gateway's deprecated MCP injection reads the flag too. - Keep /api/v2/oauth2-provider/settings reachable while disabled so an admin can configure dynamic client registration before enabling the provider. - Gate both OAuth2 navigation items on the new build info field instead of the experiment, and drop the development-build escape hatch. The oauth2 experiment is still parsed and reported; a follow-up makes it a no-op with a specific warning. Part of PLAT-492. --- cli/testdata/server-config.yaml.golden | 4 +- coderd/aibridged.go | 23 ++-- coderd/aibridgedserver/aibridgedserver.go | 14 ++- .../aibridgedserver/aibridgedserver_test.go | 42 ++++--- coderd/coderd.go | 23 +++- coderd/httpmw/oauth2provider.go | 26 +++++ coderd/httpmw/oauth2provider_test.go | 46 ++++++++ .../oauth2provider/provider_disabled_test.go | 110 ++++++++++++++++++ codersdk/deployment.go | 2 +- enterprise/coderd/aibridgeserve.go | 23 ++-- .../oauth2providersettings_audit_test.go | 4 + .../modules/management/DeploymentSidebar.tsx | 4 +- .../DeploymentSidebarView.stories.tsx | 15 ++- .../management/DeploymentSidebarView.tsx | 17 +-- .../UserSettingsPage/Sidebar.stories.tsx | 22 ++++ site/src/pages/UserSettingsPage/Sidebar.tsx | 6 +- site/src/testHelpers/storybook.tsx | 2 + 17 files changed, 309 insertions(+), 74 deletions(-) create mode 100644 coderd/httpmw/oauth2provider.go create mode 100644 coderd/httpmw/oauth2provider_test.go create mode 100644 coderd/oauth2provider/provider_disabled_test.go create mode 100644 site/src/pages/UserSettingsPage/Sidebar.stories.tsx diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 66196ed24a1..5f9a10381ac 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -921,8 +921,8 @@ ai_gateway: enabled: true # Deprecated: Injected MCP in AI Gateway is deprecated and will be removed in a # future release. Whether to inject Coder's MCP tools into intercepted AI Gateway - # requests (requires the "oauth2" and "mcp-server-http" experiments to be - # enabled). + # requests (requires CODER_OAUTH2_PROVIDER_ENABLE and the "mcp-server-http" + # experiment to be enabled). # (default: false, type: bool) inject_coder_mcp_tools: false # Length of time to retain data such as interceptions and all related records diff --git a/coderd/aibridged.go b/coderd/aibridged.go index c70e20d8c2c..890f6d73850 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -66,17 +66,18 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai mux := drpcmux.New() srv, err := aibridgedserver.NewServer(api.ctx, aibridgedserver.Options{ - Store: api.Database, - Pubsub: api.Pubsub, - AISeatTracker: api.AISeatTracker, - Enqueuer: api.NotificationsEnqueuer, - AccessURL: api.AccessURL.String(), - GatewayCfg: api.DeploymentValues.AI.BridgeConfig, - ExternalAuthConfigs: api.ExternalAuthConfigs, - Experiments: api.Experiments, - Logger: api.Logger.Named("aibridgedserver"), - Clock: api.Clock, - Metrics: api.AIGatewayServerMetrics, + Store: api.Database, + Pubsub: api.Pubsub, + AISeatTracker: api.AISeatTracker, + Enqueuer: api.NotificationsEnqueuer, + AccessURL: api.AccessURL.String(), + GatewayCfg: api.DeploymentValues.AI.BridgeConfig, + ExternalAuthConfigs: api.ExternalAuthConfigs, + Experiments: api.Experiments, + OAuth2ProviderEnabled: api.DeploymentValues.OAuth2.Provider.Enable.Value(), + Logger: api.Logger.Named("aibridgedserver"), + Clock: api.Clock, + Metrics: api.AIGatewayServerMetrics, }) if err != nil { return nil, err diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 02c96570348..6833d3795f6 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -142,6 +142,9 @@ type Options struct { GatewayCfg codersdk.AIBridgeConfig ExternalAuthConfigs []*externalauth.Config Experiments codersdk.Experiments + // OAuth2ProviderEnabled reports whether Coder's OAuth2 provider is on. + // The internal MCP server cannot be used without it. + OAuth2ProviderEnabled bool Logger slog.Logger Clock quartz.Clock @@ -182,7 +185,7 @@ func NewServer(lifecycleCtx context.Context, opts Options) (*Server, error) { if opts.GatewayCfg.InjectCoderMCPTools { opts.Logger.Warn(lifecycleCtx, "inject MCP tools option is deprecated and will be removed in a future release") - coderMCPConfig, err := getCoderMCPServerConfig(opts.Experiments, opts.AccessURL) + coderMCPConfig, err := getCoderMCPServerConfig(opts.Experiments, opts.OAuth2ProviderEnabled, opts.AccessURL) if err != nil { opts.Logger.Warn(lifecycleCtx, "failed to retrieve coder MCP server config, Coder MCP will not be available", slog.Error(err)) } @@ -1061,14 +1064,13 @@ func (s *Server) WatchAIProviders(_ *proto.WatchAIProvidersRequest, stream proto } // Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. -func getCoderMCPServerConfig(experiments codersdk.Experiments, accessURL string) (*proto.MCPServerConfig, error) { - // Both the MCP & OAuth2 experiments are currently required in order to use our - // internal MCP server. +func getCoderMCPServerConfig(experiments codersdk.Experiments, oauth2ProviderEnabled bool, accessURL string) (*proto.MCPServerConfig, error) { + // The internal MCP server needs the MCP experiment and the OAuth2 provider. if !experiments.Enabled(codersdk.ExperimentMCPServerHTTP) { return nil, xerrors.Errorf("%q experiment not enabled", codersdk.ExperimentMCPServerHTTP) } - if !experiments.Enabled(codersdk.ExperimentOAuth2) { - return nil, xerrors.Errorf("%q experiment not enabled", codersdk.ExperimentOAuth2) + if !oauth2ProviderEnabled { + return nil, xerrors.New("OAuth2 provider is disabled; set CODER_OAUTH2_PROVIDER_ENABLE=true") } u, err := url.JoinPath(accessURL, codermcp.MCPEndpoint) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 54c1a565a87..c55df5f843c 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -59,7 +59,7 @@ import ( ) var requiredExperiments = []codersdk.Experiment{ - codersdk.ExperimentMCPServerHTTP, codersdk.ExperimentOAuth2, + codersdk.ExperimentMCPServerHTTP, } // TestAuthorization validates the authorization logic. @@ -831,26 +831,29 @@ func TestGetMCPServerConfigs(t *testing.T) { name string disableCoderMCPInjection bool experiments codersdk.Experiments + oauth2ProviderEnabled bool externalAuthConfigs []*externalauth.Config expectCoderMCP bool expectedExternalMCP bool }{ { - name: "experiments not enabled", + name: "MCP experiment off, OAuth2 provider off", experiments: codersdk.Experiments{}, }, { - name: "MCP experiment enabled, not OAuth2", - experiments: codersdk.Experiments{codersdk.ExperimentMCPServerHTTP}, + name: "MCP experiment on, OAuth2 provider off", + experiments: requiredExperiments, }, { - name: "OAuth2 experiment enabled, not MCP", - experiments: codersdk.Experiments{codersdk.ExperimentOAuth2}, + name: "OAuth2 provider on, MCP experiment off", + experiments: codersdk.Experiments{}, + oauth2ProviderEnabled: true, }, { - name: "only internal MCP", - experiments: requiredExperiments, - expectCoderMCP: true, + name: "only internal MCP", + experiments: requiredExperiments, + oauth2ProviderEnabled: true, + expectCoderMCP: true, }, { name: "only external MCP", @@ -858,16 +861,18 @@ func TestGetMCPServerConfigs(t *testing.T) { expectedExternalMCP: true, }, { - name: "both internal & external MCP", - experiments: requiredExperiments, - externalAuthConfigs: externalAuthCfgs, - expectCoderMCP: true, - expectedExternalMCP: true, + name: "both internal & external MCP", + experiments: requiredExperiments, + oauth2ProviderEnabled: true, + externalAuthConfigs: externalAuthCfgs, + expectCoderMCP: true, + expectedExternalMCP: true, }, { name: "both internal & external MCP, but coder MCP tools not injected", disableCoderMCPInjection: true, experiments: requiredExperiments, + oauth2ProviderEnabled: true, externalAuthConfigs: externalAuthCfgs, expectCoderMCP: false, expectedExternalMCP: true, @@ -890,10 +895,11 @@ func TestGetMCPServerConfigs(t *testing.T) { GatewayCfg: codersdk.AIBridgeConfig{ InjectCoderMCPTools: serpent.Bool(!tc.disableCoderMCPInjection), }, - ExternalAuthConfigs: tc.externalAuthConfigs, - Experiments: tc.experiments, - Logger: logger, - Clock: quartz.NewReal(), + ExternalAuthConfigs: tc.externalAuthConfigs, + Experiments: tc.experiments, + OAuth2ProviderEnabled: tc.oauth2ProviderEnabled, + Logger: logger, + Clock: quartz.NewReal(), }) require.NoError(t, err) require.NotNil(t, srv) diff --git a/coderd/coderd.go b/coderd/coderd.go index f3799ff5a88..9f75eef8925 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1084,6 +1084,11 @@ func New(options *Options) *API { }) api.workspaceBuildOrchestrator.Start(api.ctx) + // The OAuth2 provider is opt-in. Read the flag on every request so the + // check can become a runtime setting later without changing call sites. + oauth2ProviderEnabled := func() bool { + return api.DeploymentValues.OAuth2.Provider.Enable.Value() + } apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ DB: options.Database, ActivateDormantUser: ActivateDormantUser(options.Logger, &api.Auditor, options.Database), @@ -1245,12 +1250,12 @@ func New(options *Options) *API { // OAuth2 metadata endpoint for RFC 8414 discovery r.Route("/.well-known/oauth-authorization-server", func(r chi.Router) { - r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2)) + r.Use(httpmw.RequireOAuth2Provider(oauth2ProviderEnabled)) r.Get("/*", api.oauth2AuthorizationServerMetadata()) }) // OAuth2 protected resource metadata endpoint for RFC 9728 discovery r.Route("/.well-known/oauth-protected-resource", func(r chi.Router) { - r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2)) + r.Use(httpmw.RequireOAuth2Provider(oauth2ProviderEnabled)) r.Get("/*", api.oauth2ProtectedResourceMetadata()) }) @@ -1259,7 +1264,7 @@ func New(options *Options) *API { // logging into Coder with an external OAuth2 provider. r.Route("/oauth2", func(r chi.Router) { r.Use( - httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), + httpmw.RequireOAuth2Provider(oauth2ProviderEnabled), // Every response from this tree may carry a credential, so none of // them may be retained by an intermediary cache. Mounted after // the gate, so a request the gate rejects gets no headers. That @@ -1370,7 +1375,10 @@ func New(options *Options) *API { api.registerMCPServerOAuth2Routes(r, chatAPIPrefixExperimental) // MCP HTTP transport endpoint with mandatory authentication. r.Route("/http", func(r chi.Router) { - r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2, codersdk.ExperimentMCPServerHTTP)) + r.Use( + httpmw.RequireOAuth2Provider(oauth2ProviderEnabled), + httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentMCPServerHTTP), + ) r.Mount("/", api.mcpHTTPHandler()) }) }) @@ -1995,13 +2003,16 @@ func New(options *Options) *API { r.Route("/oauth2-provider", func(r chi.Router) { r.Use( apiKeyMiddleware, - httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), // POST /apps/{app}/secrets returns a plaintext client secret, // so this tree falls under the same RFC 6749 ยง5.1 requirement - // as /oauth2. + // as /oauth2. Settings carry no credential but share the + // header; that is harmless. httpmw.NoStore, ) + // Settings stay reachable while the provider is disabled so an + // admin can configure it before turning it on. r.Route("/apps", func(r chi.Router) { + r.Use(httpmw.RequireOAuth2Provider(oauth2ProviderEnabled)) r.Get("/", api.oAuth2ProviderApps()) r.Post("/", api.postOAuth2ProviderApp()) diff --git a/coderd/httpmw/oauth2provider.go b/coderd/httpmw/oauth2provider.go new file mode 100644 index 00000000000..b6f7572b4b8 --- /dev/null +++ b/coderd/httpmw/oauth2provider.go @@ -0,0 +1,26 @@ +package httpmw + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/httpapi" +) + +// RequireOAuth2Provider returns 404 for every request while the OAuth2 +// provider is disabled. A disabled provider looks the same as a deployment +// that never had one, which is what RFC 8414 and RFC 9728 discovery clients +// expect. There is no bypass for development builds. +// +// enabled is a func so the check can become a runtime setting later without +// changing call sites. +func RequireOAuth2Provider(enabled func() bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if !enabled() { + httpapi.RouteNotFound(rw) + return + } + next.ServeHTTP(rw, r) + }) + } +} diff --git a/coderd/httpmw/oauth2provider_test.go b/coderd/httpmw/oauth2provider_test.go new file mode 100644 index 00000000000..043513b3f5a --- /dev/null +++ b/coderd/httpmw/oauth2provider_test.go @@ -0,0 +1,46 @@ +package httpmw_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/httpmw" +) + +func TestRequireOAuth2Provider(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + enabled bool + wantStatus int + wantNext bool + }{ + {name: "Disabled", enabled: false, wantStatus: http.StatusNotFound, wantNext: false}, + {name: "Enabled", enabled: true, wantStatus: http.StatusOK, wantNext: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + nextCalled := false + handler := httpmw.RequireOAuth2Provider(func() bool { return tc.enabled })( + http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + nextCalled = true + rw.WriteHeader(http.StatusOK) + }), + ) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/oauth2/authorize", nil)) + + require.Equal(t, tc.wantStatus, rec.Code) + require.Equal(t, tc.wantNext, nextCalled) + if !tc.enabled { + require.JSONEq(t, `{"message":"Route not found."}`, rec.Body.String()) + } + }) + } +} diff --git a/coderd/oauth2provider/provider_disabled_test.go b/coderd/oauth2provider/provider_disabled_test.go new file mode 100644 index 00000000000..35dfc5b538f --- /dev/null +++ b/coderd/oauth2provider/provider_disabled_test.go @@ -0,0 +1,110 @@ +package oauth2provider_test + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +// gatedRoutes lists every route that CODER_OAUTH2_PROVIDER_ENABLE controls. +var gatedRoutes = []struct { + method string + path string +}{ + {http.MethodGet, "/.well-known/oauth-authorization-server"}, + {http.MethodGet, "/.well-known/oauth-protected-resource"}, + {http.MethodGet, "/oauth2/authorize"}, + {http.MethodPost, "/oauth2/tokens"}, + {http.MethodPost, "/oauth2/revoke"}, + {http.MethodPost, "/oauth2/register"}, + {http.MethodGet, "/api/v2/oauth2-provider/apps"}, + {http.MethodPost, "/api/experimental/mcp/http"}, +} + +func newProviderClient(t *testing.T, enabled bool, experiments ...codersdk.Experiment) *codersdk.Client { + t.Helper() + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { + dv.OAuth2.Provider.Enable = serpent.Bool(enabled) + for _, exp := range experiments { + dv.Experiments = append(dv.Experiments, string(exp)) + } + }), + }) + _ = coderdtest.CreateFirstUser(t, client) + return client +} + +func TestOAuth2ProviderDisabled(t *testing.T) { + t.Parallel() + + t.Run("GatedRoutesReturn404", func(t *testing.T) { + t.Parallel() + client := newProviderClient(t, false, codersdk.ExperimentMCPServerHTTP) + ctx := testutil.Context(t, testutil.WaitLong) + + for _, route := range gatedRoutes { + res, err := client.Request(ctx, route.method, route.path, nil) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + _ = res.Body.Close() + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, res.StatusCode, "%s %s", route.method, route.path) + // Same body as an unregistered path, so a disabled provider cannot + // be told apart from a missing one. + require.JSONEq(t, `{"message":"Route not found."}`, string(body), "%s %s", route.method, route.path) + } + }) + + t.Run("GatedRoutesReachableWhenEnabled", func(t *testing.T) { + t.Parallel() + client := newProviderClient(t, true, codersdk.ExperimentMCPServerHTTP) + ctx := testutil.Context(t, testutil.WaitLong) + + for _, route := range gatedRoutes { + res, err := client.Request(ctx, route.method, route.path, nil) + require.NoError(t, err) + _ = res.Body.Close() + require.NotEqual(t, http.StatusNotFound, res.StatusCode, "%s %s", route.method, route.path) + } + }) + + t.Run("SettingsReachableWhenDisabled", func(t *testing.T) { + t.Parallel() + client := newProviderClient(t, false) + ctx := testutil.Context(t, testutil.WaitLong) + + settings, err := client.OAuth2ProviderSettings(ctx) + require.NoError(t, err) + require.NotNil(t, settings.DynamicClientRegistrationEnabled) + require.False(t, *settings.DynamicClientRegistrationEnabled) + + settings, err = client.PutOAuth2ProviderSettings(ctx, codersdk.OAuth2ProviderSettings{ + DynamicClientRegistrationEnabled: new(true), + }) + require.NoError(t, err) + require.NotNil(t, settings.DynamicClientRegistrationEnabled) + require.True(t, *settings.DynamicClientRegistrationEnabled) + }) + + t.Run("MCPExperimentStillRequired", func(t *testing.T) { + t.Parallel() + client := newProviderClient(t, true) + ctx := testutil.Context(t, testutil.WaitLong) + + res, err := client.Request(ctx, http.MethodPost, "/api/experimental/mcp/http", nil) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + _ = res.Body.Close() + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, res.StatusCode) + require.Contains(t, string(body), string(codersdk.ExperimentMCPServerHTTP)) + }) +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ddec9b189d4..7a0a90de921 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1945,7 +1945,7 @@ communicating directly.`, } aiGatewayInjectCoderMCPTools := serpent.Option{ Name: "AI Gateway Inject Coder MCP tools", - Description: "Deprecated: Injected MCP in AI Gateway is deprecated and will be removed in a future release. Whether to inject Coder's MCP tools into intercepted AI Gateway requests (requires the \"oauth2\" and \"mcp-server-http\" experiments to be enabled).", + Description: "Deprecated: Injected MCP in AI Gateway is deprecated and will be removed in a future release. Whether to inject Coder's MCP tools into intercepted AI Gateway requests (requires CODER_OAUTH2_PROVIDER_ENABLE and the \"mcp-server-http\" experiment to be enabled).", Flag: "ai-gateway-inject-coder-mcp-tools", Env: "CODER_AI_GATEWAY_INJECT_CODER_MCP_TOOLS", Value: &c.AI.BridgeConfig.InjectCoderMCPTools, diff --git a/enterprise/coderd/aibridgeserve.go b/enterprise/coderd/aibridgeserve.go index 44cd4dd16af..a9c77e6e971 100644 --- a/enterprise/coderd/aibridgeserve.go +++ b/enterprise/coderd/aibridgeserve.go @@ -135,17 +135,18 @@ func (api *API) aiGatewayServe(rw http.ResponseWriter, r *http.Request) { mux := drpcmux.New() srv, err := aibridgedserver.NewServer(connCtx, aibridgedserver.Options{ - Store: api.Database, - Pubsub: api.AGPL.Pubsub, - AISeatTracker: api.AGPL.AISeatTracker, - Enqueuer: api.AGPL.NotificationsEnqueuer, - AccessURL: api.AccessURL.String(), - GatewayCfg: api.DeploymentValues.AI.BridgeConfig, - ExternalAuthConfigs: api.ExternalAuthConfigs, - Experiments: api.AGPL.Experiments, - Logger: logger, - Clock: api.AGPL.Clock, - Metrics: api.AGPL.AIGatewayServerMetrics, + Store: api.Database, + Pubsub: api.AGPL.Pubsub, + AISeatTracker: api.AGPL.AISeatTracker, + Enqueuer: api.AGPL.NotificationsEnqueuer, + AccessURL: api.AccessURL.String(), + GatewayCfg: api.DeploymentValues.AI.BridgeConfig, + ExternalAuthConfigs: api.ExternalAuthConfigs, + Experiments: api.AGPL.Experiments, + OAuth2ProviderEnabled: api.DeploymentValues.OAuth2.Provider.Enable.Value(), + Logger: logger, + Clock: api.AGPL.Clock, + Metrics: api.AGPL.AIGatewayServerMetrics, }) if err != nil { if !xerrors.Is(err, context.Canceled) { diff --git a/enterprise/coderd/oauth2providersettings_audit_test.go b/enterprise/coderd/oauth2providersettings_audit_test.go index 9f0266759bd..03925f5f537 100644 --- a/enterprise/coderd/oauth2providersettings_audit_test.go +++ b/enterprise/coderd/oauth2providersettings_audit_test.go @@ -46,6 +46,10 @@ func TestOAuth2ProviderSettingsAuditDiff(t *testing.T) { Database: db, Pubsub: ps, Auditor: auditor, + // Settings stay reachable and audited while the provider is off. + DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { + dv.OAuth2.Provider.Enable = false + }), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ diff --git a/site/src/modules/management/DeploymentSidebar.tsx b/site/src/modules/management/DeploymentSidebar.tsx index a40e1cf6da9..6b733daac63 100644 --- a/site/src/modules/management/DeploymentSidebar.tsx +++ b/site/src/modules/management/DeploymentSidebar.tsx @@ -8,8 +8,7 @@ import { DeploymentSidebarView } from "./DeploymentSidebarView"; */ export const DeploymentSidebar: FC = () => { const { permissions } = useAuthenticated(); - const { entitlements, showOrganizations, experiments, buildInfo } = - useDashboard(); + const { entitlements, showOrganizations, buildInfo } = useDashboard(); // Trialing deployments keep the Premium tab so they can convert. const hidePremiumTab = entitlements.has_license && !entitlements.trial; @@ -18,7 +17,6 @@ export const DeploymentSidebar: FC = () => { permissions={permissions} showOrganizations={showOrganizations} hidePremiumTab={hidePremiumTab} - experiments={experiments} buildInfo={buildInfo} /> ); diff --git a/site/src/modules/management/DeploymentSidebarView.stories.tsx b/site/src/modules/management/DeploymentSidebarView.stories.tsx index 69e44ef29b8..bb4675ac427 100644 --- a/site/src/modules/management/DeploymentSidebarView.stories.tsx +++ b/site/src/modules/management/DeploymentSidebarView.stories.tsx @@ -16,7 +16,6 @@ const meta: Meta = { args: { permissions: MockPermissions, hidePremiumTab: false, - experiments: [], buildInfo: MockBuildInfo, }, }; @@ -94,3 +93,17 @@ export const PremiumTabHidden: Story = { ).toBeInTheDocument(); }, }; + +export const OAuth2ProviderEnabled: Story = {}; + +// The OAuth2 item follows the deployment flag, not the build type, so a +// development build with the flag off still hides it. +export const OAuth2ProviderDisabled: Story = { + args: { + buildInfo: { + ...MockBuildInfo, + version: "v2.99.99-devel+abcdef", + oauth2_provider: false, + }, + }, +}; diff --git a/site/src/modules/management/DeploymentSidebarView.tsx b/site/src/modules/management/DeploymentSidebarView.tsx index 7733284404d..b3cf8635859 100644 --- a/site/src/modules/management/DeploymentSidebarView.tsx +++ b/site/src/modules/management/DeploymentSidebarView.tsx @@ -1,20 +1,18 @@ import { ArrowUpRightIcon } from "lucide-react"; import type { FC } from "react"; -import type { BuildInfoResponse, Experiment } from "#/api/typesGenerated"; +import type { BuildInfoResponse } from "#/api/typesGenerated"; import { PREMIUM_PAGE_PATH } from "#/components/Paywall/Paywall"; import { Sidebar as BaseSidebar, SettingsSidebarNavItem as SidebarNavItem, } from "#/components/Sidebar/Sidebar"; import type { Permissions } from "#/modules/permissions"; -import { getPrereleaseFlag } from "#/utils/buildInfo"; interface DeploymentSidebarViewProps { /** Site-wide permissions. */ permissions: Permissions; showOrganizations: boolean; hidePremiumTab: boolean; - experiments: Experiment[]; buildInfo: BuildInfoResponse; } @@ -26,7 +24,6 @@ export const DeploymentSidebarView: FC = ({ permissions, showOrganizations, hidePremiumTab, - experiments, buildInfo, }) => { return ( @@ -53,13 +50,11 @@ export const DeploymentSidebarView: FC = ({ External Authentication )} - {permissions.viewDeploymentConfig && - (experiments.includes("oauth2") || - getPrereleaseFlag(buildInfo) === "devel") && ( - - OAuth2 Applications - - )} + {permissions.viewDeploymentConfig && buildInfo.oauth2_provider && ( + + OAuth2 Applications + + )} {permissions.viewDeploymentConfig && ( Network )} diff --git a/site/src/pages/UserSettingsPage/Sidebar.stories.tsx b/site/src/pages/UserSettingsPage/Sidebar.stories.tsx new file mode 100644 index 00000000000..a983727bb93 --- /dev/null +++ b/site/src/pages/UserSettingsPage/Sidebar.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { withDashboardProvider } from "#/testHelpers/storybook"; +import { Sidebar } from "./Sidebar"; + +const meta: Meta = { + title: "pages/UserSettingsPage/Sidebar", + component: Sidebar, + decorators: [withDashboardProvider], +}; + +export default meta; +type Story = StoryObj; + +export const OAuth2ProviderEnabled: Story = {}; + +// The OAuth2 item follows the deployment flag, not the build type, so a +// development build with the flag off still hides it. +export const OAuth2ProviderDisabled: Story = { + parameters: { + buildInfo: { version: "v2.99.99-devel+abcdef", oauth2_provider: false }, + }, +}; diff --git a/site/src/pages/UserSettingsPage/Sidebar.tsx b/site/src/pages/UserSettingsPage/Sidebar.tsx index 2f419bab32f..8169830dfba 100644 --- a/site/src/pages/UserSettingsPage/Sidebar.tsx +++ b/site/src/pages/UserSettingsPage/Sidebar.tsx @@ -3,14 +3,12 @@ import { SettingsSidebarNavItem, } from "#/components/Sidebar/Sidebar"; import { useDashboard } from "#/modules/dashboard/useDashboard"; -import { getPrereleaseFlag } from "#/utils/buildInfo"; export const Sidebar: React.FC = () => { - const { entitlements, experiments, buildInfo } = useDashboard(); + const { entitlements, buildInfo } = useDashboard(); const showSchedulePage = entitlements.features.advanced_template_scheduling.enabled; - const showOAuth2Page = - experiments.includes("oauth2") || getPrereleaseFlag(buildInfo) === "devel"; + const showOAuth2Page = buildInfo.oauth2_provider; return ( diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 0a4d3f7c1ec..dcad2cfcc10 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -36,6 +36,7 @@ export const withDashboardProvider = ( showOrganizations = false, organizations = [MockDefaultOrganization], canViewOrganizationSettings = false, + buildInfo = {}, } = parameters; const entitlements: Entitlements = { @@ -63,6 +64,7 @@ export const withDashboardProvider = ( buildInfo: { ...MockBuildInfo, version: "v0.0.0-test", + ...buildInfo, }, organizations, showOrganizations, From 08a59cc8fd464322139338fb9b746b0f1836a859 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 11 Sep 2026 17:09:30 -0700 Subject: [PATCH 2/3] fix: address review notes on the OAuth2 provider gate RequireOAuth2Provider takes a bool: the flag is read once at startup in three places, so the closure promised a runtime toggle that would ship half-working. The settings comment moves onto the route it describes, and both enabled sidebar stories set the flag explicitly instead of relying on the fixture default. --- coderd/aibridgedserver/aibridgedserver.go | 6 ++++-- coderd/coderd.go | 14 +++++++------- coderd/httpmw/oauth2provider.go | 7 +++---- coderd/httpmw/oauth2provider_test.go | 2 +- .../management/DeploymentSidebarView.stories.tsx | 7 ++++++- .../src/pages/UserSettingsPage/Sidebar.stories.tsx | 5 ++++- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 6833d3795f6..a82fdfc28a5 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -142,8 +142,8 @@ type Options struct { GatewayCfg codersdk.AIBridgeConfig ExternalAuthConfigs []*externalauth.Config Experiments codersdk.Experiments - // OAuth2ProviderEnabled reports whether Coder's OAuth2 provider is on. - // The internal MCP server cannot be used without it. + // OAuth2ProviderEnabled gates the internal MCP server, which is + // unavailable when it is off. OAuth2ProviderEnabled bool Logger slog.Logger @@ -1064,6 +1064,8 @@ func (s *Server) WatchAIProviders(_ *proto.WatchAIProvidersRequest, stream proto } // Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. +// +//nolint:revive // The flag is fixed for the life of the process. func getCoderMCPServerConfig(experiments codersdk.Experiments, oauth2ProviderEnabled bool, accessURL string) (*proto.MCPServerConfig, error) { // The internal MCP server needs the MCP experiment and the OAuth2 provider. if !experiments.Enabled(codersdk.ExperimentMCPServerHTTP) { diff --git a/coderd/coderd.go b/coderd/coderd.go index 9f75eef8925..9f228fefe7b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1084,11 +1084,10 @@ func New(options *Options) *API { }) api.workspaceBuildOrchestrator.Start(api.ctx) - // The OAuth2 provider is opt-in. Read the flag on every request so the - // check can become a runtime setting later without changing call sites. - oauth2ProviderEnabled := func() bool { - return api.DeploymentValues.OAuth2.Provider.Enable.Value() - } + // The OAuth2 provider is opt-in. The flag is read once at startup, here + // and in the build info response and the AI bridge config, so a runtime + // toggle would have to update all three. + oauth2ProviderEnabled := api.DeploymentValues.OAuth2.Provider.Enable.Value() apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ DB: options.Database, ActivateDormantUser: ActivateDormantUser(options.Logger, &api.Auditor, options.Database), @@ -2009,8 +2008,6 @@ func New(options *Options) *API { // header; that is harmless. httpmw.NoStore, ) - // Settings stay reachable while the provider is disabled so an - // admin can configure it before turning it on. r.Route("/apps", func(r chi.Router) { r.Use(httpmw.RequireOAuth2Provider(oauth2ProviderEnabled)) r.Get("/", api.oAuth2ProviderApps()) @@ -2033,6 +2030,9 @@ func New(options *Options) *API { }) }) }) + // Deliberately not gated: settings stay reachable while the + // provider is disabled so an admin can configure it before + // turning it on. r.Route("/settings", func(r chi.Router) { r.Get("/", api.oauth2ProviderSettings) r.Put("/", api.putOAuth2ProviderSettings) diff --git a/coderd/httpmw/oauth2provider.go b/coderd/httpmw/oauth2provider.go index b6f7572b4b8..736f11c9b31 100644 --- a/coderd/httpmw/oauth2provider.go +++ b/coderd/httpmw/oauth2provider.go @@ -11,12 +11,11 @@ import ( // that never had one, which is what RFC 8414 and RFC 9728 discovery clients // expect. There is no bypass for development builds. // -// enabled is a func so the check can become a runtime setting later without -// changing call sites. -func RequireOAuth2Provider(enabled func() bool) func(http.Handler) http.Handler { +//nolint:revive // The flag is fixed for the life of the process. +func RequireOAuth2Provider(enabled bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if !enabled() { + if !enabled { httpapi.RouteNotFound(rw) return } diff --git a/coderd/httpmw/oauth2provider_test.go b/coderd/httpmw/oauth2provider_test.go index 043513b3f5a..c6717d03214 100644 --- a/coderd/httpmw/oauth2provider_test.go +++ b/coderd/httpmw/oauth2provider_test.go @@ -26,7 +26,7 @@ func TestRequireOAuth2Provider(t *testing.T) { t.Parallel() nextCalled := false - handler := httpmw.RequireOAuth2Provider(func() bool { return tc.enabled })( + handler := httpmw.RequireOAuth2Provider(tc.enabled)( http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { nextCalled = true rw.WriteHeader(http.StatusOK) diff --git a/site/src/modules/management/DeploymentSidebarView.stories.tsx b/site/src/modules/management/DeploymentSidebarView.stories.tsx index bb4675ac427..4fc51dfd750 100644 --- a/site/src/modules/management/DeploymentSidebarView.stories.tsx +++ b/site/src/modules/management/DeploymentSidebarView.stories.tsx @@ -94,7 +94,12 @@ export const PremiumTabHidden: Story = { }, }; -export const OAuth2ProviderEnabled: Story = {}; +// Explicit so the story does not depend on the fixture default. +export const OAuth2ProviderEnabled: Story = { + args: { + buildInfo: { ...MockBuildInfo, oauth2_provider: true }, + }, +}; // The OAuth2 item follows the deployment flag, not the build type, so a // development build with the flag off still hides it. diff --git a/site/src/pages/UserSettingsPage/Sidebar.stories.tsx b/site/src/pages/UserSettingsPage/Sidebar.stories.tsx index a983727bb93..ec419d7a5f0 100644 --- a/site/src/pages/UserSettingsPage/Sidebar.stories.tsx +++ b/site/src/pages/UserSettingsPage/Sidebar.stories.tsx @@ -11,7 +11,10 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const OAuth2ProviderEnabled: Story = {}; +// Explicit so the story does not depend on the fixture default. +export const OAuth2ProviderEnabled: Story = { + parameters: { buildInfo: { oauth2_provider: true } }, +}; // The OAuth2 item follows the deployment flag, not the build type, so a // development build with the flag off still hides it. From 43812d8f7aec32be600d487f2ecc40f4ac0dfca4 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 11 Sep 2026 17:42:25 -0700 Subject: [PATCH 3/3] test(coderd/mcp): enable the MCP HTTP experiment in the end-to-end tests The MCP HTTP endpoint no longer has a development-build bypass for the mcp-server-http experiment, so tests that reach it must opt in. --- coderd/mcp/mcp_e2e_test.go | 40 +++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index ed880a07842..6f8477d1a0a 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -44,11 +44,22 @@ func mcpGeneratePKCE() (verifier, challenge string) { return verifier, challenge } +// mcpDeploymentValues turns on the mcp-server-http experiment. The MCP HTTP +// endpoint has no development-build bypass, so every test that reaches it +// must opt in. +func mcpDeploymentValues(t testing.TB) *codersdk.DeploymentValues { + return coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { + dv.Experiments = []string{string(codersdk.ExperimentMCPServerHTTP)} + }) +} + func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) { t.Parallel() // Setup Coder server with authentication - coderClient, closer, api := coderdtest.NewWithAPI(t, nil) + coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + }) defer closer.Close() _ = coderdtest.CreateFirstUser(t, coderClient) @@ -162,7 +173,9 @@ func TestMCPHTTP_E2E_UnauthenticatedAccess(t *testing.T) { t.Parallel() // Setup Coder server - _, closer, api := coderdtest.NewWithAPI(t, nil) + _, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + }) defer closer.Close() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -193,7 +206,9 @@ func TestMCPHTTP_E2E_UnauthenticatedAccess(t *testing.T) { func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) { t.Parallel() - coderClient, closer, api := coderdtest.NewWithAPI(t, nil) + coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + }) defer closer.Close() user := coderdtest.CreateFirstUser(t, coderClient) @@ -254,6 +269,7 @@ func TestMCPHTTP_E2E_ErrorHandling(t *testing.T) { // Setup Coder server coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), IncludeProvisionerDaemon: true, }) defer closer.Close() @@ -291,6 +307,7 @@ func TestMCPHTTP_E2E_ConcurrentRequests(t *testing.T) { // Setup Coder server coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), IncludeProvisionerDaemon: true, }) defer closer.Close() @@ -345,7 +362,9 @@ func TestMCPHTTP_E2E_RFC6750_UnauthenticatedRequest(t *testing.T) { t.Parallel() // Setup Coder server - _, closer, api := coderdtest.NewWithAPI(t, nil) + _, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + }) defer closer.Close() // Make a request without any authentication headers @@ -376,7 +395,9 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { t.Parallel() // Setup Coder server with OAuth2 provider enabled - coderClient, closer, api := coderdtest.NewWithAPI(t, nil) + coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + }) t.Cleanup(func() { closer.Close() }) _ = coderdtest.CreateFirstUser(t, coderClient) @@ -1080,6 +1101,7 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { // Setup Coder server with authentication coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), IncludeProvisionerDaemon: true, }) defer closer.Close() @@ -1208,7 +1230,9 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { func TestMCPHTTP_E2E_WorkspaceSSHAuthz(t *testing.T) { t.Parallel() - coderClient, closer, api := coderdtest.NewWithAPI(t, nil) + coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + }) defer closer.Close() admin := coderdtest.CreateFirstUser(t, coderClient) @@ -1323,7 +1347,9 @@ func TestMCPHTTP_E2E_TransportIsolation(t *testing.T) { // Construct the API before swapping DefaultTransport: coderd's guarded // MCP client clones http.DefaultTransport at construction, and safedial // panics on a non-*http.Transport rather than guessing. - coderClient, closer, api := coderdtest.NewWithAPI(t, nil) + coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + }) t.Cleanup(func() { closer.Close() }) _ = coderdtest.CreateFirstUser(t, coderClient)