Skip to content

Commit 0e88fd2

Browse files
authored
fix: add environment variable substitution in configuration management
2 parents 708b390 + 0c17541 commit 0e88fd2

File tree

3 files changed

+243
-4
lines changed

3 files changed

+243
-4
lines changed

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,15 @@ oauth_providers:
486486
client_secret: "${GOOGLE_CLIENT_SECRET}"
487487
scopes: ["openid", "email", "profile"]
488488
489+
# For Okta (alternative to Google):
490+
# okta:
491+
# client_id: "${OKTA_CLIENT_ID}"
492+
# client_secret: "${OKTA_CLIENT_SECRET}"
493+
# scopes: ["openid", "email", "profile"]
494+
# authorization_url: "https://your-domain.okta.com/oauth2/default/v1/authorize"
495+
# token_url: "https://your-domain.okta.com/oauth2/default/v1/token"
496+
# userinfo_url: "https://your-domain.okta.com/oauth2/default/v1/userinfo"
497+
489498
# For custom providers:
490499
# custom:
491500
# authorization_url: "https://provider.com/oauth/authorize"

src/config/config.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Configuration management for MCP OAuth Gateway."""
22

33
import os
4+
import re
45
from dataclasses import dataclass, field
56
from pathlib import Path
67
from typing import Any, Dict, List, Optional
@@ -165,6 +166,36 @@ def _find_config_file(self) -> str:
165166

166167
return "config.yaml" # Default
167168

169+
def _substitute_env_vars(self, content: str) -> str:
170+
"""Substitute environment variables in configuration content.
171+
172+
Supports formats:
173+
- ${VAR_NAME} - Required environment variable (raises error if not set)
174+
- ${VAR_NAME:-default_value} - Optional with default value
175+
"""
176+
177+
def replace_env_var(match):
178+
var_expr = match.group(1)
179+
180+
if ":-" in var_expr:
181+
# Format: ${VAR_NAME:-default_value}
182+
var_name, default_value = var_expr.split(":-", 1)
183+
return os.getenv(var_name, default_value)
184+
else:
185+
# Format: ${VAR_NAME}
186+
var_name = var_expr
187+
value = os.getenv(var_name)
188+
if value is None:
189+
raise ValueError(
190+
f"Environment variable '{var_name}' is required but not set. "
191+
f"Please set {var_name} or use ${{{var_name}:-default}} format for optional variables."
192+
)
193+
return value
194+
195+
# Pattern to match ${VAR_NAME} and ${VAR_NAME:-default}
196+
pattern = r"\$\{([^}]+)\}"
197+
return re.sub(pattern, replace_env_var, content)
198+
168199
def load_config(self) -> GatewayConfig:
169200
"""Load configuration from file."""
170201
config_file = Path(self.config_path)
@@ -176,7 +207,13 @@ def load_config(self) -> GatewayConfig:
176207
return self.config
177208

178209
with open(config_file) as f:
179-
data = yaml.safe_load(f) or {}
210+
raw_content = f.read()
211+
212+
# Substitute environment variables in the raw content
213+
substituted_content = self._substitute_env_vars(raw_content)
214+
215+
# Parse the substituted YAML content
216+
data = yaml.safe_load(substituted_content) or {}
180217

181218
# Parse OAuth providers with single provider validation
182219
oauth_providers = {}
@@ -258,11 +295,11 @@ def load_config(self) -> GatewayConfig:
258295
redis_data = storage_data.get("redis", {})
259296
redis_config = RedisStorageConfig(
260297
host=redis_data.get("host", "localhost"),
261-
port=redis_data.get("port", 6379),
298+
port=int(redis_data.get("port", 6379)),
262299
password=redis_data.get("password"),
263-
db=redis_data.get("db", 0),
300+
db=int(redis_data.get("db", 0)),
264301
ssl=redis_data.get("ssl", False),
265-
max_connections=redis_data.get("max_connections", 10),
302+
max_connections=int(redis_data.get("max_connections", 10)),
266303
)
267304

268305
# Parse Vault configuration

tests/config/test_config.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import tempfile
55
from unittest.mock import patch
66

7+
import pytest
8+
79
from src.config.config import (
810
ConfigManager,
911
CorsConfig,
@@ -365,6 +367,197 @@ def test_get_provider_not_exists(self):
365367
provider = config_manager.get_provider("nonexistent_provider")
366368
assert provider is None
367369

370+
@patch.dict(
371+
os.environ,
372+
{
373+
"GITHUB_CLIENT_ID": "github_test_id",
374+
"GITHUB_CLIENT_SECRET": "github_test_secret",
375+
"REDIS_HOST": "redis.test.com",
376+
"REDIS_PORT": "6380",
377+
"VAULT_TOKEN": "vault_test_token",
378+
},
379+
)
380+
def test_env_var_substitution_required_vars(self):
381+
"""Test environment variable substitution for required variables."""
382+
yaml_content = """
383+
host: "127.0.0.1"
384+
port: 8080
385+
issuer: "http://localhost:8080"
386+
session_secret: "test-secret"
387+
388+
storage:
389+
type: "redis"
390+
redis:
391+
host: "${REDIS_HOST}"
392+
port: "${REDIS_PORT}"
393+
vault:
394+
token: "${VAULT_TOKEN}"
395+
396+
oauth_providers:
397+
github:
398+
client_id: "${GITHUB_CLIENT_ID}"
399+
client_secret: "${GITHUB_CLIENT_SECRET}"
400+
scopes: ["user:email"]
401+
402+
mcp_services:
403+
calculator:
404+
name: "Calculator Service"
405+
url: "http://localhost:3001/mcp"
406+
oauth_provider: "github"
407+
auth_required: true
408+
"""
409+
410+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
411+
f.write(yaml_content)
412+
config_path = f.name
413+
414+
try:
415+
config_manager = ConfigManager(config_path)
416+
config = config_manager.load_config()
417+
418+
# Check that environment variables were substituted
419+
github_provider = config.oauth_providers["github"]
420+
assert github_provider.client_id == "github_test_id"
421+
assert github_provider.client_secret == "github_test_secret"
422+
423+
# Check storage config
424+
assert config.storage.redis.host == "redis.test.com"
425+
assert config.storage.redis.port == 6380 # Should be converted to int
426+
assert config.storage.vault.token == "vault_test_token"
427+
428+
finally:
429+
os.unlink(config_path)
430+
431+
@patch.dict(os.environ, {"REDIS_HOST": "redis.prod.com"}, clear=False)
432+
def test_env_var_substitution_with_defaults(self):
433+
"""Test environment variable substitution with default values."""
434+
yaml_content = """
435+
host: "127.0.0.1"
436+
port: 8080
437+
438+
storage:
439+
type: "redis"
440+
redis:
441+
host: "${REDIS_HOST:-localhost}"
442+
port: "${REDIS_PORT:-6379}"
443+
password: "${REDIS_PASSWORD:-}"
444+
db: "${REDIS_DB:-0}"
445+
446+
oauth_providers:
447+
github:
448+
client_id: "${GITHUB_CLIENT_ID:-default_client_id}"
449+
client_secret: "${GITHUB_CLIENT_SECRET:-default_secret}"
450+
"""
451+
452+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
453+
f.write(yaml_content)
454+
config_path = f.name
455+
456+
try:
457+
config_manager = ConfigManager(config_path)
458+
config = config_manager.load_config()
459+
460+
# Check that environment variables were used
461+
assert config.storage.redis.host == "redis.prod.com" # From env
462+
assert config.storage.redis.port == 6379 # Default value, converted to int
463+
assert config.storage.redis.password == "" # Default empty string
464+
assert config.storage.redis.db == 0 # Default value
465+
466+
# Check OAuth provider defaults
467+
github_provider = config.oauth_providers["github"]
468+
assert github_provider.client_id == "default_client_id"
469+
assert github_provider.client_secret == "default_secret"
470+
471+
finally:
472+
os.unlink(config_path)
473+
474+
def test_env_var_substitution_missing_required(self):
475+
"""Test that missing required environment variables raise an error."""
476+
yaml_content = """
477+
oauth_providers:
478+
github:
479+
client_id: "${MISSING_CLIENT_ID}"
480+
client_secret: "static_secret"
481+
"""
482+
483+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
484+
f.write(yaml_content)
485+
config_path = f.name
486+
487+
try:
488+
config_manager = ConfigManager(config_path)
489+
490+
with pytest.raises(
491+
ValueError,
492+
match="Environment variable 'MISSING_CLIENT_ID' is required but not set",
493+
):
494+
config_manager.load_config()
495+
496+
finally:
497+
os.unlink(config_path)
498+
499+
def test_env_var_substitution_mixed_formats(self):
500+
"""Test mixed environment variable formats in the same config."""
501+
yaml_content = """
502+
host: "${HOST:-0.0.0.0}"
503+
port: "${PORT}"
504+
issuer: "${ISSUER:-http://localhost:8080}"
505+
session_secret: "${SESSION_SECRET}"
506+
debug: "${DEBUG:-false}"
507+
"""
508+
509+
with patch.dict(os.environ, {"PORT": "9000", "SESSION_SECRET": "secret123"}):
510+
with tempfile.NamedTemporaryFile(
511+
mode="w", suffix=".yaml", delete=False
512+
) as f:
513+
f.write(yaml_content)
514+
config_path = f.name
515+
516+
try:
517+
config_manager = ConfigManager(config_path)
518+
config = config_manager.load_config()
519+
520+
# Check mixed substitution results
521+
assert config.host == "0.0.0.0" # Default value
522+
assert config.port == 9000 # From environment
523+
assert config.issuer == "http://localhost:8080" # Default value
524+
assert config.session_secret == "secret123" # From environment
525+
assert config.debug is False # Default value converted to boolean
526+
527+
finally:
528+
os.unlink(config_path)
529+
530+
def test_substitute_env_vars_method_direct(self):
531+
"""Test the _substitute_env_vars method directly."""
532+
config_manager = ConfigManager()
533+
534+
# Test required variable
535+
with patch.dict(os.environ, {"TEST_VAR": "test_value"}):
536+
result = config_manager._substitute_env_vars("value: ${TEST_VAR}")
537+
assert result == "value: test_value"
538+
539+
# Test variable with default
540+
result = config_manager._substitute_env_vars("value: ${MISSING_VAR:-default}")
541+
assert result == "value: default"
542+
543+
# Test missing required variable
544+
with pytest.raises(
545+
ValueError,
546+
match="Environment variable 'MISSING_REQUIRED' is required but not set",
547+
):
548+
config_manager._substitute_env_vars("value: ${MISSING_REQUIRED}")
549+
550+
# Test multiple variables
551+
with patch.dict(os.environ, {"VAR1": "value1", "VAR2": "value2"}):
552+
result = config_manager._substitute_env_vars("${VAR1} and ${VAR2}")
553+
assert result == "value1 and value2"
554+
555+
# Test variables with complex defaults
556+
result = config_manager._substitute_env_vars(
557+
"${VAR:-default with spaces and : colons}"
558+
)
559+
assert result == "default with spaces and : colons"
560+
368561

369562
class TestDataClasses:
370563
"""Test cases for configuration data classes."""

0 commit comments

Comments
 (0)