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:**
-
-
-
-
-
-
-
-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}}
+
+`))
+
+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.
-