From 04ec54fa4ed7ab105cc106faef58be95faaaf4b6 Mon Sep 17 00:00:00 2001 From: Pratham-Mishra04 Date: Tue, 16 Jun 2026 21:02:17 +0530 Subject: [PATCH] feat: adds mcp server oauth tables --- framework/configstore/clientconfig.go | 29 +++ framework/configstore/migrations.go | 46 +++++ framework/configstore/rdb.go | 106 ++++++++++ framework/configstore/store.go | 4 + framework/configstore/tables/clientconfig.go | 47 ++++- framework/configstore/tables/oauth2_server.go | 129 +++++++++++++ framework/temptoken/scope.go | 8 + transports/bifrost-http/handlers/config.go | 33 +++- .../bifrost-http/handlers/governance_test.go | 4 +- .../bifrost-http/handlers/middlewares.go | 5 + .../bifrost-http/handlers/oauth2_discovery.go | 182 ++++++++++++++++++ .../bifrost-http/handlers/oauth2_utils.go | 45 +++++ transports/bifrost-http/lib/config_test.go | 3 + transports/bifrost-http/server/server.go | 1 + transports/config.schema.json | 39 ++++ 15 files changed, 670 insertions(+), 11 deletions(-) create mode 100644 framework/configstore/tables/oauth2_server.go create mode 100644 transports/bifrost-http/handlers/oauth2_discovery.go create mode 100644 transports/bifrost-http/handlers/oauth2_utils.go diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 72a95a3226..8029a51fdb 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -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. 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 @@ -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 } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index 65fcc7a293..169004e922 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -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}, } @@ -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 + }, + }, + }) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running db migration %s: %w", migrationName, err) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 3568b6ba08..13bcd45355 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -2,7 +2,11 @@ package configstore import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "fmt" "sort" @@ -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. @@ -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 } @@ -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 + } + 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 +} + +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 +} diff --git a/framework/configstore/store.go b/framework/configstore/store.go index fbb9f80680..dc1c8310da 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -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 } diff --git a/framework/configstore/tables/clientconfig.go b/framework/configstore/tables/clientconfig.go index 642100f546..3f1a6cf6ac 100644 --- a/framework/configstore/tables/clientconfig.go +++ b/framework/configstore/tables/clientconfig.go @@ -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"` @@ -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 @@ -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 } @@ -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 } diff --git a/framework/configstore/tables/oauth2_server.go b/framework/configstore/tables/oauth2_server.go new file mode 100644 index 0000000000..4a142c4634 --- /dev/null +++ b/framework/configstore/tables/oauth2_server.go @@ -0,0 +1,129 @@ +package tables + +import ( + "fmt" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/encrypt" +) + +// MCPServerAuthMode controls how Bifrost's /mcp endpoint authenticates inbound +// MCP clients. It does not affect how Bifrost authenticates to upstream MCP +// servers (governed by MCPClientConfig.AuthType). +type MCPServerAuthMode string + +const ( + DefaultAuthCodeTTL = 600 // 10 minutes + DefaultAccessTokenTTL = 600 // 10 minutes +) + +const ( + // MCPServerAuthModeHeaders accepts header credentials only: x-bf-vk, + // Authorization: Bearer , x-api-key, x-bf-mcp-session-id. + // Discovery endpoints return 404. Default — today's behavior. + MCPServerAuthModeHeaders MCPServerAuthMode = "headers" + + // MCPServerAuthModeBoth accepts both header credentials and Bifrost-issued + // JWTs. Discovery endpoints are live; existing header-credential clients + // that never receive a 401 are unaffected. + MCPServerAuthModeBoth MCPServerAuthMode = "both" + + // MCPServerAuthModeOAuth accepts Bifrost-issued JWTs only. Header + // credentials (VK / api-key / session) are rejected on /mcp. + // WARNING: existing virtual-key MCP integrations will stop working. + MCPServerAuthModeOAuth MCPServerAuthMode = "oauth" +) + +// OAuth2ServerConfig holds the OAuth2 authorization-server settings serialized +// as JSON into config_client.oauth2_server_config_json. Only meaningful when +// MCPServerAuthMode is MCPServerAuthModeBoth or MCPServerAuthModeOAuth. +// Not a table of its own. +type OAuth2ServerConfig struct { + // IssuerURL is Bifrost's OAuth authorization-server identity — it appears + // as the `issuer` in discovery documents and as the `iss` claim in every + // issued JWT. Supports env var syntax ("env.MY_VAR"). When empty, + // BuildBaseURL(request) is used as a per-request fallback, which works for + // single-host / dev deployments. Multi-host or reverse-proxy deployments + // MUST set a stable value; token verification fails when the Host header + // differs across nodes. + IssuerURL *schemas.SecretVar `json:"issuer_url,omitempty"` + + // AuthCodeTTL is the lifetime of the one-time authorization code issued by + // /oauth2/authorize and exchanged at /oauth2/token (seconds, default 600). + // The code is single-use — it is invalidated the moment it is exchanged or + // expires. Short TTL is intentional: if the window lapses the user simply + // re-authenticates. + AuthCodeTTL int `json:"auth_code_ttl"` + + // AccessTokenTTL is the lifetime of the issued JWT Bearer token (seconds, + // default 600 = 10 min). When the token expires the client uses its refresh + // token to silently obtain a new one without any user interaction. + AccessTokenTTL int `json:"access_token_ttl"` + + // Refresh tokens have no hard expiry — they are invalidated only by: + // - rotation on use (each /oauth2/token refresh call issues a new token + // and immediately invalidates the previous one) + // - bf_sub liveness check on refresh (VK deleted / user deactivated → + // invalid_grant, forcing re-authentication) + // - explicit revocation via the Connected Clients UI + // - EnforceAuthOnInference toggled on (revokes all session-mode grants) + // No RefreshTokenTTL field exists by design — there is no timer, only + // explicit invalidation paths. +} + +// DefaultOAuth2ServerConfig returns sensible defaults for the AS-specific settings. +func DefaultOAuth2ServerConfig() *OAuth2ServerConfig { + return &OAuth2ServerConfig{ + AuthCodeTTL: DefaultAuthCodeTTL, + AccessTokenTTL: DefaultAccessTokenTTL, + } +} + +// OAuth2SigningKey holds the single RS256 keypair used to sign Bifrost-issued +// JWTs. Stored as JSON in governance_config under GovernanceConfigKeyOAuth2SigningKey. +// The private key PEM is encrypted via framework/encrypt before storage. +type OAuth2SigningKey struct { + KID string `json:"kid"` // key ID embedded in JWT headers + PrivateKeyPEM string `json:"private_key_pem"` // encrypted at rest via framework/encrypt when EncryptionStatus is "encrypted" + PublicKeyPEM string `json:"public_key_pem"` // plaintext; public key is not sensitive + EncryptionStatus string `json:"encryption_status,omitempty"` // EncryptionStatusPlainText or EncryptionStatusEncrypted — records whether PrivateKeyPEM was encrypted at write time so reads do not depend on the current encrypt.IsEnabled() state +} + +// Encrypt encrypts PrivateKeyPEM in place and stamps EncryptionStatus, mirroring +// the BeforeSave hooks on secret-bearing tables. Because the signing key is +// persisted as a JSON blob inside governance_config (not its own GORM row) it +// cannot rely on GORM hooks, so callers invoke this before marshaling/storing. +func (k *OAuth2SigningKey) Encrypt() error { + if encrypt.IsEnabled() && k.PrivateKeyPEM != "" { + if err := encryptString(&k.PrivateKeyPEM); err != nil { + return fmt.Errorf("failed to encrypt oauth2 signing key: %w", err) + } + k.EncryptionStatus = EncryptionStatusEncrypted + } else { + k.EncryptionStatus = EncryptionStatusPlainText + } + return nil +} + +// Decrypt decrypts PrivateKeyPEM in place based on the stored EncryptionStatus +// marker, mirroring the AfterFind hooks on secret-bearing tables. Keys written +// before encryption was enabled are marked plain_text and returned as-is. +// Records written before this marker existed carry an empty status and fall back +// to the historical encrypt.IsEnabled() behavior. +func (k *OAuth2SigningKey) Decrypt() error { + if k.PrivateKeyPEM == "" { + return nil + } + shouldDecrypt := k.EncryptionStatus == EncryptionStatusEncrypted || + (k.EncryptionStatus == "" && encrypt.IsEnabled()) + if shouldDecrypt { + if err := decryptString(&k.PrivateKeyPEM); err != nil { + return fmt.Errorf("failed to decrypt oauth2 signing key: %w", err) + } + } + return nil +} + +// GovernanceConfigKeyOAuth2SigningKey is the governance_config key under which +// the OAuth2 signing keypair is stored. +const GovernanceConfigKeyOAuth2SigningKey = "oauth2_signing_key" diff --git a/framework/temptoken/scope.go b/framework/temptoken/scope.go index e7cb789891..bb9660f4d7 100644 --- a/framework/temptoken/scope.go +++ b/framework/temptoken/scope.go @@ -23,6 +23,14 @@ const ( // flow endpoints. Bound resource_id is the headers flow ID. Parallel // of MCPAuthScopeName for the per-user-headers surface. MCPHeadersAuthScopeName = "mcp_headers_auth" + + // OAuth2ConsentScopeName names the scope that authorizes the OAuth2 + // downstream consent page to call the consent flow endpoints. Bound + // resource_id is the authorize-request flow ID. The consent page is + // public (outside /workspace) so it cannot rely on dashboard auth; + // this token is the sole credential binding the browser session to the + // pending authorization request. + OAuth2ConsentScopeName = "oauth2_consent" ) // RoutePattern is one (method, path) pair a Scope grants access to. The path diff --git a/transports/bifrost-http/handlers/config.go b/transports/bifrost-http/handlers/config.go index ec98657b82..45985c3178 100644 --- a/transports/bifrost-http/handlers/config.go +++ b/transports/bifrost-http/handlers/config.go @@ -534,10 +534,41 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { updatedConfig.RoutingChainMaxDepth = payload.ClientConfig.RoutingChainMaxDepth } - // Update external base URLs for OAuth server metadata and client redirect_uri (nil clears each override). + // Update external base URL for OAuth client redirect_uri (nil clears the override). // Validation is performed up front in this handler so a failure here cannot leave the process in a partial state. updatedConfig.MCPExternalClientURL = payload.ClientConfig.MCPExternalClientURL + // Validate the inbound MCP auth mode against the allowed enum before persisting + // (config.schema.json is the source of truth: headers | both | oauth). + switch payload.ClientConfig.MCPServerAuthMode { + case "", configstoreTables.MCPServerAuthModeHeaders, configstoreTables.MCPServerAuthModeBoth, configstoreTables.MCPServerAuthModeOAuth: + // valid; empty means the field was omitted from a partial update + default: + SendError(ctx, fasthttp.StatusBadRequest, "mcp_server_auth_mode must be one of: headers, both, oauth") + return + } + + // oauth2_server_config only applies when discovery is enabled (both | oauth). + // Evaluate against the effective mode so a partial update that supplies only + // the config cannot smuggle it in while the stored mode is headers. + effectiveAuthMode := payload.ClientConfig.MCPServerAuthMode + if effectiveAuthMode == "" { + effectiveAuthMode = currentConfig.MCPServerAuthMode + } + if payload.ClientConfig.OAuth2ServerConfig != nil && effectiveAuthMode == configstoreTables.MCPServerAuthModeHeaders { + SendError(ctx, fasthttp.StatusBadRequest, "oauth2_server_config is only valid when mcp_server_auth_mode is both or oauth") + return + } + + // Only update each field when explicitly provided so partial /api/config + // payloads do not clear stored values (matches the MCP field handling above). + if payload.ClientConfig.MCPServerAuthMode != "" { + updatedConfig.MCPServerAuthMode = payload.ClientConfig.MCPServerAuthMode + } + if payload.ClientConfig.OAuth2ServerConfig != nil { + updatedConfig.OAuth2ServerConfig = payload.ClientConfig.OAuth2ServerConfig + } + // Handle HeaderFilterConfig changes if !headerFilterConfigEqual(payload.ClientConfig.HeaderFilterConfig, currentConfig.HeaderFilterConfig) { // Validate that no security headers are in the allowlist or denylist diff --git a/transports/bifrost-http/handlers/governance_test.go b/transports/bifrost-http/handlers/governance_test.go index 8a18acecae..69a1b66685 100644 --- a/transports/bifrost-http/handlers/governance_test.go +++ b/transports/bifrost-http/handlers/governance_test.go @@ -1891,7 +1891,7 @@ func TestGetVirtualKeyQuota_EndToEndWithRealStore(t *testing.T) { func TestGetVirtualKeyQuota_WindowClampedToBudgetCreation(t *testing.T) { SetLogger(&mockLogger{}) ctx := context.Background() - periodStart := time.Date(2026, time.June, 24, 0, 0, 0, 0, time.UTC) // backdated "1d" boundary (midnight) + periodStart := time.Date(2026, time.June, 24, 0, 0, 0, 0, time.UTC) // backdated "1d" boundary (midnight) createdAt := time.Date(2026, time.June, 24, 11, 56, 42, 0, time.UTC) // budget created mid-day store, err := configstore.NewConfigStore(ctx, &configstore.Config{ @@ -1908,7 +1908,7 @@ func TestGetVirtualKeyQuota_WindowClampedToBudgetCreation(t *testing.T) { vk := &configstoreTables.TableVirtualKey{ ID: vkID, Name: "Clamp", - Value: *schemas.NewSecretVar("sk-bf-clamp-secret"), + Value: "sk-bf-clamp-secret", IsActive: &active, } if err := store.CreateVirtualKey(ctx, vk); err != nil { diff --git a/transports/bifrost-http/handlers/middlewares.go b/transports/bifrost-http/handlers/middlewares.go index 4474700a1a..3907b223c4 100644 --- a/transports/bifrost-http/handlers/middlewares.go +++ b/transports/bifrost-http/handlers/middlewares.go @@ -916,6 +916,11 @@ func (m *AuthMiddleware) APIMiddleware() schemas.BifrostHTTPMiddleware { // credentials securely. Management endpoints under /api/skills (without // /serve/) remain authenticated. "/api/skills/serve/", + // OAuth2 discovery endpoints (RFC 8414 AS metadata, RFC 9728 protected + // resource metadata, RFC 7517 JWKS) must be reachable without auth so + // clients can bootstrap the flow. Each handler still gates availability + // behind discoveryEnabled() and serves 404 when OAuth mode is off. + "/.well-known/", } return m.middleware(func(authConfig *configstore.AuthConfig, url string) bool { if slices.Contains(systemWhitelistedRoutes, url) || diff --git a/transports/bifrost-http/handlers/oauth2_discovery.go b/transports/bifrost-http/handlers/oauth2_discovery.go new file mode 100644 index 0000000000..3610e4fa6e --- /dev/null +++ b/transports/bifrost-http/handlers/oauth2_discovery.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + + "github.com/bytedance/sonic" + "github.com/fasthttp/router" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// OAuth2DiscoveryHandler serves the three well-known discovery endpoints that +// make Bifrost's /mcp endpoint a spec-compliant OAuth 2.1 protected resource: +// +// - GET /.well-known/oauth-protected-resource[/{path}] (RFC 9728 PRM) +// - GET /.well-known/oauth-authorization-server[/{path}] (RFC 8414 AS metadata) +// - GET /.well-known/jwks.json (RFC 7517 JWKS) +// +// All three return 404 when MCPServerAuthMode == "headers" (the default), so +// discovery is only available when the operator explicitly enables OAuth mode. +// Discoverability is the feature toggle. +type OAuth2DiscoveryHandler struct { + store *lib.Config +} + +// NewOAuth2DiscoveryHandler creates a new discovery handler. +func NewOAuth2DiscoveryHandler(store *lib.Config) *OAuth2DiscoveryHandler { + return &OAuth2DiscoveryHandler{store: store} +} + +// RegisterRoutes wires all well-known discovery routes. Routes are always +// registered; individual handlers 404 when discovery is disabled. +func (h *OAuth2DiscoveryHandler) RegisterRoutes(r *router.Router, middlewares ...schemas.BifrostHTTPMiddleware) { + // RFC 9728: both root and path-aware well-known forms are required. + r.GET("/.well-known/oauth-protected-resource", lib.ChainMiddlewares(h.handlePRM, middlewares...)) + r.GET("/.well-known/oauth-protected-resource/{path:*}", lib.ChainMiddlewares(h.handlePRM, middlewares...)) + + // RFC 8414: same two forms. + r.GET("/.well-known/oauth-authorization-server", lib.ChainMiddlewares(h.handleASMetadata, middlewares...)) + r.GET("/.well-known/oauth-authorization-server/{path:*}", lib.ChainMiddlewares(h.handleASMetadata, middlewares...)) + + // RFC 7517 JWKS. + r.GET("/.well-known/jwks.json", lib.ChainMiddlewares(h.handleJWKS, middlewares...)) +} + +// discoveryEnabled reports whether OAuth discovery is active, reading the mode +// from the in-memory ClientConfig under the read lock. +func (h *OAuth2DiscoveryHandler) discoveryEnabled() bool { + h.store.Mu.RLock() + enabled := h.store.ClientConfig.IsMCPOAuthDiscoveryEnabled() + h.store.Mu.RUnlock() + return enabled +} + +// handlePRM serves GET /.well-known/oauth-protected-resource[/{path}]. +// RFC 9728 §3 Protected Resource Metadata. +func (h *OAuth2DiscoveryHandler) handlePRM(ctx *fasthttp.RequestCtx) { + if !h.discoveryEnabled() { + ctx.SetStatusCode(fasthttp.StatusNotFound) + return + } + + base := oauth2IssuerURL(ctx, h.store) + doc := map[string]any{ + "resource": oauth2MCPResourceURL(ctx, h.store), + "authorization_servers": []string{base}, + "scopes_supported": []string{"mcp"}, + "bearer_methods_supported": []string{"header"}, + } + data, err := sonic.Marshal(doc) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("marshal protected resource metadata: %v", err)) + return + } + ctx.SetContentType("application/json") + ctx.SetBody(data) +} + +// handleASMetadata serves GET /.well-known/oauth-authorization-server[/{path}]. +// RFC 8414 Authorization Server Metadata. +func (h *OAuth2DiscoveryHandler) handleASMetadata(ctx *fasthttp.RequestCtx) { + if !h.discoveryEnabled() { + ctx.SetStatusCode(fasthttp.StatusNotFound) + return + } + + base := oauth2IssuerURL(ctx, h.store) + doc := map[string]any{ + "issuer": base, + "authorization_endpoint": base + "/oauth2/authorize", + "token_endpoint": base + "/oauth2/token", + "registration_endpoint": base + "/oauth2/register", + "jwks_uri": base + "/.well-known/jwks.json", + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"none"}, + "scopes_supported": []string{"mcp"}, + // RFC 9207: we include iss in authorization responses. + "authorization_response_iss_parameter_supported": true, + } + data, err := sonic.Marshal(doc) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("marshal authorization server metadata: %v", err)) + return + } + ctx.SetContentType("application/json") + ctx.SetBody(data) +} + +// handleJWKS serves GET /.well-known/jwks.json (RFC 7517). +func (h *OAuth2DiscoveryHandler) handleJWKS(ctx *fasthttp.RequestCtx) { + if !h.discoveryEnabled() { + ctx.SetStatusCode(fasthttp.StatusNotFound) + return + } + + if h.store.ConfigStore == nil { + ctx.SetContentType("application/json") + ctx.SetBodyString(`{"keys":[]}`) + return + } + + key, err := h.store.ConfigStore.GetOAuth2SigningKey(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to load signing key: %v", err)) + return + } + + var jwks []map[string]any + if key != nil { + pub, parseErr := parseRSAPublicKeyPEM(key.PublicKeyPEM) + if parseErr != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to parse signing key: %v", parseErr)) + return + } + jwks = []map[string]any{rsaPublicKeyToJWK(key.KID, "RS256", pub)} + } + + data, err := sonic.Marshal(map[string]any{"keys": jwks}) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("marshal jwks: %v", err)) + return + } + ctx.SetContentType("application/json") + ctx.SetBody(data) +} + +// parseRSAPublicKeyPEM decodes a PEM-encoded RSA public key. +func parseRSAPublicKeyPEM(pemStr string) (*rsa.PublicKey, error) { + block, rest := pem.Decode([]byte(pemStr)) + if block == nil || len(rest) > 0 { + return nil, fmt.Errorf("malformed public key PEM") + } + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse public key: %w", err) + } + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("expected RSA public key, got %T", pub) + } + return rsaPub, nil +} + +// rsaPublicKeyToJWK encodes an RSA public key as a JWK (RFC 7517 §6.3). +func rsaPublicKeyToJWK(kid, alg string, pub *rsa.PublicKey) map[string]any { + return map[string]any{ + "kty": "RSA", + "use": "sig", + "kid": kid, + "alg": alg, + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + } +} diff --git a/transports/bifrost-http/handlers/oauth2_utils.go b/transports/bifrost-http/handlers/oauth2_utils.go new file mode 100644 index 0000000000..6a66cb54a5 --- /dev/null +++ b/transports/bifrost-http/handlers/oauth2_utils.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "strings" + + configtables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// oauth2IssuerURL resolves the effective AS issuer URL for a request. +// Uses the explicitly configured IssuerURL when set; falls back to deriving +// it from the request Host header for single-host / dev deployments. +func oauth2IssuerURL(ctx *fasthttp.RequestCtx, store *lib.Config) string { + store.Mu.RLock() + cfg := store.ClientConfig.OAuth2ServerConfig + store.Mu.RUnlock() + if cfg != nil && cfg.IssuerURL.IsSet() { + return cfg.IssuerURL.GetValue() + } + return lib.BuildBaseURL(ctx, "") +} + +// oauth2MCPResourceURL returns the canonical RFC 8707 resource identifier for +// the /mcp endpoint — the single protected resource this server issues tokens +// for. Discovery advertises it, the authorize endpoint pins the request's +// resource parameter to it, and /mcp token verification checks the audience +// against it; routing all three through here keeps them from drifting. +func oauth2MCPResourceURL(ctx *fasthttp.RequestCtx, store *lib.Config) string { + // Trim a trailing slash so a slash-suffixed issuer_url can't produce "//mcp" + // and drift this canonical resource away from what clients normalize to. + return strings.TrimRight(oauth2IssuerURL(ctx, store), "/") + "/mcp" +} + +// oauth2ServerCfg returns the OAuth2 AS-specific config under the read lock, +// falling back to sensible defaults when not yet configured. +func oauth2ServerCfg(store *lib.Config) *configtables.OAuth2ServerConfig { + store.Mu.RLock() + cfg := store.ClientConfig.OAuth2ServerConfig + store.Mu.RUnlock() + if cfg == nil { + return configtables.DefaultOAuth2ServerConfig() + } + return cfg +} diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index 6f20e3f932..5487a60a59 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -425,6 +425,9 @@ func NewMockConfigStore() *MockConfigStore { func (m *MockConfigStore) RefreshConnectionPool(ctx context.Context) error { return nil } +func (m *MockConfigStore) GetOAuth2SigningKey(ctx context.Context) (*tables.OAuth2SigningKey, error) { + return &tables.OAuth2SigningKey{}, nil +} func (m *MockConfigStore) Ping(ctx context.Context) error { return nil } func (m *MockConfigStore) EncryptPlaintextRows(ctx context.Context) error { return nil } func (m *MockConfigStore) Close(ctx context.Context) error { return nil } diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index b926a50d6b..2740682d29 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -1401,6 +1401,7 @@ func (s *BifrostHTTPServer) RegisterAPIRoutes(ctx context.Context, callbacks Ser promptsHandler := handlers.NewPromptsHandler(s.Config.ConfigStore, promptsReloader) featureFlagsHandler := handlers.NewFeatureFlagsHandler(s.Config.FeatureFlags, s.Config.ConfigStore) // Going ahead with API handlers + handlers.NewOAuth2DiscoveryHandler(s.Config).RegisterRoutes(s.Router, middlewares...) healthHandler.RegisterRoutes(s.Router, middlewares...) providerHandler.RegisterRoutes(s.Router, middlewares...) mcpHandler.RegisterRoutes(s.Router, middlewares...) diff --git a/transports/config.schema.json b/transports/config.schema.json index 63dac4d2d7..033dfae8c9 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -279,6 +279,45 @@ } ], "description": "Public base URL Bifrost uses as the redirect_uri when acting as an OAuth client to upstream MCP servers (Notion, Jira, etc.). Set when Bifrost's callback endpoint is reached via a different URL than its server-side metadata. Supports env var syntax: \"env.MY_VAR\"." + }, + "mcp_server_auth_mode": { + "type": "string", + "enum": ["headers", "both", "oauth"], + "description": "How /mcp authenticates inbound MCP clients. 'headers' (default): VK/api-key/session headers only, discovery disabled. 'both': accepts header credentials and Bifrost-issued JWTs, discovery enabled. 'oauth': Bifrost JWTs only — WARNING: disables VK/header MCP access." + }, + "oauth2_server_config": { + "type": "object", + "properties": { + "issuer_url": { + "anyOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "value": { "type": "string" }, + "env_var": { "type": "string" }, + "from_env": { "type": "boolean" } + }, + "additionalProperties": false + } + ], + "description": "Stable public URL advertised as the OAuth2 AS issuer in discovery documents and JWT iss claim. Required for multi-host deployments; single-host deployments can omit this (falls back to request Host header). Supports env var syntax: \"env.MY_VAR\"." + }, + "auth_code_ttl": { + "type": "integer", + "minimum": 1, + "default": 600, + "description": "Lifetime of the single-use authorization code in seconds (default: 600)." + }, + "access_token_ttl": { + "type": "integer", + "minimum": 1, + "default": 600, + "description": "Lifetime of the issued JWT Bearer token in seconds (default: 600)." + } + }, + "additionalProperties": false, + "description": "OAuth2 authorization server settings for /mcp. Only relevant when mcp_server_auth_mode is 'both' or 'oauth'." } }, "additionalProperties": false