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:
parent
57387f3e32
commit
ebc4b2bf7a
5 changed files with 554 additions and 8 deletions
|
|
@ -47,7 +47,7 @@ This guide explains how to create custom plugins for EasyHAProxy. For informatio
|
||||||
|
|
||||||
## Built-in Plugins Reference
|
## 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)
|
### 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)
|
**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
|
### Summary Table
|
||||||
|
|
||||||
| Plugin | Type | Purpose | Config Generated |
|
| Plugin | Type | Purpose | Config Generated |
|
||||||
|----------------|--------|------------------------------|------------------|
|
|-----------------|--------|------------------------------|------------------|
|
||||||
| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes |
|
| `cloudflare` | DOMAIN | Restore original visitor IPs | ✅ Yes |
|
||||||
| `cleanup` | GLOBAL | Clean up temp files | ❌ No |
|
| `cleanup` | GLOBAL | Clean up temp files | ❌ No |
|
||||||
| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes |
|
| `deny_pages` | DOMAIN | Block specific paths | ✅ Yes |
|
||||||
| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes |
|
| `ip_whitelist` | DOMAIN | Restrict to specific IPs | ✅ Yes |
|
||||||
|
| `jwt_validator` | DOMAIN | Validate JWT tokens | ✅ Yes |
|
||||||
|
|
||||||
### Learning from Built-in Plugins
|
### Learning from Built-in Plugins
|
||||||
|
|
||||||
|
|
@ -184,11 +229,18 @@ http-request deny deny_status 403 if !whitelisted_ip
|
||||||
- Negated ACLs (`if !whitelisted_ip`)
|
- Negated ACLs (`if !whitelisted_ip`)
|
||||||
- CIDR range support
|
- 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:**
|
**View the source code:**
|
||||||
- [cloudflare.py](https://github.com/byjg/docker-easy-haproxy/blob/master/src/plugins/builtin/cloudflare.py)
|
- [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)
|
- [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)
|
- [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)
|
- [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
|
## Plugin API Reference
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!
|
**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
|
## Configuration Methods
|
||||||
|
|
||||||
Plugins can be configured using three methods, listed in order of precedence (highest to lowest):
|
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
|
## 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
|
### Restrict Admin Panel to Office IPs
|
||||||
|
|
||||||
Protect admin panel by only allowing access from office network:
|
Protect admin panel by only allowing access from office network:
|
||||||
|
|
|
||||||
201
src/plugins/builtin/jwt_validator.py
Normal file
201
src/plugins/builtin/jwt_validator.py
Normal 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
|
||||||
|
)
|
||||||
12
src/tests/fixtures/services-with-jwt-validator
vendored
Normal file
12
src/tests/fixtures/services-with-jwt-validator
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ from plugins.builtin.cloudflare import CloudflarePlugin
|
||||||
from plugins.builtin.cleanup import CleanupPlugin
|
from plugins.builtin.cleanup import CleanupPlugin
|
||||||
from plugins.builtin.deny_pages import DenyPagesPlugin
|
from plugins.builtin.deny_pages import DenyPagesPlugin
|
||||||
from plugins.builtin.ip_whitelist import IpWhitelistPlugin
|
from plugins.builtin.ip_whitelist import IpWhitelistPlugin
|
||||||
|
from plugins.builtin.jwt_validator import JwtValidatorPlugin
|
||||||
import easymapping
|
import easymapping
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -425,6 +426,198 @@ class TestIpWhitelistPlugin:
|
||||||
assert "http-request deny deny_status 403 if !whitelisted_ip" in haproxy_config
|
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:
|
class TestPluginManager:
|
||||||
"""Test cases for PluginManager"""
|
"""Test cases for PluginManager"""
|
||||||
|
|
||||||
|
|
@ -438,16 +631,18 @@ class TestPluginManager:
|
||||||
assert "cleanup" in manager.plugins
|
assert "cleanup" in manager.plugins
|
||||||
assert "deny_pages" in manager.plugins
|
assert "deny_pages" in manager.plugins
|
||||||
assert "ip_whitelist" in manager.plugins
|
assert "ip_whitelist" in manager.plugins
|
||||||
|
assert "jwt_validator" in manager.plugins
|
||||||
|
|
||||||
# Verify plugin types
|
# Verify plugin types
|
||||||
assert len(manager.global_plugins) == 1 # cleanup
|
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
|
# Verify plugin instances
|
||||||
assert manager.plugins["cloudflare"].name == "cloudflare"
|
assert manager.plugins["cloudflare"].name == "cloudflare"
|
||||||
assert manager.plugins["cleanup"].name == "cleanup"
|
assert manager.plugins["cleanup"].name == "cleanup"
|
||||||
assert manager.plugins["deny_pages"].name == "deny_pages"
|
assert manager.plugins["deny_pages"].name == "deny_pages"
|
||||||
assert manager.plugins["ip_whitelist"].name == "ip_whitelist"
|
assert manager.plugins["ip_whitelist"].name == "ip_whitelist"
|
||||||
|
assert manager.plugins["jwt_validator"].name == "jwt_validator"
|
||||||
|
|
||||||
def test_plugin_manager_executes_global_plugins(self):
|
def test_plugin_manager_executes_global_plugins(self):
|
||||||
"""Test plugin manager executes global plugins correctly"""
|
"""Test plugin manager executes global plugins correctly"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue