diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 0f71c910224..0ff7eb72ccf 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -87,6 +87,7 @@ type ClientConfig struct { MCPCodeModeBindingLevel string `json:"mcp_code_mode_binding_level"` // Code mode binding level: "server" or "tool" MCPToolSyncInterval int `json:"mcp_tool_sync_interval"` // Global tool sync interval in minutes (default: 10, 0 = disabled) MCPDisableAutoToolInject bool `json:"mcp_disable_auto_tool_inject"` // When true, MCP tools are not injected into requests by default + MCPEnableTempTokenAuth bool `json:"mcp_enable_temp_token_auth"` // When true, scoped temp tokens can authorize MCP per-user OAuth pages HeaderFilterConfig *tables.GlobalHeaderFilterConfig `json:"header_filter_config,omitempty"` // Global header filtering configuration for x-bf-eh-* headers AsyncJobResultTTL int `json:"async_job_result_ttl"` // Default TTL for async job results in seconds (default: 3600 = 1 hour) RequiredHeaders []string `json:"required_headers,omitempty"` // Headers that must be present on every request (case-insensitive) @@ -208,6 +209,11 @@ func (c *ClientConfig) GenerateClientConfigHash() (string, error) { hash.Write([]byte("mcpDisableAutoToolInject:true")) } + // Only hash non-default value to avoid legacy config hash churn on upgrade. + if c.MCPEnableTempTokenAuth { + hash.Write([]byte("mcpEnableTempTokenAuth:true")) + } + // Only hash non-default value to avoid legacy config hash churn on upgrade. if c.AllowPerRequestContentStorageOverride { hash.Write([]byte("allowPerRequestContentStorageOverride:true")) diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index bdde103d4e0..86cbd13b60a 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -636,6 +636,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddMCPDisableAutoToolInjectColumn(ctx, db); err != nil { return err } + if err := migrationAddMCPEnableTempTokenAuthColumn(ctx, db); err != nil { + return err + } if err := migrationBackfillAllowedModelsWildcard(ctx, db); err != nil { return err } @@ -1301,6 +1304,7 @@ func migrationDropAllowDirectKeysColumn(ctx context.Context, db *gorm.DB) error MCPCodeModeBindingLevel: cc.MCPCodeModeBindingLevel, MCPToolSyncInterval: cc.MCPToolSyncInterval, MCPDisableAutoToolInject: cc.MCPDisableAutoToolInject, + MCPEnableTempTokenAuth: cc.MCPEnableTempTokenAuth, HeaderFilterConfig: cc.HeaderFilterConfig, AsyncJobResultTTL: cc.AsyncJobResultTTL, RequiredHeaders: cc.RequiredHeaders, @@ -5335,6 +5339,35 @@ func migrationAddMCPDisableAutoToolInjectColumn(ctx context.Context, db *gorm.DB return nil } +// migrationAddMCPEnableTempTokenAuthColumn adds the mcp_enable_temp_token_auth column to the client config table. +func migrationAddMCPEnableTempTokenAuthColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_mcp_enable_temp_token_auth_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migratorInstance := tx.Migrator() + if !migratorInstance.HasColumn(&tables.TableClientConfig{}, "mcp_enable_temp_token_auth") { + if err := migratorInstance.AddColumn(&tables.TableClientConfig{}, "mcp_enable_temp_token_auth"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migratorInstance := tx.Migrator() + if err := migratorInstance.DropColumn(&tables.TableClientConfig{}, "mcp_enable_temp_token_auth"); err != nil { + return err + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running mcp enable temp token auth migration: %s", err.Error()) + } + return nil +} + // migrationAddPricingRefactorColumns adds all new pricing columns introduced in the pricing module refactor func migrationAddPricingRefactorColumns(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ @@ -6380,6 +6413,7 @@ func migrationAddRoutingChainMaxDepthColumn(ctx context.Context, db *gorm.DB) er MCPCodeModeBindingLevel: cc.MCPCodeModeBindingLevel, MCPToolSyncInterval: cc.MCPToolSyncInterval, MCPDisableAutoToolInject: cc.MCPDisableAutoToolInject, + MCPEnableTempTokenAuth: cc.MCPEnableTempTokenAuth, AsyncJobResultTTL: cc.AsyncJobResultTTL, LoggingHeaders: cc.LoggingHeaders, RequiredHeaders: cc.RequiredHeaders, @@ -8303,6 +8337,7 @@ func migrationRefreshConfigHashAfterMCPExternalServerURLRemoval(ctx context.Cont MCPCodeModeBindingLevel: cc.MCPCodeModeBindingLevel, MCPToolSyncInterval: cc.MCPToolSyncInterval, MCPDisableAutoToolInject: cc.MCPDisableAutoToolInject, + MCPEnableTempTokenAuth: cc.MCPEnableTempTokenAuth, MCPExternalClientURL: schemas.NewEnvVar(cc.MCPExternalClientURL), HeaderFilterConfig: cc.HeaderFilterConfig, AsyncJobResultTTL: cc.AsyncJobResultTTL, diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index f104ed03287..54beed459ef 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -246,6 +246,7 @@ func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientC MCPCodeModeBindingLevel: config.MCPCodeModeBindingLevel, MCPToolSyncInterval: config.MCPToolSyncInterval, MCPDisableAutoToolInject: config.MCPDisableAutoToolInject, + MCPEnableTempTokenAuth: config.MCPEnableTempTokenAuth, AsyncJobResultTTL: config.AsyncJobResultTTL, RequiredHeaders: config.RequiredHeaders, LoggingHeaders: config.LoggingHeaders, @@ -508,6 +509,7 @@ func (s *RDBConfigStore) GetClientConfig(ctx context.Context) (*ClientConfig, er MCPCodeModeBindingLevel: dbConfig.MCPCodeModeBindingLevel, MCPToolSyncInterval: dbConfig.MCPToolSyncInterval, MCPDisableAutoToolInject: dbConfig.MCPDisableAutoToolInject, + MCPEnableTempTokenAuth: dbConfig.MCPEnableTempTokenAuth, AsyncJobResultTTL: dbConfig.AsyncJobResultTTL, RequiredHeaders: dbConfig.RequiredHeaders, LoggingHeaders: dbConfig.LoggingHeaders, diff --git a/framework/configstore/tables/clientconfig.go b/framework/configstore/tables/clientconfig.go index 68ee284f75a..03ff6b5a6bf 100644 --- a/framework/configstore/tables/clientconfig.go +++ b/framework/configstore/tables/clientconfig.go @@ -30,6 +30,7 @@ type TableClientConfig struct { MCPCodeModeBindingLevel string `gorm:"default:server" json:"mcp_code_mode_binding_level"` // How tools are exposed in VFS: "server" or "tool" MCPToolSyncInterval int `gorm:"default:10" json:"mcp_tool_sync_interval"` // Global tool sync interval in minutes (default: 10, 0 = disabled) MCPDisableAutoToolInject bool `gorm:"default:false" json:"mcp_disable_auto_tool_inject"` // When true, MCP tools are not injected into requests by default + MCPEnableTempTokenAuth bool `gorm:"default:false" json:"mcp_enable_temp_token_auth"` // When true, scoped temp tokens can authorize MCP per-user OAuth pages AsyncJobResultTTL int `gorm:"default:3600" json:"async_job_result_ttl"` // Default TTL for async job results in seconds (default: 3600 = 1 hour) RequiredHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string LoggingHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string diff --git a/framework/oauth2/main.go b/framework/oauth2/main.go index 45ca86c6cd2..1cf5231b53f 100644 --- a/framework/oauth2/main.go +++ b/framework/oauth2/main.go @@ -13,6 +13,7 @@ import ( "net/url" "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -35,12 +36,17 @@ type OAuth2Provider struct { mu sync.RWMutex retryBaseDelay time.Duration // base delay for token endpoint retry backoff; doubles each attempt (1×, 2×, 4×) - // tempTokens, when non-nil, is used by InitiateUserOAuthFlow to mint a - // short-lived mcp_auth temp token and embed it in the returned auth-page - // URL as a fragment. Optional — when nil, the URL is returned without a - // fragment and the page works only for callers already authenticated to - // the dashboard. - tempTokens *temptoken.Service + // tempTokens, when non-nil and enabled in client config, is used by + // InitiateUserOAuthFlow to mint a short-lived mcp_auth temp token and + // embed it in the returned auth-page URL as a fragment. Optional — when + // nil or disabled, the URL is returned without a fragment and the page + // works only for callers already authenticated to the dashboard. + // + // Held as an atomic.Pointer rather than under p.mu: it is written once at + // startup and read on the request path, and p.mu is write-locked across + // token-refresh network I/O (RefreshAccessToken/RevokeToken). Sharing p.mu + // would stall flow init/cleanup reads behind unrelated refresh traffic. + tempTokens atomic.Pointer[temptoken.Service] } // NewOAuth2Provider creates a new OAuth provider instance @@ -61,9 +67,27 @@ func NewOAuth2Provider(configStore configstore.ConfigStore, logger schemas.Logge // have been constructed (the provider is built first by lib/config.go, // the service later by the HTTP transport). func (p *OAuth2Provider) SetTempTokenService(svc *temptoken.Service) { - p.mu.Lock() - defer p.mu.Unlock() - p.tempTokens = svc + p.tempTokens.Store(svc) +} + +// tempTokenService returns the current temp-token service. Lock-free: the +// pointer is read atomically so request-path callers never contend with the +// p.mu write lock held across token-refresh network I/O. +func (p *OAuth2Provider) tempTokenService() *temptoken.Service { + return p.tempTokens.Load() +} + +// mcpTempTokenAuthEnabled reports whether MCP per-user OAuth links may include temp-token auth. +func (p *OAuth2Provider) mcpTempTokenAuthEnabled(ctx context.Context) bool { + if p.configStore == nil { + return false + } + clientConfig, err := p.configStore.GetClientConfig(ctx) + if err != nil { + logger.Warn("Failed to read MCP temp-token auth setting: %v", err) + return false + } + return clientConfig != nil && clientConfig.MCPEnableTempTokenAuth } // cleanupFlow deletes the flow row and any temp tokens minted for it. Called @@ -80,8 +104,8 @@ func (p *OAuth2Provider) cleanupFlow(ctx context.Context, sessionID string) { if err := p.configStore.DeleteOauthUserSession(cleanupCtx, sessionID); err != nil { logger.Warn("per-user OAuth flow row cleanup failed: session_id=%s err=%v", sessionID, err) } - if p.tempTokens != nil { - if _, err := p.tempTokens.DeleteByResourceID(cleanupCtx, temptoken.MCPAuthScopeName, sessionID); err != nil { + if svc := p.tempTokenService(); svc != nil { + if _, err := svc.DeleteByResourceID(cleanupCtx, temptoken.MCPAuthScopeName, sessionID); err != nil { logger.Warn("per-user OAuth temp-token cleanup failed: session_id=%s err=%v", sessionID, err) } } @@ -1029,10 +1053,10 @@ func (p *OAuth2Provider) InitiateUserOAuthFlow(ctx context.Context, oauthConfigI // dashboard session can still call the per-user flow endpoints. The // fragment never leaves the browser (not in server logs, not in the // upstream-OAuth Referer), unlike a query param. - if p.tempTokens != nil { + if svc := p.tempTokenService(); svc != nil && p.mcpTempTokenAuthEnabled(ctx) { ttl := time.Until(expiresAt) if ttl > 0 { - plaintext, mintErr := p.tempTokens.Mint(ctx, temptoken.MCPAuthScopeName, sessionID, ttl) + plaintext, mintErr := svc.Mint(ctx, temptoken.MCPAuthScopeName, sessionID, ttl) if mintErr != nil { logger.Warn("Failed to mint mcp_auth temp token for flow %s: %v (link still usable for dashboard-authenticated callers)", sessionID, mintErr) } else { diff --git a/framework/oauth2/sync_test.go b/framework/oauth2/sync_test.go index d3d5e0ae1f6..a1ab4021804 100644 --- a/framework/oauth2/sync_test.go +++ b/framework/oauth2/sync_test.go @@ -26,6 +26,7 @@ type testConfigStore struct { mu sync.Mutex oauthConfigs map[string]*tables.TableOauthConfig oauthTokens map[string]*tables.TableOauthToken + clientConfig *configstore.ClientConfig } func newTestConfigStore() *testConfigStore { @@ -80,6 +81,15 @@ func (s *testConfigStore) UpdateOauthToken(_ context.Context, token *tables.Tabl return nil } +func (s *testConfigStore) GetClientConfig(_ context.Context) (*configstore.ClientConfig, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.clientConfig == nil { + return nil, nil + } + return bifrost.Ptr(*s.clientConfig), nil +} + func (s *testConfigStore) GetExpiringOauthTokens(_ context.Context, before time.Time) ([]*tables.TableOauthToken, error) { s.mu.Lock() defer s.mu.Unlock() @@ -159,6 +169,19 @@ func TestTestConfigStore_GetExpiringOauthTokens(t *testing.T) { }) } +func TestMCPTempTokenAuthEnabled(t *testing.T) { + store := newTestConfigStore() + provider := NewOAuth2Provider(store, bifrost.NewDefaultLogger(schemas.LogLevelError)) + + assert.False(t, provider.mcpTempTokenAuthEnabled(context.Background())) + + store.clientConfig = &configstore.ClientConfig{} + assert.False(t, provider.mcpTempTokenAuthEnabled(context.Background())) + + store.clientConfig.MCPEnableTempTokenAuth = true + assert.True(t, provider.mcpTempTokenAuthEnabled(context.Background())) +} + func TestTokenRefreshWorker_TransientError_DoesNotMarkExpired(t *testing.T) { // A 503 response from the token server is a transient failure. // The oauth_config must stay "authorized" so the connection can diff --git a/helm-charts/bifrost/templates/_helpers.tpl b/helm-charts/bifrost/templates/_helpers.tpl index 25024917c27..0d3b7a0da1d 100644 --- a/helm-charts/bifrost/templates/_helpers.tpl +++ b/helm-charts/bifrost/templates/_helpers.tpl @@ -307,6 +307,9 @@ false {{- if hasKey .Values.bifrost.client "mcpDisableAutoToolInject" }} {{- $_ := set $client "mcp_disable_auto_tool_inject" .Values.bifrost.client.mcpDisableAutoToolInject }} {{- end }} +{{- if hasKey .Values.bifrost.client "mcpEnableTempTokenAuth" }} +{{- $_ := set $client "mcp_enable_temp_token_auth" .Values.bifrost.client.mcpEnableTempTokenAuth }} +{{- end }} {{- if .Values.bifrost.client.routingChainMaxDepth }} {{- $_ := set $client "routing_chain_max_depth" .Values.bifrost.client.routingChainMaxDepth }} {{- end }} @@ -1666,4 +1669,4 @@ Call this template at the beginning of deployment/stateful templates {{- end }} {{- end }} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json index d0c763477c7..f3f759812b1 100644 --- a/helm-charts/bifrost/values.schema.json +++ b/helm-charts/bifrost/values.schema.json @@ -446,6 +446,11 @@ "description": "DEPRECATED: When true, MCP tools are not automatically injected into requests. Use bifrost.mcp.toolManagerConfig.disableAutoToolInject.", "default": false }, + "mcpEnableTempTokenAuth": { + "type": "boolean", + "description": "When true, Bifrost mints and accepts scoped temp tokens for MCP per-user OAuth auth pages.", + "default": false + }, "routingChainMaxDepth": { "type": "integer", "minimum": 1, diff --git a/helm-charts/bifrost/values.yaml b/helm-charts/bifrost/values.yaml index 98ab546fa8f..c024b34976a 100644 --- a/helm-charts/bifrost/values.yaml +++ b/helm-charts/bifrost/values.yaml @@ -247,6 +247,7 @@ bifrost: # mcpCodeModeBindingLevel: "server" # mcpToolSyncInterval: 10 # mcpDisableAutoToolInject: false + # mcpEnableTempTokenAuth: false # hideDeletedVirtualKeysInFilters: false # Omit deleted virtual keys from logs/MCP filter data # whitelistedRoutes: [] # Routes that bypass auth middleware # routingChainMaxDepth: 10 # Maximum depth for routing rule chain evaluation @@ -1075,4 +1076,4 @@ envFrom: [] # Init containers to run before the main application container. # Provide a list of init containers using standard Kubernetes container spec. -initContainers: [] \ No newline at end of file +initContainers: [] diff --git a/transports/bifrost-http/handlers/config.go b/transports/bifrost-http/handlers/config.go index adaf2249f77..57ab4c1f388 100644 --- a/transports/bifrost-http/handlers/config.go +++ b/transports/bifrost-http/handlers/config.go @@ -348,6 +348,7 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { if payload.ClientConfig.MCPToolSyncInterval != currentConfig.MCPToolSyncInterval { updatedConfig.MCPToolSyncInterval = payload.ClientConfig.MCPToolSyncInterval } + updatedConfig.MCPEnableTempTokenAuth = payload.ClientConfig.MCPEnableTempTokenAuth // Reload MCP tool manager config with all current values in one call if shouldReloadMCPToolManagerConfig && h.store.MCPConfig != nil { diff --git a/transports/bifrost-http/handlers/middlewares.go b/transports/bifrost-http/handlers/middlewares.go index 46b4d225400..e5712373a85 100644 --- a/transports/bifrost-http/handlers/middlewares.go +++ b/transports/bifrost-http/handlers/middlewares.go @@ -700,11 +700,12 @@ type AuthMiddleware struct { authConfig atomic.Pointer[configstore.AuthConfig] wsTicketStore *WSTicketStore tempTokensService *temptoken.Service // optional; when nil, temp-token fallback is disabled + tempTokensEnabled atomic.Bool } // InitAuthMiddleware initializes the auth middleware. The tempTokens service -// is optional — when nil, the temp-token fallback path is disabled and the -// middleware behaves exactly as before. +// is optional and still gated by client config — when nil or disabled, the +// temp-token fallback path is skipped. func InitAuthMiddleware(store configstore.ConfigStore, wsTicketStore *WSTicketStore, tempTokensService *temptoken.Service) (*AuthMiddleware, error) { if store == nil { return nil, fmt.Errorf("store is not present") @@ -726,9 +727,11 @@ func InitAuthMiddleware(store configstore.ConfigStore, wsTicketStore *WSTicketSt clientConfig, err := store.GetClientConfig(context.Background()) if err == nil && clientConfig != nil { am.whitelistedRoutes.Store(&clientConfig.WhitelistedRoutes) + am.tempTokensEnabled.Store(clientConfig.MCPEnableTempTokenAuth) } else { emptyRoutes := []string{} am.whitelistedRoutes.Store(&emptyRoutes) + am.tempTokensEnabled.Store(false) } return am, nil @@ -743,6 +746,11 @@ func (m *AuthMiddleware) UpdateWhitelistedRoutes(routes []string) { m.whitelistedRoutes.Store(&routes) } +// UpdateTempTokenAuthEnabled updates whether scoped temp-token fallback auth is accepted. +func (m *AuthMiddleware) UpdateTempTokenAuthEnabled(enabled bool) { + m.tempTokensEnabled.Store(enabled) +} + // tryTempTokenOrUnauthorized is the last-resort auth path: a request that // failed every conventional credential check (no Authorization header, no // valid cookie) is given one more chance to present an X-Bifrost-Temp-Token @@ -756,7 +764,7 @@ func (m *AuthMiddleware) UpdateWhitelistedRoutes(routes []string) { // their own success/failure semantics and silently rescuing a bad password // with a temp token would be surprising. func (m *AuthMiddleware) tryTempTokenOrUnauthorized(ctx *fasthttp.RequestCtx, next fasthttp.RequestHandler) { - if m.tempTokensService != nil { + if m.tempTokensService != nil && m.tempTokensEnabled.Load() { token := string(ctx.Request.Header.Peek("X-Bifrost-Temp-Token")) if token != "" { validated, err := m.tempTokensService.Validate(ctx, token, string(ctx.Method()), string(ctx.Path())) diff --git a/transports/bifrost-http/handlers/session.go b/transports/bifrost-http/handlers/session.go index a93d6714f71..7ac3678bb09 100644 --- a/transports/bifrost-http/handlers/session.go +++ b/transports/bifrost-http/handlers/session.go @@ -44,6 +44,8 @@ func (h *SessionHandler) isAuthEnabled(ctx *fasthttp.RequestCtx) { if h.configStore == nil { SendJSON(ctx, map[string]any{ "is_auth_enabled": false, + "has_valid_token": false, + "auth_type": "none", }) return } @@ -55,6 +57,8 @@ func (h *SessionHandler) isAuthEnabled(ctx *fasthttp.RequestCtx) { if authConfig == nil { SendJSON(ctx, map[string]any{ "is_auth_enabled": false, + "has_valid_token": false, + "auth_type": "none", }) return } @@ -76,9 +80,18 @@ func (h *SessionHandler) isAuthEnabled(ctx *fasthttp.RequestCtx) { SendJSON(ctx, map[string]any{ "is_auth_enabled": authConfig.IsEnabled, "has_valid_token": hasValidToken, + "auth_type": dashboardAuthType(authConfig.IsEnabled), }) } +// dashboardAuthType reports the dashboard session auth mode for frontend flows. +func dashboardAuthType(isEnabled bool) string { + if isEnabled { + return "password" + } + return "none" +} + // login handles POST /api/session/login - Login a user func (h *SessionHandler) login(ctx *fasthttp.RequestCtx) { if h.configStore == nil { @@ -209,7 +222,7 @@ func (h *SessionHandler) issueWSTicket(ctx *fasthttp.RequestCtx) { SendError(ctx, fasthttp.StatusServiceUnavailable, "WebSocket tickets are not available") return } - sessionToken,ok := ctx.UserValue(schemas.BifrostContextKeySessionToken).(string) + sessionToken, ok := ctx.UserValue(schemas.BifrostContextKeySessionToken).(string) if !ok { SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") return diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index a5eaf5572ea..9744eb1f386 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -398,6 +398,7 @@ var DefaultClientConfig = configstore.ClientConfig{ MCPAgentDepth: 10, MCPToolExecutionTimeout: 30, MCPCodeModeBindingLevel: string(schemas.CodeModeBindingLevelServer), + MCPEnableTempTokenAuth: false, HideDeletedVirtualKeysInFilters: false, RoutingChainMaxDepth: governance.DefaultRoutingChainMaxDepth, } diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index ee8c1f58590..7fb095d442d 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -17271,6 +17271,7 @@ func assertDefaultClientConfigValues(t *testing.T, cc configstore.ClientConfig) require.Equal(t, 100, cc.MaxRequestBodySizeMB, "MaxRequestBodySizeMB should default to 100") require.Equal(t, 10, cc.MCPAgentDepth, "MCPAgentDepth should default to 10") require.Equal(t, 30, cc.MCPToolExecutionTimeout, "MCPToolExecutionTimeout should default to 30") + require.Equal(t, false, cc.MCPEnableTempTokenAuth, "MCPEnableTempTokenAuth should default to false") require.Equal(t, false, cc.Compat.ConvertTextToChat, "Compat.ConvertTextToChat should default to false") require.Equal(t, false, cc.Compat.ConvertChatToResponses, "Compat.ConvertChatToResponses should default to false") require.Equal(t, false, cc.Compat.ShouldDropParams, "Compat.ShouldDropParams should default to false") diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index fe81ea98696..89a94b13c65 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -135,8 +135,8 @@ type BifrostHTTPServer struct { devPprofHandler *handlers.DevPprofHandler IntegrationHandler *handlers.IntegrationHandler - AuthMiddleware *handlers.AuthMiddleware - TracingMiddleware *handlers.TracingMiddleware + AuthMiddleware *handlers.AuthMiddleware + TracingMiddleware *handlers.TracingMiddleware WSTicketStore *handlers.WSTicketStore TempTokens *temptoken.Service TempTokenSweepWorker *temptoken.SweepWorker @@ -747,6 +747,7 @@ func (s *BifrostHTTPServer) ReloadClientConfigFromConfigStore(ctx context.Contex // Reloading whitelisted routes from the client config if s.AuthMiddleware != nil { s.AuthMiddleware.UpdateWhitelistedRoutes(config.WhitelistedRoutes) + s.AuthMiddleware.UpdateTempTokenAuthEnabled(config.MCPEnableTempTokenAuth) } // Reloading config in bifrost client if s.Client != nil { diff --git a/transports/config.schema.json b/transports/config.schema.json index 801324d3870..33e731bb49f 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -218,6 +218,11 @@ "description": "DEPRECATED: When true, MCP tools are not automatically injected into requests. Use mcp.tool_manager_config.disable_auto_tool_inject.", "default": false }, + "mcp_enable_temp_token_auth": { + "type": "boolean", + "description": "When true, Bifrost mints and accepts scoped temp tokens for MCP per-user OAuth auth pages.", + "default": false + }, "routing_chain_max_depth": { "type": "integer", "minimum": 1, diff --git a/transports/schema_test/config_schema_test.go b/transports/schema_test/config_schema_test.go index 4235625ba65..ea866e8b3ed 100644 --- a/transports/schema_test/config_schema_test.go +++ b/transports/schema_test/config_schema_test.go @@ -362,6 +362,7 @@ func TestSchemaClientMCPFields(t *testing.T) { "mcp_code_mode_binding_level", "mcp_tool_sync_interval", "mcp_disable_auto_tool_inject", + "mcp_enable_temp_token_auth", } for _, field := range fields { t.Run("client has "+field, func(t *testing.T) { @@ -381,7 +382,8 @@ func TestSchemaClientMCPFields(t *testing.T) { "mcp_tool_execution_timeout": 60, "mcp_code_mode_binding_level": "server", "mcp_tool_sync_interval": 10, - "mcp_disable_auto_tool_inject": false + "mcp_disable_auto_tool_inject": false, + "mcp_enable_temp_token_auth": true } }` if err := validateConfig(t, compiled, config); err != nil { diff --git a/ui/app/login/layout.tsx b/ui/app/login/layout.tsx index 80fd281b09d..4317bed4713 100644 --- a/ui/app/login/layout.tsx +++ b/ui/app/login/layout.tsx @@ -1,61 +1,75 @@ import { ThemeProvider } from "@/components/themeProvider"; import { ReduxProvider } from "@/lib/store/provider"; +import { + DEFAULT_POST_LOGIN_PATH, + getLoginGotoFromSearch, +} from "@/lib/utils/loginGoto"; import { getApiBaseUrl } from "@/lib/utils/port"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { NuqsAdapter } from "nuqs/adapters/tanstack-router"; import LoginPage from "./page"; function RouteComponent() { - return ( - - - -
- -
-
-
-
- ); + return ( + + + +
+ +
+
+
+
+ ); } function PendingComponent() { - return ( - -
-
-
-
- Bifrost -
-
-
Checking authentication...
-
-
-
-
-
- ); + return ( + +
+
+
+
+ Bifrost +
+
+
+ Checking authentication... +
+
+
+
+
+
+ ); } export const Route = createFileRoute("/login")({ - loader: async () => { - let data: { is_auth_enabled: boolean; has_valid_token: boolean } | null = null; - try { - const res = await fetch(`${getApiBaseUrl()}/session/is-auth-enabled`, { - credentials: "include", - }); - if (res.ok) { - data = await res.json(); - } - } catch { - // Fetch failed — fall through to login page - } - if (data && (!data.is_auth_enabled || data.has_valid_token)) { - throw redirect({ to: "/workspace" }); - } - }, - pendingComponent: PendingComponent, - pendingMs: 0, - component: RouteComponent, -}); \ No newline at end of file + loader: async ({ location }) => { + const postLoginPath = + getLoginGotoFromSearch(location.searchStr) ?? DEFAULT_POST_LOGIN_PATH; + let data: { is_auth_enabled: boolean; has_valid_token: boolean } | null = + null; + try { + const res = await fetch(`${getApiBaseUrl()}/session/is-auth-enabled`, { + credentials: "include", + }); + if (res.ok) { + data = await res.json(); + } + } catch { + // Fetch failed — fall through to login page + } + if (data && (!data.is_auth_enabled || data.has_valid_token)) { + throw redirect({ href: postLoginPath }); + } + }, + pendingComponent: PendingComponent, + pendingMs: 0, + component: RouteComponent, +}); diff --git a/ui/app/workspace/config/views/mcpView.tsx b/ui/app/workspace/config/views/mcpView.tsx index a93ec566ec3..9d60b4877f8 100644 --- a/ui/app/workspace/config/views/mcpView.tsx +++ b/ui/app/workspace/config/views/mcpView.tsx @@ -1,327 +1,454 @@ -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { EnvVarInput } from "@/components/ui/envVarInput"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store"; +import { IS_ENTERPRISE } from "@/lib/constants/config"; +import { + getErrorMessage, + useGetCoreConfigQuery, + useUpdateCoreConfigMutation, +} from "@/lib/store"; import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config"; import { EnvVar } from "@/lib/types/schemas"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { useGetAuthTypeQuery } from "@enterprise/lib/store/apis/scimApi"; import { AlertTriangle } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; const envVarEquals = (a?: EnvVar, b?: EnvVar) => - (a?.value ?? "") === (b?.value ?? "") && (a?.env_var ?? "") === (b?.env_var ?? "") && (a?.from_env ?? false) === (b?.from_env ?? false); + (a?.value ?? "") === (b?.value ?? "") && + (a?.env_var ?? "") === (b?.env_var ?? "") && + (a?.from_env ?? false) === (b?.from_env ?? false); export default function MCPView() { - const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update); - const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true }); - const config = bifrostConfig?.client_config; - const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation(); - const [localConfig, setLocalConfig] = useState(DefaultCoreConfig); + const hasSettingsUpdateAccess = useRbac( + RbacResource.Settings, + RbacOperation.Update, + ); + const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true }); + const { data: authType } = useGetAuthTypeQuery(undefined, { + skip: !IS_ENTERPRISE, + }); + const config = bifrostConfig?.client_config; + const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation(); + const [localConfig, setLocalConfig] = useState(DefaultCoreConfig); + const isSCIMEnabled = IS_ENTERPRISE && authType?.type === "sso"; - const [localValues, setLocalValues] = useState<{ - mcp_agent_depth: string; - mcp_tool_execution_timeout: string; - mcp_code_mode_binding_level: string; - mcp_tool_sync_interval: string; - }>({ - mcp_agent_depth: "10", - mcp_tool_execution_timeout: "30", - mcp_code_mode_binding_level: "server", - mcp_tool_sync_interval: "10", - }); + const [localValues, setLocalValues] = useState<{ + mcp_agent_depth: string; + mcp_tool_execution_timeout: string; + mcp_code_mode_binding_level: string; + mcp_tool_sync_interval: string; + }>({ + mcp_agent_depth: "10", + mcp_tool_execution_timeout: "30", + mcp_code_mode_binding_level: "server", + mcp_tool_sync_interval: "10", + }); - useEffect(() => { - if (bifrostConfig && config) { - setLocalConfig(config); - setLocalValues({ - mcp_agent_depth: config?.mcp_agent_depth?.toString() || "10", - mcp_tool_execution_timeout: config?.mcp_tool_execution_timeout?.toString() || "30", - mcp_code_mode_binding_level: config?.mcp_code_mode_binding_level || "server", - mcp_tool_sync_interval: config?.mcp_tool_sync_interval?.toString() || "10", - }); - } - }, [config, bifrostConfig]); + useEffect(() => { + if (bifrostConfig && config) { + setLocalConfig(config); + setLocalValues({ + mcp_agent_depth: config?.mcp_agent_depth?.toString() || "10", + mcp_tool_execution_timeout: + config?.mcp_tool_execution_timeout?.toString() || "30", + mcp_code_mode_binding_level: + config?.mcp_code_mode_binding_level || "server", + mcp_tool_sync_interval: + config?.mcp_tool_sync_interval?.toString() || "10", + }); + } + }, [config, bifrostConfig]); - const hasChanges = useMemo(() => { - if (!config) return false; - const clientURLChanged = !envVarEquals(localConfig.mcp_external_client_url, config.mcp_external_client_url); - return ( - localConfig.mcp_agent_depth !== config.mcp_agent_depth || - localConfig.mcp_tool_execution_timeout !== config.mcp_tool_execution_timeout || - localConfig.mcp_code_mode_binding_level !== (config.mcp_code_mode_binding_level || "server") || - localConfig.mcp_tool_sync_interval !== (config.mcp_tool_sync_interval ?? 10) || - localConfig.mcp_disable_auto_tool_inject !== (config.mcp_disable_auto_tool_inject ?? false) || - clientURLChanged - ); - }, [config, localConfig]); + const hasChanges = useMemo(() => { + if (!config) return false; + const clientURLChanged = !envVarEquals( + localConfig.mcp_external_client_url, + config.mcp_external_client_url, + ); + return ( + localConfig.mcp_agent_depth !== config.mcp_agent_depth || + localConfig.mcp_tool_execution_timeout !== + config.mcp_tool_execution_timeout || + localConfig.mcp_code_mode_binding_level !== + (config.mcp_code_mode_binding_level || "server") || + localConfig.mcp_tool_sync_interval !== + (config.mcp_tool_sync_interval ?? 10) || + localConfig.mcp_disable_auto_tool_inject !== + (config.mcp_disable_auto_tool_inject ?? false) || + localConfig.mcp_enable_temp_token_auth !== + (config.mcp_enable_temp_token_auth ?? false) || + clientURLChanged + ); + }, [config, localConfig]); - const handleAgentDepthChange = useCallback((value: string) => { - setLocalValues((prev) => ({ ...prev, mcp_agent_depth: value })); - const numValue = Number.parseInt(value); - if (!isNaN(numValue) && numValue > 0) { - setLocalConfig((prev) => ({ ...prev, mcp_agent_depth: numValue })); - } - }, []); + const handleAgentDepthChange = useCallback((value: string) => { + setLocalValues((prev) => ({ ...prev, mcp_agent_depth: value })); + const numValue = Number.parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + setLocalConfig((prev) => ({ ...prev, mcp_agent_depth: numValue })); + } + }, []); - const handleToolExecutionTimeoutChange = useCallback((value: string) => { - setLocalValues((prev) => ({ ...prev, mcp_tool_execution_timeout: value })); - const numValue = Number.parseInt(value); - if (!isNaN(numValue) && numValue > 0) { - setLocalConfig((prev) => ({ - ...prev, - mcp_tool_execution_timeout: numValue, - })); - } - }, []); + const handleToolExecutionTimeoutChange = useCallback((value: string) => { + setLocalValues((prev) => ({ ...prev, mcp_tool_execution_timeout: value })); + const numValue = Number.parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + setLocalConfig((prev) => ({ + ...prev, + mcp_tool_execution_timeout: numValue, + })); + } + }, []); - const handleCodeModeBindingLevelChange = useCallback((value: string) => { - setLocalValues((prev) => ({ ...prev, mcp_code_mode_binding_level: value })); - if (value === "server" || value === "tool") { - setLocalConfig((prev) => ({ - ...prev, - mcp_code_mode_binding_level: value, - })); - } - }, []); + const handleCodeModeBindingLevelChange = useCallback((value: string) => { + setLocalValues((prev) => ({ ...prev, mcp_code_mode_binding_level: value })); + if (value === "server" || value === "tool") { + setLocalConfig((prev) => ({ + ...prev, + mcp_code_mode_binding_level: value, + })); + } + }, []); - const handleToolSyncIntervalChange = useCallback((value: string) => { - setLocalValues((prev) => ({ ...prev, mcp_tool_sync_interval: value })); - const numValue = Number.parseInt(value); - if (!isNaN(numValue) && numValue >= 0) { - setLocalConfig((prev) => ({ ...prev, mcp_tool_sync_interval: numValue })); - } - }, []); + const handleToolSyncIntervalChange = useCallback((value: string) => { + setLocalValues((prev) => ({ ...prev, mcp_tool_sync_interval: value })); + const numValue = Number.parseInt(value); + if (!isNaN(numValue) && numValue >= 0) { + setLocalConfig((prev) => ({ ...prev, mcp_tool_sync_interval: numValue })); + } + }, []); - const handleDisableAutoToolInjectChange = useCallback((checked: boolean) => { - setLocalConfig((prev) => ({ - ...prev, - mcp_disable_auto_tool_inject: checked, - })); - }, []); + const handleDisableAutoToolInjectChange = useCallback((checked: boolean) => { + setLocalConfig((prev) => ({ + ...prev, + mcp_disable_auto_tool_inject: checked, + })); + }, []); - const handleClientURLChange = useCallback((value: EnvVar) => { - setLocalConfig((prev) => ({ ...prev, mcp_external_client_url: value })); - }, []); + const handleTempTokenAuthChange = useCallback((checked: boolean) => { + setLocalConfig((prev) => ({ + ...prev, + mcp_enable_temp_token_auth: checked, + })); + }, []); - const handleSave = useCallback(async () => { - try { - const agentDepth = Number.parseInt(localValues.mcp_agent_depth); - const toolTimeout = Number.parseInt(localValues.mcp_tool_execution_timeout); + const handleClientURLChange = useCallback((value: EnvVar) => { + setLocalConfig((prev) => ({ ...prev, mcp_external_client_url: value })); + }, []); - if (isNaN(agentDepth) || agentDepth <= 0) { - toast.error("Max agent depth must be a positive number."); - return; - } + const handleSave = useCallback(async () => { + try { + const agentDepth = Number.parseInt(localValues.mcp_agent_depth); + const toolTimeout = Number.parseInt( + localValues.mcp_tool_execution_timeout, + ); - if (isNaN(toolTimeout) || toolTimeout <= 0) { - toast.error("Tool execution timeout must be a positive number."); - return; - } + if (isNaN(agentDepth) || agentDepth <= 0) { + toast.error("Max agent depth must be a positive number."); + return; + } - if (!bifrostConfig) { - toast.error("Configuration not loaded. Please refresh and try again."); - return; - } - await updateCoreConfig({ - ...bifrostConfig, - client_config: localConfig, - }).unwrap(); - toast.success("MCP settings updated successfully."); - } catch (error) { - toast.error(getErrorMessage(error)); - } - }, [bifrostConfig, localConfig, localValues, updateCoreConfig]); + if (isNaN(toolTimeout) || toolTimeout <= 0) { + toast.error("Tool execution timeout must be a positive number."); + return; + } - return ( -
-
-

MCP Settings

-

Configure MCP (Model Context Protocol) agent and tool settings.

-
-
- {/* Max Agent Depth */} -
-
- -

Maximum depth for MCP agent execution.

-
- handleAgentDepthChange(e.target.value)} - min="1" - /> -
+ if (!bifrostConfig) { + toast.error("Configuration not loaded. Please refresh and try again."); + return; + } + await updateCoreConfig({ + ...bifrostConfig, + client_config: localConfig, + }).unwrap(); + toast.success("MCP settings updated successfully."); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }, [bifrostConfig, localConfig, localValues, updateCoreConfig]); - {/* Tool Execution Timeout */} -
-
- -

Maximum time in seconds for tool execution.

-
- handleToolExecutionTimeoutChange(e.target.value)} - min="1" - /> -
+ return ( +
+
+

MCP Settings

+

+ Configure MCP (Model Context Protocol) agent and tool settings. +

+
+
+ {/* Max Agent Depth */} +
+
+ +

+ Maximum depth for MCP agent execution. +

+
+ handleAgentDepthChange(e.target.value)} + min="1" + /> +
- {/* Tool Sync Interval */} -
-
- -

How often to refresh tool lists from MCP servers. Set to 0 to disable.

-
- handleToolSyncIntervalChange(e.target.value)} - min="0" - /> -
+ {/* Tool Execution Timeout */} +
+
+ +

+ Maximum time in seconds for tool execution. +

+
+ handleToolExecutionTimeoutChange(e.target.value)} + min="1" + /> +
- {/* Disable Auto Tool Injection */} -
-
- -

- When enabled, MCP tools are not automatically included in every request. Tools are only injected when explicitly specified via - request headers (x-bf-mcp-include-tools) and still must be allowed by the virtual key MCP - configuration. -

-
- -
+ {/* Tool Sync Interval */} +
+
+ +

+ How often to refresh tool lists from MCP servers. Set to 0 to + disable. +

+
+ handleToolSyncIntervalChange(e.target.value)} + min="0" + /> +
- {/* Code Mode Binding Level */} -
-
- -

- How tools are exposed in the VFS: server-level (all tools per server) or tool-level (individual tools). -

-
- + {/* Disable Auto Tool Injection */} +
+
+ +

+ When enabled, MCP tools are not automatically included in every + request. Tools are only injected when explicitly specified via + request headers ( + x-bf-mcp-include-tools) and still + must be allowed by the virtual key MCP configuration. +

+
+ +
- {/* Visual Example */} -
-

VFS Structure:

+ {isSCIMEnabled && ( + /* Temp Token Auth */ +
+
+ +

+ When enabled, per-user MCP OAuth links can include a short-lived + scoped token so someone without an active Bifrost dashboard + session can complete the flow. Keep disabled to require normal + dashboard authentication. +

+
+ +
+ )} - {localValues.mcp_code_mode_binding_level === "server" ? ( -
-
-
servers/
-
├─ calculator.py
-
├─ youtube.py
-
└─ weather.py
-
-

All tools per server in a single .py file

-
- ) : ( -
-
-
servers/
-
├─ calculator/
-
├─ add.py
-
└─ subtract.py
-
├─ youtube/
-
├─ GET_CHANNELS.py
-
└─ SEARCH_VIDEOS.py
-
└─ weather/
-
└─ get_forecast.py
-
-

Individual .py file for each tool

-
- )} -
-
- {/* Advanced Settings — collapsed by default so people don't accidentally + {/* Code Mode Binding Level */} +
+
+ +

+ How tools are exposed in the VFS: server-level (all tools per + server) or tool-level (individual tools). +

+
+ + + {/* Visual Example */} +
+

+ VFS Structure: +

+ + {localValues.mcp_code_mode_binding_level === "server" ? ( +
+
+
servers/
+
├─ calculator.py
+
├─ youtube.py
+
└─ weather.py
+
+

+ All tools per server in a single .py file +

+
+ ) : ( +
+
+
servers/
+
├─ calculator/
+
├─ add.py
+
└─ subtract.py
+
├─ youtube/
+
├─ GET_CHANNELS.py
+
└─ SEARCH_VIDEOS.py
+
└─ weather/
+
└─ get_forecast.py
+
+

+ Individual .py file for each tool +

+
+ )} +
+
+ {/* Advanced Settings — collapsed by default so people don't accidentally edit the redirect_uri, which would break already-authorized MCP clients. */} - - - - Advanced Settings - - - -

- Override Bifrost's public base URL when it runs behind a reverse proxy.{" "} - Leave blank to derive the URL from the incoming Host header. Used as the{" "} - redirect_uri Bifrost registers with upstream OAuth providers when it acts as a client to an - MCP server (e.g. Notion or Jira redirect the browser to{" "} - {"/api/oauth/callback"} after login). Supports env var syntax (e.g.{" "} - env.BIFROST_EXTERNAL_URL). -

- - - - Changing this URL can break existing MCP clients - -

- Upstream OAuth providers lock the redirect_uri to whatever was registered initially, - so MCP clients that already completed OAuth will fail with "Invalid redirect URI". To recover, - clear the stored OAuth client credentials for the affected MCP servers and re-authorize so Bifrost re-runs Dynamic - Client Registration with the new URL. -

-
-
-
-
-
-
-
- -
-
- ); -} \ No newline at end of file + + + + Advanced Settings + + + +

+ Override Bifrost's public base URL when it runs behind a reverse + proxy. Leave blank to derive the URL from the incoming{" "} + Host header. Used as the{" "} + redirect_uri Bifrost registers + with upstream OAuth providers when it acts as a client to an MCP + server (e.g. Notion or Jira redirect the browser to{" "} + {"/api/oauth/callback"}{" "} + after login). Supports env var syntax (e.g.{" "} + env.BIFROST_EXTERNAL_URL). +

+ + + + + Changing this URL can break existing MCP clients + + +

+ Upstream OAuth providers lock the{" "} + redirect_uri to whatever + was registered initially, so MCP clients that already + completed OAuth will fail with{" "} + "Invalid redirect URI". To recover, clear + the stored OAuth client credentials for the affected MCP + servers and re-authorize so Bifrost re-runs Dynamic Client + Registration with the new URL. +

+
+
+
+
+
+
+
+ +
+
+ ); +} diff --git a/ui/app/workspace/mcp-sessions/auth/page.tsx b/ui/app/workspace/mcp-sessions/auth/page.tsx index 12ef2699285..b350e694cc6 100644 --- a/ui/app/workspace/mcp-sessions/auth/page.tsx +++ b/ui/app/workspace/mcp-sessions/auth/page.tsx @@ -9,9 +9,11 @@ // /api/oauth/callback which completes the flow server-side. import FullPageLoader from "@/components/fullPageLoader"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; +import { getActiveTempToken } from "@/lib/store/apis/tempToken"; import { getErrorMessage, useGetMCPFlowDetailQuery, @@ -25,10 +27,13 @@ import { Fingerprint, KeyRound, Loader2, + LogIn, ShieldCheck, + TriangleAlert, UserRound, } from "lucide-react"; import { useQueryState } from "nuqs"; +import { useMemo, useState } from "react"; export default function MCPSessionsAuthPage() { const { toast } = useToast(); @@ -41,6 +46,27 @@ export default function MCPSessionsAuthPage() { error, } = useGetMCPFlowDetailQuery(flowId ?? "", { skip }); const [startFlow, { isLoading: starting }] = useStartMCPFlowMutation(); + const { data: authState } = useIsAuthEnabledQuery(); + const [usingTempToken] = useState(() => getActiveTempToken() !== null); + const loginGoto = useMemo(() => { + if (typeof window !== "undefined") { + return `${window.location.pathname}${window.location.search}`; + } + if (flowId) { + return `/workspace/mcp-sessions/auth?flow=${encodeURIComponent(flowId)}`; + } + return "/workspace/mcp-sessions/auth"; + }, [flowId]); + const loginHref = useMemo( + () => `/login?goto=${encodeURIComponent(loginGoto)}`, + [loginGoto], + ); + const showLoginOption = + usingTempToken && + authState?.is_auth_enabled === true && + authState.has_valid_token === false; + const showTempTokenSSOWarning = + showLoginOption && authState.auth_type === "sso"; if (!flowId) { return ( @@ -164,6 +190,32 @@ export default function MCPSessionsAuthPage() { )}

+ {showTempTokenSSOWarning ? ( + + + Temporary token in use + +

+ If you continue with the temporary link, activity from this + credential will be attributed to the bound key instead of your + user account. +

+ +
+
+ ) : null} +
{isReauth ? "Re-authenticate" : "Authenticate"} + {showLoginOption && !showTempTokenSSOWarning ? ( + + ) : null} diff --git a/ui/lib/store/apis/sessionApi.ts b/ui/lib/store/apis/sessionApi.ts index f023346022d..eb20d5e7ec8 100644 --- a/ui/lib/store/apis/sessionApi.ts +++ b/ui/lib/store/apis/sessionApi.ts @@ -12,6 +12,7 @@ export interface LoginResponse { export interface IsAuthEnabledResponse { is_auth_enabled: boolean; has_valid_token: boolean; + auth_type?: "sso" | "password" | "none"; } export interface LogoutResponse { diff --git a/ui/lib/types/config.ts b/ui/lib/types/config.ts index d0e593267a7..b9e336e2ef0 100644 --- a/ui/lib/types/config.ts +++ b/ui/lib/types/config.ts @@ -495,6 +495,7 @@ export interface CoreConfig { mcp_code_mode_binding_level?: string; mcp_tool_sync_interval: number; mcp_disable_auto_tool_inject: boolean; + mcp_enable_temp_token_auth: boolean; async_job_result_ttl: number; required_headers: string[]; logging_headers: string[]; @@ -524,6 +525,7 @@ export const DefaultCoreConfig: CoreConfig = { mcp_code_mode_binding_level: "server", mcp_tool_sync_interval: 10, mcp_disable_auto_tool_inject: false, + mcp_enable_temp_token_auth: false, async_job_result_ttl: 3600, allowed_headers: [], required_headers: [], diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index 36b785e935a..334fc9fbbd3 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -675,6 +675,7 @@ export const coreConfigSchema = z.object({ mcp_tool_execution_timeout: z.number().min(1).default(30), mcp_code_mode_binding_level: z.enum(["server", "tool"]).default("server"), mcp_disable_auto_tool_inject: z.boolean().default(false), + mcp_enable_temp_token_auth: z.boolean().default(false), }); // Bifrost config schema diff --git a/ui/lib/utils/loginGoto.ts b/ui/lib/utils/loginGoto.ts new file mode 100644 index 00000000000..1884b87f305 --- /dev/null +++ b/ui/lib/utils/loginGoto.ts @@ -0,0 +1,30 @@ +export const DEFAULT_POST_LOGIN_PATH = "/workspace"; + +export function normalizeLoginGoto( + value: string | null | undefined, +): string | null { + if ( + !value || + !isWorkspaceRoute(value) || + value.startsWith("//") || + value.includes("\\") || + value.includes("\n") || + value.includes("\r") + ) { + return null; + } + return value; +} + +export function getLoginGotoFromSearch(search: string): string | null { + return normalizeLoginGoto(new URLSearchParams(search).get("goto")); +} + +function isWorkspaceRoute(value: string): boolean { + return ( + value === DEFAULT_POST_LOGIN_PATH || + value.startsWith(`${DEFAULT_POST_LOGIN_PATH}/`) || + value.startsWith(`${DEFAULT_POST_LOGIN_PATH}?`) || + value.startsWith(`${DEFAULT_POST_LOGIN_PATH}#`) + ); +}