From 56fc86d77d7d1367000c873ce1195a140d4189a9 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 1 Dec 2025 17:43:05 -0500 Subject: [PATCH] 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. --- docs/plugins/jwt-validator.md | 73 ++++++++++++++++++++++++---- src/plugins/builtin/jwt_validator.py | 47 +++++++++++++----- 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/docs/plugins/jwt-validator.md b/docs/plugins/jwt-validator.md index c2c594c..d7cdb07 100644 --- a/docs/plugins/jwt-validator.md +++ b/docs/plugins/jwt-validator.md @@ -13,16 +13,17 @@ 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) | -| `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) | -| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) | -| `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` | +| 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) | +| `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) | +| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) | +| `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 diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py index 93df35d..5a7b4af 100644 --- a/src/plugins/builtin/jwt_validator.py +++ b/src/plugins/builtin/jwt_validator.py @@ -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 - lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}") + 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: