From 62728676e66d22915a46c13caa47803292a790b4 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 13 Nov 2025 16:46:11 +0000 Subject: [PATCH] Add environment variable support to header_injection auth Adds header_value_env field to header_injection authentication strategy, enabling secrets to be resolved from environment variables at config load time instead of hardcoded in YAML files. Changes: - Add header_value_env field to rawHeaderInjectionAuth struct - Implement validation: exactly one of header_value or header_value_env required - Update token_exchange and service_account to use env.Reader consistently - Add 6 comprehensive test cases covering all edge cases - Update documentation and examples to show both usage patterns This follows the same pattern as token_exchange (client_secret_env) and service_account (credentials_env) strategies. Environment variables are resolved at config load time with fail-fast validation. Backward compatible: existing header_value (literal) configurations continue to work unchanged. Example usage: outgoing_auth: backends: github: type: header_injection header_injection: header_name: "Authorization" header_value_env: "GITHUB_API_TOKEN" Fixes: #2573 --- examples/vmcp-config.yaml | 11 +- pkg/vmcp/auth/strategies/header_injection.go | 11 +- pkg/vmcp/config/yaml_loader.go | 30 ++- pkg/vmcp/config/yaml_loader_test.go | 192 +++++++++++++++++++ 4 files changed, 230 insertions(+), 14 deletions(-) diff --git a/examples/vmcp-config.yaml b/examples/vmcp-config.yaml index bda5d6ab9..6fef9cd63 100644 --- a/examples/vmcp-config.yaml +++ b/examples/vmcp-config.yaml @@ -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: diff --git a/pkg/vmcp/auth/strategies/header_injection.go b/pkg/vmcp/auth/strategies/header_injection.go index af0b55a5e..e6b85fad2 100644 --- a/pkg/vmcp/auth/strategies/header_injection.go +++ b/pkg/vmcp/auth/strategies/header_injection.go @@ -19,6 +19,8 @@ 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 @@ -26,7 +28,6 @@ import ( // - 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 ") // - Value rotation and refresh mechanisms type HeaderInjectionStrategy struct{} @@ -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 } diff --git a/pkg/vmcp/config/yaml_loader.go b/pkg/vmcp/config/yaml_loader.go index 8c2c5ca5f..f8b3f26e8 100644 --- a/pkg/vmcp/config/yaml_loader.go +++ b/pkg/vmcp/config/yaml_loader.go @@ -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 { @@ -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, @@ -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: diff --git a/pkg/vmcp/config/yaml_loader_test.go b/pkg/vmcp/config/yaml_loader_test.go index a3226b73d..722215237 100644 --- a/pkg/vmcp/config/yaml_loader_test.go +++ b/pkg/vmcp/config/yaml_loader_test.go @@ -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 {