Add path-based JWT validation support in JwtValidatorPlugin
- Introduced `paths` and `only_paths` configuration options to define protected API paths. - Enhanced HAProxy configuration generation to handle path-specific JWT validation. - Updated plugin metadata to include path details and validation logic. - Modified documentation with detailed examples for protecting paths. - Added comprehensive tests for path-based validation scenarios and edge cases.
This commit is contained in:
parent
90e0df42a1
commit
51f8cd4659
3 changed files with 310 additions and 22 deletions
122
docs/plugins.md
122
docs/plugins.md
|
|
@ -177,9 +177,16 @@ Validates JWT (JSON Web Token) authentication tokens using HAProxy's built-in JW
|
||||||
- `issuer` - Expected JWT issuer (optional, set to `none`/`null` to skip validation)
|
- `issuer` - Expected JWT issuer (optional, set to `none`/`null` to skip validation)
|
||||||
- `audience` - Expected JWT audience (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_path` - Path to public key file (required if `pubkey` not provided)
|
||||||
- `pubkey` - Public key content as string (required if `pubkey_path` not provided)
|
- `pubkey` - Public key content as base64-encoded string (required if `pubkey_path` not provided)
|
||||||
|
- `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
|
||||||
|
|
||||||
**Enable via container label:**
|
**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, other paths pass through without validation
|
||||||
|
- **Paths configured + `only_paths=true`:** Only specified paths are accessible (with JWT validation), all other paths are denied
|
||||||
|
|
||||||
|
**Enable via container label (protect all paths):**
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
|
|
@ -194,6 +201,24 @@ services:
|
||||||
- ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
|
- ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Protect specific paths only (others can pass without JWT):**
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
easyhaproxy.http.plugins: jwt_validator
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.only_paths: false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Only allow specific paths (deny all others):**
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
easyhaproxy.http.plugins: jwt_validator
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.paths: /api/public,/api/v1
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
||||||
|
```
|
||||||
|
|
||||||
**Skip issuer/audience validation:**
|
**Skip issuer/audience validation:**
|
||||||
```yaml
|
```yaml
|
||||||
labels:
|
labels:
|
||||||
|
|
@ -202,7 +227,7 @@ labels:
|
||||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
**HAProxy config generated:**
|
**HAProxy config generated (all paths protected):**
|
||||||
```
|
```
|
||||||
# JWT Validator - Validate JWT tokens
|
# JWT Validator - Validate JWT tokens
|
||||||
http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
|
http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
|
||||||
|
|
@ -224,6 +249,59 @@ 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 }
|
http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**HAProxy config generated (specific paths, only_paths=false):**
|
||||||
|
```
|
||||||
|
# JWT Validator - Validate JWT tokens
|
||||||
|
|
||||||
|
# Define paths that require JWT validation
|
||||||
|
acl jwt_protected_path path_beg /api/admin
|
||||||
|
acl jwt_protected_path path_beg /api/sensitive
|
||||||
|
|
||||||
|
http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } if jwt_protected_path
|
||||||
|
|
||||||
|
# Extract JWT header and payload
|
||||||
|
http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path
|
||||||
|
http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if jwt_protected_path
|
||||||
|
http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if jwt_protected_path
|
||||||
|
http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if jwt_protected_path
|
||||||
|
|
||||||
|
# Validate JWT (only on protected paths)
|
||||||
|
http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if jwt_protected_path
|
||||||
|
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 } if jwt_protected_path
|
||||||
|
|
||||||
|
# Validate expiration
|
||||||
|
http-request set-var(txn.now) date() if jwt_protected_path
|
||||||
|
http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if jwt_protected_path
|
||||||
|
```
|
||||||
|
|
||||||
|
**HAProxy config generated (specific paths, only_paths=true):**
|
||||||
|
```
|
||||||
|
# JWT Validator - Validate JWT tokens
|
||||||
|
|
||||||
|
# Define paths that require JWT validation
|
||||||
|
acl jwt_protected_path path_beg /api/public
|
||||||
|
acl jwt_protected_path path_beg /api/v1
|
||||||
|
|
||||||
|
# Deny access to paths not in the protected list
|
||||||
|
http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path
|
||||||
|
|
||||||
|
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 (all requests at this point are on allowed paths)
|
||||||
|
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 }
|
||||||
|
```
|
||||||
|
|
||||||
**What it validates:**
|
**What it validates:**
|
||||||
- ✅ Authorization header presence
|
- ✅ Authorization header presence
|
||||||
- ✅ JWT signing algorithm (RS256, RS512, etc.)
|
- ✅ JWT signing algorithm (RS256, RS512, etc.)
|
||||||
|
|
@ -250,11 +328,13 @@ metadata:
|
||||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||||
# Enable plugins
|
# Enable plugins
|
||||||
easyhaproxy.plugins: "jwt_validator,deny_pages"
|
easyhaproxy.plugins: "jwt_validator,deny_pages"
|
||||||
# Configure jwt_validator plugin
|
# Configure jwt_validator plugin (protect specific paths only)
|
||||||
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
|
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
|
||||||
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
|
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
|
||||||
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
|
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
|
||||||
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem"
|
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem"
|
||||||
|
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
|
||||||
|
easyhaproxy.plugin.jwt_validator.only_paths: "false"
|
||||||
# Configure deny_pages plugin
|
# Configure deny_pages plugin
|
||||||
easyhaproxy.plugin.deny_pages.paths: "/admin,/private"
|
easyhaproxy.plugin.deny_pages.paths: "/admin,/private"
|
||||||
easyhaproxy.plugin.deny_pages.status_code: "403"
|
easyhaproxy.plugin.deny_pages.status_code: "403"
|
||||||
|
|
@ -352,7 +432,7 @@ EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst
|
||||||
|
|
||||||
### Protect API with JWT Authentication
|
### Protect API with JWT Authentication
|
||||||
|
|
||||||
Secure your API endpoints with JWT token validation:
|
**Secure entire API domain:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
|
|
@ -367,6 +447,38 @@ services:
|
||||||
- ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
|
- ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Protect only admin/sensitive endpoints:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
labels:
|
||||||
|
easyhaproxy.http.host: api.example.com
|
||||||
|
easyhaproxy.http.plugins: jwt_validator
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/users,/api/billing
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.only_paths: false
|
||||||
|
volumes:
|
||||||
|
- ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
|
||||||
|
# /api/health, /api/docs, etc. remain publicly accessible
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restrict API to only allow specific endpoints:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
labels:
|
||||||
|
easyhaproxy.http.host: api.example.com
|
||||||
|
easyhaproxy.http.plugins: jwt_validator
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.paths: /api/v1,/api/v2
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
||||||
|
volumes:
|
||||||
|
- ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
|
||||||
|
# All paths except /api/v1 and /api/v2 are denied
|
||||||
|
```
|
||||||
|
|
||||||
### 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:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,14 @@ Configuration:
|
||||||
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
|
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
|
||||||
- audience: Expected JWT audience (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_path: Path to public key file (required if pubkey not provided)
|
||||||
- pubkey: Public key content as string (required if pubkey_path not provided)
|
- pubkey: Public key content as base64-encoded string (required if pubkey_path not provided)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
Example YAML config:
|
Example YAML config:
|
||||||
plugins:
|
plugins:
|
||||||
|
|
@ -20,6 +27,10 @@ Example YAML config:
|
||||||
issuer: https://myaccount.auth0.com/
|
issuer: https://myaccount.auth0.com/
|
||||||
audience: https://api.mywebsite.com
|
audience: https://api.mywebsite.com
|
||||||
pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem
|
pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem
|
||||||
|
paths:
|
||||||
|
- /api/admin
|
||||||
|
- /api/sensitive
|
||||||
|
only_paths: false
|
||||||
|
|
||||||
Example Container Label:
|
Example Container Label:
|
||||||
easyhaproxy.http.plugins: "jwt_validator"
|
easyhaproxy.http.plugins: "jwt_validator"
|
||||||
|
|
@ -27,6 +38,8 @@ Example Container Label:
|
||||||
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/
|
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.audience: https://api.example.com
|
||||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive
|
||||||
|
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
||||||
|
|
||||||
HAProxy Config Generated:
|
HAProxy Config Generated:
|
||||||
# JWT Validator - Validate JWT tokens
|
# JWT Validator - Validate JWT tokens
|
||||||
|
|
@ -49,6 +62,7 @@ HAProxy Config Generated:
|
||||||
http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }
|
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 os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
@ -69,6 +83,8 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
self.audience = None # Optional
|
self.audience = None # Optional
|
||||||
self.pubkey_path = None # Path to public key file
|
self.pubkey_path = None # Path to public key file
|
||||||
self.pubkey = None # Public key content (alternative to pubkey_path)
|
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
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|
@ -89,7 +105,9 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
- issuer: Expected JWT issuer (optional)
|
- issuer: Expected JWT issuer (optional)
|
||||||
- audience: Expected JWT audience (optional)
|
- audience: Expected JWT audience (optional)
|
||||||
- pubkey_path: Path to public key file
|
- pubkey_path: Path to public key file
|
||||||
- pubkey: Public key content as string
|
- 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)
|
||||||
"""
|
"""
|
||||||
if "enabled" in config:
|
if "enabled" in config:
|
||||||
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
|
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
|
||||||
|
|
@ -114,7 +132,22 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
self.pubkey_path = config["pubkey_path"]
|
self.pubkey_path = config["pubkey_path"]
|
||||||
|
|
||||||
if "pubkey" in config:
|
if "pubkey" in config:
|
||||||
self.pubkey = config["pubkey"]
|
# 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"]
|
||||||
|
|
||||||
def process(self, context: PluginContext) -> PluginResult:
|
def process(self, context: PluginContext) -> PluginResult:
|
||||||
"""
|
"""
|
||||||
|
|
@ -143,38 +176,59 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
# Build HAProxy configuration
|
# Build HAProxy configuration
|
||||||
lines = ["# JWT Validator - Validate JWT tokens"]
|
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
|
# Check for Authorization header
|
||||||
lines.append("http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }")
|
lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}")
|
||||||
|
|
||||||
# Extract JWT parts
|
# Extract JWT parts
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("# Extract JWT header and payload")
|
lines.append("# Extract JWT header and payload")
|
||||||
lines.append("http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')")
|
lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){path_condition}")
|
||||||
lines.append("http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')")
|
lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){path_condition}")
|
||||||
lines.append("http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')")
|
lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){path_condition}")
|
||||||
lines.append("http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')")
|
lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){path_condition}")
|
||||||
|
|
||||||
# Validate JWT
|
# Validate JWT
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("# Validate JWT")
|
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} }}")
|
lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}{path_condition}")
|
||||||
|
|
||||||
# Validate issuer (if configured)
|
# Validate issuer (if configured)
|
||||||
if self.issuer:
|
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} }}")
|
lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}{path_condition}")
|
||||||
|
|
||||||
# Validate audience (if configured)
|
# Validate audience (if configured)
|
||||||
if self.audience:
|
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} }}")
|
lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}{path_condition}")
|
||||||
|
|
||||||
# Validate signature
|
# 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 }}")
|
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 }}{path_condition}")
|
||||||
|
|
||||||
# Validate expiration
|
# Validate expiration
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("# Validate expiration")
|
lines.append("# Validate expiration")
|
||||||
lines.append("http-request set-var(txn.now) date()")
|
lines.append(f"http-request set-var(txn.now) date(){path_condition}")
|
||||||
lines.append("http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }")
|
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 }}{path_condition}")
|
||||||
|
|
||||||
haproxy_config = "\n".join(lines)
|
haproxy_config = "\n".join(lines)
|
||||||
|
|
||||||
|
|
@ -184,7 +238,9 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
"algorithm": self.algorithm,
|
"algorithm": self.algorithm,
|
||||||
"pubkey_file": pubkey_file,
|
"pubkey_file": pubkey_file,
|
||||||
"validates_issuer": self.issuer is not None,
|
"validates_issuer": self.issuer is not None,
|
||||||
"validates_audience": self.audience is not None
|
"validates_audience": self.audience is not None,
|
||||||
|
"path_validation": len(self.paths) > 0,
|
||||||
|
"only_paths": self.only_paths
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.issuer:
|
if self.issuer:
|
||||||
|
|
@ -193,6 +249,8 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
metadata["audience"] = self.audience
|
metadata["audience"] = self.audience
|
||||||
if self.pubkey:
|
if self.pubkey:
|
||||||
metadata["pubkey_content"] = self.pubkey
|
metadata["pubkey_content"] = self.pubkey
|
||||||
|
if self.paths:
|
||||||
|
metadata["paths"] = self.paths
|
||||||
|
|
||||||
return PluginResult(
|
return PluginResult(
|
||||||
haproxy_config=haproxy_config,
|
haproxy_config=haproxy_config,
|
||||||
|
|
|
||||||
|
|
@ -518,11 +518,13 @@ class TestJwtValidatorPlugin:
|
||||||
assert result.metadata["validates_audience"] is True
|
assert result.metadata["validates_audience"] is True
|
||||||
|
|
||||||
def test_jwt_validator_plugin_generates_config_with_pubkey_content(self):
|
def test_jwt_validator_plugin_generates_config_with_pubkey_content(self):
|
||||||
"""Test plugin generates correct HAProxy config using pubkey content"""
|
"""Test plugin generates correct HAProxy config using pubkey content (base64-encoded)"""
|
||||||
plugin = JwtValidatorPlugin()
|
plugin = JwtValidatorPlugin()
|
||||||
|
# Base64-encoded version of "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----"
|
||||||
|
pubkey_base64 = "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaC4uLgotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0="
|
||||||
plugin.configure({
|
plugin.configure({
|
||||||
"algorithm": "RS256",
|
"algorithm": "RS256",
|
||||||
"pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----"
|
"pubkey": pubkey_base64
|
||||||
})
|
})
|
||||||
|
|
||||||
context = PluginContext(
|
context = PluginContext(
|
||||||
|
|
@ -539,6 +541,7 @@ class TestJwtValidatorPlugin:
|
||||||
assert result.haproxy_config is not None
|
assert result.haproxy_config is not None
|
||||||
assert "JWT Validator" in result.haproxy_config
|
assert "JWT Validator" in result.haproxy_config
|
||||||
assert "/etc/haproxy/jwt_keys/api_example_com_pubkey.pem" in result.haproxy_config
|
assert "/etc/haproxy/jwt_keys/api_example_com_pubkey.pem" in result.haproxy_config
|
||||||
|
# Verify the decoded content is stored in metadata
|
||||||
assert result.metadata["pubkey_content"] == "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----"
|
assert result.metadata["pubkey_content"] == "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----"
|
||||||
|
|
||||||
def test_jwt_validator_plugin_no_issuer_audience_validation(self):
|
def test_jwt_validator_plugin_no_issuer_audience_validation(self):
|
||||||
|
|
@ -617,6 +620,121 @@ class TestJwtValidatorPlugin:
|
||||||
assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" 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
|
assert "jwt_verify" in haproxy_config
|
||||||
|
|
||||||
|
def test_jwt_validator_plugin_with_paths_only_paths_false(self):
|
||||||
|
"""Test plugin with paths configured and only_paths=false"""
|
||||||
|
plugin = JwtValidatorPlugin()
|
||||||
|
plugin.configure({
|
||||||
|
"pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem",
|
||||||
|
"paths": ["/api/admin", "/api/sensitive"],
|
||||||
|
"only_paths": "false"
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
# Check that ACLs are defined for paths
|
||||||
|
assert "acl jwt_protected_path path_beg /api/admin" in result.haproxy_config
|
||||||
|
assert "acl jwt_protected_path path_beg /api/sensitive" in result.haproxy_config
|
||||||
|
# Check that validation rules have "if jwt_protected_path" condition
|
||||||
|
assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" in result.haproxy_config
|
||||||
|
assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path" in result.haproxy_config
|
||||||
|
# Check that "Access denied" for non-protected paths is NOT present (only_paths=false)
|
||||||
|
assert "Access denied" not in result.haproxy_config
|
||||||
|
# Check metadata
|
||||||
|
assert result.metadata["path_validation"] is True
|
||||||
|
assert result.metadata["only_paths"] is False
|
||||||
|
assert result.metadata["paths"] == ["/api/admin", "/api/sensitive"]
|
||||||
|
|
||||||
|
def test_jwt_validator_plugin_with_paths_only_paths_true(self):
|
||||||
|
"""Test plugin with paths configured and only_paths=true"""
|
||||||
|
plugin = JwtValidatorPlugin()
|
||||||
|
plugin.configure({
|
||||||
|
"pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem",
|
||||||
|
"paths": ["/api/public"],
|
||||||
|
"only_paths": "true"
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
# Check that ACL is defined for path
|
||||||
|
assert "acl jwt_protected_path path_beg /api/public" in result.haproxy_config
|
||||||
|
# Check that "Access denied" for non-protected paths IS present (only_paths=true)
|
||||||
|
assert "http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path" in result.haproxy_config
|
||||||
|
# Check that validation rules do NOT have "if jwt_protected_path" (since all non-protected paths are denied)
|
||||||
|
assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" not in result.haproxy_config
|
||||||
|
# The rules should not have any condition suffix when only_paths=true
|
||||||
|
assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }" in result.haproxy_config
|
||||||
|
# Check metadata
|
||||||
|
assert result.metadata["path_validation"] is True
|
||||||
|
assert result.metadata["only_paths"] is True
|
||||||
|
assert result.metadata["paths"] == ["/api/public"]
|
||||||
|
|
||||||
|
def test_jwt_validator_plugin_paths_from_comma_separated_string(self):
|
||||||
|
"""Test plugin parses comma-separated paths from container labels"""
|
||||||
|
plugin = JwtValidatorPlugin()
|
||||||
|
plugin.configure({
|
||||||
|
"pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem",
|
||||||
|
"paths": "/api/admin,/api/sensitive,/api/protected"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert plugin.paths == ["/api/admin", "/api/sensitive", "/api/protected"]
|
||||||
|
|
||||||
|
def test_jwt_validator_plugin_paths_from_list(self):
|
||||||
|
"""Test plugin parses paths from list (YAML config)"""
|
||||||
|
plugin = JwtValidatorPlugin()
|
||||||
|
plugin.configure({
|
||||||
|
"pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem",
|
||||||
|
"paths": ["/api/admin", "/api/sensitive"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert plugin.paths == ["/api/admin", "/api/sensitive"]
|
||||||
|
|
||||||
|
def test_jwt_validator_plugin_no_paths_protects_all(self):
|
||||||
|
"""Test plugin protects all paths when paths is 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
|
||||||
|
# Check that no ACL is defined
|
||||||
|
assert "acl jwt_protected_path" not in result.haproxy_config
|
||||||
|
# Check that validation rules do NOT have any condition suffix (all paths protected)
|
||||||
|
assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" not in result.haproxy_config
|
||||||
|
assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }" in result.haproxy_config
|
||||||
|
# Check metadata
|
||||||
|
assert result.metadata["path_validation"] is False
|
||||||
|
|
||||||
|
|
||||||
class TestPluginManager:
|
class TestPluginManager:
|
||||||
"""Test cases for PluginManager"""
|
"""Test cases for PluginManager"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue