Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/testdata/server-config.yaml.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 12 additions & 11 deletions coderd/aibridged.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions coderd/aibridgedserver/aibridgedserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 24 additions & 18 deletions coderd/aibridgedserver/aibridgedserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import (
)

var requiredExperiments = []codersdk.Experiment{
codersdk.ExperimentMCPServerHTTP, codersdk.ExperimentOAuth2,
codersdk.ExperimentMCPServerHTTP,
}

// TestAuthorization validates the authorization logic.
Expand Down Expand Up @@ -831,43 +831,48 @@ 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",
externalAuthConfigs: externalAuthCfgs,
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,
Expand All @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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())
})

Expand All @@ -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
Expand Down Expand Up @@ -1370,7 +1374,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())
})
})
Expand Down Expand Up @@ -1995,13 +2002,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())

Expand All @@ -2022,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)
Expand Down
25 changes: 25 additions & 0 deletions coderd/httpmw/oauth2provider.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
46 changes: 46 additions & 0 deletions coderd/httpmw/oauth2provider_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
Loading
Loading