1
0
Fork 0

Add allow_anonymous option to JwtValidatorPlugin

- Introduced `allow_anonymous` configuration option to permit requests without an Authorization header.
- Updated HAProxy configuration to handle optional JWT validation for anonymous access.
- Enhanced documentation with use cases, examples, and configuration details for `allow_anonymous`.
- Adjusted tests and plugin logic to support anonymous access scenarios.
This commit is contained in:
Joao Gilberto Magalhaes 2025-12-01 17:43:05 -05:00
parent ae6eb1b55a
commit 56fc86d77d
2 changed files with 98 additions and 22 deletions

View file

@ -14,7 +14,7 @@ Protect APIs and services with JWT authentication without needing application-le
## Configuration Options
| Option | Description | Default |
|---------------|----------------------------------------------------------------------------------------------|-------------|
|-------------------|----------------------------------------------------------------------------------------------|-------------|
| `enabled` | Enable/disable plugin | `true` |
| `algorithm` | JWT signing algorithm | `RS256` |
| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) |
@ -23,6 +23,7 @@ Protect APIs and services with JWT authentication without needing application-le
| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (optional) |
| `paths` | List of paths that require JWT validation (optional) | (all paths) |
| `only_paths` | If `true`, only specified paths are accessible; if `false`, only specified paths require JWT | `false` |
| `allow_anonymous` | If `true`, allows requests without Authorization header (validates JWT if present) | `false` |
## Path Validation Logic
@ -30,6 +31,17 @@ Protect APIs and services with JWT authentication without needing application-le
- **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
## Anonymous Access Logic
- **`allow_anonymous=false` (default):** Requests without `Authorization` header are denied with "Missing Authorization HTTP header"
- **`allow_anonymous=true`:** Requests without `Authorization` header are allowed to pass through, but JWTs are validated if the header is present
**Use Cases for `allow_anonymous=true`:**
- Optional authentication (show different content for authenticated vs anonymous users)
- Mixed public/private content where some users have enhanced access with JWT
- Gradual JWT authentication rollout
- Public APIs that provide additional features to authenticated users
## Configuration Examples
### Docker/Docker Compose (Protect All Paths)
@ -79,6 +91,23 @@ labels:
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
```
### Allow Anonymous Access (Optional JWT)
```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.allow_anonymous: true
volumes:
- ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
# Requests without Authorization header are allowed
# Requests with Authorization header are validated
# Invalid JWTs are rejected
```
### Kubernetes Annotations
```yaml
@ -204,6 +233,30 @@ 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 }
```
### Allow Anonymous Access (allow_anonymous=true)
```haproxy
# JWT Validator - Validate JWT tokens
# Allow anonymous access - validate JWT only if Authorization header is present
# Extract JWT header and payload
http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if { req.hdr(authorization) -m found }
http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if { req.hdr(authorization) -m found }
http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if { req.hdr(authorization) -m found }
http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if { req.hdr(authorization) -m found }
# Validate JWT (only if Authorization header is present)
http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if { req.hdr(authorization) -m found }
http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } if { req.hdr(authorization) -m found }
http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } if { req.hdr(authorization) -m found }
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 { req.hdr(authorization) -m found }
# Validate expiration (only if Authorization header is present)
http-request set-var(txn.now) date() if { req.hdr(authorization) -m found }
http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if { req.hdr(authorization) -m found }
```
## What It Validates
- ✅ Authorization header presence

View file

@ -13,12 +13,17 @@ Configuration:
- 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
- allow_anonymous: If true, allows requests without Authorization header (validates JWT if present); if false (default), requires Authorization header
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:
@ -85,6 +90,7 @@ class JwtValidatorPlugin(PluginInterface):
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
@property
def name(self) -> str:
@ -108,6 +114,7 @@ class JwtValidatorPlugin(PluginInterface):
- 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"]
@ -149,6 +156,9 @@ class JwtValidatorPlugin(PluginInterface):
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 process(self, context: PluginContext) -> PluginResult:
"""
Generate HAProxy config to validate JWT tokens
@ -198,37 +208,49 @@ class JwtValidatorPlugin(PluginInterface):
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'){path_condition}")
lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){path_condition}")
lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){path_condition}")
lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){path_condition}")
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} }}{path_condition}")
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} }}{path_condition}")
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} }}{path_condition}")
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 }}{path_condition}")
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(){path_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 }}{path_condition}")
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)
@ -240,7 +262,8 @@ class JwtValidatorPlugin(PluginInterface):
"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
"only_paths": self.only_paths,
"allow_anonymous": self.allow_anonymous
}
if self.issuer: