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:
parent
ae6eb1b55a
commit
56fc86d77d
2 changed files with 98 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue