From ebc4b2bf7ab69b8ed806a6a26eb976f08d085f4a Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 27 Nov 2025 18:48:10 -0500 Subject: [PATCH] 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. --- docs/plugin-development.md | 66 +++++- docs/plugins.md | 86 ++++++++ src/plugins/builtin/jwt_validator.py | 201 ++++++++++++++++++ .../fixtures/services-with-jwt-validator | 12 ++ src/tests/test_plugins.py | 197 ++++++++++++++++- 5 files changed, 554 insertions(+), 8 deletions(-) create mode 100644 src/plugins/builtin/jwt_validator.py create mode 100644 src/tests/fixtures/services-with-jwt-validator diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 79b0141..4b2ad66 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -47,7 +47,7 @@ This guide explains how to create custom plugins for EasyHAProxy. For informatio ## Built-in Plugins Reference -EasyHAProxy includes four built-in plugins that serve as both functional tools and reference implementations for plugin development. +EasyHAProxy includes five built-in plugins that serve as both functional tools and reference implementations for plugin development. ### CloudflarePlugin (DOMAIN) @@ -151,14 +151,59 @@ http-request deny deny_status 403 if !whitelisted_ip **Usage:** See [IP Whitelist Plugin documentation](plugins.md#ip-whitelist-plugin-domain) +### JwtValidatorPlugin (DOMAIN) + +**Purpose:** Validate JWT authentication tokens + +**Type:** DOMAIN - Executes once per domain + +**Source:** `src/plugins/builtin/jwt_validator.py` + +**Configuration Options:** +- `enabled` (bool) - Enable/disable plugin (default: `true`) +- `algorithm` (str) - JWT signing algorithm (default: `RS256`) +- `issuer` (str) - Expected JWT issuer (optional, `none`/`null` skips validation) +- `audience` (str) - Expected JWT audience (optional, `none`/`null` skips validation) +- `pubkey_path` (str) - Path to public key file (required if `pubkey` not provided) +- `pubkey` (str) - Public key content as string (required if `pubkey_path` not provided) + +**What it does:** +- Validates JWT tokens using HAProxy's JWT functionality +- Checks Authorization header presence +- Validates algorithm, issuer, audience, signature, and expiration +- Optionally skips issuer/audience validation if set to "none" + +**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 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 } +``` + +**Usage:** See [JWT Validator Plugin documentation](plugins.md#jwt-validator-plugin-domain) + ### Summary Table -| Plugin | Type | Purpose | Config Generated | -|----------------|--------|------------------------------|------------------| -| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes | -| `cleanup` | GLOBAL | Clean up temp files | ❌ No | -| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes | -| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes | +| Plugin | Type | Purpose | Config Generated | +|-----------------|--------|------------------------------|------------------| +| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes | +| `cleanup` | GLOBAL | Clean up temp files | ❌ No | +| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes | +| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes | +| `jwt_validator` | DOMAIN | Validate JWT tokens | ✅ Yes | ### Learning from Built-in Plugins @@ -184,11 +229,18 @@ http-request deny deny_status 403 if !whitelisted_ip - Negated ACLs (`if !whitelisted_ip`) - CIDR range support +5. **JwtValidatorPlugin** shows: + - Complex HAProxy JWT validation + - Optional validation (skip with "none"/"null") + - Dynamic file path generation based on domain + - Multiple configuration options + **View the source code:** - [cloudflare.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cloudflare.py) - [cleanup.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cleanup.py) - [deny_pages.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/deny_pages.py) - [ip_whitelist.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/ip_whitelist.py) +- [jwt_validator.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/jwt_validator.py) ## Plugin API Reference diff --git a/docs/plugins.md b/docs/plugins.md index 6920ccd..24a1682 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -165,6 +165,75 @@ http-request deny deny_status 403 if !whitelisted_ip **Important:** This blocks ALL IPs except those in the whitelist. Make sure to include your own IP! +### JWT Validator Plugin (Domain) + +Validates JWT (JSON Web Token) authentication tokens using HAProxy's built-in JWT functionality. + +**Why use it:** Protect APIs and services with JWT authentication without needing application-level code. + +**Configuration options:** +- `enabled` - Enable/disable 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) + +**Enable via container label:** +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + 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 + volumes: + - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +``` + +**Skip issuer/audience validation:** +```yaml +labels: + easyhaproxy.http.plugin.jwt_validator.issuer: none + easyhaproxy.http.plugin.jwt_validator.audience: none + 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 } +``` + +**What it validates:** +- ✅ Authorization header presence +- ✅ JWT signing algorithm (RS256, RS512, etc.) +- ✅ JWT issuer (if configured) +- ✅ JWT audience (if configured) +- ✅ JWT signature using public key +- ✅ JWT expiration time + +**Important:** Requires HAProxy 2.5+ with JWT support. Mount public key file as read-only volume. + ## Configuration Methods Plugins can be configured using three methods, listed in order of precedence (highest to lowest): @@ -240,6 +309,23 @@ EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst ## Common Use Cases +### Protect API with JWT Authentication + +Secure your API endpoints with JWT token validation: + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth0.myapp.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 + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +``` + ### Restrict Admin Panel to Office IPs Protect admin panel by only allowing access from office network: diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py new file mode 100644 index 0000000..3ca1aa4 --- /dev/null +++ b/src/plugins/builtin/jwt_validator.py @@ -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 + ) diff --git a/src/tests/fixtures/services-with-jwt-validator b/src/tests/fixtures/services-with-jwt-validator new file mode 100644 index 0000000..4b1957e --- /dev/null +++ b/src/tests/fixtures/services-with-jwt-validator @@ -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" + } +} diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py index 0d15b7c..f131bfe 100644 --- a/src/tests/test_plugins.py +++ b/src/tests/test_plugins.py @@ -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"""