Skip to content
Open
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
29 changes: 29 additions & 0 deletions framework/configstore/clientconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,19 @@ type ClientConfig struct {
HideDeletedVirtualKeysInFilters bool `json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys from logs/MCP filter data
RoutingChainMaxDepth int `json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10)
MCPExternalClientURL *schemas.SecretVar `json:"mcp_external_client_url,omitempty"` // Public base URL used as redirect_uri when Bifrost acts as an OAuth client to upstream MCP servers. Supports env var syntax ("env.MY_VAR")
MCPServerAuthMode tables.MCPServerAuthMode `json:"mcp_server_auth_mode,omitempty"` // How /mcp authenticates inbound clients: headers (default), both, or oauth.
OAuth2ServerConfig *tables.OAuth2ServerConfig `json:"oauth2_server_config,omitempty"` // OAuth2 AS-specific settings (IssuerURL, token TTLs). Only relevant when MCPServerAuthMode is both or oauth.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized)
DumpErrorsInConsoleLogs bool `json:"dump_errors_in_console_logs"` // Dump error details in console logs
}

// IsMCPOAuthDiscoveryEnabled reports whether the well-known OAuth discovery
// endpoints and JWKS endpoint should be live. True when MCPServerAuthMode is
// both or oauth.
func (c *ClientConfig) IsMCPOAuthDiscoveryEnabled() bool {
return c.MCPServerAuthMode == tables.MCPServerAuthModeBoth || c.MCPServerAuthMode == tables.MCPServerAuthModeOAuth
}

// UnmarshalJSON defaults all bool fields to true when absent from JSON.
func (c *ClientConfig) UnmarshalJSON(data []byte) error {
type ClientConfigAlias ClientConfig
Expand Down Expand Up @@ -372,6 +381,26 @@ func (c *ClientConfig) GenerateClientConfigHash() (string, error) {
}
}

// Only hash non-default values to avoid legacy config hash churn on upgrade —
// existing configs carry an empty auth mode and a nil OAuth2 server config.
if c.MCPServerAuthMode != "" {
hash.Write([]byte("mcpServerAuthMode:" + string(c.MCPServerAuthMode)))
}
// Hash OAuth2ServerConfig field-by-field (not via Marshal) for a stable,
// deterministic byte stream that does not depend on serializer field order.
if c.OAuth2ServerConfig != nil {
oc := c.OAuth2ServerConfig
if oc.IssuerURL.IsSet() {
if oc.IssuerURL.IsFromEnv() {
hash.Write([]byte("oauth2IssuerURL:env:" + oc.IssuerURL.GetRawRef()))
} else {
hash.Write([]byte("oauth2IssuerURL:val:" + oc.IssuerURL.GetValue()))
}
}
hash.Write([]byte("oauth2AuthCodeTTL:" + strconv.Itoa(oc.AuthCodeTTL)))
hash.Write([]byte("oauth2AccessTokenTTL:" + strconv.Itoa(oc.AccessTokenTTL)))
}

return hex.EncodeToString(hash.Sum(nil)), nil
}

Expand Down
46 changes: 46 additions & 0 deletions framework/configstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ var configstoreMigrationSteps = []migrationStep{
{IDs: []string{"add_customer_name_unique_constraint_dedup", "add_customer_name_unique_constraint_index"}, run: migrationAddCustomerNameUniqueConstraint},
{IDs: []string{"null_legacy_customer_budget_id_refs"}, run: migrationNullLegacyCustomerBudgetID},
{IDs: []string{"add_skills_repo_tables"}, run: migrationAddSkillsRepoTables},
{IDs: []string{"add_oauth2_server_tables"}, run: migrationAddOAuth2ServerTables},
{IDs: []string{"add_dump_errors_in_console_logs_column"}, run: migrationAddDumpErrorsInConsoleLogsColumn},
}

Expand Down Expand Up @@ -10068,3 +10069,48 @@ func migrationAddCustomerNameUniqueConstraint(ctx context.Context, db *gorm.DB,
},
})
}

func migrationAddOAuth2ServerTables(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
migrationName := "add_oauth2_server_tables"
logger.Info("[configstore] starting migration %s", migrationName)
defer logger.Info("[configstore] finished migration %s", migrationName)
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{
{
ID: migrationName,
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasColumn(&tables.TableClientConfig{}, "mcp_server_auth_mode") {
if err := mg.AddColumn(&tables.TableClientConfig{}, "MCPServerAuthMode"); err != nil {
return fmt.Errorf("add mcp_server_auth_mode column: %w", err)
}
}
if !mg.HasColumn(&tables.TableClientConfig{}, "oauth2_server_config_json") {
if err := mg.AddColumn(&tables.TableClientConfig{}, "OAuth2ServerConfigJSON"); err != nil {
return fmt.Errorf("add oauth2_server_config_json column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableClientConfig{}, "oauth2_server_config_json") {
if err := mg.DropColumn(&tables.TableClientConfig{}, "OAuth2ServerConfigJSON"); err != nil {
return fmt.Errorf("drop oauth2_server_config_json column: %w", err)
}
}
if mg.HasColumn(&tables.TableClientConfig{}, "mcp_server_auth_mode") {
if err := mg.DropColumn(&tables.TableClientConfig{}, "MCPServerAuthMode"); err != nil {
return fmt.Errorf("drop mcp_server_auth_mode column: %w", err)
}
}
return nil
},
},
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running db migration %s: %w", migrationName, err)
}
return nil
}
106 changes: 106 additions & 0 deletions framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package configstore

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"sort"
Expand Down Expand Up @@ -267,6 +271,8 @@ func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientC
AllowPerRequestContentStorageOverride: config.AllowPerRequestContentStorageOverride,
AllowPerRequestRawOverride: config.AllowPerRequestRawOverride,
AllowDirectKeys: config.AllowDirectKeys,
MCPServerAuthMode: config.MCPServerAuthMode,
OAuth2ServerConfig: config.OAuth2ServerConfig,
ConfigHash: config.ConfigHash,
}
// Delete existing client config and create new one in a transaction.
Expand Down Expand Up @@ -532,6 +538,8 @@ func (s *RDBConfigStore) GetClientConfig(ctx context.Context) (*ClientConfig, er
AllowPerRequestContentStorageOverride: dbConfig.AllowPerRequestContentStorageOverride,
AllowPerRequestRawOverride: dbConfig.AllowPerRequestRawOverride,
AllowDirectKeys: dbConfig.AllowDirectKeys,
MCPServerAuthMode: dbConfig.MCPServerAuthMode,
OAuth2ServerConfig: dbConfig.OAuth2ServerConfig,
ConfigHash: dbConfig.ConfigHash,
}, nil
}
Expand Down Expand Up @@ -6675,3 +6683,101 @@ func (s *RDBConfigStore) ReconcileMCPHeadersAfterMCPChange(ctx context.Context,
return nil
})
}

// GetOAuth2SigningKey returns the signing key, creating and persisting a new
// RS2048 keypair if none exists yet.
func (s *RDBConfigStore) GetOAuth2SigningKey(ctx context.Context) (*tables.OAuth2SigningKey, error) {
key, err := s.loadOAuth2SigningKey(ctx)
if err != nil {
if errors.Is(err, ErrNotFound) {
// No key persisted yet — generate and store one atomically.
return s.createOAuth2SigningKey(ctx)
}
return nil, err
}
return key, nil
}

// loadOAuth2SigningKey reads and decrypts the persisted signing key. It returns
// ErrNotFound when no key has been generated yet.
func (s *RDBConfigStore) loadOAuth2SigningKey(ctx context.Context) (*tables.OAuth2SigningKey, error) {
row, err := s.GetConfig(ctx, tables.GovernanceConfigKeyOAuth2SigningKey)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get oauth2 signing key: %w", err)
}
if row == nil || row.Value == "" {
return nil, ErrNotFound
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
var key tables.OAuth2SigningKey
if err := json.Unmarshal([]byte(row.Value), &key); err != nil {
return nil, fmt.Errorf("unmarshal oauth2 signing key: %w", err)
}
// Decrypt off the stored marker, not the live encrypt.IsEnabled() flag, so a
// key persisted while encryption was disabled is not mangled once encryption
// is later turned on (mirrors the AfterFind hooks on secret-bearing tables).
if err := key.Decrypt(); err != nil {
return nil, err
}
return &key, nil
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

func (s *RDBConfigStore) createOAuth2SigningKey(ctx context.Context) (*tables.OAuth2SigningKey, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generate RSA key: %w", err)
}

privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, fmt.Errorf("marshal RSA private key: %w", err)
}
privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}))

pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshal RSA public key: %w", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))

key := &tables.OAuth2SigningKey{
KID: uuid.New().String(),
PrivateKeyPEM: privPEM,
PublicKeyPEM: pubPEM,
}

// Encrypt the private key in place and stamp EncryptionStatus before storage
// (mirrors the BeforeSave hooks on secret-bearing tables).
if err := key.Encrypt(); err != nil {
return nil, err
}

data, err := json.Marshal(key)
if err != nil {
return nil, fmt.Errorf("marshal oauth2 signing key: %w", err)
}

// Persist atomically with INSERT ... ON CONFLICT DO NOTHING so concurrent
// first-use callers cannot last-writer-wins different keypairs. If the row
// already exists (RowsAffected == 0), another caller won the race — reload
// and return the persisted key so every caller agrees on a single keypair.
res := s.DB().WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoNothing: true,
}).Create(&tables.TableGovernanceConfig{
Key: tables.GovernanceConfigKeyOAuth2SigningKey,
Value: string(data),
})
if res.Error != nil {
return nil, fmt.Errorf("persist oauth2 signing key: %w", res.Error)
}
if res.RowsAffected == 0 {
return s.loadOAuth2SigningKey(ctx)
}

// We won the insert — return with plaintext private key for immediate use.
key.PrivateKeyPEM = privPEM
return key, nil
}
4 changes: 4 additions & 0 deletions framework/configstore/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,10 @@ type ConfigStore interface {
// pool, whose connections carry no cached plans. SQLite is a no-op.
RefreshConnectionPool(ctx context.Context) error

// GetOAuth2SigningKey returns the signing key, creating and persisting one
// on first call. Always returns a usable key — never nil on a nil error.
GetOAuth2SigningKey(ctx context.Context) (*tables.OAuth2SigningKey, error)

// Cleanup
Close(ctx context.Context) error
}
Expand Down
47 changes: 39 additions & 8 deletions framework/configstore/tables/clientconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ type TableClientConfig struct {
CompatShouldDropParams bool `gorm:"column:compat_should_drop_params;default:false" json:"-"`
CompatShouldConvertParams bool `gorm:"column:compat_should_convert_params;default:false" json:"-"`

// MCPServerAuthMode controls how /mcp authenticates inbound clients.
// Stored as a plain varchar column so it can be read without JSON parsing.
MCPServerAuthMode MCPServerAuthMode `gorm:"column:mcp_server_auth_mode;type:varchar(20);not null;default:'headers'" json:"mcp_server_auth_mode"`
// OAuth2ServerConfigJSON holds the OAuth2 AS-specific settings (IssuerURL,
// AuthCodeTTL, AccessTokenTTL) as a JSON blob. Only relevant when
// MCPServerAuthMode is both or oauth. Deserialized into OAuth2ServerConfig
// by AfterFind. The explicit column name avoids GORM deriving the leading
// acronym as "o_auth2_..." from the field name.
OAuth2ServerConfigJSON string `gorm:"column:oauth2_server_config_json;type:text" json:"-"`

// Config hash is used to detect the changes synced from config.json file
// Every time we sync the config.json file, we will update the config hash
ConfigHash string `gorm:"type:varchar(255);null" json:"config_hash"`
Expand All @@ -57,14 +67,15 @@ type TableClientConfig struct {
UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"`

// Virtual fields for runtime use (not stored in DB)
PrometheusLabels []string `gorm:"-" json:"prometheus_labels"`
AllowedOrigins []string `gorm:"-" json:"allowed_origins,omitempty"`
AllowedHeaders []string `gorm:"-" json:"allowed_headers,omitempty"`
RequiredHeaders []string `gorm:"-" json:"required_headers,omitempty"`
LoggingHeaders []string `gorm:"-" json:"logging_headers,omitempty"`
WhitelistedRoutes []string `gorm:"-" json:"whitelisted_routes,omitempty"`
HeaderFilterConfig *GlobalHeaderFilterConfig `gorm:"-" json:"header_filter_config,omitempty"`
Metadata map[string]any `gorm:"-" json:"metadata,omitempty"`
PrometheusLabels []string `gorm:"-" json:"prometheus_labels"`
AllowedOrigins []string `gorm:"-" json:"allowed_origins,omitempty"`
AllowedHeaders []string `gorm:"-" json:"allowed_headers,omitempty"`
RequiredHeaders []string `gorm:"-" json:"required_headers,omitempty"`
LoggingHeaders []string `gorm:"-" json:"logging_headers,omitempty"`
WhitelistedRoutes []string `gorm:"-" json:"whitelisted_routes,omitempty"`
HeaderFilterConfig *GlobalHeaderFilterConfig `gorm:"-" json:"header_filter_config,omitempty"`
Metadata map[string]any `gorm:"-" json:"metadata,omitempty"`
OAuth2ServerConfig *OAuth2ServerConfig `gorm:"-" json:"oauth2_server_config,omitempty"`
}

// TableName sets the table name for each model
Expand Down Expand Up @@ -153,6 +164,16 @@ func (cc *TableClientConfig) BeforeSave(tx *gorm.DB) error {
cc.MetadataJSON = string(data)
}

if cc.OAuth2ServerConfig != nil {
data, err := json.Marshal(cc.OAuth2ServerConfig)
if err != nil {
return err
}
cc.OAuth2ServerConfigJSON = string(data)
} else {
cc.OAuth2ServerConfigJSON = ""
}

return nil
}

Expand Down Expand Up @@ -212,5 +233,15 @@ func (cc *TableClientConfig) AfterFind(tx *gorm.DB) error {
cc.Metadata = nil
}

if cc.OAuth2ServerConfigJSON != "" {
var authCfg OAuth2ServerConfig
if err := json.Unmarshal([]byte(cc.OAuth2ServerConfigJSON), &authCfg); err != nil {
return err
}
cc.OAuth2ServerConfig = &authCfg
} else {
cc.OAuth2ServerConfig = nil
}

return nil
}
Loading
Loading