Skip to content
Merged
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
6 changes: 6 additions & 0 deletions framework/configstore/clientconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
Expand Down
35 changes: 35 additions & 0 deletions framework/configstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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{{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions framework/configstore/tables/clientconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 37 additions & 13 deletions framework/oauth2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/url"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/google/uuid"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions framework/oauth2/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion helm-charts/bifrost/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -1666,4 +1669,4 @@ Call this template at the beginning of deployment/stateful templates
{{- end }}
{{- end }}

{{- end -}}
{{- end -}}
5 changes: 5 additions & 0 deletions helm-charts/bifrost/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion helm-charts/bifrost/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: []
initContainers: []
1 change: 1 addition & 0 deletions transports/bifrost-http/handlers/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 11 additions & 3 deletions transports/bifrost-http/handlers/middlewares.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()))
Expand Down
15 changes: 14 additions & 1 deletion transports/bifrost-http/handlers/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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"
}
Comment thread
roroghost17 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// login handles POST /api/session/login - Login a user
func (h *SessionHandler) login(ctx *fasthttp.RequestCtx) {
if h.configStore == nil {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading