- Refactored HAProxy configuration files, templates, and paths to use `/etc/easyhaproxy` instead of `/etc/haproxy`. - Updated Dockerfile to generate DH params and placeholder certificates in the new configuration directory. - Added health check support with timeout to `DockerComposeFixture` in E2E test utilities. - Adjusted tests, templates, and plugins to use the new `Consts`-based configuration paths. - Introduced pytest fixtures for environment isolation and temporary directory management.
335 lines
16 KiB
Python
335 lines
16 KiB
Python
"""
|
|
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 in container (priority: 1)
|
|
- pubkey: Public key content as base64-encoded string (priority: 2)
|
|
- k8s_secret.pubkey: Kubernetes secret containing public key (priority: 3, Kubernetes only)
|
|
- paths: List of paths that require JWT validation (optional, if not set ALL domain is protected)
|
|
- only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation
|
|
- allow_anonymous: If true, allows requests without Authorization header (validates JWT if present); if false (default), requires Authorization header
|
|
|
|
Priority Order (first configured option wins):
|
|
1. pubkey_path - Direct file path (explicit configuration)
|
|
2. pubkey - Base64-encoded key content (inline configuration)
|
|
3. k8s_secret.pubkey - Kubernetes secret name (processed by K8s processor into pubkey)
|
|
|
|
Kubernetes Secret Pattern (Kubernetes only):
|
|
For Kubernetes deployments, you can load the public key from a Kubernetes Secret:
|
|
|
|
- Auto-detect key: easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret_name"
|
|
- Explicit key: easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret_name/key_name"
|
|
|
|
See documentation for details:
|
|
- General k8s_secret pattern: docs/kubernetes.md#loading-plugin-configuration-from-kubernetes-secrets
|
|
- JWT Validator with Secrets: docs/Plugins/jwt-validator.md#kubernetes-with-secrets-recommended
|
|
|
|
Path Validation Logic:
|
|
- No paths configured: ALL requests to the domain require JWT validation (default behavior)
|
|
- Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through
|
|
- Paths configured + only_paths=true: Only specified paths are accessible (with JWT), all others are denied
|
|
|
|
Anonymous Access Logic:
|
|
- allow_anonymous=false (default): Requests without Authorization header are denied
|
|
- allow_anonymous=true: Requests without Authorization header are allowed, but JWTs are validated if present
|
|
|
|
Example YAML config:
|
|
plugins:
|
|
jwt_validator:
|
|
enabled: true
|
|
algorithm: RS256
|
|
issuer: https://myaccount.auth0.com/
|
|
audience: https://api.mywebsite.com
|
|
pubkey_path: /etc/easyhaproxy/jwt_keys/pubkey.pem
|
|
paths:
|
|
- /api/admin
|
|
- /api/sensitive
|
|
only_paths: false
|
|
|
|
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/easyhaproxy/jwt_keys/api_pubkey.pem
|
|
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive
|
|
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
|
|
|
Example Kubernetes Annotations:
|
|
# Using k8s_secret pattern (recommended for Kubernetes):
|
|
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret"
|
|
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
|
|
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
|
|
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
|
|
|
|
# Using inline pubkey (for testing):
|
|
easyhaproxy.plugin.jwt_validator.pubkey: "LS0tLS1CRUdJTi..."
|
|
|
|
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/easyhaproxy/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 base64
|
|
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 functions import Functions, logger_easyhaproxy, Consts
|
|
from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
|
|
|
|
|
|
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)
|
|
self.paths = [] # List of paths that require JWT validation
|
|
self.only_paths = False # If true, only specified paths are accessible
|
|
self.allow_anonymous = False # If true, allow requests without Authorization header
|
|
# Make JWT_KEYS_DIR configurable via environment variable (for testing)
|
|
self.jwt_keys_dir = os.getenv("EASYHAPROXY_JWT_KEYS_DIR", Consts.base_path + "/jwt_keys")
|
|
|
|
@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 base64-encoded string
|
|
- paths: List of paths that require JWT validation (optional)
|
|
- only_paths: If true, only specified paths are accessible (default: false)
|
|
- allow_anonymous: If true, allow requests without Authorization header (default: false)
|
|
"""
|
|
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:
|
|
# Decode from base64 (consistent with sslcert parameter)
|
|
self.pubkey = base64.b64decode(config["pubkey"]).decode('ascii')
|
|
|
|
# Path configuration
|
|
if "paths" in config:
|
|
paths_config = config["paths"]
|
|
if isinstance(paths_config, list):
|
|
self.paths = [str(p).strip() for p in paths_config if str(p).strip()]
|
|
elif isinstance(paths_config, str):
|
|
# Support comma-separated paths for container labels
|
|
self.paths = [p.strip() for p in paths_config.split(",") if p.strip()]
|
|
else:
|
|
self.paths = []
|
|
|
|
if "only_paths" in config:
|
|
self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"]
|
|
|
|
if "allow_anonymous" in config:
|
|
self.allow_anonymous = str(config["allow_anonymous"]).lower() in ["true", "1", "yes"]
|
|
|
|
def initialize(self) -> InitializationResult:
|
|
"""
|
|
Initialize plugin resources - create JWT keys directory
|
|
|
|
Returns:
|
|
InitializationResult with directory creation request
|
|
"""
|
|
return InitializationResult(
|
|
resources=[
|
|
ResourceRequest(resource_type="directory", path=self.jwt_keys_dir)
|
|
]
|
|
)
|
|
|
|
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"{self.jwt_keys_dir}/{domain_safe}_pubkey.pem"
|
|
|
|
# Write the public key file (with error handling for test environments)
|
|
try:
|
|
# Ensure directory exists (defensive - normally created by initialize())
|
|
os.makedirs(self.jwt_keys_dir, exist_ok=True)
|
|
Functions.save(pubkey_file, self.pubkey)
|
|
logger_easyhaproxy.debug(f"Wrote JWT public key to {pubkey_file} for domain {context.domain}")
|
|
except (PermissionError, OSError) as e:
|
|
# In test environments or restricted environments, file write may fail
|
|
# This is okay - the config is still generated correctly
|
|
logger_easyhaproxy.debug(f"Could not write JWT public key file (may be test environment): {e}")
|
|
else:
|
|
logger_easyhaproxy.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"]
|
|
|
|
# Determine path condition suffix
|
|
path_condition = ""
|
|
if self.paths:
|
|
# Define ACL for protected paths
|
|
lines.append("")
|
|
lines.append("# Define paths that require JWT validation")
|
|
for path in self.paths:
|
|
lines.append(f"acl jwt_protected_path path_beg {path}")
|
|
lines.append("")
|
|
|
|
if self.only_paths:
|
|
# Deny all paths that are not in the protected list
|
|
lines.append("# Deny access to paths not in the protected list")
|
|
lines.append("http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path")
|
|
lines.append("")
|
|
# All remaining requests are on protected paths, no condition needed
|
|
path_condition = ""
|
|
else:
|
|
# Only validate JWT on protected paths
|
|
path_condition = " if jwt_protected_path"
|
|
|
|
# Check for Authorization header
|
|
if not self.allow_anonymous:
|
|
# Require Authorization header (default behavior)
|
|
lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}")
|
|
jwt_condition = path_condition
|
|
else:
|
|
# Allow anonymous access - only validate JWT if Authorization header is present
|
|
lines.append("")
|
|
lines.append("# Allow anonymous access - validate JWT only if Authorization header is present")
|
|
if path_condition:
|
|
# Combine path condition with Authorization header check
|
|
jwt_condition = f"{path_condition} if {{ req.hdr(authorization) -m found }}"
|
|
else:
|
|
jwt_condition = " if { req.hdr(authorization) -m found }"
|
|
|
|
# Extract JWT parts
|
|
lines.append("")
|
|
lines.append("# Extract JWT header and payload")
|
|
lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){jwt_condition}")
|
|
lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){jwt_condition}")
|
|
lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){jwt_condition}")
|
|
lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){jwt_condition}")
|
|
|
|
# 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} }}{jwt_condition}")
|
|
|
|
# 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} }}{jwt_condition}")
|
|
|
|
# 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} }}{jwt_condition}")
|
|
|
|
# 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 }}{jwt_condition}")
|
|
|
|
# Validate expiration
|
|
lines.append("")
|
|
lines.append("# Validate expiration")
|
|
lines.append(f"http-request set-var(txn.now) date(){jwt_condition}")
|
|
lines.append(f"http-request deny content-type 'text/html' string 'JWT has expired' if {{ var(txn.exp),sub(txn.now) -m int lt 0 }}{jwt_condition}")
|
|
|
|
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,
|
|
"path_validation": len(self.paths) > 0,
|
|
"only_paths": self.only_paths,
|
|
"allow_anonymous": self.allow_anonymous
|
|
}
|
|
|
|
if self.issuer:
|
|
metadata["issuer"] = self.issuer
|
|
if self.audience:
|
|
metadata["audience"] = self.audience
|
|
if self.pubkey:
|
|
# Keep pubkey_content in metadata for backward compatibility with tests
|
|
metadata["pubkey_content"] = self.pubkey
|
|
if self.paths:
|
|
metadata["paths"] = self.paths
|
|
|
|
return PluginResult(
|
|
haproxy_config=haproxy_config,
|
|
modified_easymapping=None,
|
|
metadata=metadata
|
|
)
|