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
|
## Configuration Options
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|---------------|----------------------------------------------------------------------------------------------|-------------|
|
|-------------------|----------------------------------------------------------------------------------------------|-------------|
|
||||||
| `enabled` | Enable/disable plugin | `true` |
|
| `enabled` | Enable/disable plugin | `true` |
|
||||||
| `algorithm` | JWT signing algorithm | `RS256` |
|
| `algorithm` | JWT signing algorithm | `RS256` |
|
||||||
| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) |
|
| `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) |
|
| `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) |
|
| `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` |
|
| `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
|
## 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=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
|
- **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
|
## Configuration Examples
|
||||||
|
|
||||||
### Docker/Docker Compose (Protect All Paths)
|
### 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
|
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
|
### Kubernetes Annotations
|
||||||
|
|
||||||
```yaml
|
```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 }
|
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
|
## What It Validates
|
||||||
|
|
||||||
- ✅ Authorization header presence
|
- ✅ Authorization header presence
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,17 @@ Configuration:
|
||||||
- pubkey: Public key content as base64-encoded 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)
|
- 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
|
- 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:
|
Path Validation Logic:
|
||||||
- No paths configured: ALL requests to the domain require JWT validation (default behavior)
|
- 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=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
|
- 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:
|
Example YAML config:
|
||||||
plugins:
|
plugins:
|
||||||
jwt_validator:
|
jwt_validator:
|
||||||
|
|
@ -85,6 +90,7 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
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.paths = [] # List of paths that require JWT validation
|
||||||
self.only_paths = False # If true, only specified paths are accessible
|
self.only_paths = False # If true, only specified paths are accessible
|
||||||
|
self.allow_anonymous = False # If true, allow requests without Authorization header
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|
@ -108,6 +114,7 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
- pubkey: Public key content as base64-encoded string
|
- pubkey: Public key content as base64-encoded string
|
||||||
- paths: List of paths that require JWT validation (optional)
|
- paths: List of paths that require JWT validation (optional)
|
||||||
- only_paths: If true, only specified paths are accessible (default: false)
|
- 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:
|
if "enabled" in config:
|
||||||
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
|
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
|
||||||
|
|
@ -149,6 +156,9 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
if "only_paths" in config:
|
if "only_paths" in config:
|
||||||
self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"]
|
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:
|
def process(self, context: PluginContext) -> PluginResult:
|
||||||
"""
|
"""
|
||||||
Generate HAProxy config to validate JWT tokens
|
Generate HAProxy config to validate JWT tokens
|
||||||
|
|
@ -198,37 +208,49 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
path_condition = " if jwt_protected_path"
|
path_condition = " if jwt_protected_path"
|
||||||
|
|
||||||
# Check for Authorization header
|
# 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}")
|
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
|
# Extract JWT parts
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("# Extract JWT header and payload")
|
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.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'){path_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'){path_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'){path_condition}")
|
lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){jwt_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} }}{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)
|
# 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} }}{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)
|
# 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} }}{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
|
# 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
|
# Validate expiration
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("# Validate expiration")
|
lines.append("# Validate expiration")
|
||||||
lines.append(f"http-request set-var(txn.now) date(){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 }}{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 }}{jwt_condition}")
|
||||||
|
|
||||||
haproxy_config = "\n".join(lines)
|
haproxy_config = "\n".join(lines)
|
||||||
|
|
||||||
|
|
@ -240,7 +262,8 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
"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,
|
"path_validation": len(self.paths) > 0,
|
||||||
"only_paths": self.only_paths
|
"only_paths": self.only_paths,
|
||||||
|
"allow_anonymous": self.allow_anonymous
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.issuer:
|
if self.issuer:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue