mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: gate the OAuth2 provider on CODER_OAUTH2_PROVIDER_ENABLE (#29251)
Second of five for PLAT-492. Gates all OAuth2 provider routes and dashboard navigation on CODER_OAUTH2_PROVIDER_ENABLE instead of the oauth2 experiment. The flag is off by default, so deployments using CODER_EXPERIMENTS=oauth2 must set it before upgrading or OAuth2 clients will receive 404s. Existing apps, tokens, secrets, and DCR settings are unchanged. Stacked on #29248 and labeled release/breaking; the next PR makes the old experiment a no-op with a migration warning.
This commit is contained in:
+2
-2
@@ -926,8 +926,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
|
||||
|
||||
+12
-11
@@ -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
|
||||
|
||||
@@ -142,6 +142,9 @@ type Options struct {
|
||||
GatewayCfg codersdk.AIBridgeConfig
|
||||
ExternalAuthConfigs []*externalauth.Config
|
||||
Experiments codersdk.Experiments
|
||||
// OAuth2ProviderEnabled gates the internal MCP server, which is
|
||||
// unavailable when it is off.
|
||||
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,15 @@ 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.
|
||||
//
|
||||
//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) {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+17
-6
@@ -1084,6 +1084,10 @@ func New(options *Options) *API {
|
||||
})
|
||||
api.workspaceBuildOrchestrator.Start(api.ctx)
|
||||
|
||||
// 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),
|
||||
@@ -1245,12 +1249,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 +1263,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
|
||||
@@ -1375,7 +1379,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())
|
||||
})
|
||||
})
|
||||
@@ -2000,13 +2007,14 @@ 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,
|
||||
)
|
||||
r.Route("/apps", func(r chi.Router) {
|
||||
r.Use(httpmw.RequireOAuth2Provider(oauth2ProviderEnabled))
|
||||
r.Get("/", api.oAuth2ProviderApps())
|
||||
r.Post("/", api.postOAuth2ProviderApp())
|
||||
|
||||
@@ -2027,6 +2035,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)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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.
|
||||
//
|
||||
//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 {
|
||||
httpapi.RouteNotFound(rw)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(rw, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -1949,7 +1949,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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ const meta: Meta<typeof DeploymentSidebarView> = {
|
||||
args: {
|
||||
permissions: MockPermissions,
|
||||
hidePremiumTab: false,
|
||||
experiments: [],
|
||||
buildInfo: MockBuildInfo,
|
||||
},
|
||||
};
|
||||
@@ -94,3 +93,22 @@ export const PremiumTabHidden: Story = {
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// 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.
|
||||
export const OAuth2ProviderDisabled: Story = {
|
||||
args: {
|
||||
buildInfo: {
|
||||
...MockBuildInfo,
|
||||
version: "v2.99.99-devel+abcdef",
|
||||
oauth2_provider: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<DeploymentSidebarViewProps> = ({
|
||||
permissions,
|
||||
showOrganizations,
|
||||
hidePremiumTab,
|
||||
experiments,
|
||||
buildInfo,
|
||||
}) => {
|
||||
return (
|
||||
@@ -53,13 +50,11 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
|
||||
External Authentication
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.viewDeploymentConfig &&
|
||||
(experiments.includes("oauth2") ||
|
||||
getPrereleaseFlag(buildInfo) === "devel") && (
|
||||
<SidebarNavItem href="/deployment/oauth2-provider/apps">
|
||||
OAuth2 Applications
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.viewDeploymentConfig && buildInfo.oauth2_provider && (
|
||||
<SidebarNavItem href="/deployment/oauth2-provider/apps">
|
||||
OAuth2 Applications
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.viewDeploymentConfig && (
|
||||
<SidebarNavItem href="/deployment/network">Network</SidebarNavItem>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { withDashboardProvider } from "#/testHelpers/storybook";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
const meta: Meta<typeof Sidebar> = {
|
||||
title: "pages/UserSettingsPage/Sidebar",
|
||||
component: Sidebar,
|
||||
decorators: [withDashboardProvider],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Sidebar>;
|
||||
|
||||
// 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.
|
||||
export const OAuth2ProviderDisabled: Story = {
|
||||
parameters: {
|
||||
buildInfo: { version: "v2.99.99-devel+abcdef", oauth2_provider: false },
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<BaseSidebar>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user