Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions examples/vmcp-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,19 @@ outgoing_auth:
# NOT for authenticating Virtual MCP to backend MCP servers.
# Backend MCP servers receive properly scoped tokens and use them to call upstream APIs.
backends:
# Example: API key authentication using header_injection
# Example 1: API key from environment variable (recommended for secrets)
github:
type: header_injection
header_injection:
header_name: "Authorization"
header_value: "your-github-api-token-here"
header_value_env: "GITHUB_API_TOKEN" # Read from environment variable

# Example 2: Static header value (for non-secret values only)
# api-service:
# type: header_injection
# header_injection:
# header_name: "X-API-Version"
# header_value: "v1" # Literal value

# Example: OAuth 2.0 Token Exchange (RFC 8693) for GitHub API access
# github:
Expand Down
11 changes: 2 additions & 9 deletions pkg/vmcp/auth/strategies/header_injection.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import (
// Required metadata fields:
// - header_name: The HTTP header name to use (e.g., "X-API-Key", "Authorization")
// - header_value: The header value to inject (can be an API key, token, or any value)
// Note: In YAML configuration, use either header_value (literal) or header_value_env (from environment).
// The value is resolved at config load time and passed here as header_value.
//
// This strategy is appropriate when:
// - The backend requires a static header value for authentication
// - The header value is stored securely in the vMCP configuration
// - No dynamic token exchange or user-specific authentication is required
//
// Future enhancements may include:
// - Secret reference resolution (e.g., ${SECRET_REF:...})
// - Support for multiple header formats (e.g., "Bearer <key>")
// - Value rotation and refresh mechanisms
type HeaderInjectionStrategy struct{}
Expand Down Expand Up @@ -66,14 +67,6 @@ func (*HeaderInjectionStrategy) Authenticate(_ context.Context, req *http.Reques
return fmt.Errorf("header_value required in metadata")
}

// TODO: Future enhancement - resolve secret references
// if strings.HasPrefix(headerValue, "${SECRET_REF:") {
// headerValue, err = s.secretResolver.Resolve(ctx, headerValue)
// if err != nil {
// return fmt.Errorf("failed to resolve secret reference: %w", err)
// }
// }

req.Header.Set(headerName, headerValue)
return nil
}
Expand Down
30 changes: 27 additions & 3 deletions pkg/vmcp/config/yaml_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ type rawBackendAuthStrategy struct {
}

type rawHeaderInjectionAuth struct {
HeaderName string `yaml:"header_name"`
HeaderValue string `yaml:"header_value"`
HeaderName string `yaml:"header_name"`
HeaderValue string `yaml:"header_value"`
HeaderValueEnv string `yaml:"header_value_env"`
}

type rawTokenExchangeAuth struct {
Expand Down Expand Up @@ -304,6 +305,7 @@ func (l *YAMLLoader) transformOutgoingAuth(raw *rawOutgoingAuth) (*OutgoingAuthC
return cfg, nil
}

//nolint:gocyclo // We should split this into multiple functions per strategy type.
func (l *YAMLLoader) transformBackendAuthStrategy(raw *rawBackendAuthStrategy) (*BackendAuthStrategy, error) {
strategy := &BackendAuthStrategy{
Type: raw.Type,
Expand All @@ -316,9 +318,31 @@ func (l *YAMLLoader) transformBackendAuthStrategy(raw *rawBackendAuthStrategy) (
return nil, fmt.Errorf("header_injection configuration is required")
}

// Validate that exactly one of header_value or header_value_env is set
// to make the life of the strategy easier, we read the value here in set preference
// order and pass it in metadata in a single value regardless of how it was set.
hasValue := raw.HeaderInjection.HeaderValue != ""
hasValueEnv := raw.HeaderInjection.HeaderValueEnv != ""

if hasValue && hasValueEnv {
return nil, fmt.Errorf("header_injection: only one of header_value or header_value_env must be set")
}
if !hasValue && !hasValueEnv {
return nil, fmt.Errorf("header_injection: either header_value or header_value_env must be set")
}

// Resolve header value from environment if env var name is provided
headerValue := raw.HeaderInjection.HeaderValue
if hasValueEnv {
headerValue = l.envReader.Getenv(raw.HeaderInjection.HeaderValueEnv)
if headerValue == "" {
return nil, fmt.Errorf("environment variable %s not set or empty", raw.HeaderInjection.HeaderValueEnv)
}
}

strategy.Metadata = map[string]any{
strategies.MetadataHeaderName: raw.HeaderInjection.HeaderName,
strategies.MetadataHeaderValue: raw.HeaderInjection.HeaderValue,
strategies.MetadataHeaderValue: headerValue,
}

case strategies.StrategyTypeUnauthenticated:
Expand Down
192 changes: 192 additions & 0 deletions pkg/vmcp/config/yaml_loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,198 @@ composite_tools:
wantErr: true,
errMsg: "missing 'type' field",
},
{
name: "header_injection with header_value_env resolves environment variable",
yaml: `
name: test-vmcp
group: test-group

incoming_auth:
type: anonymous

outgoing_auth:
source: inline
backends:
github:
type: header_injection
header_injection:
header_name: "Authorization"
header_value_env: "GITHUB_TOKEN"

aggregation:
conflict_resolution: prefix
conflict_resolution_config:
prefix_format: "{workload}_"
`,
envVars: map[string]string{
"GITHUB_TOKEN": "secret-token-123",
},
want: func(t *testing.T, cfg *Config) {
t.Helper()
backend, ok := cfg.OutgoingAuth.Backends["github"]
if !ok {
t.Fatal("github backend not found")
}
if backend.Type != "header_injection" {
t.Errorf("Backend.Type = %v, want header_injection", backend.Type)
}
// Verify the resolved value is in metadata
headerValue, ok := backend.Metadata["header_value"].(string)
if !ok {
t.Fatal("header_value not found in metadata")
}
if headerValue != "secret-token-123" {
t.Errorf("header_value = %v, want secret-token-123", headerValue)
}
},
wantErr: false,
},
{
name: "header_injection with literal header_value works",
yaml: `
name: test-vmcp
group: test-group

incoming_auth:
type: anonymous

outgoing_auth:
source: inline
backends:
api-service:
type: header_injection
header_injection:
header_name: "X-API-Version"
header_value: "v1"

aggregation:
conflict_resolution: prefix
conflict_resolution_config:
prefix_format: "{workload}_"
`,
want: func(t *testing.T, cfg *Config) {
t.Helper()
backend, ok := cfg.OutgoingAuth.Backends["api-service"]
if !ok {
t.Fatal("api-service backend not found")
}
headerValue, ok := backend.Metadata["header_value"].(string)
if !ok {
t.Fatal("header_value not found in metadata")
}
if headerValue != "v1" {
t.Errorf("header_value = %v, want v1", headerValue)
}
},
wantErr: false,
},
{
name: "header_injection fails when env var not set",
yaml: `
name: test-vmcp
group: test-group

incoming_auth:
type: anonymous

outgoing_auth:
source: inline
backends:
github:
type: header_injection
header_injection:
header_name: "Authorization"
header_value_env: "MISSING_TOKEN"

aggregation:
conflict_resolution: prefix
conflict_resolution_config:
prefix_format: "{workload}_"
`,
wantErr: true,
errMsg: "environment variable MISSING_TOKEN not set",
},
{
name: "header_injection fails when both header_value and header_value_env set",
yaml: `
name: test-vmcp
group: test-group

incoming_auth:
type: anonymous

outgoing_auth:
source: inline
backends:
github:
type: header_injection
header_injection:
header_name: "Authorization"
header_value: "literal-value"
header_value_env: "ENV_VALUE"

aggregation:
conflict_resolution: prefix
conflict_resolution_config:
prefix_format: "{workload}_"
`,
wantErr: true,
errMsg: "only one of header_value or header_value_env must be set",
},
{
name: "header_injection fails when neither header_value nor header_value_env set",
yaml: `
name: test-vmcp
group: test-group

incoming_auth:
type: anonymous

outgoing_auth:
source: inline
backends:
github:
type: header_injection
header_injection:
header_name: "Authorization"

aggregation:
conflict_resolution: prefix
conflict_resolution_config:
prefix_format: "{workload}_"
`,
wantErr: true,
errMsg: "either header_value or header_value_env must be set",
},
{
name: "header_injection fails when env var is empty string",
yaml: `
name: test-vmcp
group: test-group

incoming_auth:
type: anonymous

outgoing_auth:
source: inline
backends:
github:
type: header_injection
header_injection:
header_name: "Authorization"
header_value_env: "EMPTY_TOKEN"

aggregation:
conflict_resolution: prefix
conflict_resolution_config:
prefix_format: "{workload}_"
`,
envVars: map[string]string{
"EMPTY_TOKEN": "",
},
wantErr: true,
errMsg: "environment variable EMPTY_TOKEN not set or empty",
},
}

for _, tt := range tests {
Expand Down
Loading