1
0
Fork 0

Add JWT Validator Plugin and corresponding tests

- Introduced `JwtValidatorPlugin` (DOMAIN): Validates JWT tokens for protected API endpoints using HAProxy's JWT functionality.
- Added configuration options: `enabled`, `algorithm`, `issuer`, `audience`, `pubkey_path`, and `pubkey`.
- Documented the plugin setup and usage in `plugins.md` and `plugin-development.md`.
- Created fixtures for `services-with-jwt-validator`.
- Added extensive test cases to validate configuration and HAProxy output generation.
- Ensured seamless integration into the plugin framework alongside existing domain plugins.
This commit is contained in:
Joao Gilberto Magalhaes 2025-11-27 18:48:10 -05:00
parent 57387f3e32
commit ebc4b2bf7a
5 changed files with 554 additions and 8 deletions

View file

@ -0,0 +1,201 @@
"""
JWT Validator Plugin for EasyHAProxy
This plugin validates JWT tokens using HAProxy's built-in JWT functionality.
It runs as a DOMAIN plugin (once per domain).
Configuration:
- enabled: Enable/disable the plugin (default: true)
- algorithm: JWT signing algorithm (default: RS256)
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
- audience: Expected JWT audience (optional, set to "none"/"null" to skip validation)
- pubkey_path: Path to public key file (required if pubkey not provided)
- pubkey: Public key content as string (required if pubkey_path not provided)
Example YAML config:
plugins:
jwt_validator:
enabled: true
algorithm: RS256
issuer: https://myaccount.auth0.com/
audience: https://api.mywebsite.com
pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem
Example Container Label:
easyhaproxy.http.plugins: "jwt_validator"
easyhaproxy.http.plugin.jwt_validator.algorithm: RS256
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/
easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
HAProxy Config Generated:
# JWT Validator - Validate JWT tokens
http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
# Extract JWT header and payload
http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')
http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')
http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')
http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')
# Validate JWT
http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 }
http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ }
http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com }
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 }
# Validate expiration
http-request set-var(txn.now) date()
http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }
"""
import os
import sys
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
from functions import loggerEasyHaproxy
class JwtValidatorPlugin(PluginInterface):
"""Plugin to validate JWT tokens"""
def __init__(self):
self.enabled = True
self.algorithm = "RS256"
self.issuer = None # Optional
self.audience = None # Optional
self.pubkey_path = None # Path to public key file
self.pubkey = None # Public key content (alternative to pubkey_path)
@property
def name(self) -> str:
return "jwt_validator"
@property
def plugin_type(self) -> PluginType:
return PluginType.DOMAIN
def configure(self, config: dict) -> None:
"""
Configure the plugin
Args:
config: Dictionary with configuration options
- enabled: Whether plugin is enabled
- algorithm: JWT signing algorithm (default: RS256)
- issuer: Expected JWT issuer (optional)
- audience: Expected JWT audience (optional)
- pubkey_path: Path to public key file
- pubkey: Public key content as string
"""
if "enabled" in config:
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
if "algorithm" in config:
self.algorithm = config["algorithm"]
# Parse issuer (optional - if not set, issuer validation is skipped)
if "issuer" in config:
issuer = str(config["issuer"]).strip()
if issuer: # Only set if not empty
self.issuer = issuer
# Parse audience (optional - if not set, audience validation is skipped)
if "audience" in config:
audience = str(config["audience"]).strip()
if audience: # Only set if not empty
self.audience = audience
# Public key configuration
if "pubkey_path" in config:
self.pubkey_path = config["pubkey_path"]
if "pubkey" in config:
self.pubkey = config["pubkey"]
def process(self, context: PluginContext) -> PluginResult:
"""
Generate HAProxy config to validate JWT tokens
Args:
context: Plugin execution context with domain information
Returns:
PluginResult with HAProxy configuration snippet
"""
if not self.enabled:
return PluginResult()
# Determine public key file path
if self.pubkey_path:
pubkey_file = self.pubkey_path
elif self.pubkey:
# Generate path for pubkey based on domain
domain_safe = context.domain.replace(".", "_").replace(":", "_")
pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem"
else:
loggerEasyHaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
return PluginResult()
# Build HAProxy configuration
lines = ["# JWT Validator - Validate JWT tokens"]
# Check for Authorization header
lines.append(" http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }")
# Extract JWT parts
lines.append("")
lines.append(" # Extract JWT header and payload")
lines.append(" http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')")
lines.append(" http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')")
lines.append(" http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')")
lines.append(" http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')")
# Validate JWT
lines.append("")
lines.append(" # Validate JWT")
lines.append(f" http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}")
# Validate issuer (if configured)
if self.issuer:
lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}")
# Validate audience (if configured)
if self.audience:
lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}")
# Validate signature
lines.append(f" http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}")
# Validate expiration
lines.append("")
lines.append(" # Validate expiration")
lines.append(" http-request set-var(txn.now) date()")
lines.append(" http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }")
haproxy_config = "\n".join(lines)
# Build metadata
metadata = {
"domain": context.domain,
"algorithm": self.algorithm,
"pubkey_file": pubkey_file,
"validates_issuer": self.issuer is not None,
"validates_audience": self.audience is not None
}
if self.issuer:
metadata["issuer"] = self.issuer
if self.audience:
metadata["audience"] = self.audience
if self.pubkey:
metadata["pubkey_content"] = self.pubkey
return PluginResult(
haproxy_config=haproxy_config,
modified_easymapping=None,
metadata=metadata
)

View file

@ -0,0 +1,12 @@
{
"192.168.1.50": {
"easyhaproxy.http.host": "api.example.com",
"easyhaproxy.http.port": "80",
"easyhaproxy.http.localport": "8080",
"easyhaproxy.http.plugins": "jwt_validator",
"easyhaproxy.http.plugin.jwt_validator.algorithm": "RS256",
"easyhaproxy.http.plugin.jwt_validator.issuer": "https://auth.example.com/",
"easyhaproxy.http.plugin.jwt_validator.audience": "https://api.example.com",
"easyhaproxy.http.plugin.jwt_validator.pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem"
}
}

View file

@ -21,6 +21,7 @@ from plugins.builtin.cloudflare import CloudflarePlugin
from plugins.builtin.cleanup import CleanupPlugin
from plugins.builtin.deny_pages import DenyPagesPlugin
from plugins.builtin.ip_whitelist import IpWhitelistPlugin
from plugins.builtin.jwt_validator import JwtValidatorPlugin
import easymapping
@ -425,6 +426,198 @@ class TestIpWhitelistPlugin:
assert "http-request deny deny_status 403 if !whitelisted_ip" in haproxy_config
class TestJwtValidatorPlugin:
"""Test cases for JwtValidatorPlugin (DOMAIN plugin)"""
def test_jwt_validator_plugin_initialization(self):
"""Test plugin initializes with correct defaults"""
plugin = JwtValidatorPlugin()
assert plugin.name == "jwt_validator"
assert plugin.enabled is True
assert plugin.algorithm == "RS256"
assert plugin.issuer is None
assert plugin.audience is None
assert plugin.pubkey_path is None
assert plugin.pubkey is None
def test_jwt_validator_plugin_configuration(self):
"""Test plugin configuration"""
plugin = JwtValidatorPlugin()
# Test basic config
plugin.configure({
"algorithm": "RS512",
"issuer": "https://auth.example.com/",
"audience": "https://api.example.com",
"pubkey_path": "/etc/haproxy/keys/api.pem"
})
assert plugin.algorithm == "RS512"
assert plugin.issuer == "https://auth.example.com/"
assert plugin.audience == "https://api.example.com"
assert plugin.pubkey_path == "/etc/haproxy/keys/api.pem"
# Test empty values skip validation (use fresh plugin)
plugin2 = JwtValidatorPlugin()
plugin2.configure({
"issuer": "",
"audience": ""
})
assert plugin2.issuer is None
assert plugin2.audience is None
# Test not providing issuer/audience at all (use fresh plugin)
plugin2b = JwtValidatorPlugin()
plugin2b.configure({
"algorithm": "RS256",
"pubkey_path": "/etc/haproxy/keys/api.pem"
})
assert plugin2b.issuer is None
assert plugin2b.audience is None
# Test enabled (use fresh plugin)
plugin3 = JwtValidatorPlugin()
plugin3.configure({"enabled": "false"})
assert plugin3.enabled is False
def test_jwt_validator_plugin_generates_config_with_path(self):
"""Test plugin generates correct HAProxy config using pubkey_path"""
plugin = JwtValidatorPlugin()
plugin.configure({
"algorithm": "RS256",
"issuer": "https://auth.example.com/",
"audience": "https://api.example.com",
"pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem"
})
context = PluginContext(
parsed_object={},
easymapping=[],
container_env={},
domain="api.example.com",
port="80",
host_config={}
)
result = plugin.process(context)
assert result.haproxy_config is not None
assert "JWT Validator" in result.haproxy_config
assert "Missing Authorization HTTP header" in result.haproxy_config
assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" in result.haproxy_config
assert "http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')" in result.haproxy_config
assert "http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')" in result.haproxy_config
assert "http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')" in result.haproxy_config
assert "var(txn.alg) -m str RS256" in result.haproxy_config
assert "var(txn.iss) -m str https://auth.example.com/" in result.haproxy_config
assert "var(txn.aud) -m str https://api.example.com" in result.haproxy_config
assert 'jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem")' in result.haproxy_config
assert "JWT has expired" in result.haproxy_config
assert result.metadata["domain"] == "api.example.com"
assert result.metadata["algorithm"] == "RS256"
assert result.metadata["validates_issuer"] is True
assert result.metadata["validates_audience"] is True
def test_jwt_validator_plugin_generates_config_with_pubkey_content(self):
"""Test plugin generates correct HAProxy config using pubkey content"""
plugin = JwtValidatorPlugin()
plugin.configure({
"algorithm": "RS256",
"pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----"
})
context = PluginContext(
parsed_object={},
easymapping=[],
container_env={},
domain="api.example.com",
port="80",
host_config={}
)
result = plugin.process(context)
assert result.haproxy_config is not None
assert "JWT Validator" in result.haproxy_config
assert "/etc/haproxy/jwt_keys/api_example_com_pubkey.pem" in result.haproxy_config
assert result.metadata["pubkey_content"] == "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----"
def test_jwt_validator_plugin_no_issuer_audience_validation(self):
"""Test plugin skips issuer/audience validation when not configured"""
plugin = JwtValidatorPlugin()
plugin.configure({
"pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem"
})
context = PluginContext(
parsed_object={},
easymapping=[],
container_env={},
domain="api.example.com",
port="80",
host_config={}
)
result = plugin.process(context)
assert result.haproxy_config is not None
assert "Invalid JWT issuer" not in result.haproxy_config
assert "Invalid JWT audience" not in result.haproxy_config
assert result.metadata["validates_issuer"] is False
assert result.metadata["validates_audience"] is False
def test_jwt_validator_plugin_disabled(self):
"""Test plugin returns empty config when disabled"""
plugin = JwtValidatorPlugin()
plugin.configure({
"enabled": "false",
"pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem"
})
context = PluginContext(
parsed_object={},
easymapping=[],
container_env={},
domain="api.example.com"
)
result = plugin.process(context)
assert result.haproxy_config == ""
assert result.metadata == {}
def test_jwt_validator_plugin_no_pubkey(self):
"""Test plugin returns empty config when no pubkey configured"""
plugin = JwtValidatorPlugin()
context = PluginContext(
parsed_object={},
easymapping=[],
container_env={},
domain="api.example.com"
)
result = plugin.process(context)
assert result.haproxy_config == ""
def test_jwt_validator_plugin_in_haproxy_config(self):
"""Test JWT Validator plugin integration in full HAProxy config generation"""
line_list = load_fixture("services-with-jwt-validator")
result = {
"customerrors": False,
"certbot": {"email": "test@example.com"},
"stats": {"port": 0}
}
cfg = easymapping.HaproxyConfigGenerator(result)
haproxy_config = cfg.generate(line_list)
# Verify JWT Validator config is in the output
assert "JWT Validator - Validate JWT tokens" in haproxy_config
assert "Missing Authorization HTTP header" in haproxy_config
assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" in haproxy_config
assert "jwt_verify" in haproxy_config
class TestPluginManager:
"""Test cases for PluginManager"""
@ -438,16 +631,18 @@ class TestPluginManager:
assert "cleanup" in manager.plugins
assert "deny_pages" in manager.plugins
assert "ip_whitelist" in manager.plugins
assert "jwt_validator" in manager.plugins
# Verify plugin types
assert len(manager.global_plugins) == 1 # cleanup
assert len(manager.domain_plugins) == 3 # cloudflare, deny_pages, ip_whitelist
assert len(manager.domain_plugins) == 4 # cloudflare, deny_pages, ip_whitelist, jwt_validator
# Verify plugin instances
assert manager.plugins["cloudflare"].name == "cloudflare"
assert manager.plugins["cleanup"].name == "cleanup"
assert manager.plugins["deny_pages"].name == "deny_pages"
assert manager.plugins["ip_whitelist"].name == "ip_whitelist"
assert manager.plugins["jwt_validator"].name == "jwt_validator"
def test_plugin_manager_executes_global_plugins(self):
"""Test plugin manager executes global plugins correctly"""