diff --git a/.github/workflows/configs/withpostgresmcpclientsinconfig/config.json b/.github/workflows/configs/withpostgresmcpclientsinconfig/config.json index 5c6b59fe9a..8d31639223 100644 --- a/.github/workflows/configs/withpostgresmcpclientsinconfig/config.json +++ b/.github/workflows/configs/withpostgresmcpclientsinconfig/config.json @@ -1,7 +1,6 @@ { "$schema": "https://www.getbifrost.ai/schema", "client": { - "allow_direct_keys": false, "allowed_origins": [ "*" ], diff --git a/core/bifrost.go b/core/bifrost.go index f118b13b05..65a58d60c1 100644 --- a/core/bifrost.go +++ b/core/bifrost.go @@ -5795,7 +5795,7 @@ func (bifrost *Bifrost) requestWorker(provider schemas.Provider, config *schemas if len(supportedKeys) == 0 { // SkipKeySelection path — keyProvider stays nil, zero Key is used. } else if !canRotate { - // Fixed key (DirectKey, explicit ID/name, session stickiness): always + // Fixed key (explicit ID/name, session stickiness): always // return the same key regardless of usedKeyIDs. fixedKey := supportedKeys[0] keyProvider = func(_ map[string]bool) (schemas.Key, error) { @@ -7145,18 +7145,6 @@ func (bifrost *Bifrost) releaseMCPRequest(req *schemas.BifrostMCPRequest) { // getAllSupportedKeys retrieves all valid keys for a ListModels request. // allowing the provider to aggregate results from multiple keys. func (bifrost *Bifrost) getAllSupportedKeys(ctx *schemas.BifrostContext, providerKey schemas.ModelProvider, baseProviderType schemas.ModelProvider) ([]schemas.Key, error) { - // Check if key has been set in the context explicitly - if ctx != nil { - key, ok := ctx.Value(schemas.BifrostContextKeyDirectKey).(schemas.Key) - if ok { - if err := validateKey(baseProviderType, &key); err != nil { - return nil, fmt.Errorf("invalid direct key for provider %v: %w", baseProviderType, err) - } - // If a direct key is specified, return it as a single-element slice - return []schemas.Key{key}, nil - } - } - keys, err := bifrost.account.GetKeysForProvider(ctx, providerKey) if err != nil { return nil, err @@ -7195,18 +7183,6 @@ func (bifrost *Bifrost) getAllSupportedKeys(ctx *schemas.BifrostContext, provide // For batch operations, only keys with UseForBatchAPI enabled are included. // Model filtering: if model is specified and key has model restrictions, only include if model is in list. func (bifrost *Bifrost) getKeysForBatchAndFileOps(ctx *schemas.BifrostContext, providerKey schemas.ModelProvider, baseProviderType schemas.ModelProvider, model *string, isBatchOp bool) ([]schemas.Key, error) { - // Check if key has been set in the context explicitly - if ctx != nil { - key, ok := ctx.Value(schemas.BifrostContextKeyDirectKey).(schemas.Key) - if ok { - if err := validateKey(baseProviderType, &key); err != nil { - return nil, fmt.Errorf("invalid direct key for provider %v: %w", baseProviderType, err) - } - // If a direct key is specified, return it as a single-element slice - return []schemas.Key{key}, nil - } - } - keys, err := bifrost.account.GetKeysForProvider(ctx, providerKey) if err != nil { return nil, err @@ -7278,7 +7254,6 @@ func (bifrost *Bifrost) getKeysForBatchAndFileOps(ctx *schemas.BifrostContext, p // via the keyProvider closure built by the caller. // // canRotate=false is returned for cases where the caller must always use the same key: -// - DirectKey (caller-supplied key bypasses all selection) // - SkipKeySelection (provider allows keyless requests; empty slice returned) // - Explicit BifrostContextKeyAPIKeyID / APIKeyName (user pinned a specific key) // - Session stickiness (key persisted in KV store for the session lifetime) @@ -7287,15 +7262,6 @@ func (bifrost *Bifrost) getKeysForBatchAndFileOps(ctx *schemas.BifrostContext, p // canRotate=true is returned when there are two or more eligible keys and no pinning // or stickiness constraint is in effect. func (bifrost *Bifrost) selectKeyFromProviderForModelWithPool(ctx *schemas.BifrostContext, requestType schemas.RequestType, providerKey schemas.ModelProvider, model string, baseProviderType schemas.ModelProvider) ([]schemas.Key, bool, error) { - // DirectKey: caller supplied a key directly — no pool, no rotation. - if ctx != nil { - if key, ok := ctx.Value(schemas.BifrostContextKeyDirectKey).(schemas.Key); ok { - if err := validateKey(baseProviderType, &key); err != nil { - return nil, false, fmt.Errorf("invalid direct key for provider %v: %w", baseProviderType, err) - } - return []schemas.Key{key}, false, nil - } - } // SkipKeySelection: provider allows keyless requests — return empty pool, no rotation. if skipKeySelection, ok := ctx.Value(schemas.BifrostContextKeySkipKeySelection).(bool); ok && skipKeySelection && isKeySkippingAllowed(providerKey) { return []schemas.Key{}, false, nil diff --git a/core/internal/llmtests/account.go b/core/internal/llmtests/account.go index cef86e4142..87726014b1 100644 --- a/core/internal/llmtests/account.go +++ b/core/internal/llmtests/account.go @@ -204,12 +204,6 @@ func replicateProviderTestKeys() []schemas.Key { } } -// ReplicateDirectKeyForListModels returns the key used for Replicate ListModels (deployments endpoint). -// List-models tests set it on the context as schemas.BifrostContextKeyDirectKey so Bifrost passes only this key. -func ReplicateDirectKeyForListModels() schemas.Key { - return replicateProviderTestKeys()[0] -} - // GetKeysForProvider returns the API keys and associated models for a given provider. func (account *ComprehensiveTestAccount) GetKeysForProvider(ctx context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { switch providerKey { diff --git a/core/internal/llmtests/list_models.go b/core/internal/llmtests/list_models.go index 3c2133aef5..417e8be66c 100644 --- a/core/internal/llmtests/list_models.go +++ b/core/internal/llmtests/list_models.go @@ -9,13 +9,14 @@ import ( "github.com/maximhq/bifrost/core/schemas" ) -// listModelsBifrostContext returns a context for ListModels. For Replicate, sets BifrostContextKeyDirectKey -// so only the deployments key is used (see replicateProviderTestKeys in account.go). That key must not use an -// empty Models allowlist, or ListModelsPipeline.ShouldEarlyExit returns no models before the API runs. +// listModelsBifrostContext returns a context for ListModels. For Replicate, pins the deployments-endpoint +// key by name (see replicateProviderTestKeys in account.go) so the test always exercises that specific key. +// That key must not use an empty Models allowlist, or ListModelsPipeline.ShouldEarlyExit returns no models +// before the API runs. func listModelsBifrostContext(parent context.Context, provider schemas.ModelProvider) *schemas.BifrostContext { bfCtx := schemas.NewBifrostContext(parent, schemas.NoDeadline) if provider == schemas.Replicate { - bfCtx.SetValue(schemas.BifrostContextKeyDirectKey, ReplicateDirectKeyForListModels()) + bfCtx.SetValue(schemas.BifrostContextKeyAPIKeyName, ReplicateKeyNameListModels) } return bfCtx } diff --git a/core/mcp/clientmanager.go b/core/mcp/clientmanager.go index 7dd334c98d..c1cfb15af5 100644 --- a/core/mcp/clientmanager.go +++ b/core/mcp/clientmanager.go @@ -63,7 +63,7 @@ func (m *MCPManager) ReconnectClient(id string) error { // Reconnect is not applicable because auth is resolved per request/user identity. if client.ExecutionConfig != nil && client.ExecutionConfig.AuthType == schemas.MCPAuthTypePerUserOauth { m.mu.Unlock() - return fmt.Errorf("reconnect is not supported for per_user_oauth clients") + return fmt.Errorf("per-user OAuth clients do not maintain a shared upstream connection (each user manages their own auth): %w", schemas.ErrMCPReconnectNotApplicable) } config := client.ExecutionConfig m.mu.Unlock() diff --git a/core/mcp/utils/utils.go b/core/mcp/utils/utils.go index eaf4d8f892..494838bc1c 100644 --- a/core/mcp/utils/utils.go +++ b/core/mcp/utils/utils.go @@ -25,7 +25,11 @@ func ResolvePerUserOAuthToken(ctx *schemas.BifrostContext, client *schemas.MCPCl } accessToken, err := oauth2Provider.GetUserAccessTokenByIdentity(ctx, virtualKeyID, userID, sessionToken, client.ExecutionConfig.ID) - if err != nil && !errors.Is(err, schemas.ErrOAuth2TokenNotFound) { + // Both sentinels mean "this user must re-authenticate": + // - ErrOAuth2TokenNotFound: row missing (never authed, or purged after permanent refresh failure) + // - ErrOAuth2TokenExpired: row present but tokens unusable (access expired + no refresh available) + // Either way, fall through to the re-auth branch below to surface an inline auth URL. + if err != nil && !errors.Is(err, schemas.ErrOAuth2TokenNotFound) && !errors.Is(err, schemas.ErrOAuth2TokenExpired) { return "", fmt.Errorf("failed to get user access token for MCP server %s: %w", client.ExecutionConfig.Name, err) } if err != nil { diff --git a/core/providers/utils/utils.go b/core/providers/utils/utils.go index 35dc5f25e1..9a00af118c 100644 --- a/core/providers/utils/utils.go +++ b/core/providers/utils/utils.go @@ -2786,11 +2786,11 @@ func completeDeferredSpan(ctx *schemas.BifrostContext, result *schemas.BifrostRe // CheckAndSetDefaultProvider checks if the default provider should be used based on the context. // It returns the default provider if it should be used, otherwise it returns an empty string. -// Checks if the direct key is set in the context, or if key selection is skipped. -// Or if the available providers are set in the context and the default provider is in the list. +// Checks if key selection is skipped, or if the available providers are set in the context +// and the default provider is in the list. func CheckAndSetDefaultProvider(ctx *schemas.BifrostContext, defaultProvider schemas.ModelProvider) schemas.ModelProvider { if ctx != nil { - if ctx.Value(schemas.BifrostContextKeyDirectKey) != nil || ctx.Value(schemas.BifrostContextKeySkipKeySelection) != nil { + if skip, ok := ctx.Value(schemas.BifrostContextKeySkipKeySelection).(bool); ok && skip { return defaultProvider } if ctx.Value(schemas.BifrostContextKeyAvailableProviders) != nil { diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index 6b0acbe40f..5b8067d19a 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -172,7 +172,6 @@ const ( BifrostContextKeyAPIKeyID BifrostContextKey = "x-bf-api-key-id" // string (explicit key ID selection, takes priority over name) BifrostContextKeyRequestID BifrostContextKey = "request-id" // string BifrostContextKeyFallbackRequestID BifrostContextKey = "fallback-request-id" // string - BifrostContextKeyDirectKey BifrostContextKey = "bifrost-direct-key" // Key struct // NOTE: []string is used for both keys, and by default all clients/tools are included (when nil). // If "*" is present, all clients/tools are included, and [] means no clients/tools are included. diff --git a/core/schemas/context.go b/core/schemas/context.go index b836dd112d..44c87c4675 100644 --- a/core/schemas/context.go +++ b/core/schemas/context.go @@ -16,7 +16,6 @@ var reservedKeys = []any{ BifrostContextKeyAPIKeyID, BifrostContextKeyRequestID, BifrostContextKeyFallbackRequestID, - BifrostContextKeyDirectKey, BifrostContextKeySelectedKeyID, BifrostContextKeySelectedKeyName, BifrostContextKeyNumberOfRetries, diff --git a/core/schemas/mcp.go b/core/schemas/mcp.go index b6e265a737..0112ee289e 100644 --- a/core/schemas/mcp.go +++ b/core/schemas/mcp.go @@ -29,6 +29,11 @@ var ( ErrOAuth2NotPerUserSession = errors.New("state does not match a per-user oauth session") ErrOAuth2TokenNotFound = errors.New("per-user oauth token not found for this identity and mcp server") ErrPerUserOAuthPendingFlowExpired = errors.New("per-user oauth pending flow has expired") + // ErrMCPReconnectNotApplicable signals that the reconnect operation is not + // meaningful for this client type — e.g. per-user OAuth clients, where + // each user manages their own auth and there is no shared upstream + // connection to "reconnect". Distinct from "not implemented". + ErrMCPReconnectNotApplicable = errors.New("reconnect is not applicable for this client type") ) // MCPUserOAuthRequiredError is returned when a per-user OAuth MCP server requires diff --git a/docs/deployment-guides/config-json.mdx b/docs/deployment-guides/config-json.mdx index 2c967bdb30..a46105fc8f 100644 --- a/docs/deployment-guides/config-json.mdx +++ b/docs/deployment-guides/config-json.mdx @@ -136,7 +136,6 @@ A production-ready file with PostgreSQL storage, multi-provider setup, governanc "enable_logging": true, "log_retention_days": 90, "enforce_auth_on_inference": true, - "allow_direct_keys": false, "allowed_origins": ["https://app.yourcompany.com"] }, diff --git a/docs/deployment-guides/config-json/client.mdx b/docs/deployment-guides/config-json/client.mdx index 4fdb78d38b..500b8c156d 100644 --- a/docs/deployment-guides/config-json/client.mdx +++ b/docs/deployment-guides/config-json/client.mdx @@ -127,7 +127,6 @@ These settings are also configurable via the UI (**MCP Gateway → MCP Settings* | Field | Type | Default | Description | |-------|------|---------|-------------| | `allowed_origins` | array | `["*"]` | CORS allowed origins (use URIs or `"*"`) | -| `allow_direct_keys` | boolean | `false` | Allow callers to pass provider keys directly in requests | | `enforce_auth_on_inference` | boolean | `false` | Require auth (virtual key, API key, or user token) on `/v1/*` inference routes | | `max_request_body_size_mb` | integer | `100` | Maximum allowed request body size in MB | | `whitelisted_routes` | array of strings | `[]` | Routes that bypass auth middleware | @@ -140,7 +139,6 @@ These settings are also configurable via the UI (**MCP Gateway → MCP Settings* "https://app.yourcompany.com", "https://admin.yourcompany.com" ], - "allow_direct_keys": false, "enforce_auth_on_inference": true, "max_request_body_size_mb": 50, "whitelisted_routes": ["/health", "/metrics"] @@ -324,7 +322,6 @@ A top-level `auth_config` is also accepted for backwards compatibility, but `gov "mcp_external_client_url": "env.BIFROST_EXTERNAL_URL", "allowed_origins": ["https://app.yourcompany.com"], - "allow_direct_keys": false, "enforce_auth_on_inference": true, "max_request_body_size_mb": 100, diff --git a/docs/deployment-guides/config-json/schema-reference.mdx b/docs/deployment-guides/config-json/schema-reference.mdx index 17d0bb39e2..5554b0e9ed 100644 --- a/docs/deployment-guides/config-json/schema-reference.mdx +++ b/docs/deployment-guides/config-json/schema-reference.mdx @@ -61,7 +61,6 @@ Controls the worker pool, logging pipeline, security, and SDK shims. All fields | `log_retention_days` | integer | `365` | Days to retain log entries | | `logging_headers` | array | `[]` | HTTP headers to capture in log metadata | | `enforce_auth_on_inference` | boolean | `false` | Require a virtual key on every `/v1/*` request | -| `allow_direct_keys` | boolean | `false` | Allow callers to pass provider API keys directly | | `allowed_origins` | array | `["*"]` | CORS allowed origins | | `max_request_body_size_mb` | integer | `100` | Maximum request body in MB | | `whitelisted_routes` | array | `[]` | Routes that bypass auth middleware | diff --git a/docs/deployment-guides/helm.mdx b/docs/deployment-guides/helm.mdx index 0f6b7a5a60..15ff7d6a7e 100644 --- a/docs/deployment-guides/helm.mdx +++ b/docs/deployment-guides/helm.mdx @@ -405,7 +405,6 @@ bifrost: disableContentLogging: false # set true for HIPAA/compliance logRetentionDays: 365 enforceGovernanceHeader: true - allowDirectKeys: false maxRequestBodySizeMb: 100 allowedOrigins: - "https://yourcompany.com" diff --git a/docs/deployment-guides/helm/client.mdx b/docs/deployment-guides/helm/client.mdx index b11d1ab8cc..b3d97a246f 100644 --- a/docs/deployment-guides/helm/client.mdx +++ b/docs/deployment-guides/helm/client.mdx @@ -76,7 +76,6 @@ helm upgrade bifrost bifrost/bifrost \ | Parameter | Description | Default | |-----------|-------------|---------| | `bifrost.client.allowedOrigins` | CORS allowed origins | `["*"]` | -| `bifrost.client.allowDirectKeys` | Allow callers to pass provider keys directly in requests | `false` | | `bifrost.client.enforceGovernanceHeader` | Require `x-bf-vk` virtual-key header on every request | `false` | | `bifrost.client.maxRequestBodySizeMb` | Maximum allowed request body size | `100` | | `bifrost.client.whitelistedRoutes` | Routes that bypass auth middleware | `[]` | @@ -87,7 +86,6 @@ bifrost: allowedOrigins: - "https://app.yourdomain.com" - "https://admin.yourdomain.com" - allowDirectKeys: false # Prevent callers from supplying raw provider keys enforceGovernanceHeader: true # Every request must carry a virtual key maxRequestBodySizeMb: 50 whitelistedRoutes: @@ -98,8 +96,7 @@ bifrost: ```bash helm install bifrost bifrost/bifrost \ --set image.tag=v1.4.11 \ - --set bifrost.client.enforceGovernanceHeader=true \ - --set bifrost.client.allowDirectKeys=false + --set bifrost.client.enforceGovernanceHeader=true ``` --- @@ -295,7 +292,6 @@ bifrost: disableContentLogging: false logRetentionDays: 90 enforceGovernanceHeader: true - allowDirectKeys: false maxRequestBodySizeMb: 100 headerFilterConfig: allowlist: [] diff --git a/docs/deployment-guides/helm/values.mdx b/docs/deployment-guides/helm/values.mdx index 6badddb2ed..2c3eb7ce88 100644 --- a/docs/deployment-guides/helm/values.mdx +++ b/docs/deployment-guides/helm/values.mdx @@ -466,7 +466,6 @@ bifrost: client: enableLogging: true - allowDirectKeys: false providers: openai: diff --git a/docs/enterprise/migration-guides/v1.4.0.mdx b/docs/enterprise/migration-guides/v1.4.0.mdx index 654e77f201..abfbac866f 100644 --- a/docs/enterprise/migration-guides/v1.4.0.mdx +++ b/docs/enterprise/migration-guides/v1.4.0.mdx @@ -26,6 +26,7 @@ Enterprise v1.4.0 ships with the full v1.5.0 OSS base, so every breaking change | 7 | **WhiteList validation** | Lists cannot mix `["*"]` with specific values, and cannot contain duplicates | | 8 | **`weight` is now nullable** | Update API consumers to handle `null` | | 9 | **`selected_key_id` cleared on terminal retry failures** | Read `attempt_trail` for failure attribution | +| 10 | **Direct Key Bypass removed (HTTP + Go SDK)** | Drop `allow_direct_keys` from `config.json`; migrate header-passed provider keys to Bifrost-managed keys + virtual keys; replace any Go SDK usage of `BifrostContextKeyDirectKey` with `BifrostContextKeyAPIKeyID` / `BifrostContextKeyAPIKeyName` | The automatic database migration on startup converts existing records to the new semantics. Only `config.json` and any REST API integrations need manual updates. @@ -158,7 +159,7 @@ Snapshot your config store database (Postgres dump or SQLite file copy) before s -Work through the [OSS v1.5.0 Migration Guide](/migration-guides/v1.5.0) checklist: update `models`, `allowed_models`, `key_ids`, `tools_to_execute`, rename `allowed_keys` to `key_ids`, ensure every VK has at least one provider config, migrate provider key management to dedicated endpoints, and update Go SDK references. +Work through the [OSS v1.5.0 Migration Guide](/migration-guides/v1.5.0) checklist: update `models`, `allowed_models`, `key_ids`, `tools_to_execute`, rename `allowed_keys` to `key_ids`, ensure every VK has at least one provider config, migrate provider key management to dedicated endpoints, remove `allow_direct_keys`, migrate HTTP header-key callers to Bifrost-managed keys + virtual keys, migrate Go SDK `BifrostContextKeyDirectKey` callers to `BifrostContextKeyAPIKeyID`/`BifrostContextKeyAPIKeyName`, and update Go SDK references. diff --git a/docs/features/governance/virtual-keys.mdx b/docs/features/governance/virtual-keys.mdx index 688e56ade9..37f15362bd 100644 --- a/docs/features/governance/virtual-keys.mdx +++ b/docs/features/governance/virtual-keys.mdx @@ -16,8 +16,6 @@ Virtual Keys are the primary governance entity in Bifrost. Users and application Old virtual keys(without `sk-bf-*` prefix) are only supported by `x-bf-vk` header. -You can also use `Authorization`, `x-api-key` and `x-goog-api-key` headers to pass direct keys to the provider. Read more about it in [Direct Key Bypass](../keys-management#direct-key-bypass). - **Key Features:** - **Access Control** - Model and provider filtering - **Cost Management** - Independent budgets (checked along with team/customer budgets if attached) diff --git a/docs/features/keys-management.mdx b/docs/features/keys-management.mdx index f1449b6a0a..bfc4ba1639 100644 --- a/docs/features/keys-management.mdx +++ b/docs/features/keys-management.mdx @@ -4,137 +4,6 @@ description: "Intelligent API key management with weighted load balancing, model icon: "scale-balanced" --- -## Smart Key Distribution - -Bifrost's key management system goes beyond simple API key storage. It provides intelligent load balancing, model-specific key filtering, and weighted distribution to optimize performance and manage costs across multiple API keys. - -When you configure multiple keys for a provider, Bifrost automatically distributes requests using sophisticated selection algorithms that consider key weights, model compatibility, and deployment mappings. - -## How Key Selection Works - -Bifrost follows a precise selection process for every request: - -1. **Context Override Check**: First checks if a key is explicitly provided in context (bypassing management) -2. **Provider Key Lookup**: Retrieves all configured keys for the requested provider -3. **Model Filtering**: Filters keys that support the requested model (respecting `models` allowlists and `blacklisted_models` denylists) -4. **Deployment Validation**: For Azure/Bedrock, validates deployment mappings -5. **Weighted Selection**: Uses weighted random selection among eligible keys - -This ensures optimal key usage while respecting your configuration constraints. - -## Implementation Examples - - - - - -```bash -# 1. Create or ensure the provider exists -curl -X POST http://localhost:8080/api/providers \ - -H "Content-Type: application/json" \ - -d '{ "provider": "openai" }' - -# 2. Add keys individually via the dedicated keys API -curl -X POST http://localhost:8080/api/providers/openai/keys \ - -H "Content-Type: application/json" \ - -d '{ - "name": "openai-key-1", - "value": "env.OPENAI_API_KEY_1", - "models": ["gpt-4o", "gpt-4o-mini"], - "weight": 0.7 - }' - -curl -X POST http://localhost:8080/api/providers/openai/keys \ - -H "Content-Type: application/json" \ - -d '{ - "name": "openai-key-2", - "value": "env.OPENAI_API_KEY_2", - "models": ["*"], - "weight": 0.3 - }' - -# Regular request (uses weighted key selection) -curl -X POST http://localhost:8080/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "model": "openai/gpt-4o-mini", - "messages": [{"role": "user", "content": "Hello!"}] - }' - -# Request with direct API key (bypasses key management) -curl -X POST http://localhost:8080/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-your-direct-api-key" \ - -d '{ - "model": "openai/gpt-4o-mini", - "messages": [{"role": "user", "content": "Hello!"}] - }' -``` - - - - - -```go -package main - -import ( - "context" - "github.com/maximhq/bifrost/core/schemas" -) - -func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { - switch provider { - case schemas.OpenAI: - return []schemas.Key{ - { - ID: "primary-key", - Value: "env.OPENAI_API_KEY_1", - Models: ["gpt-4o", "gpt-4o-mini"], // Model whitelist - Weight: 0.7, // 70% of traffic - }, - { - ID: "secondary-key", - Value: "env.OPENAI_API_KEY_2", - Models: []string{"*"}, // ["*"] = supports all models (empty slice denies all in v1.5.0+) - Weight: 0.3, // 30% of traffic - }, - }, nil - case schemas.Anthropic: - return []schemas.Key{ - { - Value: "env.ANTHROPIC_API_KEY", - Models: ["claude-3-5-sonnet-20241022"], - Weight: 1.0, - }, - }, nil - } - return nil, fmt.Errorf("provider %s not supported", provider) -} - -// Using with explicit context key (bypasses key management) -func makeRequestWithDirectKey() { - ctx := context.Background() - - // Direct key bypasses all key management - directKey := schemas.Key{ - Value: "sk-direct-api-key", - Weight: 1.0, - } - ctx = context.WithValue(ctx, schemas.BifrostContextKeyDirectKey, directKey) - - response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{ - Provider: schemas.OpenAI, - Model: "gpt-4o-mini", - Input: messages, - }) -} -``` - - - - - ## Weighted Load Balancing Bifrost uses weighted random selection to distribute requests across multiple keys. This allows you to: @@ -292,47 +161,13 @@ Response (example): Note: This is not a weighted selection, by providing a specific key name you are explicitly telling Bifrost which stored key to use, so weighted distribution is bypassed. The example above demonstrates the error returned when a referenced key name cannot be resolved. -## Direct Key Bypass - -For scenarios requiring explicit key control, Bifrost supports bypassing the entire key management system: - -**Go SDK Context Override:** -Pass a key directly in the request context using `schemas.BifrostContextKeyDirectKey`. This completely bypasses provider key lookup and selection. - -**Gateway Header-based Keys:** -Send API keys in `Authorization` (Bearer), `x-api-key` or `x-goog-api-key` headers. Requires `allow_direct_keys` setting to be enabled. - -**Enable Direct Keys:** - - - - - -![Web UI](../media/ui-config-direct-keys.png) - -1. Navigate to **Configuration** page -2. Toggle **"Allow Direct Keys"** to enabled -3. Save configuration - - - - -```json -{ - "client": { - "allow_direct_keys": true - } -} -``` - - +## Direct Key Bypass — Removed in v1.5 - + +The "Direct Key Bypass" feature has been **removed entirely in v1.5**, on both the HTTP gateway and the Go SDK. -If a Bifrost virtual key (`sk-bf-*`) is attached in the auth header, direct key bypass will be skipped. +- **HTTP gateway:** the `allow_direct_keys` config flag and the `Authorization` / `x-api-key` / `x-goog-api-key` header pass-through (plus the Bedrock `x-bf-bedrock-*` and Azure `x-bf-azure-endpoint` integration paths) no longer forward keys to upstream providers. +- **Go SDK:** the `schemas.BifrostContextKeyDirectKey` context value and the `Direct Key (Go SDK Only)` API have been removed. -**When to Use Direct Keys:** -- Per-user API key scenarios -- External key management systems -- Testing with specific keys -- Debugging key-related issues +All requests must use Bifrost-managed provider keys. To pin a specific key per request from the Go SDK, set `schemas.BifrostContextKeyAPIKeyID` or `schemas.BifrostContextKeyAPIKeyName` against a key managed by Bifrost (for example, one created via the providers API or returned by your `Account` implementation). See the [v1.5.0 migration guide](/migration-guides/v1.5.0) for the full rationale and migration recipes. + diff --git a/docs/features/observability/default.mdx b/docs/features/observability/default.mdx index 31f635f473..6b1c7d8779 100644 --- a/docs/features/observability/default.mdx +++ b/docs/features/observability/default.mdx @@ -120,7 +120,6 @@ curl --location 'http://localhost:8080/api/config' \ "drop_excess_requests": false, "initial_pool_size": 300, "enforce_auth_on_inference": false, - "allow_direct_keys": false, "prometheus_labels": [], "allowed_origins": [] } @@ -157,8 +156,7 @@ In your `config.json` file, you can enable logging and configure the log store: "enable_logging": true, "disable_content_logging": false, "drop_excess_requests": false, - "initial_pool_size": 300, - "allow_direct_keys": false + "initial_pool_size": 300 }, "logs_store": { "enabled": true, diff --git a/docs/integrations/anthropic-sdk/overview.mdx b/docs/integrations/anthropic-sdk/overview.mdx index 9273d9f7e7..50de367e18 100644 --- a/docs/integrations/anthropic-sdk/overview.mdx +++ b/docs/integrations/anthropic-sdk/overview.mdx @@ -225,109 +225,6 @@ const response = await anthropic.messages.create({ --- -## Using Direct Keys - -Pass API keys directly in requests to bypass Bifrost's load balancing. You can pass any provider's API key (OpenAI, Anthropic, Mistral, etc.) since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. - -> **Learn more:** See [Key Management](../../features/keys-management#direct-key-bypass) for enabling direct API key usage. - - - - -```python -import anthropic - -# Using Anthropic's API key directly -client_with_direct_key = anthropic.Anthropic( - base_url="http://localhost:8080/anthropic", - api_key="sk-your-anthropic-key" # Anthropic's API key works -) - -anthropic_response = client_with_direct_key.messages.create( - model="claude-3-sonnet-20240229", - max_tokens=1000, - messages=[{"role": "user", "content": "Hello from Claude!"}] -) - -# or pass different provider keys per request using headers -client = anthropic.Anthropic( - base_url="http://localhost:8080/anthropic", - api_key="dummy-key" -) - -# Use Anthropic key for Claude -anthropic_response = client.messages.create( - model="claude-3-sonnet-20240229", - max_tokens=1000, - messages=[{"role": "user", "content": "Hello Claude!"}], - extra_headers={ - "x-api-key": "sk-ant-your-anthropic-key" - } -) - -# Use OpenAI key for GPT models -openai_response = client.messages.create( - model="openai/gpt-4o-mini", - max_tokens=1000, - messages=[{"role": "user", "content": "Hello GPT!"}], - extra_headers={ - "Authorization": "Bearer sk-your-openai-key" - } -) -``` - - - - -```javascript -import Anthropic from "@anthropic-ai/sdk"; - -// Using Anthropic's API key directly -const anthropicWithDirectKey = new Anthropic({ - baseURL: "http://localhost:8080/anthropic", - apiKey: "sk-your-anthropic-key", // Anthropic's API key works -}); - - -const anthropicResponse = await anthropicWithDirectKey.messages.create({ - model: "claude-3-sonnet-20240229", - max_tokens: 1000, - messages: [{ role: "user", content: "Hello from Claude!" }], -}); - - -// or pass different provider keys per request using headers -const anthropic = new Anthropic({ - baseURL: "http://localhost:8080/anthropic", - apiKey: "dummy-key", -}); - -// Use Anthropic key for Claude -const anthropicResponse = await anthropic.messages.create({ - model: "claude-3-sonnet-20240229", - max_tokens: 1000, - messages: [{ role: "user", content: "Hello Claude!" }], - headers: { - "x-api-key": "sk-ant-your-anthropic-key", - }, -}); - -// Use OpenAI key for GPT models -const openaiResponseWithHeader = await anthropic.messages.create({ - model: "openai/gpt-4o-mini", - max_tokens: 1000, - messages: [{ role: "user", content: "Hello GPT!" }], - headers: { - "Authorization": "Bearer sk-your-openai-key", - }, -}); -``` - - - - ---- - ## Async Inference Submit inference requests asynchronously and poll for results later using the `x-bf-async` header. This is useful for long-running requests where you don't want to hold a connection open. See [Async Inference](../../features/async-inference) for full details. diff --git a/docs/integrations/bedrock-sdk/overview.mdx b/docs/integrations/bedrock-sdk/overview.mdx index 9d5c71aa15..b49ec25659 100644 --- a/docs/integrations/bedrock-sdk/overview.mdx +++ b/docs/integrations/bedrock-sdk/overview.mdx @@ -211,42 +211,6 @@ for event in response.get("body"): print(result["completion"], end="", flush=True) ``` -## Using Direct Keys - -Pass AWS credentials or Bedrock API keys directly in requests to bypass Bifrost's load balancing. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. - -> **Learn more:** See [Key Management](../../features/keys-management#direct-key-bypass) for enabling direct API key usage. - -When direct keys are enabled, you can pass your AWS credentials directly to the boto3 client instead of using dummy credentials. - - - - -```python -import boto3 - -# When direct keys are enabled, pass real AWS credentials to boto3 -client = boto3.client( - service_name="bedrock-runtime", - endpoint_url="http://localhost:8080/bedrock", - region_name="us-west-2", - aws_access_key_id="your-aws-access-key", # Real credentials when direct keys enabled - aws_secret_access_key="your-aws-secret-key" # Real credentials when direct keys enabled -) - -response = client.converse( - modelId="anthropic.claude-3-5-sonnet-20240620-v1:0", - messages=[{"role": "user", "content": [{"text": "Hello!"}]}] -) -``` - - - - -> **Note:** When using Bifrost's configured keys (not direct keys), you must provide dummy AWS credentials (`aws_access_key_id` and `aws_secret_access_key`) to the boto3 client. This is because boto3 requires credentials to sign requests, even though Bifrost will use its own configured keys. The dummy values can be any string (e.g., `"bifrost-dummy-key"` and `"bifrost-dummy-secret"`). - ---- - ## Supported Features The Bedrock integration currently supports: diff --git a/docs/integrations/genai-sdk/overview.mdx b/docs/integrations/genai-sdk/overview.mdx index 07745109f6..a801056f55 100644 --- a/docs/integrations/genai-sdk/overview.mdx +++ b/docs/integrations/genai-sdk/overview.mdx @@ -195,91 +195,6 @@ const response = await model.generateContent("Hello with custom headers!"); --- -## Using Direct Keys - -Pass API keys directly in requests to bypass Bifrost's load balancing. You can pass any provider's API key (OpenAI, Anthropic, Mistral, etc.) since Bifrost only looks for `Authorization`, `x-api-key` and `x-goog-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. - -> **Learn more:** See [Key Management](../../features/keys-management#direct-key-bypass) for enabling direct API key usage. - - - - -```python -from google import genai -from google.genai.types import HttpOptions - -# Pass different provider keys per request using headers -client = genai.Client( - api_key="gemini-key", - http_options=HttpOptions(base_url="http://localhost:8080/genai") -) - -# Use Gemini key directly -gemini_response = client.models.generate_content( - model="gemini-1.5-flash", - contents="Hello Gemini!" -) - -# Use Anthropic key for Claude models -anthropic_response = client.models.generate_content( - model="anthropic/claude-3-sonnet-20240229", - contents="Hello Claude!", - request_options={ - "headers": {"x-api-key": "your-anthropic-api-key"} - } -) - -# Use OpenAI key for GPT models -openai_response = client.models.generate_content( - model="openai/gpt-4o-mini", - contents="Hello GPT!", - request_options={ - "headers": {"Authorization": "Bearer sk-your-openai-key"} - } -) -``` - - - - -```javascript -import { GoogleGenerativeAI } from "@google/generative-ai"; - -// Pass different provider keys per request using headers -const genAI = new GoogleGenerativeAI("gemini-key", { - baseUrl: "http://localhost:8080/genai", -}); - -// Use Gemini key directly -const geminiModel = genAI.getGenerativeModel({ - model: "gemini-1.5-flash" -}); -const geminiResponse = await geminiModel.generateContent("Hello Gemini!"); - -// Use Anthropic key for Claude models -const anthropicModel = genAI.getGenerativeModel({ - model: "anthropic/claude-3-sonnet-20240229", - requestOptions: { - customHeaders: { "x-api-key": "your-anthropic-api-key" } - } -}); -const anthropicResponse = await anthropicModel.generateContent("Hello Claude!"); - -// Use OpenAI key for GPT models -const gptModel = genAI.getGenerativeModel({ - model: "openai/gpt-4o-mini", - requestOptions: { - customHeaders: { "Authorization": "Bearer sk-your-openai-key" } - } -}); -const gptResponse = await gptModel.generateContent("Hello GPT!"); -``` - - - - ---- - ## Dynamic Thinking Budget When `thinkingConfig.thinkingBudget` is set to `-1`, Bifrost handles it differently per provider: diff --git a/docs/integrations/langchain-sdk.mdx b/docs/integrations/langchain-sdk.mdx index 40b5c24ebd..9ff356ad5f 100644 --- a/docs/integrations/langchain-sdk.mdx +++ b/docs/integrations/langchain-sdk.mdx @@ -341,112 +341,6 @@ console.log(response.content); --- -## Using Direct Keys - -Pass API keys directly to bypass Bifrost's key management. You can pass any provider's API key since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. - -> **Learn more:** See [Key Management](../features/keys-management#direct-key-bypass) for enabling direct API key usage. - - - - -```python -from langchain_openai import ChatOpenAI -from langchain_anthropic import ChatAnthropic -from langchain_core.messages import HumanMessage - -# Using OpenAI key directly -openai_llm = ChatOpenAI( - model="gpt-4o-mini", - openai_api_base="http://localhost:8080/langchain", - default_headers={ - "Authorization": "Bearer sk-your-openai-key" - } -) - -# Using Anthropic key for Claude models -anthropic_llm = ChatAnthropic( - model="claude-3-sonnet-20240229", - anthropic_api_url="http://localhost:8080/langchain", - default_headers={ - "x-api-key": "sk-ant-your-anthropic-key" - } -) - -# Using Azure with direct Azure key -from langchain_openai import AzureChatOpenAI - -azure_llm = AzureChatOpenAI( - deployment_name="gpt-4o-aug", - api_key="your-azure-api-key", - azure_endpoint="http://localhost:8080/langchain", - api_version="2024-05-01-preview", - max_tokens=100, - default_headers={ - "x-bf-azure-endpoint": "https://your-resource.openai.azure.com", - } -) - -openai_response = openai_llm.invoke([HumanMessage(content="Hello GPT!")]) -anthropic_response = anthropic_llm.invoke([HumanMessage(content="Hello Claude!")]) -azure_response = azure_llm.invoke([HumanMessage(content="Hello from Azure!")]) -``` - - - - -```javascript -import { ChatOpenAI } from "@langchain/openai"; -import { ChatAnthropic } from "@langchain/anthropic"; - -// Using OpenAI key directly -const openaiLlm = new ChatOpenAI({ - model: "gpt-4o-mini", - configuration: { - baseURL: "http://localhost:8080/langchain", - defaultHeaders: { - "Authorization": "Bearer sk-your-openai-key" - } - } -}); - -// Using Anthropic key for Claude models -const anthropicLlm = new ChatAnthropic({ - model: "claude-3-sonnet-20240229", - clientOptions: { - baseURL: "http://localhost:8080/langchain", - defaultHeaders: { - "x-api-key": "sk-ant-your-anthropic-key" - } - } -}); - -// Using Azure with direct Azure key -import { AzureChatOpenAI } from "@langchain/openai"; - -const azureLlm = new AzureChatOpenAI({ - deploymentName: "gpt-4o-aug", - apiKey: "your-azure-api-key", - azureOpenAIEndpoint: "http://localhost:8080/langchain", - apiVersion: "2024-05-01-preview", - maxTokens: 100, - configuration: { - defaultHeaders: { - "x-bf-azure-endpoint": "https://your-resource.openai.azure.com", - } - } -}); - -const openaiResponse = await openaiLlm.invoke("Hello GPT!"); -const anthropicResponse = await anthropicLlm.invoke("Hello Claude!"); -const azureResponse = await azureLlm.invoke("Hello from Azure!"); -``` - - - - ---- - ## Reasoning/Thinking Models Control extended reasoning capabilities for models that support thinking/reasoning modes. diff --git a/docs/integrations/litellm-sdk.mdx b/docs/integrations/litellm-sdk.mdx index 10f1fd6077..8ce671ca17 100644 --- a/docs/integrations/litellm-sdk.mdx +++ b/docs/integrations/litellm-sdk.mdx @@ -111,62 +111,6 @@ print(response.choices[0].message.content) --- -## Using Direct Keys - -Pass API keys directly to bypass Bifrost's key management. You can pass any provider's API key since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. - -> **Learn more:** See [Key Management](../features/keys-management#direct-key-bypass) for enabling direct API key usage. - - - - -```python -from litellm import completion - -# Using OpenAI key directly -openai_response = completion( - model="gpt-4o-mini", - messages=[{"role": "user", "content": "Hello GPT!"}], - base_url="http://localhost:8080/litellm", - extra_headers={ - "Authorization": "Bearer sk-your-openai-key" - } -) - -# Using Anthropic key for Claude models -anthropic_response = completion( - model="claude-3-sonnet-20240229", - messages=[{"role": "user", "content": "Hello Claude!"}], - base_url="http://localhost:8080/litellm", - extra_headers={ - "x-api-key": "sk-ant-your-anthropic-key" - } -) - -# Using Azure with direct Azure key -import os - -deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", "my-azure-deployment") -model = f"azure/{deployment}" - -azure_response = completion( - model=model, - messages=[{"role": "user", "content": "Hello from LiteLLM (Azure demo)!"}], - base_url="http://localhost:8080/litellm", - api_key=os.getenv("AZURE_API_KEY", "your-azure-api-key"), - deployment_id=os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4o-aug"), - max_tokens=100, - extra_headers={ - "x-bf-azure-endpoint": "https://your-resource.openai.azure.com", - } -) -``` - - - - ---- - ## Supported Features The LiteLLM integration supports all features that are available in both the LiteLLM SDK and Bifrost core functionality. Your existing LiteLLM code works seamlessly with Bifrost's enterprise features. 😄 diff --git a/docs/integrations/openai-sdk/overview.mdx b/docs/integrations/openai-sdk/overview.mdx index 1d57a23d77..d8fc1bc756 100644 --- a/docs/integrations/openai-sdk/overview.mdx +++ b/docs/integrations/openai-sdk/overview.mdx @@ -205,170 +205,6 @@ const response = await openai.chat.completions.create({ --- -## Using Direct Keys - -Pass API keys directly in requests to bypass Bifrost's load balancing. You can pass any provider's API key (OpenAI, Anthropic, Mistral, etc.) since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. - -> **Learn more:** See [Key Management](../../features/keys-management#direct-key-bypass) for enabling direct API key usage. - - - - -```python -import openai - -# Using OpenAI's API key directly -client_with_direct_key = openai.OpenAI( - base_url="http://localhost:8080/openai", - api_key="sk-your-openai-key" # OpenAI's API key works -) - -openai_response = client_with_direct_key.chat.completions.create( - model="openai/gpt-4o-mini", - messages=[{"role": "user", "content": "Hello from GPT!"}] -) - -# Or pass different provider keys per request -client = openai.OpenAI( - base_url="http://localhost:8080/openai", - api_key="dummy-key" -) - -# Use OpenAI key for GPT models -openai_response = client.chat.completions.create( - model="gpt-4o-mini", - messages=[{"role": "user", "content": "Hello GPT!"}], - extra_headers={ - "Authorization": "Bearer sk-your-openai-key" - } -) - -# Use Anthropic key for Claude models -anthropic_response = client.chat.completions.create( - model="anthropic/claude-3-sonnet-20240229", - messages=[{"role": "user", "content": "Hello Claude!"}], - extra_headers={ - "x-api-key": "sk-ant-your-anthropic-key" - } -) - -# Use Gemini key for Gemini models -gemini_response = client.chat.completions.create( - model="gemini/gemini-2.5-flash", - messages=[{"role": "user", "content": "Hello Gemini!"}], - extra_headers={ - "x-goog-api-key": "sk-gemini-your-gemini-key" - } -) -``` - - - - -```javascript -import OpenAI from "openai"; - -// Using OpenAI's API key directly -const openaiWithDirectKey = new OpenAI({ - baseURL: "http://localhost:8080/openai", - apiKey: "sk-your-openai-key", // OpenAI's API key works -}); - -const openaiResponse = await openaiWithDirectKey.chat.completions.create({ - model: "openai/gpt-4o-mini", - messages: [{ role: "user", content: "Hello from GPT!" }], -}); - -// Or pass different provider keys per request -const openai = new OpenAI({ - baseURL: "http://localhost:8080/openai", - apiKey: "dummy-key", -}); - -// Use OpenAI key for GPT models -const openaiResponse = await openai.chat.completions.create({ - model: "gpt-4o-mini", - messages: [{ role: "user", content: "Hello GPT!" }], - headers: { - "Authorization": "Bearer sk-your-openai-key", - }, -}); - -// Use Anthropic key for Claude models -const anthropicResponseWithHeader = await openai.chat.completions.create({ - model: "anthropic/claude-3-sonnet-20240229", - messages: [{ role: "user", content: "Hello Claude!" }], - headers: { - "x-api-key": "sk-ant-your-anthropic-key", - }, -}); - -// Use Gemini key for Gemini models -const geminiResponseWithHeader = await openai.chat.completions.create({ - model: "gemini/gemini-2.5-flash", - messages: [{ role: "user", content: "Hello Gemini!" }], - headers: { - "x-goog-api-key": "sk-gemini-your-gemini-key", - }, -}); -``` - - - - -For Azure, you can use the AzureOpenAI client and point it to Bifrost integration endpoint. The `x-bf-azure-endpoint` header is required to specify your Azure resource endpoint. - - - - -```python -from openai import AzureOpenAI - -azure_client = AzureOpenAI( - api_key="your-azure-api-key", - api_version="2024-02-01", - azure_endpoint="http://localhost:8080/openai", # Point to Bifrost - default_headers={ - "x-bf-azure-endpoint": "https://your-resource.openai.azure.com" - } -) - -azure_response = azure_client.chat.completions.create( - model="gpt-4-deployment", # Your deployment name - messages=[{"role": "user", "content": "Hello from Azure!"}] -) - -print(azure_response.choices[0].message.content) -``` - - - - -```javascript -import { AzureOpenAI } from "openai"; - -const azureClient = new AzureOpenAI({ - apiKey: "your-azure-api-key", - apiVersion: "2024-02-01", - baseURL: "http://localhost:8080/openai", // Point to Bifrost - defaultHeaders: { - "x-bf-azure-endpoint": "https://your-resource.openai.azure.com" - } -}); - -const azureResponse = await azureClient.chat.completions.create({ - model: "gpt-4-deployment", // Your deployment name - messages: [{ role: "user", content: "Hello from Azure!" }], -}); - -console.log(azureResponse.choices[0].message.content); -``` - - - - ---- - ## Async Inference Submit inference requests asynchronously and poll for results later using the `x-bf-async` header. This is useful for long-running requests where you don't want to hold a connection open. See [Async Inference](../../features/async-inference) for full details. diff --git a/docs/integrations/pydanticai-sdk.mdx b/docs/integrations/pydanticai-sdk.mdx index b754cd4772..324e371371 100644 --- a/docs/integrations/pydanticai-sdk.mdx +++ b/docs/integrations/pydanticai-sdk.mdx @@ -291,58 +291,6 @@ print(result.output) --- -## Using Direct Keys - -Pass API keys directly to bypass Bifrost's key management. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. - -> **Learn more:** See [Key Management](../features/keys-management#direct-key-bypass) for enabling direct API key usage. - - - - -```python {8,15,26} -from httpx import AsyncClient -from pydantic_ai import Agent -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.models.anthropic import AnthropicModel -from pydantic_ai.providers.openai import OpenAIProvider -from pydantic_ai.providers.anthropic import AnthropicProvider - -base_url = "http://localhost:8080/pydanticai" - -# Using OpenAI key directly -openai_client = AsyncClient( - headers={"Authorization": "Bearer sk-your-openai-key"} -) -openai_provider = OpenAIProvider( - base_url=f"{base_url}/v1", - http_client=openai_client -) -openai_model = OpenAIChatModel("gpt-4o-mini", provider=openai_provider) -openai_agent = Agent(openai_model) - -# Using Anthropic key directly -# Note: Anthropic SDK adds /v1 internally, so we don't append it here -anthropic_client = AsyncClient( - headers={"x-api-key": "sk-ant-your-anthropic-key"} -) -anthropic_provider = AnthropicProvider( - base_url=base_url, - http_client=anthropic_client -) -anthropic_model = AnthropicModel("claude-3-haiku-20240307", provider=anthropic_provider) -anthropic_agent = Agent(anthropic_model) - -# Both work through Bifrost with your own keys -openai_result = openai_agent.run_sync("Hello GPT!") -anthropic_result = anthropic_agent.run_sync("Hello Claude!") -``` - - - - ---- - ## Multi-turn Conversations Maintain conversation history across multiple turns: diff --git a/docs/mcp/gateway-url.mdx b/docs/mcp/gateway-url.mdx index 6aaa64a1b2..1e47c866ff 100644 --- a/docs/mcp/gateway-url.mdx +++ b/docs/mcp/gateway-url.mdx @@ -312,6 +312,10 @@ The `WWW-Authenticate` URL above and the `redirect_uri` Bifrost hands to upstrea In most setups both are the same public URL. They can differ when the management/discovery surface and the OAuth callback surface live behind different proxies. See [Reverse Proxy configuration →](../deployment-guides/config-json/client#reverse-proxy) for the full reference and examples. + +**Changing `mcp_external_client_url` breaks already-connected MCP clients.** Upstream OAuth providers lock the `redirect_uri` to whatever was registered during Dynamic Client Registration (RFC 7591). If you change this URL afterwards, existing clients fail with **"Invalid redirect URI"** at the authorize step. To recover, clear the stored OAuth client credentials for the affected MCP server and re-authorize so Bifrost re-registers with the new URL. + + --- ## Security Considerations diff --git a/docs/mcp/oauth.mdx b/docs/mcp/oauth.mdx index 4b87de9fa2..20fef6e391 100644 --- a/docs/mcp/oauth.mdx +++ b/docs/mcp/oauth.mdx @@ -189,6 +189,10 @@ Bifrost will: 2. Register a new client using `registration_url` 3. Use the registered client ID for authorization + +The `redirect_uri` is registered with the upstream provider at this point and is **locked** to Bifrost's current client URL (`mcp_external_client_url`, or the `Host` header if unset). If you later change Bifrost's public URL, the upstream provider will reject the authorize call with **"Invalid redirect URI"**. To recover, clear the stored client credentials for this MCP server so Bifrost re-runs Dynamic Client Registration with the new URL. + + #### OAuth Discovery Bifrost can automatically discover OAuth endpoints from your MCP server's metadata: diff --git a/docs/migration-guides/v1.5.0.mdx b/docs/migration-guides/v1.5.0.mdx index 80e305edaf..2d8212e2eb 100644 --- a/docs/migration-guides/v1.5.0.mdx +++ b/docs/migration-guides/v1.5.0.mdx @@ -521,6 +521,96 @@ Single-key, pinned (`x-bf-key-id` / `x-bf-key-name`), and session-sticky request --- +## Breaking Change 13: Direct Key Bypass Removed (HTTP Gateway and Go SDK) + +The "Direct Key Bypass" feature has been **removed entirely** in v1.5.0, on both surfaces: + +- **HTTP gateway:** the `allow_direct_keys` config flag and the header pass-through (`Authorization`, `x-api-key`, `x-goog-api-key`, plus the Bedrock `x-bf-bedrock-*` and Azure `x-bf-azure-endpoint` integration paths) no longer forward keys to upstream providers. +- **Go SDK:** the `schemas.BifrostContextKeyDirectKey` context value has been deleted, along with the documented "Direct Key (Go SDK Only)" API. + +**All requests must now resolve to a Bifrost-managed provider key**, either implicitly (the configured key pool plus weighted selection) or by pinning a registered key via `BifrostContextKeyAPIKeyID` / `BifrostContextKeyAPIKeyName` (Go SDK) or virtual keys (`sk-bf-*`, HTTP). + +### What changed + +| Field / surface | Status | +|---|---| +| `client.allow_direct_keys` in `config.json` | **Removed** — field is no longer recognized; ignored if present | +| `client_config.allow_direct_keys` over `PUT /api/config` | **Removed** — field is dropped from the request payload | +| Web UI **Settings → Security → "Allow Direct API Keys"** toggle | **Removed** | +| `Authorization: Bearer sk-...` header → upstream provider | **No longer forwarded** as a direct key. Bearer values starting with `sk-bf-` continue to work as virtual keys | +| `x-api-key` / `x-goog-api-key` header → upstream provider | **No longer forwarded** as a direct key | +| `x-bf-bedrock-api-key` / `x-bf-bedrock-access-key` / `x-bf-bedrock-secret-key` / `x-bf-bedrock-session-token` / `x-bf-bedrock-region` | **No longer extracted** by the Bedrock integration | +| `x-bf-azure-endpoint` + bare Authorization header on Azure OpenAI integration routes | **No longer extracted** as a direct Azure key | +| Database column `config_client.allow_direct_keys` | **Auto-dropped** on first startup of v1.5.0 | +| `schemas.BifrostContextKeyDirectKey` (Go SDK constant) | **Removed** — code referencing it will fail to compile | +| `Bifrost.getAllSupportedKeys` / `getKeysForBatchAndFileOps` / `selectKeyFromProviderForModelWithPool` DirectKey branches | **Removed** — these no longer special-case a caller-supplied key | + +### How to update + +**1. Remove the field from `config.json`:** + +```diff +{ + "client": { + "enable_logging": true, +- "allow_direct_keys": false, + "allowed_origins": ["*"] + } +} +``` + +The field is silently ignored, but leaving it in is misleading. + +**2. Remove the field from any REST API integration that calls `PUT /api/config`:** the field has been dropped from the `client_config` schema. + +**3. Migrate any HTTP caller that relied on header-passed keys:** + +- **For per-tenant or per-user key isolation:** create a Bifrost virtual key per tenant (with budgets, rate limits, and provider/model allow-lists) and have callers send `Authorization: Bearer sk-bf-`. +- **For routing requests across multiple provider keys:** add the keys to Bifrost via `POST /api/providers/{provider}/keys` and let Bifrost handle weighted selection and rotation. +- **For Bedrock callers using AWS credentials in `x-bf-bedrock-*` headers:** add a Bedrock provider key with the same credentials (`access_key`, `secret_key`, `region`, optional `session_token`) via the providers API and reference it from your virtual key. +- **For Azure direct-key callers using `x-bf-azure-endpoint` + Authorization:** add the Azure deployment as a provider key with `azure_key_config.endpoint` and reference it from your virtual key. + +**4. Migrate Go SDK callers off `BifrostContextKeyDirectKey`:** + +The constant is gone. Replace any in-process injection with one of the following supported alternatives. + +**Before (no longer compiles):** +```go +ctx = context.WithValue(ctx, schemas.BifrostContextKeyDirectKey, schemas.Key{ + Value: *schemas.NewEnvVar("sk-runtime-secret"), + Models: []string{"gpt-4o"}, + Weight: 1.0, +}) +``` + +**After — register the key with the account, then pin by ID or name:** + +```go +// In your schemas.Account implementation, return the key from GetKeysForProvider. +// Each registered key has a stable ID and Name; reference either one from the request context. + +ctx = context.WithValue(ctx, schemas.BifrostContextKeyAPIKeyName, "runtime-secret") +// or +ctx = context.WithValue(ctx, schemas.BifrostContextKeyAPIKeyID, "") +``` + +If your account is database-backed, add the key via `POST /api/providers/{provider}/keys`. If you maintain a custom in-memory `Account`, return the key from `GetKeysForProvider`. Both routes give you the same per-request pinning behaviour DirectKey provided, plus governance, rotation on retry, and per-key cost attribution. + +**For providers that allow keyless requests** (ambient credentials, IAM roles, etc.), `BifrostContextKeySkipKeySelection` is unchanged. + +### Why it was removed + +The direct-key path bypassed Bifrost's key management entirely, which meant: + +- **No governance** — virtual key budgets, rate limits, provider/model allow-lists, and routing rules were not applied to direct-key traffic +- **No rotation or fallback** — direct keys were used as-is with no retry across alternate keys +- **No observability attribution** — header-provided keys had a synthetic `key_id: "header-provided"` that defeated per-key cost and usage analytics +- **Security surface area** — a misconfigured HTTP deployment could leak provider credentials through logs or proxy chains; the Go SDK equivalent had the same hazard for any caller logging context contents + +The supported `BifrostContextKeyAPIKeyID` / `BifrostContextKeyAPIKeyName` path covers the "pick a specific key per request" use case without the governance and observability gaps. + +--- + ## Opting Out: `version: 1` Compatibility Mode If you are not ready to adopt the new deny-by-default semantics, you can add a single field to `config.json` to restore v1.4.x behavior for all allow-list fields loaded from that file: @@ -611,6 +701,10 @@ Replace `.Model` with `.RequestedModel` (and optionally `.ResolvedModel`) on any If your code reads `selected_key_id` / `selected_key_name` from the request context or log entries to attribute failed requests, add a null/empty check and fall back to `attempt_trail` for the full per-attempt key history. + + +Remove `allow_direct_keys` from `config.json` and any `PUT /api/config` payloads. Audit HTTP callers that sent provider keys in `Authorization` / `x-api-key` / `x-goog-api-key` / `x-bf-bedrock-*` / `x-bf-azure-endpoint` headers — those keys are no longer forwarded. Audit Go SDK callers for any reference to `schemas.BifrostContextKeyDirectKey` — the constant is removed and code referencing it will not compile. Replace both flavours with a Bifrost-managed provider key, optionally pinned per request via `BifrostContextKeyAPIKeyID` / `BifrostContextKeyAPIKeyName` (Go SDK) or a virtual key (`sk-bf-*`, HTTP). + --- @@ -644,3 +738,11 @@ If you used `replicate_key_config.deployments`, move the mappings to the top-lev **Go SDK compilation errors on `ModelRequested` or `StreamAccumulatorResult.Model`** Rename to `OriginalModelRequested`/`ResolvedModelUsed` on ExtraFields, and `RequestedModel`/`ResolvedModel` on StreamAccumulatorResult. + +**Calls authenticating with raw provider keys started failing with auth errors** + +The HTTP gateway no longer extracts provider keys from `Authorization` / `x-api-key` / `x-goog-api-key` headers (or `x-bf-bedrock-*` / `x-bf-azure-endpoint` on the Bedrock and Azure integrations). Either issue a virtual key (`sk-bf-*`) per caller and have them send that, or register the provider credentials as a Bifrost-managed key and route via virtual keys. See [Breaking Change 13](#breaking-change-13-direct-key-bypass-removed-http-gateway-and-go-sdk). + +**Go SDK build error: `undefined: schemas.BifrostContextKeyDirectKey`** + +The constant is removed in v1.5.0. Register the key on your `Account` implementation (or via `POST /api/providers/{provider}/keys` if you use the database-backed config store) and pin it per request with `BifrostContextKeyAPIKeyID` or `BifrostContextKeyAPIKeyName`. See [Breaking Change 13](#breaking-change-13-direct-key-bypass-removed-http-gateway-and-go-sdk) for a before/after example. diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 818ec6e4c1..8784bd28f8 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -52517,10 +52517,6 @@ "deprecated": true, "description": "Deprecated: use enforce_auth_on_inference instead" }, - "allow_direct_keys": { - "type": "boolean", - "description": "Whether to allow direct API keys" - }, "max_request_body_size_mb": { "type": "integer", "description": "Maximum request body size in MB" @@ -52786,10 +52782,6 @@ "deprecated": true, "description": "Deprecated: use enforce_auth_on_inference instead" }, - "allow_direct_keys": { - "type": "boolean", - "description": "Whether to allow direct API keys" - }, "max_request_body_size_mb": { "type": "integer", "description": "Maximum request body size in MB" diff --git a/docs/openapi/schemas/management/config.yaml b/docs/openapi/schemas/management/config.yaml index 4aaf16b098..a398d1a964 100644 --- a/docs/openapi/schemas/management/config.yaml +++ b/docs/openapi/schemas/management/config.yaml @@ -46,9 +46,6 @@ ClientConfig: type: boolean deprecated: true description: "Deprecated: use enforce_auth_on_inference instead" - allow_direct_keys: - type: boolean - description: Whether to allow direct API keys max_request_body_size_mb: type: integer description: Maximum request body size in MB diff --git a/docs/providers/request-options.mdx b/docs/providers/request-options.mdx index 5ad6cb5e6b..515052066b 100644 --- a/docs/providers/request-options.mdx +++ b/docs/providers/request-options.mdx @@ -22,7 +22,6 @@ Bifrost provides request options that control behavior, enable features, and pas | `BifrostContextKeyDisableContentLogging` | `x-bf-disable-content-logging` | `bool` | Per-request override for content logging; only honored when `allow_per_request_content_storage_override` is enabled in logging config | | `BifrostContextKeyPassthroughExtraParams` | `x-bf-passthrough-extra-params` | `bool` | Enable passthrough for extra parameters | | `BifrostContextKeyExtraHeaders` | `x-bf-eh-*` | `map[string][]string` | Custom headers forwarded to provider | -| `BifrostContextKeyDirectKey` | `-` | `schemas.Key` | Direct key credentials (Go SDK only) | | `BifrostContextKeySkipKeySelection` | `-` | `bool` | Skip key selection process (Go SDK only) | | `BifrostContextKeyURLPath` | `-` | `string` | Custom URL path appended to provider base URL (Go SDK only) | | `BifrostContextKeyUseRawRequestBody` | `-` | `bool` | Use raw request body (Go SDK only, requires RawRequestBody field) | @@ -570,31 +569,6 @@ ExtraParams: map[string]interface{}{ - Nested parameters are merged recursively with existing structures -### Direct Key (Go SDK Only) - -**Context Key:** `BifrostContextKeyDirectKey` -**Header:** `-` (not available via HTTP) -**Type:** `schemas.Key` -**Required:** No - -Bypass key selection and provide credentials directly. Useful for dynamic key scenarios. - -```go -directKey := schemas.Key{ - Value: "sk-direct-api-key", - Models: []string{"gpt-4o"}, - Weight: 1.0, -} -ctx := context.Background() -ctx = context.WithValue(ctx, schemas.BifrostContextKeyDirectKey, directKey) - -response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{ - Provider: schemas.OpenAI, - Model: "gpt-4o", - Input: messages, -}) -```` - ### Skip Key Selection (Go SDK Only) **Context Key:** `BifrostContextKeySkipKeySelection` diff --git a/docs/quickstart/go-sdk/context-keys.mdx b/docs/quickstart/go-sdk/context-keys.mdx index 88f1b74829..0f7101a679 100644 --- a/docs/quickstart/go-sdk/context-keys.mdx +++ b/docs/quickstart/go-sdk/context-keys.mdx @@ -70,18 +70,6 @@ Explicitly select a named API key from your configured keys. bfCtx.SetValue(schemas.BifrostContextKeyAPIKeyName, "premium-key") ``` -### Direct Key - -Provide credentials directly, bypassing Bifrost's key selection entirely. Useful for dynamic or per-request key scenarios. - -```go -bfCtx.SetValue(schemas.BifrostContextKeyDirectKey, schemas.Key{ - Value: "sk-direct-api-key", - Models: []string{"gpt-4o"}, - Weight: 1.0, -}) -``` - ### Skip Key Selection Skip key selection entirely and pass an empty key to the provider. Useful for providers that don't require authentication or when using ambient credentials (e.g., IAM roles). @@ -358,7 +346,6 @@ func makeRequest(client *bifrost.Bifrost) { | `BifrostContextKeyAPIKeyID` | `string` | Set | Explicit API key ID selection (priority over name) | | `BifrostContextKeyRequestID` | `string` | Set | Custom request ID for tracking | | `BifrostContextKeyFallbackRequestID` | `string` | Read | Request ID used for fallback attempt | -| `BifrostContextKeyDirectKey` | `schemas.Key` | Set | Provide credentials directly, bypassing key selection | | `BifrostContextKeySkipKeySelection` | `bool` | Set | Skip key selection entirely | | `BifrostContextKeySessionID` | `string` | Set | Session ID for key stickiness (requires KV store) | | `BifrostContextKeySessionTTL` | `time.Duration` | Set | TTL for session-to-key cache (default: 1 hour) | diff --git a/examples/configs/withpostgresmcpclientsinconfig/config.json b/examples/configs/withpostgresmcpclientsinconfig/config.json index 8ace7f7468..8f4639e586 100644 --- a/examples/configs/withpostgresmcpclientsinconfig/config.json +++ b/examples/configs/withpostgresmcpclientsinconfig/config.json @@ -1,7 +1,6 @@ { "$schema": "https://www.getbifrost.ai/schema", "client": { - "allow_direct_keys": false, "allowed_origins": [ "*" ], diff --git a/examples/configs/withprompushgateway/config.json b/examples/configs/withprompushgateway/config.json index 93a8c796b0..42b6ce776f 100644 --- a/examples/configs/withprompushgateway/config.json +++ b/examples/configs/withprompushgateway/config.json @@ -207,7 +207,6 @@ ], "enable_logging": true, "enforce_auth_on_inference": false, - "allow_direct_keys": false, "max_request_body_size_mb": 100 }, "config_store": { diff --git a/examples/configs/withvirtualkeys/config.json b/examples/configs/withvirtualkeys/config.json index 6165a300fb..ac29bd301f 100644 --- a/examples/configs/withvirtualkeys/config.json +++ b/examples/configs/withvirtualkeys/config.json @@ -1,7 +1,6 @@ { "$schema": "https://www.getbifrost.ai/schema", "client": { - "allow_direct_keys": false, "allowed_origins": [ "*" ], diff --git a/examples/dockers/data/config.json b/examples/dockers/data/config.json index 072691c2ea..146717cc40 100644 --- a/examples/dockers/data/config.json +++ b/examples/dockers/data/config.json @@ -22,7 +22,6 @@ "disable_content_logging": false, "log_retention_days": 365, "enforce_auth_on_inference": false, - "allow_direct_keys": false, "allowed_origins": [ "*" ], @@ -37,4 +36,4 @@ "pricing_sync_interval": 86400 } } -} +} \ No newline at end of file diff --git a/examples/k8s/examples/values-client-configs.yaml b/examples/k8s/examples/values-client-configs.yaml index 90051851c1..70f3da7048 100644 --- a/examples/k8s/examples/values-client-configs.yaml +++ b/examples/k8s/examples/values-client-configs.yaml @@ -21,7 +21,6 @@ bifrost: logRetentionDays: 365 # Deprecated in transport schema; prefer enforceAuthOnInference. enforceSCIMAuth: false - allowDirectKeys: true maxRequestBodySizeMb: 101 compat: convertTextToChat: true diff --git a/examples/mcps/oauth-demo-server/go.mod b/examples/mcps/oauth-demo-server/go.mod new file mode 100644 index 0000000000..48b5603389 --- /dev/null +++ b/examples/mcps/oauth-demo-server/go.mod @@ -0,0 +1,17 @@ +module oauth-demo-server + +go 1.26.2 + +require github.com/mark3labs/mcp-go v0.43.2 + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/examples/mcps/oauth-demo-server/go.sum b/examples/mcps/oauth-demo-server/go.sum new file mode 100644 index 0000000000..17bd675e2b --- /dev/null +++ b/examples/mcps/oauth-demo-server/go.sum @@ -0,0 +1,39 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/mcps/oauth-demo-server/main.go b/examples/mcps/oauth-demo-server/main.go new file mode 100644 index 0000000000..e44859a552 --- /dev/null +++ b/examples/mcps/oauth-demo-server/main.go @@ -0,0 +1,760 @@ +package main + +// oauth-demo-server is a self-contained mock OAuth 2.1 authorization server + +// MCP resource server. It exists so you can exercise Bifrost's per-user OAuth +// path end-to-end — including access-token expiry and refresh-token rotation — +// without depending on a real upstream OAuth provider. +// +// Why this exists +// ─────────────── +// Bifrost discovers OAuth servers via RFC 9728 (protected resource metadata) +// and RFC 8414 (authorization server metadata). It then performs: +// 1. Dynamic client registration (RFC 7591) +// 2. Authorization code flow with PKCE (S256, mandatory) +// 3. refresh_token grant when an access token has expired +// +// This server speaks all of that, with a deliberately tiny access-token TTL +// so the refresh path is easy to observe in logs. +// +// Endpoints +// ───────── +// GET /.well-known/oauth-protected-resource (RFC 9728) +// GET /.well-known/oauth-authorization-server (RFC 8414) +// POST /register (RFC 7591 dynamic client reg) +// GET /authorize (auth code + PKCE — renders a tiny login form) +// POST /token (authorization_code + refresh_token grants) +// ANY /mcp (MCP server, gated by Bearer token) +// +// Bifrost MCP client config +// ───────────────────────── +// +// { +// "name": "oauth_demo", +// "connection_type": "http", +// "connection_string": "http://localhost:3003/mcp", +// "auth_type": "oauth2", +// "is_per_user": true, +// "tools_to_execute": ["*"] +// } +// +// On the first tool call the MCP path returns 401 with a WWW-Authenticate +// header pointing at the protected-resource metadata. Bifrost follows the +// discovery chain, registers itself as a client, and runs the consent flow. +// +// Observing refresh +// ───────────────── +// Access tokens are issued with a 30-second TTL. Make a tool call, wait ≥30s, +// make another — Bifrost should call POST /token with grant_type=refresh_token +// before forwarding the second call. Watch the [token] log lines on this +// server to confirm. + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + listenAddr = "localhost:3003" + issuer = "http://localhost:3003" + + // Deliberately tiny so refresh fires after a single idle pause. + accessTokenTTL = 30 * time.Second + + // Long enough that several access-token refreshes succeed, short enough + // that the full re-authentication path is observable in a single test + // session. When this expires Bifrost should fall back to the consent flow. + refreshTokenTTL = 2 * time.Minute + + // Authorization codes are single-use; this is the upper bound on how long + // the user has to complete the redirect+token exchange. + authCodeTTL = 1 * time.Minute +) + +// ─── In-memory state ────────────────────────────────────────────────────────── + +type registeredClient struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name,omitempty"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope,omitempty"` +} + +type authCode struct { + ClientID string + RedirectURI string + CodeChallenge string + Scope string + UserID string + ExpiresAt time.Time +} + +type tokenRecord struct { + AccessToken string + RefreshToken string + ClientID string + Scope string + UserID string + ExpiresAt time.Time // access token expiry + RefreshExpiresAt time.Time // refresh token expiry +} + +type store struct { + mu sync.RWMutex + clients map[string]*registeredClient + authCodes map[string]*authCode + accessTokens map[string]*tokenRecord + refreshTokens map[string]*tokenRecord +} + +// All state is in-memory. Restarting this server forgets every registered +// client, every issued token, and every auth code — by design, since this is +// a test fixture. NOTE: Bifrost (or any OAuth client) stores the client_id it +// received from dynamic registration in its own DB and reuses it on the next +// /authorize call. So restarting the mock server while leaving Bifrost's DB +// intact will trip "unknown client_id (run dynamic registration first)" — to +// recover, either re-create the MCP client config in Bifrost so it +// re-registers, or restart with a fresh Bifrost DB. If you need persistence +// across restarts, add a JSON-file load/save around the `clients` map. +var st = &store{ + clients: map[string]*registeredClient{}, + authCodes: map[string]*authCode{}, + accessTokens: map[string]*tokenRecord{}, + refreshTokens: map[string]*tokenRecord{}, +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +func randHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeOAuthError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]string{"error": code, "error_description": desc}) +} + +// ─── Access logging ────────────────────────────────────────────────────────── + +// loggingResponseWriter captures the status code so the request logger can +// report it. Defaults to 200 since http.ResponseWriter implicitly does. +type loggingResponseWriter struct { + http.ResponseWriter + status int +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.status = code + lrw.ResponseWriter.WriteHeader(code) +} + +// tagForPath gives each endpoint a short, recognizable label in the log so +// you can see which step of the OAuth dance Bifrost is currently on. +func tagForPath(method, path string) string { + switch { + case path == "/.well-known/oauth-protected-resource": + return "discovery:protected-resource" + case path == "/.well-known/oauth-authorization-server": + return "discovery:auth-server" + case path == "/register": + return "register" + case path == "/authorize": + return "authorize" + case path == "/token": + return "token" + case path == "/mcp" || strings.HasPrefix(path, "/mcp/"): + return "mcp" + default: + return "other" + } +} + +func requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lrw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(lrw, r) + log.Printf("[http] %-30s %s %s -> %d (%s)", + tagForPath(r.Method, r.URL.Path), r.Method, r.URL.Path, lrw.status, time.Since(start).Round(time.Microsecond)) + }) +} + +// ─── Discovery ──────────────────────────────────────────────────────────────── + +func handleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) { + log.Printf("[discovery] RFC 9728 protected-resource metadata requested by %s", r.UserAgent()) + writeJSON(w, http.StatusOK, map[string]any{ + "resource": issuer + "/mcp", + "authorization_servers": []string{issuer}, + "scopes_supported": []string{"mcp:read", "mcp:write"}, + "bearer_methods_supported": []string{"header"}, + }) +} + +func handleAuthServerMetadata(w http.ResponseWriter, r *http.Request) { + log.Printf("[discovery] RFC 8414 authorization-server metadata requested by %s", r.UserAgent()) + writeJSON(w, http.StatusOK, map[string]any{ + "issuer": issuer, + "authorization_endpoint": issuer + "/authorize", + "token_endpoint": issuer + "/token", + "registration_endpoint": issuer + "/register", + "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:read", "mcp:write"}, + }) +} + +// ─── Dynamic Client Registration ────────────────────────────────────────────── + +func handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + ClientName string `json:"client_name"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_client_metadata", "invalid JSON: "+err.Error()) + return + } + if len(req.RedirectURIs) == 0 { + writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uris required") + return + } + if req.TokenEndpointAuthMethod == "" { + req.TokenEndpointAuthMethod = "none" + } + if len(req.GrantTypes) == 0 { + req.GrantTypes = []string{"authorization_code", "refresh_token"} + } + if len(req.ResponseTypes) == 0 { + req.ResponseTypes = []string{"code"} + } + + c := ®isteredClient{ + ClientID: "client-" + randHex(12), + ClientName: req.ClientName, + RedirectURIs: req.RedirectURIs, + GrantTypes: req.GrantTypes, + ResponseTypes: req.ResponseTypes, + TokenEndpointAuthMethod: req.TokenEndpointAuthMethod, + Scope: req.Scope, + } + st.mu.Lock() + st.clients[c.ClientID] = c + st.mu.Unlock() + + log.Printf("[register] client_id=%s name=%q redirect_uris=%v", c.ClientID, c.ClientName, c.RedirectURIs) + writeJSON(w, http.StatusCreated, c) +} + +// ─── Authorize (auth code + PKCE) ───────────────────────────────────────────── + +var loginPage = template.Must(template.New("login").Parse(` +oauth-demo-server — sign in + +

oauth-demo-server

+

Mock OAuth login. Pick any username — the server will issue a token bound to it. Use distinct names to simulate per-user OAuth.

+

Client: {{.ClientID}} · Scope: {{.Scope}}

+
+ {{range $k, $v := .Hidden}}{{end}} + + + +
+`)) + +func handleAuthorize(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + responseType := q.Get("response_type") + clientID := q.Get("client_id") + redirectURI := q.Get("redirect_uri") + codeChallenge := q.Get("code_challenge") + codeChallengeMethod := q.Get("code_challenge_method") + state := q.Get("state") + scope := q.Get("scope") + user := strings.TrimSpace(q.Get("user")) + + if responseType != "code" { + http.Error(w, "unsupported_response_type: only 'code' is supported", http.StatusBadRequest) + return + } + st.mu.RLock() + client, ok := st.clients[clientID] + st.mu.RUnlock() + if !ok { + // Bifrost (or any OAuth client) caches the client_id from a previous + // dynamic-registration response in its own DB and reuses it. If the + // mock server has been restarted since then, that cached id is + // unknown here. Re-create the MCP client config in Bifrost so it + // re-registers, or start with a fresh Bifrost DB. + http.Error(w, "unknown client_id (mock server state may have been wiped — re-create the MCP client in Bifrost so it re-registers)", http.StatusBadRequest) + return + } + redirectAllowed := false + for _, ru := range client.RedirectURIs { + if ru == redirectURI { + redirectAllowed = true + break + } + } + if !redirectAllowed { + http.Error(w, "redirect_uri not registered for this client", http.StatusBadRequest) + return + } + if codeChallenge == "" || codeChallengeMethod != "S256" { + http.Error(w, "PKCE required: code_challenge with code_challenge_method=S256", http.StatusBadRequest) + return + } + + // First hop: render the login form so the human picks a username. + if user == "" { + hidden := map[string]string{ + "response_type": responseType, + "client_id": clientID, + "redirect_uri": redirectURI, + "code_challenge": codeChallenge, + "code_challenge_method": codeChallengeMethod, + } + if state != "" { + hidden["state"] = state + } + if scope != "" { + hidden["scope"] = scope + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = loginPage.Execute(w, map[string]any{ + "ClientID": clientID, + "Scope": scope, + "Hidden": hidden, + }) + return + } + + // Second hop: user has been chosen — mint a code and redirect back. + code := "code-" + randHex(16) + st.mu.Lock() + st.authCodes[code] = &authCode{ + ClientID: clientID, + RedirectURI: redirectURI, + CodeChallenge: codeChallenge, + Scope: scope, + UserID: user, + ExpiresAt: time.Now().Add(authCodeTTL), + } + st.mu.Unlock() + + log.Printf("[authorize] approve client=%s user=%q scope=%q -> code=%s", clientID, user, scope, code) + + redirect, err := url.Parse(redirectURI) + if err != nil { + http.Error(w, "invalid redirect_uri", http.StatusBadRequest) + return + } + rq := redirect.Query() + rq.Set("code", code) + if state != "" { + rq.Set("state", state) + } + redirect.RawQuery = rq.Encode() + http.Redirect(w, r, redirect.String(), http.StatusFound) +} + +// ─── Token endpoint (authorization_code + refresh_token) ────────────────────── + +func verifyPKCE(verifier, challenge string) bool { + if verifier == "" { + return false + } + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) == challenge +} + +// issueTokens optionally inherits the refresh-token expiry from a prior pair +// so refresh-rotation does NOT extend the refresh-token lifetime — once the +// session crosses refreshTokenTTL the user must re-authenticate, regardless of +// how many times they've refreshed in between. Pass time.Time{} (zero) for +// fresh issuance from the authorization_code grant. +func issueTokens(clientID, scope, userID string, inheritedRefreshExpiry time.Time) *tokenRecord { + now := time.Now() + refreshExpiresAt := inheritedRefreshExpiry + if refreshExpiresAt.IsZero() { + refreshExpiresAt = now.Add(refreshTokenTTL) + } + rec := &tokenRecord{ + AccessToken: "at-" + randHex(20), + RefreshToken: "rt-" + randHex(20), + ClientID: clientID, + Scope: scope, + UserID: userID, + ExpiresAt: now.Add(accessTokenTTL), + RefreshExpiresAt: refreshExpiresAt, + } + st.mu.Lock() + st.accessTokens[rec.AccessToken] = rec + st.refreshTokens[rec.RefreshToken] = rec + st.mu.Unlock() + + log.Printf("[issue access] token=%s user=%q expires_at=%s (in %s)", + tokenPrefix(rec.AccessToken), rec.UserID, rec.ExpiresAt.Format(time.RFC3339), accessTokenTTL) + log.Printf("[issue refresh] token=%s user=%q expires_at=%s (in %s%s)", + tokenPrefix(rec.RefreshToken), rec.UserID, + rec.RefreshExpiresAt.Format(time.RFC3339), + time.Until(rec.RefreshExpiresAt).Round(time.Second), + func() string { + if !inheritedRefreshExpiry.IsZero() { + return ", inherited from prior session" + } + return "" + }()) + + // Passive [expire access] log: fires at accessTokenTTL regardless of + // whether anyone hits the server. Suppressed if the token was already + // rotated by a refresh — the [revoke access] line covered it. + at := rec.AccessToken + userIDCopy := rec.UserID + accessExpiresAt := rec.ExpiresAt + time.AfterFunc(accessTokenTTL, func() { + st.mu.RLock() + _, stillActive := st.accessTokens[at] + st.mu.RUnlock() + if stillActive { + log.Printf("[expire access] token=%s user=%q expired_at=%s (still in store — next /mcp call will trigger refresh)", + tokenPrefix(at), userIDCopy, accessExpiresAt.Format(time.RFC3339)) + } + }) + + // Passive [expire refresh] log: same idea, fires at the absolute refresh + // expiry. Suppressed if the refresh has already been rotated/revoked. + rt := rec.RefreshToken + refreshDelay := time.Until(rec.RefreshExpiresAt) + if refreshDelay > 0 { + refreshExpiry := rec.RefreshExpiresAt + time.AfterFunc(refreshDelay, func() { + st.mu.RLock() + _, stillActive := st.refreshTokens[rt] + st.mu.RUnlock() + if stillActive { + log.Printf("[expire refresh] token=%s user=%q expired_at=%s (still in store — next refresh attempt will fail; client must re-authenticate)", + tokenPrefix(rt), userIDCopy, refreshExpiry.Format(time.RFC3339)) + } + }) + } + + return rec +} + +func tokenResponse(rec *tokenRecord) map[string]any { + return map[string]any{ + "access_token": rec.AccessToken, + "token_type": "Bearer", + "expires_in": int(accessTokenTTL.Seconds()), + "refresh_token": rec.RefreshToken, + "scope": rec.Scope, + } +} + +func handleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid form: "+err.Error()) + return + } + switch r.PostForm.Get("grant_type") { + case "authorization_code": + handleAuthorizationCodeGrant(w, r) + case "refresh_token": + handleRefreshTokenGrant(w, r) + default: + writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "supported: authorization_code, refresh_token") + } +} + +func handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { + code := r.PostForm.Get("code") + redirectURI := r.PostForm.Get("redirect_uri") + clientID := r.PostForm.Get("client_id") + codeVerifier := r.PostForm.Get("code_verifier") + + st.mu.Lock() + ac, ok := st.authCodes[code] + if ok { + delete(st.authCodes, code) // single-use + } + st.mu.Unlock() + + if !ok { + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "unknown or already-used code") + return + } + if time.Now().After(ac.ExpiresAt) { + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "code expired") + return + } + if clientID != "" && clientID != ac.ClientID { + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "client_id mismatch") + return + } + if redirectURI != ac.RedirectURI { + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "redirect_uri mismatch") + return + } + if !verifyPKCE(codeVerifier, ac.CodeChallenge) { + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "PKCE verification failed") + return + } + + // Fresh authentication — refresh-token lifetime starts now. + rec := issueTokens(ac.ClientID, ac.Scope, ac.UserID, time.Time{}) + log.Printf("[token] code-grant client=%s user=%q -> at=%s rt=%s expires_in=%ds", + rec.ClientID, rec.UserID, tokenPrefix(rec.AccessToken), tokenPrefix(rec.RefreshToken), int(accessTokenTTL.Seconds())) + writeJSON(w, http.StatusOK, tokenResponse(rec)) +} + +func handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { + rt := r.PostForm.Get("refresh_token") + if rt == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "refresh_token required") + return + } + + st.mu.Lock() + old, ok := st.refreshTokens[rt] + st.mu.Unlock() + if !ok { + log.Printf("[token] refresh REJECTED: unknown or already-rotated refresh_token=%s", tokenPrefix(rt)) + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "unknown or revoked refresh_token") + return + } + if time.Now().After(old.RefreshExpiresAt) { + // Refresh expired — clean up so the [expire refresh] passive log + // (which has already fired) reflects reality, then reject. + st.mu.Lock() + delete(st.refreshTokens, rt) + delete(st.accessTokens, old.AccessToken) + st.mu.Unlock() + log.Printf("[token] refresh REJECTED: refresh_token=%s for user=%q expired %s — client must re-authenticate via /authorize", + tokenPrefix(rt), old.UserID, expiryRelative(old.RefreshExpiresAt)) + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "refresh_token expired; client must re-authenticate") + return + } + + // Refresh is valid — rotate: invalidate old refresh + access tokens. + st.mu.Lock() + delete(st.refreshTokens, rt) + delete(st.accessTokens, old.AccessToken) + st.mu.Unlock() + + log.Printf("[revoke refresh] token=%s user=%q (rotated by client; refresh would have expired %s)", + tokenPrefix(rt), old.UserID, expiryRelative(old.RefreshExpiresAt)) + log.Printf("[revoke access] token=%s user=%q (companion to rotated refresh; %s)", + tokenPrefix(old.AccessToken), old.UserID, expiryRelative(old.ExpiresAt)) + + // Inherit the original refresh expiry so rotation does not extend the + // session past refreshTokenTTL from the original /authorize. + rec := issueTokens(old.ClientID, old.Scope, old.UserID, old.RefreshExpiresAt) + log.Printf("[token] refresh client=%s user=%q old_rt=%s -> at=%s rt=%s expires_in=%ds", + rec.ClientID, rec.UserID, tokenPrefix(rt), tokenPrefix(rec.AccessToken), tokenPrefix(rec.RefreshToken), int(accessTokenTTL.Seconds())) + writeJSON(w, http.StatusOK, tokenResponse(rec)) +} + +// ─── Bearer-protected MCP server ────────────────────────────────────────────── + +type ctxKey string + +const userCtxKey ctxKey = "oauth_user" + +// bearerMiddleware enforces a valid, non-expired Bearer access token. On +// failure it returns 401 with a WWW-Authenticate header pointing at the +// protected-resource metadata — this is the signal Bifrost uses to kick off +// OAuth discovery. +func bearerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + challenge := fmt.Sprintf( + `Bearer resource_metadata="%s/.well-known/oauth-protected-resource"`, + issuer, + ) + unauthorized := func(reason string) { + log.Printf("[mcp] %s %s -> 401 (%s)", r.Method, r.URL.Path, reason) + w.Header().Set("WWW-Authenticate", challenge+fmt.Sprintf(`, error="invalid_token", error_description=%q`, reason)) + http.Error(w, "unauthorized: "+reason, http.StatusUnauthorized) + } + h := r.Header.Get("Authorization") + if h == "" { + log.Printf("[mcp] %s %s -> 401 (no Authorization header — Bifrost should now follow WWW-Authenticate to discovery)", r.Method, r.URL.Path) + w.Header().Set("WWW-Authenticate", challenge) + http.Error(w, "missing Authorization header", http.StatusUnauthorized) + return + } + if !strings.HasPrefix(h, "Bearer ") { + unauthorized("Authorization scheme must be Bearer") + return + } + tok := strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + st.mu.RLock() + rec, ok := st.accessTokens[tok] + st.mu.RUnlock() + if !ok { + unauthorized("unknown access token: " + tokenPrefix(tok)) + return + } + if time.Now().After(rec.ExpiresAt) { + unauthorized(fmt.Sprintf("access token expired %s ago: %s", time.Since(rec.ExpiresAt).Round(time.Second), tokenPrefix(tok))) + return + } + log.Printf("[mcp] %s %s authed user=%q token=%s (expires in %s)", + r.Method, r.URL.Path, rec.UserID, tokenPrefix(tok), time.Until(rec.ExpiresAt).Round(time.Second)) + ctx := context.WithValue(r.Context(), userCtxKey, rec.UserID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// tokenPrefix shortens an access/refresh token for log lines so they remain +// recognizable (you can correlate with [token] grant logs) without dumping the +// full secret on every request. +func tokenPrefix(t string) string { + if len(t) <= 12 { + return t + } + return t[:12] + "…" +} + +// expiryRelative renders an absolute expiry timestamp as a human-readable +// "in 12s" / "expired 5s ago" so log lines about revoked tokens make sense at +// a glance. +func expiryRelative(t time.Time) string { + d := time.Until(t).Round(time.Second) + if d >= 0 { + return "in " + d.String() + } + return "expired " + (-d).String() + " ago" +} + +func whoamiHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + user, _ := ctx.Value(userCtxKey).(string) + if user == "" { + user = "(unknown)" + } + return mcp.NewToolResultText(fmt.Sprintf( + "authenticated as %q at %s", user, time.Now().Format(time.RFC3339), + )), nil +} + +func protectedDataHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + user, _ := ctx.Value(userCtxKey).(string) + var args struct { + Resource string `json:"resource"` + } + b, _ := json.Marshal(req.Params.Arguments) + _ = json.Unmarshal(b, &args) + return mcp.NewToolResultText(fmt.Sprintf( + "user=%s resource=%q payload=secret-%s ts=%s", + user, args.Resource, randHex(4), time.Now().Format(time.RFC3339), + )), nil +} + +// ─── Wiring ────────────────────────────────────────────────────────────────── + +func main() { + mcpServer := server.NewMCPServer("oauth-demo-server", "1.0.0") + mcpServer.AddTool( + mcp.NewTool("whoami", + mcp.WithDescription("Returns the username encoded in the Bearer token. Useful for verifying which identity is bound to the current access token."), + ), + whoamiHandler, + ) + mcpServer.AddTool( + mcp.NewTool("protected_data", + mcp.WithDescription("Returns a fake protected payload. Requires a valid, non-expired Bearer access token."), + mcp.WithString("resource", mcp.Required(), mcp.Description("Name of the resource to fetch.")), + ), + protectedDataHandler, + ) + + httpMCP := server.NewStreamableHTTPServer(mcpServer) + gatedMCP := bearerMiddleware(httpMCP) + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-protected-resource", handleProtectedResourceMetadata) + mux.HandleFunc("/.well-known/oauth-authorization-server", handleAuthServerMetadata) + mux.HandleFunc("/register", handleRegister) + mux.HandleFunc("/authorize", handleAuthorize) + mux.HandleFunc("/token", handleToken) + mux.Handle("/mcp", gatedMCP) + mux.Handle("/mcp/", gatedMCP) + + // Wrap the whole mux so every request — discovery, register, authorize, + // token, and MCP — produces a one-line access log entry. + handler := requestLogger(mux) + + log.Printf("oauth-demo-server listening on http://%s", listenAddr) + log.Printf(" Issuer: %s", issuer) + log.Printf(" Protected resource: %s/mcp", issuer) + log.Printf(" RFC 9728 discovery: %s/.well-known/oauth-protected-resource", issuer) + log.Printf(" RFC 8414 discovery: %s/.well-known/oauth-authorization-server", issuer) + log.Printf(" Access-token TTL: %s ← short so refresh fires after one idle pause", accessTokenTTL) + log.Printf(" Refresh-token TTL: %s ← inherited across rotations; once it expires, client must re-auth", refreshTokenTTL) + log.Printf("") + log.Printf("Bifrost MCP client config:") + log.Printf(`{ + "name": "oauth_demo", + "connection_type": "http", + "connection_string": "%s/mcp", + "auth_type": "oauth2", + "is_per_user": true, + "tools_to_execute": ["*"] +}`, issuer) + log.Printf("") + log.Printf("To exercise refresh: call a tool, wait >%s, call again. Watch [token] log lines.", accessTokenTTL) + + if err := http.ListenAndServe(listenAddr, handler); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index afe424d32b..f54b49d9f5 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -58,7 +58,6 @@ type ClientConfig struct { EnforceAuthOnInference bool `json:"enforce_auth_on_inference"` // Require auth (VK, API key, or user token) on inference endpoints EnforceGovernanceHeader bool `json:"enforce_governance_header,omitempty"` // Deprecated: use EnforceAuthOnInference EnforceSCIMAuth bool `json:"enforce_scim_auth,omitempty"` // Deprecated: use EnforceAuthOnInference - AllowDirectKeys bool `json:"allow_direct_keys"` // Allow direct keys to be used for requests AllowedOrigins []string `json:"allowed_origins,omitempty"` // Additional allowed origins for CORS and WebSocket (localhost is always allowed) AllowedHeaders []string `json:"allowed_headers,omitempty"` // Additional allowed headers for CORS and WebSocket MaxRequestBodySizeMB int `json:"max_request_body_size_mb"` // The maximum request body size in MB @@ -117,12 +116,6 @@ func (c *ClientConfig) GenerateClientConfigHash() (string, error) { hash.Write([]byte("enforceAuthOnInference:false")) } - if c.AllowDirectKeys { - hash.Write([]byte("allowDirectKeys:true")) - } else { - hash.Write([]byte("allowDirectKeys:false")) - } - if c.Compat.ConvertTextToChat { hash.Write([]byte("compatConvertTextToChat:true")) } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index cc57a8cec7..3490000703 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -635,6 +635,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddMCPClientDisabledColumn(ctx, db); err != nil { return err } + if err := migrationDropAllowDirectKeysColumn(ctx, db); err != nil { + return err + } return nil } @@ -1015,16 +1018,17 @@ func migrationAddAllowedOriginsJSONColumn(ctx context.Context, db *gorm.DB) erro return nil } -// migrationAddAllowDirectKeysColumn adds the allow_direct_keys column to the client config table +// migrationAddAllowDirectKeysColumn adds the allow_direct_keys column to the client config table. +// Use raw SQL since the struct field was removed in v1.5 when the feature was retired. +// This column is subsequently dropped by migrationDropAllowDirectKeysColumn. func migrationAddAllowDirectKeysColumn(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ ID: "add_allow_direct_keys_column", Migrate: func(tx *gorm.DB) error { tx = tx.WithContext(ctx) migrator := tx.Migrator() - if !migrator.HasColumn(&tables.TableClientConfig{}, "allow_direct_keys") { - if err := migrator.AddColumn(&tables.TableClientConfig{}, "allow_direct_keys"); err != nil { + if err := tx.Exec("ALTER TABLE config_client ADD COLUMN allow_direct_keys BOOLEAN DEFAULT FALSE").Error; err != nil { return err } } @@ -1038,6 +1042,93 @@ func migrationAddAllowDirectKeysColumn(ctx context.Context, db *gorm.DB) error { return nil } +// migrationDropAllowDirectKeysColumn drops the allow_direct_keys column. +// The "Allow Direct Keys" feature was retired in v1.5 — keys passed through +// HTTP headers are no longer accepted. +// +// AllowDirectKeys was previously included in GenerateClientConfigHash, so +// every existing config_hash on disk would mismatch on first v1.5 startup +// and trigger a spurious config-reload cycle. Recompute config_hash for all +// rows here to avoid that, mirroring migrationAddRoutingChainMaxDepthColumn. +func migrationDropAllowDirectKeysColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "drop_allow_direct_keys_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + if mig.HasColumn(&tables.TableClientConfig{}, "allow_direct_keys") { + if err := mig.DropColumn(&tables.TableClientConfig{}, "allow_direct_keys"); err != nil { + return err + } + } + + var clientConfigs []tables.TableClientConfig + if err := tx.Find(&clientConfigs).Error; err != nil { + return fmt.Errorf("failed to fetch client configs for hash recompute: %w", err) + } + for _, cc := range clientConfigs { + if cc.ConfigHash == "" { + continue + } + clientConfig := ClientConfig{ + DropExcessRequests: cc.DropExcessRequests, + InitialPoolSize: cc.InitialPoolSize, + PrometheusLabels: cc.PrometheusLabels, + EnableLogging: cc.EnableLogging, + DisableContentLogging: cc.DisableContentLogging, + AllowPerRequestContentStorageOverride: cc.AllowPerRequestContentStorageOverride, + AllowPerRequestRawOverride: cc.AllowPerRequestRawOverride, + DisableDBPingsInHealth: cc.DisableDBPingsInHealth, + LogRetentionDays: cc.LogRetentionDays, + EnforceAuthOnInference: cc.EnforceAuthOnInference, + AllowedOrigins: cc.AllowedOrigins, + AllowedHeaders: cc.AllowedHeaders, + MaxRequestBodySizeMB: cc.MaxRequestBodySizeMB, + MCPAgentDepth: cc.MCPAgentDepth, + MCPToolExecutionTimeout: cc.MCPToolExecutionTimeout, + MCPCodeModeBindingLevel: cc.MCPCodeModeBindingLevel, + MCPToolSyncInterval: cc.MCPToolSyncInterval, + MCPDisableAutoToolInject: cc.MCPDisableAutoToolInject, + HeaderFilterConfig: cc.HeaderFilterConfig, + AsyncJobResultTTL: cc.AsyncJobResultTTL, + RequiredHeaders: cc.RequiredHeaders, + LoggingHeaders: cc.LoggingHeaders, + WhitelistedRoutes: cc.WhitelistedRoutes, + HideDeletedVirtualKeysInFilters: cc.HideDeletedVirtualKeysInFilters, + RoutingChainMaxDepth: cc.RoutingChainMaxDepth, + Compat: CompatConfig{ + ConvertTextToChat: cc.CompatConvertTextToChat, + ConvertChatToResponses: cc.CompatConvertChatToResponses, + ShouldDropParams: cc.CompatShouldDropParams, + ShouldConvertParams: cc.CompatShouldConvertParams, + }, + } + newHash, err := clientConfig.GenerateClientConfigHash() + if err != nil { + return fmt.Errorf("failed to generate hash for client config %d: %w", cc.ID, err) + } + if err := tx.Model(&cc).Update("config_hash", newHash).Error; err != nil { + return fmt.Errorf("failed to update hash for client config %d: %w", cc.ID, err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if !tx.Migrator().HasColumn(&tables.TableClientConfig{}, "allow_direct_keys") { + if err := tx.Exec("ALTER TABLE config_client ADD COLUMN allow_direct_keys BOOLEAN DEFAULT FALSE").Error; err != nil { + return err + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running drop_allow_direct_keys_column migration: %s", err.Error()) + } + return nil +} + // migrationAddEnableLiteLLMFallbacksColumn adds the enable_litellm_fallbacks column to the client config table func migrationAddEnableLiteLLMFallbacksColumn(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ @@ -2417,7 +2508,6 @@ func migrationAddAdditionalConfigHashColumns(ctx context.Context, db *gorm.DB) e DisableContentLogging: cc.DisableContentLogging, LogRetentionDays: cc.LogRetentionDays, EnforceGovernanceHeader: cc.EnforceGovernanceHeader, - AllowDirectKeys: cc.AllowDirectKeys, AllowedOrigins: cc.AllowedOrigins, MaxRequestBodySizeMB: cc.MaxRequestBodySizeMB, } @@ -6000,7 +6090,6 @@ func migrationAddRoutingChainMaxDepthColumn(ctx context.Context, db *gorm.DB) er DisableDBPingsInHealth: cc.DisableDBPingsInHealth, LogRetentionDays: cc.LogRetentionDays, EnforceAuthOnInference: cc.EnforceAuthOnInference, - AllowDirectKeys: cc.AllowDirectKeys, AllowedOrigins: cc.AllowedOrigins, AllowedHeaders: cc.AllowedHeaders, MaxRequestBodySizeMB: cc.MaxRequestBodySizeMB, diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 18919d31c6..115eb34e17 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -162,7 +162,6 @@ func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientC EnforceAuthOnInference: config.EnforceAuthOnInference, EnforceGovernanceHeader: config.EnforceGovernanceHeader, EnforceSCIMAuth: config.EnforceSCIMAuth, - AllowDirectKeys: config.AllowDirectKeys, PrometheusLabels: config.PrometheusLabels, AllowedOrigins: config.AllowedOrigins, AllowedHeaders: config.AllowedHeaders, @@ -375,7 +374,6 @@ func (s *RDBConfigStore) GetClientConfig(ctx context.Context) (*ClientConfig, er EnforceAuthOnInference: dbConfig.EnforceAuthOnInference, EnforceGovernanceHeader: dbConfig.EnforceGovernanceHeader, EnforceSCIMAuth: dbConfig.EnforceSCIMAuth, - AllowDirectKeys: dbConfig.AllowDirectKeys, AllowedOrigins: dbConfig.AllowedOrigins, AllowedHeaders: dbConfig.AllowedHeaders, MaxRequestBodySizeMB: dbConfig.MaxRequestBodySizeMB, diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index 9e2cbf758d..be89671cc0 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -858,7 +858,6 @@ func TestUpdateClientConfig(t *testing.T) { config := &ClientConfig{ EnableLogging: new(true), - AllowDirectKeys: true, InitialPoolSize: 100, LogRetentionDays: 30, MaxRequestBodySizeMB: 50, diff --git a/framework/configstore/tables/clientconfig.go b/framework/configstore/tables/clientconfig.go index 9afe044eaa..7a2c576a57 100644 --- a/framework/configstore/tables/clientconfig.go +++ b/framework/configstore/tables/clientconfig.go @@ -23,7 +23,6 @@ type TableClientConfig struct { EnforceAuthOnInference bool `gorm:"default:false" json:"enforce_auth_on_inference"` EnforceGovernanceHeader bool `gorm:"" json:"enforce_governance_header"` EnforceSCIMAuth bool `gorm:"default:false" json:"enforce_scim_auth"` - AllowDirectKeys bool `gorm:"" json:"allow_direct_keys"` MaxRequestBodySizeMB int `gorm:"default:100" json:"max_request_body_size_mb"` MCPAgentDepth int `gorm:"default:10" json:"mcp_agent_depth"` MCPToolExecutionTimeout int `gorm:"default:30" json:"mcp_tool_execution_timeout"` // Timeout for individual tool execution in seconds (default: 30) diff --git a/framework/configstore/tables/oauth.go b/framework/configstore/tables/oauth.go index aed322205d..88c86c04e1 100644 --- a/framework/configstore/tables/oauth.go +++ b/framework/configstore/tables/oauth.go @@ -15,23 +15,23 @@ type TableOauthConfig struct { ID string `gorm:"type:varchar(255);primaryKey" json:"id"` // UUID ClientID *schemas.EnvVar `gorm:"type:varchar(512)" json:"client_id"` // OAuth provider's client ID (optional for public clients) ClientSecret *schemas.EnvVar `gorm:"type:text" json:"-"` // Encrypted OAuth client secret (optional for public clients) - AuthorizeURL string `gorm:"type:text" json:"authorize_url"` // Provider's authorization endpoint (optional, can be discovered) - TokenURL string `gorm:"type:text" json:"token_url"` // Provider's token endpoint (optional, can be discovered) - RegistrationURL *string `gorm:"type:text" json:"registration_url,omitempty"` // Provider's dynamic registration endpoint (optional, can be discovered) - RedirectURI string `gorm:"type:text;not null" json:"redirect_uri"` // Callback URL - Scopes string `gorm:"type:text" json:"scopes"` // JSON array of scopes (optional, can be discovered) - State string `gorm:"type:varchar(255);uniqueIndex;not null" json:"-"` // CSRF state token - CodeVerifier string `gorm:"type:text" json:"-"` // PKCE code verifier (generated, kept secret) - CodeChallenge string `gorm:"type:varchar(255)" json:"code_challenge"` // PKCE code challenge (sent to provider) - Status string `gorm:"type:varchar(50);not null;index" json:"status"` // "pending", "authorized", "failed", "expired", "revoked" - TokenID *string `gorm:"type:varchar(255);index" json:"token_id"` // Foreign key to oauth_tokens.ID (set after callback) - ServerURL string `gorm:"type:text" json:"server_url"` // MCP server URL for OAuth discovery - UseDiscovery bool `gorm:"default:false" json:"use_discovery"` // Flag to enable OAuth discovery - MCPClientConfigJSON *string `gorm:"type:text" json:"-"` // JSON serialized MCPClientConfig for multi-instance support (pending MCP client waiting for OAuth completion) - EncryptionStatus string `gorm:"type:varchar(20);default:'plain_text'" json:"-"` - CreatedAt time.Time `gorm:"index;not null" json:"created_at"` - UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` - ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"` // State expiry (15 min) + AuthorizeURL string `gorm:"type:text" json:"authorize_url"` // Provider's authorization endpoint (optional, can be discovered) + TokenURL string `gorm:"type:text" json:"token_url"` // Provider's token endpoint (optional, can be discovered) + RegistrationURL *string `gorm:"type:text" json:"registration_url,omitempty"` // Provider's dynamic registration endpoint (optional, can be discovered) + RedirectURI string `gorm:"type:text;not null" json:"redirect_uri"` // Callback URL + Scopes string `gorm:"type:text" json:"scopes"` // JSON array of scopes (optional, can be discovered) + State string `gorm:"type:varchar(255);uniqueIndex;not null" json:"-"` // CSRF state token + CodeVerifier string `gorm:"type:text" json:"-"` // PKCE code verifier (generated, kept secret) + CodeChallenge string `gorm:"type:varchar(255)" json:"code_challenge"` // PKCE code challenge (sent to provider) + Status string `gorm:"type:varchar(50);not null;index" json:"status"` // "pending", "authorized", "failed", "expired", "revoked" + TokenID *string `gorm:"type:varchar(255);index" json:"token_id"` // Foreign key to oauth_tokens.ID (set after callback) + ServerURL string `gorm:"type:text" json:"server_url"` // MCP server URL for OAuth discovery + UseDiscovery bool `gorm:"default:false" json:"use_discovery"` // Flag to enable OAuth discovery + MCPClientConfigJSON *string `gorm:"type:text" json:"-"` // JSON serialized MCPClientConfig for multi-instance support (pending MCP client waiting for OAuth completion) + EncryptionStatus string `gorm:"type:varchar(20);default:'plain_text'" json:"-"` + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` + ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"` // State expiry (15 min) } // TableName sets the table name @@ -105,7 +105,7 @@ type TableOauthToken struct { AccessToken string `gorm:"type:text;not null" json:"-"` // Encrypted access token RefreshToken string `gorm:"type:text" json:"-"` // Encrypted refresh token (optional) TokenType string `gorm:"type:varchar(50);not null" json:"token_type"` // "Bearer" - ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` // Token expiration (nil means unknown/non-expiring) + ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` // Token expiration (nil means unknown/non-expiring) Scopes string `gorm:"type:text" json:"scopes"` // JSON array of granted scopes LastRefreshedAt *time.Time `gorm:"index" json:"last_refreshed_at,omitempty"` // Track when token was last refreshed EncryptionStatus string `gorm:"type:varchar(20);default:'plain_text'" json:"-"` @@ -220,7 +220,7 @@ type TableOauthUserToken struct { AccessToken string `gorm:"type:text;not null" json:"-"` // Encrypted user's OAuth access token RefreshToken string `gorm:"type:text" json:"-"` // Encrypted user's OAuth refresh token TokenType string `gorm:"type:varchar(50);not null" json:"token_type"` // "Bearer" - ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` // Token expiry (nil means unknown/non-expiring) + ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` // Token expiry (nil means unknown/non-expiring) Scopes string `gorm:"type:text" json:"scopes"` // JSON array of granted scopes LastRefreshedAt *time.Time `gorm:"index" json:"last_refreshed_at,omitempty"` // Last refresh time EncryptionStatus string `gorm:"type:varchar(20);default:'plain_text'" json:"-"` @@ -295,7 +295,7 @@ type TablePerUserOAuthSession struct { RefreshTokenHash string `gorm:"type:varchar(64);index" json:"-"` // SHA-256 hash for secure lookups (not unique — refresh tokens are optional) ClientID string `gorm:"type:varchar(255);not null;index" json:"client_id"` // Which OAuth client registered this session VirtualKeyID *string `gorm:"type:varchar(255);index" json:"virtual_key_id"` // Linked VK identity (set when VK is present during auth) - VirtualKey *TableVirtualKey `gorm:"foreignKey:VirtualKeyID" json:"-"` // Linked VK identity (server-only, not serialized) + VirtualKey *TableVirtualKey `gorm:"foreignKey:VirtualKeyID" json:"-"` // Linked VK identity (server-only, not serialized) UserID *string `gorm:"type:varchar(255);index" json:"user_id"` // Linked enterprise user identity (set when user ID is present) ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"` EncryptionStatus string `gorm:"type:varchar(20);default:'plain_text'" json:"-"` diff --git a/framework/oauth2/main.go b/framework/oauth2/main.go index 5844053305..f8e5a69631 100644 --- a/framework/oauth2/main.go +++ b/framework/oauth2/main.go @@ -216,7 +216,7 @@ func (p *OAuth2Provider) RevokeToken(ctx context.Context, oauthConfigID string) return fmt.Errorf("failed to update oauth config: %w", err) } - logger.Info("OAuth token revoked", "oauth_config_id", oauthConfigID) + logger.Debug("OAuth token revoked", "oauth_config_id", oauthConfigID) return nil } @@ -854,22 +854,47 @@ func (p *OAuth2Provider) InitiateUserOAuthFlow(ctx context.Context, oauthConfigI if userID != "" { uid = &userID } - session := &tables.TableOauthUserSession{ - ID: sessionID, - MCPClientID: mcpClientID, - OauthConfigID: oauthConfigID, - State: state, - RedirectURI: redirectURI, - CodeVerifier: codeVerifier, - SessionToken: sessionToken, - VirtualKeyID: vkId, - UserID: uid, - Status: "pending", - ExpiresAt: expiresAt, - } - - if err := p.configStore.CreateOauthUserSession(ctx, session); err != nil { - return nil, "", fmt.Errorf("failed to create per-user oauth session: %w", err) + // session_token_hash has a unique index, so if the user is re-authenticating + // with a session token that already has a row (e.g. after a previous + // authorized flow whose tokens later got purged), we must update the + // existing row rather than insert a fresh one — otherwise INSERT trips the + // unique constraint. The CSRF state, PKCE verifier, and flow expiry are + // always rotated; identity fields refresh from the current context. + existing, err := p.configStore.GetOauthUserSessionBySessionToken(ctx, sessionToken) + if err != nil { + return nil, "", fmt.Errorf("failed to lookup per-user oauth session for re-auth: %w", err) + } + if existing != nil { + existing.MCPClientID = mcpClientID + existing.OauthConfigID = oauthConfigID + existing.State = state + existing.RedirectURI = redirectURI + existing.CodeVerifier = codeVerifier + existing.VirtualKeyID = vkId + existing.UserID = uid + existing.Status = "pending" + existing.ExpiresAt = expiresAt + if err := p.configStore.UpdateOauthUserSession(ctx, existing); err != nil { + return nil, "", fmt.Errorf("failed to update per-user oauth session for re-auth: %w", err) + } + sessionID = existing.ID + } else { + session := &tables.TableOauthUserSession{ + ID: sessionID, + MCPClientID: mcpClientID, + OauthConfigID: oauthConfigID, + State: state, + RedirectURI: redirectURI, + CodeVerifier: codeVerifier, + SessionToken: sessionToken, + VirtualKeyID: vkId, + UserID: uid, + Status: "pending", + ExpiresAt: expiresAt, + } + if err := p.configStore.CreateOauthUserSession(ctx, session); err != nil { + return nil, "", fmt.Errorf("failed to create per-user oauth session: %w", err) + } } // Build authorize URL with PKCE @@ -1096,6 +1121,41 @@ func (p *OAuth2Provider) RefreshUserAccessToken(ctx context.Context, sessionToke token.RefreshToken, ) if err != nil { + // Permanent rejection (HTTP 401, or 400 with invalid_grant / + // unauthorized_client per RFC 6749 §5.2) means the refresh token is + // dead — revoked, expired, or bound to a grant the AS no longer + // honors. Purge the row and signal re-authentication via the + // ErrOAuth2TokenExpired sentinel, which ResolvePerUserOAuthToken + // converts into an inline MCPUserOAuthRequiredError with auth URL. + var permErr *PermanentOAuthError + if errors.As(err, &permErr) { + // The delete is load-bearing: without it, the dead refresh token + // row survives and the caller's subsequent reauth can collide + // with it (or replay the rejected refresh). If purge fails, + // surface the error rather than misleadingly returning the + // "re-authentication required" sentinel. + if delErr := p.configStore.DeleteOauthUserToken(ctx, token.ID); delErr != nil { + return fmt.Errorf("per-user oauth refresh permanently rejected but token cleanup failed (mcp_client=%s upstream_status=%d): %w", + token.MCPClientID, permErr.StatusCode, delErr) + } + logger.Debug("Per-user OAuth refresh permanently rejected; token purged — re-authentication required: mcp_client=%s upstream_status=%d", + token.MCPClientID, permErr.StatusCode) + // Surface the state on the session row so dashboards / + // observability can show "this user needs to reconnect". The token + // delete above is the load-bearing action; this update never gates + // the OAuth flow, so a failure here is logged and ignored. + if token.SessionToken != "" { + if sess, sessErr := p.configStore.GetOauthUserSessionBySessionToken(ctx, token.SessionToken); sessErr == nil && sess != nil { + sess.Status = "needs_reauth" + if updErr := p.configStore.UpdateOauthUserSession(ctx, sess); updErr != nil { + logger.Warn("Failed to mark session needs_reauth after permanent refresh failure: session_id=%s err=%v", sess.ID, updErr) + } + } + } + return fmt.Errorf("refresh token rejected by upstream OAuth server, re-authentication required: %w", schemas.ErrOAuth2TokenExpired) + } + // Transient failure (5xx, network blip, non-permanent 400) — keep the + // row so the next call retries refresh. return fmt.Errorf("per-user token refresh failed: %w", err) } diff --git a/helm-charts/bifrost/templates/_helpers.tpl b/helm-charts/bifrost/templates/_helpers.tpl index 0e4f3c2be5..78f6a60678 100644 --- a/helm-charts/bifrost/templates/_helpers.tpl +++ b/helm-charts/bifrost/templates/_helpers.tpl @@ -221,9 +221,6 @@ false {{- if hasKey .Values.bifrost.client "enforceGovernanceHeader" }} {{- $_ := set $client "enforce_governance_header" .Values.bifrost.client.enforceGovernanceHeader }} {{- end }} -{{- if hasKey .Values.bifrost.client "allowDirectKeys" }} -{{- $_ := set $client "allow_direct_keys" .Values.bifrost.client.allowDirectKeys }} -{{- end }} {{- if .Values.bifrost.client.maxRequestBodySizeMb }} {{- $_ := set $client "max_request_body_size_mb" .Values.bifrost.client.maxRequestBodySizeMb }} {{- end }} diff --git a/helm-charts/bifrost/values-examples/providers-and-virtual-keys.yaml b/helm-charts/bifrost/values-examples/providers-and-virtual-keys.yaml index c43ba8f057..5ff7959489 100644 --- a/helm-charts/bifrost/values-examples/providers-and-virtual-keys.yaml +++ b/helm-charts/bifrost/values-examples/providers-and-virtual-keys.yaml @@ -92,7 +92,6 @@ bifrost: - "*" enableLogging: true enforceGovernanceHeader: false - allowDirectKeys: false maxRequestBodySizeMb: 100 # ========================================================================== diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json index 749890cd46..f9c0ca45ed 100644 --- a/helm-charts/bifrost/values.schema.json +++ b/helm-charts/bifrost/values.schema.json @@ -316,10 +316,6 @@ "type": "boolean", "description": "Deprecated: use enforceAuthOnInference" }, - "allowDirectKeys": { - "type": "boolean", - "description": "Allow provider keys" - }, "maxRequestBodySizeMb": { "type": "integer", "minimum": 1, diff --git a/helm-charts/bifrost/values.yaml b/helm-charts/bifrost/values.yaml index fd6a6712d7..894d286aff 100644 --- a/helm-charts/bifrost/values.yaml +++ b/helm-charts/bifrost/values.yaml @@ -207,7 +207,6 @@ bifrost: # Require auth (VK, API key, or user token) on inference endpoints. # When unset, inference endpoints follow authConfig.disableAuthOnInference behavior. enforceAuthOnInference: true - allowDirectKeys: false maxRequestBodySizeMb: 100 compat: convertTextToChat: false diff --git a/recipes/values-local-k8s.yaml b/recipes/values-local-k8s.yaml index 16752208d0..fa542f11b7 100644 --- a/recipes/values-local-k8s.yaml +++ b/recipes/values-local-k8s.yaml @@ -96,7 +96,6 @@ bifrost: - "*" enableLogging: true enforceGovernanceHeader: false - allowDirectKeys: true maxRequestBodySizeMb: 100 # Provider configuration - empty by default, configure via UI or uncomment below diff --git a/tests/governance/config.json b/tests/governance/config.json index b8cedf9a3e..ed95a5016d 100644 --- a/tests/governance/config.json +++ b/tests/governance/config.json @@ -61,7 +61,6 @@ ], "enable_logging": true, "enforce_auth_on_inference": true, - "allow_direct_keys": false, "max_request_body_size_mb": 100 } } diff --git a/tests/integrations/python/config.json b/tests/integrations/python/config.json index e90a19eb09..356f978120 100644 --- a/tests/integrations/python/config.json +++ b/tests/integrations/python/config.json @@ -339,7 +339,6 @@ "allowed_origins": ["*"], "enable_logging": true, "enforce_auth_on_inference": false, - "allow_direct_keys": false, "max_request_body_size_mb": 100 } } \ No newline at end of file diff --git a/tests/integrations/typescript/config.json b/tests/integrations/typescript/config.json index 46bc65af6b..aaf922097f 100644 --- a/tests/integrations/typescript/config.json +++ b/tests/integrations/typescript/config.json @@ -219,7 +219,6 @@ ], "enable_logging": true, "enforce_auth_on_inference": false, - "allow_direct_keys": false, "max_request_body_size_mb": 100 } } diff --git a/transports/bifrost-http/handlers/config.go b/transports/bifrost-http/handlers/config.go index 636900670f..4e40b8b58a 100644 --- a/transports/bifrost-http/handlers/config.go +++ b/transports/bifrost-http/handlers/config.go @@ -214,6 +214,19 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { return } + // Validate MCP external URL overrides up front — the rest of this handler + // applies live mutations (drop-excess flag, MCP tool-manager reload, compat + // plugin reload, in-memory MCP config) before persisting, so a late + // rejection would leave the process in a partially-updated state. + if err := lib.ValidateBaseURL(payload.ClientConfig.MCPExternalServerURL.GetValue()); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("mcp_external_server_url %v", err)) + return + } + if err := lib.ValidateBaseURL(payload.ClientConfig.MCPExternalClientURL.GetValue()); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("mcp_external_client_url %v", err)) + return + } + // Validating framework config if payload.FrameworkConfig.PricingURL != nil && *payload.FrameworkConfig.PricingURL != modelcatalog.DefaultPricingURL { // Checking the accessibility of the pricing URL @@ -342,7 +355,6 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { // and ReloadClientConfigFromConfigStore mutates the struct in place so the next request picks up the new value. updatedConfig.DisableContentLogging = payload.ClientConfig.DisableContentLogging updatedConfig.DisableDBPingsInHealth = payload.ClientConfig.DisableDBPingsInHealth - updatedConfig.AllowDirectKeys = payload.ClientConfig.AllowDirectKeys updatedConfig.EnforceAuthOnInference = payload.ClientConfig.EnforceAuthOnInference // Sync deprecated columns to match new field so they stay consistent in the DB @@ -429,6 +441,7 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { } // Update external base URLs for OAuth server metadata and client redirect_uri (nil clears each override). + // Validation is performed up front in this handler so a failure here cannot leave the process in a partial state. updatedConfig.MCPExternalServerURL = payload.ClientConfig.MCPExternalServerURL updatedConfig.MCPExternalClientURL = payload.ClientConfig.MCPExternalClientURL diff --git a/transports/bifrost-http/handlers/mcp.go b/transports/bifrost-http/handlers/mcp.go index 6b43dd4535..e2f3bfe068 100644 --- a/transports/bifrost-http/handlers/mcp.go +++ b/transports/bifrost-http/handlers/mcp.go @@ -421,6 +421,13 @@ func (h *MCPHandler) reconnectMCPClient(ctx *fasthttp.RequestCtx) { } } if err := h.mcpManager.ReconnectMCPClient(ctx, id); err != nil { + // Per-user OAuth (and any future client type that opts out of the + // shared-connection model) is a 400-class error: the request is + // well-formed, the client just doesn't support this operation. + if errors.Is(err, schemas.ErrMCPReconnectNotApplicable) { + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to reconnect MCP client: %v", err)) return } diff --git a/transports/bifrost-http/handlers/mcpserver.go b/transports/bifrost-http/handlers/mcpserver.go index d70891c5f0..d743a44630 100644 --- a/transports/bifrost-http/handlers/mcpserver.go +++ b/transports/bifrost-http/handlers/mcpserver.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "time" "github.com/bytedance/sonic" @@ -21,6 +22,12 @@ import ( "github.com/valyala/fasthttp" ) +// sseHeartbeatInterval is the cadence of SSE comment pings on the MCP SSE +// stream. It must stay below typical proxy/load-balancer idle timeouts (60s on +// most stacks) so connections aren't reaped, while being large enough to avoid +// gratuitous wake-ups on idle clients. +const sseHeartbeatInterval = 15 * time.Second + // MCPToolExecutor interface defines the method needed for executing MCP tools type MCPToolManager interface { GetAvailableMCPTools(ctx context.Context) []schemas.ChatTool @@ -147,10 +154,29 @@ func (h *MCPServerHandler) handleMCPServerSSE(ctx *fasthttp.RequestCtx) { return } + // Signal to transport-plugin and tracing middlewares that this is a streaming + // response. Without this, fasthttpResponseToHTTPResponse calls ctx.Response.Body() + // during post-hook processing, which materializes the SSE body stream and + // deadlocks waiting for an EOF that only arrives after the goroutine exits. + ctx.SetUserValue(schemas.BifrostContextKeyDeferTraceCompletion, true) + + // Pre-allocate atomic.Value slot for the transport post-hook completer. + // TransportInterceptorMiddleware stores the completer into this slot after next(ctx) + // returns. The goroutine reads from the closure-captured pointer, avoiding any ctx + // access after the handler returns (fasthttp recycles RequestCtx). + var completerSlot atomic.Value + ctx.SetUserValue(schemas.BifrostContextKeyTransportPostHookCompleter, &completerSlot) + + // Get the trace completer function for use in the streaming callback. + // Signature: func([]schemas.PluginLogEntry) — accepts transport plugin logs so it + // never needs to read from ctx.UserValue (ctx may be recycled). + traceCompleter, _ := ctx.UserValue(schemas.BifrostContextKeyTraceCompleter).(func([]schemas.PluginLogEntry)) + // Set SSE headers ctx.SetContentType("text/event-stream") ctx.Response.Header.Set("Cache-Control", "no-cache") ctx.Response.Header.Set("Connection", "keep-alive") + ctx.Response.Header.Set("X-Accel-Buffering", "no") // Convert context bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.config) @@ -162,9 +188,66 @@ func (h *MCPServerHandler) handleMCPServerSSE(ctx *fasthttp.RequestCtx) { ctx.Response.SetBodyStream(reader, -1) go func() { + var transportLogs []schemas.PluginLogEntry + completerRan := false + // runCompleter invokes the transport post-hook completer at most once. + // sendSSEOnError=true emits plugin errors as SSE "event: error" frames so the + // client sees them; =false logs server-side only (defer fallback, after stream + // termination). The MCP SSE handler has no happy-path completion point, so it + // only ever invokes this from the defer with sendSSEOnError=false. + runCompleter := func(sendSSEOnError bool) { + if completerRan { + return + } + // Bounded wait for TransportInterceptorMiddleware to publish the completer. + // It calls slot.Store after next(ctx) returns, which races with this goroutine + // on fast/empty streams. 100ms is ample — the store runs a few instructions + // after the handler returns. + var loaded any + deadline := time.Now().Add(100 * time.Millisecond) + for { + if loaded = completerSlot.Load(); loaded != nil { + break + } + if time.Now().After(deadline) { + break + } + time.Sleep(time.Millisecond) + } + if loaded == nil { + return + } + postHookCompleter, ok := loaded.(func() ([]schemas.PluginLogEntry, error)) + if !ok { + return + } + completerRan = true + logs, err := postHookCompleter() + if err != nil { + if sendSSEOnError { + errorJSON, marshalErr := sonic.Marshal(map[string]string{"error": err.Error()}) + if marshalErr == nil { + reader.SendError(errorJSON) + } + } else { + logger.Warn("transport post-hook failed after stream terminated: %v", err) + } + } + transportLogs = logs + } + defer func() { + // Run the deferred transport post-hook completer before cancelling the + // context so plugins see a live context. Errors are logged server-side + // only — the stream is already closing. + runCompleter(false) cancel() reader.Done() + // Complete the trace after streaming finishes, passing transport plugin logs. + // This ensures all spans are properly ended before the trace is sent to OTEL. + if traceCompleter != nil { + traceCompleter(transportLogs) + } }() // Send initial connection message @@ -177,11 +260,27 @@ func (h *MCPServerHandler) handleMCPServerSSE(ctx *fasthttp.RequestCtx) { buf = append(buf, "data: "...) buf = append(buf, initJSON...) buf = append(buf, '\n', '\n') - reader.Send(buf) + if !reader.Send(buf) { + return + } } - // Wait for context cancellation (client disconnect or server-side cancel) - <-(*bifrostCtx).Done() + // Periodic SSE comment heartbeats keep idle connections alive through + // proxies and let us detect client disconnect via reader.Send() returning + // false — fasthttp.RequestCtx never cancels bifrostCtx on its own. + ticker := time.NewTicker(sseHeartbeatInterval) + defer ticker.Stop() + ping := []byte(": ping\n\n") + for { + select { + case <-ticker.C: + if !reader.Send(ping) { + return + } + case <-(*bifrostCtx).Done(): + return + } + } }() } diff --git a/transports/bifrost-http/handlers/webrtc_realtime.go b/transports/bifrost-http/handlers/webrtc_realtime.go index 342328ec00..da10f0a9cb 100644 --- a/transports/bifrost-http/handlers/webrtc_realtime.go +++ b/transports/bifrost-http/handlers/webrtc_realtime.go @@ -302,7 +302,6 @@ func (h *WebRTCRealtimeHandler) resolveRealtimeWebRTCKeys( applyRealtimeEphemeralKeyMapping(bifrostCtx, mapping) } if isRealtimeEphemeralToken(inboundToken) && !mapped { - bifrostCtx.ClearValue(schemas.BifrostContextKeyDirectKey) bifrostCtx.ClearValue(schemas.BifrostContextKeyAPIKeyID) bifrostCtx.ClearValue(schemas.BifrostContextKeyAPIKeyName) bifrostCtx.ClearValue(schemas.BifrostContextKeySelectedKeyID) @@ -1045,7 +1044,6 @@ func newRealtimeRelayContext(requestCtx *schemas.BifrostContext) (*schemas.Bifro schemas.BifrostContextKeyVirtualKey, schemas.BifrostContextKeyAPIKeyName, schemas.BifrostContextKeyAPIKeyID, - schemas.BifrostContextKeyDirectKey, schemas.BifrostContextKeyExtraHeaders, schemas.BifrostContextKeyRequestHeaders, schemas.BifrostContextKeyUserAgent, diff --git a/transports/bifrost-http/handlers/webrtc_realtime_test.go b/transports/bifrost-http/handlers/webrtc_realtime_test.go index a95652949e..8ed36bd040 100644 --- a/transports/bifrost-http/handlers/webrtc_realtime_test.go +++ b/transports/bifrost-http/handlers/webrtc_realtime_test.go @@ -18,7 +18,6 @@ type testHandlerStore struct { kv *kvstore.Store } -func (s testHandlerStore) ShouldAllowDirectKeys() bool { return true } func (s testHandlerStore) GetHeaderMatcher() *lib.HeaderMatcher { return nil } func (s testHandlerStore) GetProvidersForModel(model string) []schemas.ModelProvider { return nil } func (s testHandlerStore) GetStreamChunkInterceptor() lib.StreamChunkInterceptor { @@ -299,7 +298,6 @@ func TestResolveRealtimeWebRTCKeys_UnmappedEphemeralTokenStaysAnonymous(t *testi ctx.Request.Header.Set("Authorization", "Bearer ek_test_unmapped") bifrostCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) - bifrostCtx.SetValue(schemas.BifrostContextKeyDirectKey, schemas.Key{ID: "header-provided"}) bifrostCtx.SetValue(schemas.BifrostContextKeySelectedKeyID, "selected") bifrostCtx.SetValue(schemas.BifrostContextKeySelectedKeyName, "selected-name") bifrostCtx.SetValue(schemas.BifrostContextKeyAPIKeyID, "mapped-id") @@ -315,9 +313,6 @@ func TestResolveRealtimeWebRTCKeys_UnmappedEphemeralTokenStaysAnonymous(t *testi if selectedKey != nil { t.Fatalf("selectedKey = %#v, want nil", selectedKey) } - if got := bifrostCtx.Value(schemas.BifrostContextKeyDirectKey); got != nil { - t.Fatalf("direct key context = %#v, want nil", got) - } if got := bifrostCtx.Value(schemas.BifrostContextKeySelectedKeyID); got != nil { t.Fatalf("selected key id context = %#v, want nil", got) } diff --git a/transports/bifrost-http/handlers/wsresponses.go b/transports/bifrost-http/handlers/wsresponses.go index 2b7a18c1ce..f93f20a5fd 100644 --- a/transports/bifrost-http/handlers/wsresponses.go +++ b/transports/bifrost-http/handlers/wsresponses.go @@ -658,41 +658,17 @@ func createBifrostContextFromAuth(handlerStore lib.HandlerStore, auth *authHeade token := strings.TrimPrefix(auth.authorization, "Bearer ") if strings.HasPrefix(token, "sk-bf-") { ctx.SetValue(schemas.BifrostContextKeyVirtualKey, token) - } else if handlerStore != nil && handlerStore.ShouldAllowDirectKeys() { - key := schemas.Key{ - ID: "header-provided", - Value: *schemas.NewEnvVar(token), - Models: schemas.WhiteList{"*"}, - Weight: 1.0, - } - ctx.SetValue(schemas.BifrostContextKeyDirectKey, key) } } } if auth.apiKey != "" { if strings.HasPrefix(auth.apiKey, "sk-bf-") { ctx.SetValue(schemas.BifrostContextKeyVirtualKey, auth.apiKey) - } else if handlerStore != nil && handlerStore.ShouldAllowDirectKeys() { - key := schemas.Key{ - ID: "header-provided", - Value: *schemas.NewEnvVar(auth.apiKey), - Models: schemas.WhiteList{"*"}, - Weight: 1.0, - } - ctx.SetValue(schemas.BifrostContextKeyDirectKey, key) } } if auth.googAPIKey != "" { if strings.HasPrefix(auth.googAPIKey, "sk-bf-") { ctx.SetValue(schemas.BifrostContextKeyVirtualKey, auth.googAPIKey) - } else if handlerStore != nil && handlerStore.ShouldAllowDirectKeys() { - key := schemas.Key{ - ID: "header-provided", - Value: *schemas.NewEnvVar(auth.googAPIKey), - Models: schemas.WhiteList{"*"}, - Weight: 1.0, - } - ctx.SetValue(schemas.BifrostContextKeyDirectKey, key) } } diff --git a/transports/bifrost-http/handlers/wsresponses_test.go b/transports/bifrost-http/handlers/wsresponses_test.go index 931b7f1ec9..e39ffd6e43 100644 --- a/transports/bifrost-http/handlers/wsresponses_test.go +++ b/transports/bifrost-http/handlers/wsresponses_test.go @@ -17,12 +17,7 @@ import ( ) type testWSHandlerStore struct { - allowDirectKeys bool - matcher *lib.HeaderMatcher -} - -func (s testWSHandlerStore) ShouldAllowDirectKeys() bool { - return s.allowDirectKeys + matcher *lib.HeaderMatcher } func (s testWSHandlerStore) GetHeaderMatcher() *lib.HeaderMatcher { diff --git a/transports/bifrost-http/integrations/bedrock.go b/transports/bifrost-http/integrations/bedrock.go index 14c2bd826d..5d5a338175 100644 --- a/transports/bifrost-http/integrations/bedrock.go +++ b/transports/bifrost-http/integrations/bedrock.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" - "github.com/google/uuid" bifrost "github.com/maximhq/bifrost/core" "github.com/maximhq/bifrost/core/providers/bedrock" "github.com/maximhq/bifrost/core/schemas" @@ -619,58 +618,9 @@ func createBedrockBatchRouteConfigs(pathPrefix string, handlerStore lib.HandlerS return routes } -// bedrockBatchPreCallback returns a pre-callback for Bedrock batch create requests +// bedrockBatchPreCallback returns a pre-callback for Bedrock batch create requests. func bedrockBatchPreCallback(handlerStore lib.HandlerStore) func(ctx *fasthttp.RequestCtx, bifrostCtx *schemas.BifrostContext, req interface{}) error { return func(ctx *fasthttp.RequestCtx, bifrostCtx *schemas.BifrostContext, req interface{}) error { - // Handle direct key authentication if allowed - if !handlerStore.ShouldAllowDirectKeys() { - return nil - } - - // Check for Bedrock API Key (alternative to AWS Credentials) - apiKey := string(ctx.Request.Header.Peek("x-bf-bedrock-api-key")) - - // Check for AWS Credentials - accessKey := string(ctx.Request.Header.Peek("x-bf-bedrock-access-key")) - secretKey := string(ctx.Request.Header.Peek("x-bf-bedrock-secret-key")) - region := string(ctx.Request.Header.Peek("x-bf-bedrock-region")) - sessionToken := string(ctx.Request.Header.Peek("x-bf-bedrock-session-token")) - - if apiKey != "" { - key := schemas.Key{ - ID: uuid.New().String(), - Value: *schemas.NewEnvVar(apiKey), - BedrockKeyConfig: &schemas.BedrockKeyConfig{}, - } - if region != "" { - key.BedrockKeyConfig.Region = schemas.NewEnvVar(region) - } - bifrostCtx.SetValue(schemas.BifrostContextKeyDirectKey, key) - return nil - } - - if accessKey != "" && secretKey != "" { - if region == "" { - return errors.New("x-bf-bedrock-region header is required when using direct keys") - } - - key := schemas.Key{ - ID: uuid.New().String(), - BedrockKeyConfig: &schemas.BedrockKeyConfig{ - AccessKey: *schemas.NewEnvVar(accessKey), - SecretKey: *schemas.NewEnvVar(secretKey), - }, - } - - key.BedrockKeyConfig.Region = schemas.NewEnvVar(region) - - if sessionToken != "" { - key.BedrockKeyConfig.SessionToken = schemas.NewEnvVar(sessionToken) - } - - bifrostCtx.SetValue(schemas.BifrostContextKeyDirectKey, key) - } - return nil } } @@ -1279,59 +1229,6 @@ func bedrockPreCallback(handlerStore lib.HandlerStore) func(ctx *fasthttp.Reques return errors.New("invalid request type for bedrock model extraction") } - // Handle direct key authentication if allowed - if !handlerStore.ShouldAllowDirectKeys() { - return nil - } - - // Check for Bedrock API Key (alternative to AWS Credentials) - apiKey := string(ctx.Request.Header.Peek("x-bf-bedrock-api-key")) - - // Check for AWS Credentials - accessKey := string(ctx.Request.Header.Peek("x-bf-bedrock-access-key")) - secretKey := string(ctx.Request.Header.Peek("x-bf-bedrock-secret-key")) - region := string(ctx.Request.Header.Peek("x-bf-bedrock-region")) - sessionToken := string(ctx.Request.Header.Peek("x-bf-bedrock-session-token")) - - if apiKey != "" { - // Case 1: API Key Authentication - key := schemas.Key{ - ID: uuid.New().String(), - Value: *schemas.NewEnvVar(apiKey), - // BedrockKeyConfig is required by the provider even if using API Key - BedrockKeyConfig: &schemas.BedrockKeyConfig{}, - } - - if region != "" { - key.BedrockKeyConfig.Region = schemas.NewEnvVar(region) - } - bifrostCtx.SetValue(schemas.BifrostContextKeyDirectKey, key) - return nil - } else if accessKey != "" && secretKey != "" { - // Case 2: AWS Credentials Authentication - if region == "" { - return errors.New("x-bf-bedrock-region header is required when using direct keys") - } - - key := schemas.Key{ - ID: uuid.New().String(), - BedrockKeyConfig: &schemas.BedrockKeyConfig{ - AccessKey: *schemas.NewEnvVar(accessKey), - SecretKey: *schemas.NewEnvVar(secretKey), - }, - } - - if region != "" { - key.BedrockKeyConfig.Region = schemas.NewEnvVar(region) - } - - if sessionToken != "" { - key.BedrockKeyConfig.SessionToken = schemas.NewEnvVar(sessionToken) - } - - bifrostCtx.SetValue(schemas.BifrostContextKeyDirectKey, key) - } - return nil } } diff --git a/transports/bifrost-http/integrations/bedrock_test.go b/transports/bifrost-http/integrations/bedrock_test.go index 723b22a629..4cdc4942b9 100644 --- a/transports/bifrost-http/integrations/bedrock_test.go +++ b/transports/bifrost-http/integrations/bedrock_test.go @@ -16,16 +16,11 @@ import ( // mockHandlerStore implements lib.HandlerStore for testing type mockHandlerStore struct { - allowDirectKeys bool headerMatcher *lib.HeaderMatcher availableProviders []schemas.ModelProvider mcpHeaderCombinedAllowlist schemas.WhiteList } -func (m *mockHandlerStore) ShouldAllowDirectKeys() bool { - return m.allowDirectKeys -} - func (m *mockHandlerStore) GetHeaderMatcher() *lib.HeaderMatcher { return m.headerMatcher } @@ -127,7 +122,7 @@ func Test_parseS3URI(t *testing.T) { } func Test_createBedrockRouteConfigs(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} routes := CreateBedrockRouteConfigs("/bedrock", handlerStore) assert.Len(t, routes, 6, "should have 6 bedrock routes") @@ -154,7 +149,7 @@ func Test_createBedrockRouteConfigs(t *testing.T) { } func Test_createBedrockConverseRouteConfig(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} route := createBedrockConverseRouteConfig("/bedrock", handlerStore) assert.Equal(t, "/bedrock/model/{modelId}/converse", route.Path) @@ -173,7 +168,7 @@ func Test_createBedrockConverseRouteConfig(t *testing.T) { } func Test_createBedrockConverseStreamRouteConfig(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} route := createBedrockConverseStreamRouteConfig("/bedrock", handlerStore) assert.Equal(t, "/bedrock/model/{modelId}/converse-stream", route.Path) @@ -189,7 +184,7 @@ func Test_createBedrockConverseStreamRouteConfig(t *testing.T) { } func Test_createBedrockInvokeRouteConfig(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} route := createBedrockInvokeRouteConfig("/bedrock", handlerStore) assert.Equal(t, "/bedrock/model/{modelId}/invoke", route.Path) @@ -205,7 +200,7 @@ func Test_createBedrockInvokeRouteConfig(t *testing.T) { } func Test_createBedrockInvokeWithResponseStreamRouteConfig(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} route := createBedrockInvokeWithResponseStreamRouteConfig("/bedrock", handlerStore) assert.Equal(t, "/bedrock/model/{modelId}/invoke-with-response-stream", route.Path) @@ -222,7 +217,7 @@ func Test_createBedrockInvokeWithResponseStreamRouteConfig(t *testing.T) { } func Test_createBedrockRerankRouteConfig(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} route := createBedrockRerankRouteConfig("/bedrock", handlerStore) assert.Equal(t, "/bedrock/rerank", route.Path) @@ -243,7 +238,7 @@ func Test_createBedrockRerankRouteConfig(t *testing.T) { } func Test_createBedrockRerankResponseConverterUsesRawResponse(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} route := createBedrockRerankRouteConfig("/bedrock", handlerStore) require.NotNil(t, route.RerankResponseConverter) @@ -260,7 +255,7 @@ func Test_createBedrockRerankResponseConverterUsesRawResponse(t *testing.T) { } func Test_createBedrockRerankRouteRequestConverter(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} route := createBedrockRerankRouteConfig("/bedrock", handlerStore) require.NotNil(t, route.RequestConverter) @@ -307,7 +302,7 @@ func Test_createBedrockRerankRouteRequestConverter(t *testing.T) { } func Test_createBedrockRouteConfigsIncludesRerankForCompositePrefixes(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} prefixes := []string{"/litellm", "/langchain", "/pydanticai"} for _, prefix := range prefixes { @@ -324,7 +319,7 @@ func Test_createBedrockRouteConfigsIncludesRerankForCompositePrefixes(t *testing } func Test_createBedrockBatchRouteConfigs(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} routes := createBedrockBatchRouteConfigs("/bedrock", handlerStore) assert.Len(t, routes, 4, "should have 4 batch routes") @@ -351,7 +346,7 @@ func Test_createBedrockBatchRouteConfigs(t *testing.T) { } func Test_createBedrockFilesRouteConfigs(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} routes := createBedrockFilesRouteConfigs("/bedrock/files", handlerStore) assert.Len(t, routes, 5, "should have 5 file routes") @@ -616,7 +611,7 @@ func Test_s3ListObjectsV2PostCallback(t *testing.T) { } func Test_extractBedrockBatchListQueryParams(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: false} + handlerStore := &mockHandlerStore{} tests := []struct { name string @@ -683,7 +678,7 @@ func Test_extractBedrockBatchListQueryParams(t *testing.T) { } func Test_extractBedrockJobArnFromPath(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: false} + handlerStore := &mockHandlerStore{} tests := []struct { name string @@ -758,7 +753,7 @@ func Test_extractBedrockJobArnFromPath(t *testing.T) { } func Test_extractS3ListObjectsV2Params(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: false} + handlerStore := &mockHandlerStore{} tests := []struct { name string @@ -836,7 +831,7 @@ func Test_extractS3ListObjectsV2Params(t *testing.T) { } func Test_extractS3BucketKeyFromPath(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: false} + handlerStore := &mockHandlerStore{} tests := []struct { name string diff --git a/transports/bifrost-http/integrations/openai.go b/transports/bifrost-http/integrations/openai.go index 950ce337a4..5b09f9395c 100644 --- a/transports/bifrost-http/integrations/openai.go +++ b/transports/bifrost-http/integrations/openai.go @@ -12,7 +12,6 @@ import ( "strings" "github.com/bytedance/sonic" - "github.com/google/uuid" bifrost "github.com/maximhq/bifrost/core" "github.com/maximhq/bifrost/core/providers/openai" "github.com/maximhq/bifrost/core/schemas" @@ -155,10 +154,6 @@ func AzureEndpointPreHook(handlerStore lib.HandlerStore) func(ctx *fasthttp.Requ hydrateOpenAIRequestFromLargePayloadMetadata(ctx, bifrostCtx, req) schemas.ExtractAndSetUserAgentFromHeaders(extractHeadersFromRequest(ctx), bifrostCtx) - azureKey := ctx.Request.Header.Peek("authorization") - deploymentEndpoint := ctx.Request.Header.Peek("x-bf-azure-endpoint") - apiVersion := string(ctx.QueryArgs().Peek("api-version")) - // ----------------------------- // Parse deploymentPath wildcard // ----------------------------- @@ -266,40 +261,6 @@ func AzureEndpointPreHook(handlerStore lib.HandlerStore) func(ctx *fasthttp.Requ } } - // ----------------------------- - // Direct Azure Keys - // ----------------------------- - - if deploymentEndpoint == nil || azureKey == nil || !handlerStore.ShouldAllowDirectKeys() { - return nil - } - - // Non-Azure providers skip direct Azure keys - if deploymentProviderStr != "" && deploymentProviderStr != string(schemas.Azure) { - return nil - } - - azureKeyStr := string(azureKey) - deploymentEndpointStr := string(deploymentEndpoint) - apiVersionStr := apiVersion - - key := schemas.Key{ - ID: uuid.New().String(), - Models: schemas.WhiteList{"*"}, - AzureKeyConfig: &schemas.AzureKeyConfig{}, - } - - if deploymentEndpointStr != "" && deploymentIDStr != "" && azureKeyStr != "" { - key.Value = *schemas.NewEnvVar(strings.TrimPrefix(azureKeyStr, "Bearer ")) - key.AzureKeyConfig.Endpoint = *schemas.NewEnvVar(deploymentEndpointStr) - } - - if apiVersionStr != "" { - key.AzureKeyConfig.APIVersion = schemas.NewEnvVar(apiVersionStr) - } - - ctx.SetUserValue(schemas.BifrostContextKeyDirectKey, key) - return nil } } diff --git a/transports/bifrost-http/integrations/router.go b/transports/bifrost-http/integrations/router.go index 55f701070e..6338bf380a 100644 --- a/transports/bifrost-http/integrations/router.go +++ b/transports/bifrost-http/integrations/router.go @@ -763,14 +763,6 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle } } - // Set direct key from context if available - if ctx.UserValue(string(schemas.BifrostContextKeyDirectKey)) != nil { - key, ok := ctx.UserValue(string(schemas.BifrostContextKeyDirectKey)).(schemas.Key) - if ok { - bifrostCtx.SetValue(schemas.BifrostContextKeyDirectKey, key) - } - } - // Set available providers to context if config.GetRequestModel != nil { model, err := config.GetRequestModel(ctx, req) @@ -2919,11 +2911,6 @@ func (g *GenericRouter) handlePassthrough(ctx *fasthttp.RequestCtx) { }) bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, g.handlerStore) - if directKey := ctx.UserValue(string(schemas.BifrostContextKeyDirectKey)); directKey != nil { - if key, ok := directKey.(schemas.Key); ok { - bifrostCtx.SetValue(schemas.BifrostContextKeyDirectKey, key) - } - } path := string(ctx.Path()) for _, prefix := range g.passthroughCfg.StripPrefix { diff --git a/transports/bifrost-http/integrations/router_large_payload_test.go b/transports/bifrost-http/integrations/router_large_payload_test.go index d375abac31..389227b526 100644 --- a/transports/bifrost-http/integrations/router_large_payload_test.go +++ b/transports/bifrost-http/integrations/router_large_payload_test.go @@ -13,7 +13,7 @@ import ( ) func TestCreateHandler_SkipsRequestParserInLargePayloadMode(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} parserCalls := 0 route := RouteConfig{ @@ -55,7 +55,7 @@ func TestCreateHandler_SkipsRequestParserInLargePayloadMode(t *testing.T) { } func TestCreateHandler_UsesRequestParserWhenNotInLargePayloadMode(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} parserCalls := 0 route := RouteConfig{ diff --git a/transports/bifrost-http/integrations/router_test.go b/transports/bifrost-http/integrations/router_test.go index da71c41ed0..ad5d598784 100644 --- a/transports/bifrost-http/integrations/router_test.go +++ b/transports/bifrost-http/integrations/router_test.go @@ -104,7 +104,7 @@ func TestRequestWithSettableExtraParams_AllOpenAIRequestTypes(t *testing.T) { } func TestExtraParamsRequiresPassthroughHeader(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} routes := CreateOpenAIRouteConfigs("/openai", handlerStore) var chatRoute *RouteConfig @@ -313,7 +313,7 @@ func TestExtraParamsPassthrough_NoExtraParamsKey(t *testing.T) { // passes req to config.RequestConverter after the extra params block -- both // variables must reference the same underlying struct via pointer semantics. func TestExtraParamsSetViaInterfaceMutatesOriginalReq(t *testing.T) { - handlerStore := &mockHandlerStore{allowDirectKeys: true} + handlerStore := &mockHandlerStore{} routes := CreateOpenAIRouteConfigs("/openai", handlerStore) var chatRoute *RouteConfig diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index fac5a42dde..7abce81b93 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -60,8 +60,6 @@ type StreamChunkInterceptor interface { // This interface allows handlers to access only the configuration they need // without depending on the entire ConfigStore, improving testability and decoupling. type HandlerStore interface { - // ShouldAllowDirectKeys returns whether direct API keys in headers are allowed - ShouldAllowDirectKeys() bool // GetHeaderMatcher returns the precompiled header matcher for header filtering GetHeaderMatcher() *HeaderMatcher // GetProvidersForModel returns the list of providers that can serve a given model. @@ -320,7 +318,6 @@ var DefaultClientConfig = configstore.ClientConfig{ EnableLogging: new(true), DisableContentLogging: false, EnforceAuthOnInference: false, - AllowDirectKeys: false, AllowedOrigins: []string{"*"}, AllowedHeaders: []string{}, WhitelistedRoutes: []string{}, @@ -693,6 +690,26 @@ func applyClientConfigDefaults(cc *configstore.ClientConfig) { } } +// sanitizeMCPExternalOAuthURLs validates the MCP external OAuth URL overrides +// on a ClientConfig and clears any invalid override so it cannot leak into +// OAuth URL generation. The warning intentionally omits the offending value: +// these fields support env-var references (`env.MY_VAR`), and echoing the +// resolved value would let a misconfigured deployment surface env contents +// in logs. +func sanitizeMCPExternalOAuthURLs(client *configstore.ClientConfig) { + if client == nil { + return + } + if err := ValidateBaseURL(client.MCPExternalServerURL.GetValue()); err != nil { + logger.Warn("mcp_external_server_url %v; override will be ignored and OAuth URLs will fall back to the request Host header", err) + client.MCPExternalServerURL = nil + } + if err := ValidateBaseURL(client.MCPExternalClientURL.GetValue()); err != nil { + logger.Warn("mcp_external_client_url %v; override will be ignored and OAuth URLs will fall back to the request Host header", err) + client.MCPExternalClientURL = nil + } +} + // loadClientConfig loads and merges client config from file with store using hash-based reconciliation func loadClientConfig(ctx context.Context, config *Config, configData *ConfigData) { var clientConfig *configstore.ClientConfig @@ -707,6 +724,7 @@ func loadClientConfig(ctx context.Context, config *Config, configData *ConfigDat if clientConfig == nil { logger.Debug("client config not found in store, using config file") if configData.Client != nil { + sanitizeMCPExternalOAuthURLs(configData.Client) config.ClientConfig = configData.Client applyClientConfigDefaults(config.ClientConfig) // Generate hash for the file config @@ -751,6 +769,7 @@ func loadClientConfig(ctx context.Context, config *Config, configData *ConfigDat if clientConfig.ConfigHash != fileHash { // Hash mismatch - config.json was changed, sync from file logger.Info("client config was updated in config.json, syncing. Note that: file config takes precedence.") + sanitizeMCPExternalOAuthURLs(configData.Client) config.ClientConfig = configData.Client config.ClientConfig.ConfigHash = fileHash applyClientConfigDefaults(config.ClientConfig) @@ -3245,14 +3264,6 @@ func (c *Config) GetProviderConfigRaw(provider schemas.ModelProvider) (*configst // HandlerStore interface implementation -// ShouldAllowDirectKeys returns whether direct API keys in headers are allowed -// Note: This method doesn't use locking for performance. In rare cases during -// config updates, it may return stale data, but this is acceptable since bool -// reads are atomic and won't cause panics. -func (c *Config) ShouldAllowDirectKeys() bool { - return c.ClientConfig.AllowDirectKeys -} - // ShouldAllowPerRequestStorageOverride returns whether per-request content storage overrides are permitted. func (c *Config) ShouldAllowPerRequestStorageOverride() bool { return c.ClientConfig.AllowPerRequestContentStorageOverride diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index 07dc2adeb6..a87ac83df0 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -12462,7 +12462,6 @@ func TestGenerateClientConfigHash(t *testing.T) { DisableContentLogging: false, LogRetentionDays: 30, EnforceAuthOnInference: false, - AllowDirectKeys: true, AllowedOrigins: []string{"http://localhost:3000"}, MaxRequestBodySizeMB: 100, } @@ -12537,14 +12536,6 @@ func TestGenerateClientConfigHash(t *testing.T) { t.Error("Different EnforceAuthOnInference should produce different hash") } - // Different AllowDirectKeys should produce different hash - cc10 := cc1 - cc10.AllowDirectKeys = false - hash10, _ := cc10.GenerateClientConfigHash() - if hash1 == hash10 { - t.Error("Different AllowDirectKeys should produce different hash") - } - // Different AllowedOrigins should produce different hash cc11 := cc1 cc11.AllowedOrigins = []string{"http://example.com"} @@ -13879,7 +13870,6 @@ func TestGenerateClientConfigHash_RuntimeVsMigrationParity(t *testing.T) { DisableContentLogging: false, LogRetentionDays: 30, EnforceAuthOnInference: false, - AllowDirectKeys: true, MaxRequestBodySizeMB: 100, } @@ -13892,7 +13882,6 @@ func TestGenerateClientConfigHash_RuntimeVsMigrationParity(t *testing.T) { DisableContentLogging: ccToSave.DisableContentLogging, LogRetentionDays: ccToSave.LogRetentionDays, EnforceAuthOnInference: ccToSave.EnforceAuthOnInference, - AllowDirectKeys: ccToSave.AllowDirectKeys, MaxRequestBodySizeMB: ccToSave.MaxRequestBodySizeMB, Compat: configstore.CompatConfig{ ConvertTextToChat: ccToSave.CompatConvertTextToChat, @@ -13916,7 +13905,6 @@ func TestGenerateClientConfigHash_RuntimeVsMigrationParity(t *testing.T) { DisableContentLogging: ccFromDB.DisableContentLogging, LogRetentionDays: ccFromDB.LogRetentionDays, EnforceAuthOnInference: ccFromDB.EnforceAuthOnInference, - AllowDirectKeys: ccFromDB.AllowDirectKeys, MaxRequestBodySizeMB: ccFromDB.MaxRequestBodySizeMB, Compat: configstore.CompatConfig{ ConvertTextToChat: ccFromDB.CompatConvertTextToChat, @@ -17209,7 +17197,6 @@ func assertDefaultClientConfigValues(t *testing.T, cc configstore.ClientConfig) require.Equal(t, true, *cc.EnableLogging, "EnableLogging should default to true") require.Equal(t, false, cc.DisableContentLogging, "DisableContentLogging should default to false") require.Equal(t, false, cc.EnforceAuthOnInference, "EnforceAuthOnInference should default to false") - require.Equal(t, false, cc.AllowDirectKeys, "AllowDirectKeys should default to false") require.Equal(t, []string{"*"}, cc.AllowedOrigins, "AllowedOrigins should default to [*]") require.Equal(t, 100, cc.MaxRequestBodySizeMB, "MaxRequestBodySizeMB should default to 100") require.Equal(t, 10, cc.MCPAgentDepth, "MCPAgentDepth should default to 10") diff --git a/transports/bifrost-http/lib/ctx.go b/transports/bifrost-http/lib/ctx.go index e1069b6024..6265dbaa8c 100644 --- a/transports/bifrost-http/lib/ctx.go +++ b/transports/bifrost-http/lib/ctx.go @@ -149,13 +149,11 @@ func ParseSessionIDFromBaggage(header string) string { // // session stickiness, and extra headers func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, store HandlerStore) (*schemas.BifrostContext, context.CancelFunc) { - allowDirectKeys := false var matcher *HeaderMatcher mcpHeaderCombinedAllowlist := schemas.WhiteList{} allowPerRequestStorageOverride := false allowPerRequestRawOverride := false if store != nil { - allowDirectKeys = store.ShouldAllowDirectKeys() matcher = store.GetHeaderMatcher() mcpHeaderCombinedAllowlist = store.GetMCPHeaderCombinedAllowlist() allowPerRequestStorageOverride = store.ShouldAllowPerRequestStorageOverride() @@ -618,55 +616,29 @@ func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, store HandlerStore) (*sch bifrostCtx.SetValue(schemas.BifrostContextKeyOAuthRedirectURI, baseURL+"/api/oauth/callback") } - if allowDirectKeys { - // Extract API key from Authorization header (Bearer format), x-api-key, or x-goog-api-key header - var apiKey string - - // TODO: fix plugin data leak - // Check Authorization header (Bearer format only - OpenAI style) - authHeader := string(ctx.Request.Header.Peek("Authorization")) - if authHeader != "" { - // Only accept Bearer token format: "Bearer ..." - if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { - authHeaderValue := strings.TrimSpace(authHeader[7:]) // Remove "Bearer " prefix - if authHeaderValue != "" && !strings.HasPrefix(strings.ToLower(authHeaderValue), governance.VirtualKeyPrefix) { - apiKey = authHeaderValue - } - } else { - apiKey = authHeader - } - } - - if apiKey == "" { - // Check x-api-key (Anthropic style) header if no valid Authorization header found - xAPIKey := string(ctx.Request.Header.Peek("x-api-key")) - if xAPIKey != "" && !strings.HasPrefix(strings.ToLower(xAPIKey), governance.VirtualKeyPrefix) { - apiKey = strings.TrimSpace(xAPIKey) - } else { - // Check x-goog-api-key (Google Gemini style) header if no valid Authorization header found - xGoogleAPIKey := string(ctx.Request.Header.Peek("x-goog-api-key")) - if xGoogleAPIKey != "" && !strings.HasPrefix(strings.ToLower(xGoogleAPIKey), governance.VirtualKeyPrefix) { - apiKey = strings.TrimSpace(xGoogleAPIKey) - } - } - } - - // If we found an API key, create a Key object and store it in context - if apiKey != "" { - key := schemas.Key{ - ID: "header-provided", // Identifier for header-provided keys - Value: *schemas.NewEnvVar(apiKey), - Models: schemas.WhiteList{"*"}, // Allow all models - Weight: 1.0, // Default weight - } - bifrostCtx.SetValue(schemas.BifrostContextKeyDirectKey, key) - } - } bifrostCtx.SetValue(schemas.BifrostContextKeyAllowPerRequestStorageOverride, allowPerRequestStorageOverride) bifrostCtx.SetValue(schemas.BifrostContextKeyAllowPerRequestRawOverride, allowPerRequestRawOverride) return bifrostCtx, cancel } +// ValidateBaseURL checks that a URL is parseable with both scheme and host — +// the same gate BuildBaseURL applies before honoring an override. Empty values +// are accepted (caller decides whether absence is allowed). Logging is the +// caller's responsibility. The error intentionally omits the offending value: +// callers may pass URLs resolved from env vars (e.g. `env.MY_SECRET_URL`), so +// echoing the value back in API responses or logs would let an attacker probe +// process env state by feeding malformed env-var references. +func ValidateBaseURL(val string) error { + if val == "" { + return nil + } + parsed, err := url.Parse(val) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("must be a fully-qualified URL with scheme and host (e.g. https://proxy.example.com)") + } + return nil +} + // BuildBaseURL returns the effective base URL for OAuth callbacks and metadata discovery. // When externalBaseURL is non-empty (set via config/UI/API), it takes priority so that // deployments behind a reverse proxy advertise the proxy's public URL rather than the diff --git a/transports/bifrost-http/lib/ctx_test.go b/transports/bifrost-http/lib/ctx_test.go index 2720f1174f..e95cf65f30 100644 --- a/transports/bifrost-http/lib/ctx_test.go +++ b/transports/bifrost-http/lib/ctx_test.go @@ -14,11 +14,9 @@ import ( // testHandlerStore is a minimal HandlerStore for ctx tests. type testHandlerStore struct { - allowDirectKeys bool - matcher *HeaderMatcher + matcher *HeaderMatcher } -func (s testHandlerStore) ShouldAllowDirectKeys() bool { return s.allowDirectKeys } func (s testHandlerStore) GetHeaderMatcher() *HeaderMatcher { return s.matcher } func (s testHandlerStore) GetProvidersForModel(_ string) []schemas.ModelProvider { return nil } func (s testHandlerStore) GetStreamChunkInterceptor() StreamChunkInterceptor { return nil } diff --git a/transports/config.schema.json b/transports/config.schema.json index 885a54001c..bdcfb126a2 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -101,10 +101,6 @@ "type": "boolean", "description": "Deprecated: use enforce_auth_on_inference" }, - "allow_direct_keys": { - "type": "boolean", - "description": "Allow provider keys" - }, "max_request_body_size_mb": { "type": "integer", "minimum": 1, diff --git a/ui/app/workspace/config/views/mcpView.tsx b/ui/app/workspace/config/views/mcpView.tsx index 0498fe5e0d..8067241cc1 100644 --- a/ui/app/workspace/config/views/mcpView.tsx +++ b/ui/app/workspace/config/views/mcpView.tsx @@ -392,6 +392,14 @@ export default function MCPView() { Notion/Jira will redirect the browser to after login ( {"/api/oauth/callback"}).

+

+ Heads up: changing this after MCP clients have already completed OAuth + will break them. The upstream provider locks the redirect_uri{" "} + to whatever was registered initially, so existing clients will fail with{" "} + "Invalid redirect URI". Clear the stored OAuth client credentials + for affected MCP servers and re-authorize so Bifrost re-runs Dynamic Client Registration + with the new URL. +

{/* Allowed Origins */} {needsRestart && } - {/* Allow Direct API Keys */} -
-
- -

- Allow API keys to be passed directly in request headers (Authorization,{" "} - x-api-key, or x-goog-api-key). Bifrost will directly use the key. -

-
- handleConfigChange("allow_direct_keys", checked)} - /> -
diff --git a/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx b/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx index d1b3b449ae..572e034ecc 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx @@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useToast } from "@/hooks/use-toast"; import { MCP_STATUS_COLORS } from "@/lib/constants/config"; import { getErrorMessage, useDeleteMCPClientMutation, useReconnectMCPClientMutation } from "@/lib/store"; @@ -124,6 +125,23 @@ export default function MCPClientsTable({ } }; + const getAuthTypeDisplay = (type: string | undefined) => { + switch (type) { + case "none": + case undefined: + case "": + return "None"; + case "headers": + return "Headers"; + case "oauth": + return "OAuth"; + case "per_user_oauth": + return "Per-user OAuth"; + default: + return type; + } + }; + const handleRowClick = (mcpClient: MCPClient) => { setSelectedMCPClient(mcpClient); setShowDetailSheet(true); @@ -192,6 +210,7 @@ export default function MCPClientsTable({ Name Connection Type + Auth Code Mode Connection Info Enabled Tools @@ -204,7 +223,7 @@ export default function MCPClientsTable({ {mcpClients.length === 0 ? ( - + No matching MCP servers found. @@ -231,6 +250,7 @@ export default function MCPClientsTable({ > {c.config.name} {getConnectionTypeDisplay(c.config.connection_type)} + {getAuthTypeDisplay(c.config.auth_type)} e.stopPropagation()}> - - - + + + {/* The wrapping is required: Radix Tooltip (and native title) don't fire on disabled buttons because the browser swallows pointer events. The span receives them and forwards to the tooltip. */} + + + + + + + {isPerUserOAuth + ? "Reconnect is not applicable for per-user OAuth, each user manages their own auth." + : c.config.disabled + ? "Enable the client before reconnecting." + : "Reconnect"} + + + diff --git a/ui/lib/types/config.ts b/ui/lib/types/config.ts index 0cbd6767a9..ce4333e31c 100644 --- a/ui/lib/types/config.ts +++ b/ui/lib/types/config.ts @@ -477,7 +477,6 @@ export interface CoreConfig { disable_db_pings_in_health: boolean; log_retention_days: number; enforce_auth_on_inference: boolean; - allow_direct_keys: boolean; allowed_origins: string[]; allowed_headers: string[]; max_request_body_size_mb: number; @@ -509,7 +508,6 @@ export const DefaultCoreConfig: CoreConfig = { disable_db_pings_in_health: false, log_retention_days: 365, enforce_auth_on_inference: false, - allow_direct_keys: false, allowed_origins: [], max_request_body_size_mb: 100, compat: { convert_text_to_chat: false, convert_chat_to_responses: false, should_drop_params: false, should_convert_params: false }, diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index 656220daba..3245bdb0ce 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -746,7 +746,6 @@ export const coreConfigSchema = z.object({ enable_logging: z.boolean().default(true), disable_content_logging: z.boolean().default(false), enforce_auth_on_inference: z.boolean().default(false), - allow_direct_keys: z.boolean().default(false), hide_deleted_virtual_keys_in_filters: z.boolean().default(false), allowed_origins: z.array(z.string()).default(["*"]), max_request_body_size_mb: z.number().min(1).default(100),