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:
Bobby Ho
2026-09-14 13:47:48 -07:00
committed by GitHub
parent 46c904d181
commit 4ee70c66c3
18 changed files with 351 additions and 81 deletions
+2 -2
View File
@@ -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
View File
@@ -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
+10 -6
View File
@@ -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)
+24 -18
View File
@@ -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
View File
@@ -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)
+25
View File
@@ -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
View File
@@ -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())
}
})
}
}
+33 -7
View File
@@ -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))
})
}
+1 -1
View File
@@ -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,
+12 -11
View File
@@ -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 },
},
};
+2 -4
View File
@@ -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>
+2
View File
@@ -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,