Compare commits

..
Author SHA1 Message Date
lukaszraczylo 0dfc252c95 fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-21 22:50:32 +01:00
lukaszraczylo 71574090bf fixup! Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-21 22:39:45 +01:00
lukaszraczylo de91edb514 Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-20 23:58:42 +01:00
lukaszraczylo 667b4213fe Well.. that escalated quickly.
Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ).
2025-06-20 19:23:50 +01:00
lukaszraczylo 70443f0855 Add ability to overwrite the default scopes in the settings file 2025-06-20 11:31:29 +01:00
lukaszraczylo 7a443c626c Fix claims issue. 2025-06-20 09:47:22 +01:00
lukaszraczylo 48de8265c5 Multiple changes to improve performance and reduce complexity.
- Optimise the errors and recovery.
- Deduplicate code in metadata cache.
- Remove unused performance monitoring code.
- Simplify session management and settings handling.
2025-06-20 09:00:02 +01:00
lukaszraczylo d8d1b74175 Fieldalignment 2025-06-20 08:07:03 +01:00
lukaszraczylo c233aa92ef Modernize run 2025-06-20 08:02:26 +01:00
lukaszraczylo c400251625 Refactoring code to fix the issues identified by the users. 2025-06-19 10:10:54 +01:00
lukaszraczylo 48faf7fadf Additional fixes and cleanup 2025-06-18 01:09:14 +01:00
lukaszraczylo 84d7cd3d76 Improvements targetting possible memory usage spikes. 2025-06-18 00:50:12 +01:00
lukaszraczylo 488264028b Ensure that appended roles are unique. Update the documentation. 2025-06-18 00:19:26 +01:00
lukaszraczylo e23135ded0 Fixes issue #51 2025-06-18 00:04:10 +01:00
lukaszraczylo cd307f88a1 Fix bug affecting Azure OIDC authentication ( and most likely others ) 2025-06-17 20:21:36 +01:00
lukaszraczylo efa0cd708b Fixes issue #50 2025-05-26 02:48:20 +01:00
lukaszraczylo 99881f5837 Multiple fixes
- Unbounded Replay Cache: Now bounded to 10,000 entries with automatic cleanup
- Session Pool Leaks: Proper object lifecycle prevents accumulation
- HTTP Client Leaks: Reusable clients eliminate connection overhead
- Goroutine Leaks: Tracked lifecycle with graceful shutdown
2025-05-23 10:55:57 +01:00
lukaszraczylo 82a640cc3b Large scale refactoring for the v0.6
Cryptographic:
RSA Algorithm Support: RS256, RS384, RS512 (PKCS1v15) + PS256, PS384, PS512 (PSS)
Elliptic Curve Support: ES256 (P-256), ES384 (P-384), ES512 (P-521)
Security-First Approach: Proper rejection of HS256/HS384/HS512 and "none" algorithms
Algorithm Confusion Protection: Prevents downgrade attacks
JWK Multi-Format Support: RSA and EC key handling with correct curve parameters
Signature Verification: Comprehensive support for all major JWT algorithms

Security:
Real-time threat detection with automatic IP blocking
Comprehensive input validation against 11+ attack vectors
Advanced authentication protection with session security
CSRF protection with token-based validation
Multi-algorithm JWT support with proper cryptographic implementation
OWASP Top 10 compliance with full coverage
Zero vulnerabilities across all categories
Thread-safe security monitoring with proper synchronization
Header injection protection with complete validation

Reliability:
Circuit breaker patterns for automatic failure recovery
Retry mechanisms with exponential backoff
Graceful degradation for service continuity
Resource protection with memory and connection limits
Zero panics with comprehensive error handling
Perfect race condition elimination
Robust error recovery with modern Go patterns

Performance:
High throughput: 108,312 operations/second
Low latency: P95 < 1ms, P99 < 5ms
Efficient caching: 95%+ hit ratio
Optimized resource usage with automatic cleanup
Perfect metrics collection with detailed monitoring
Thread-safe performance tracking
2025-05-23 01:52:08 +01:00
31 changed files with 11804 additions and 898 deletions
+102 -8
View File
@@ -35,11 +35,8 @@ testData:
logoutURL: /oauth2/logout # Path for handling logout requests (if not provided, it will be set to callbackURL + "/logout")
postLogoutRedirectURI: /oidc/different-logout # URL to redirect to after logout (default: "/")
scopes: # OAuth 2.0 scopes to request (default: ["openid", "email", "profile"])
- openid
- email
- profile
- roles # Include this to get role information from the provider
scopes: # Additional scopes to append to defaults ["openid", "profile", "email"]
- roles # Result: ["openid", "profile", "email", "roles"]
allowedUserDomains: # Restricts access to specific email domains (if not provided, relies on OIDC provider)
- company.com
@@ -79,6 +76,99 @@ testData:
oidcEndSessionURL: https://accounts.google.com/logout # Provider's end session endpoint
enablePKCE: false # Enables PKCE (Proof Key for Code Exchange) for additional security
# --- Provider Specific Configuration Examples ---
#
# Below are example configurations tailored for specific OIDC providers.
# Uncomment and adapt the relevant section for your provider.
# Remember to replace placeholder values (like client IDs, secrets, domains)
# with your actual credentials and settings.
#
# For all providers, ensure claims like email, roles, and groups are
# configured to be included in the ID TOKEN. This plugin validates ID tokens.
# --- Keycloak Example ---
# testDataKeycloak:
# providerURL: https://your-keycloak-domain/realms/your-realm # e.g., http://localhost:8080/realms/master
# clientID: your-keycloak-client-id
# clientSecret: your-keycloak-client-secret # Store securely, e.g., urn:k8s:secret:namespace:secret-name:key
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-keycloak"
# scopes: # Default ["openid", "profile", "email"] are usually sufficient. Add others if mappers depend on them.
# - roles # Example: if you mapped Keycloak roles to a 'roles' claim in the ID token
# - groups # Example: if you mapped Keycloak groups to a 'groups' claim in the ID token
# allowedRolesAndGroups: # Corresponds to 'Token Claim Name' in Keycloak mappers
# - admin
# - editor
# # Ensure Keycloak client mappers add 'email', 'roles', 'groups' etc. to the ID Token.
# # See README.md "Provider Configuration Recommendations" for Keycloak.
# --- Azure AD (Microsoft Entra ID) Example ---
# testDataAzureAD:
# providerURL: https://login.microsoftonline.com/your-tenant-id/v2.0 # Replace your-tenant-id
# clientID: your-azure-ad-client-id
# clientSecret: your-azure-ad-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-azure"
# scopes: # Defaults ["openid", "profile", "email"] are good.
# # Azure AD may require specific scopes for certain graph API permissions if you were to use the access token,
# # but for ID token claims, defaults are often enough.
# # Group claims need to be configured in Azure AD App Registration -> Token Configuration -> Add groups claim.
# allowedUserDomains:
# - yourcompany.com
# allowedRolesAndGroups: # If you configured group claims (typically 'groups') or app roles in Azure AD
# - "group-object-id-1" # Azure AD group claims can be Object IDs by default
# - "AppRoleName"
# # See README.md "Provider Configuration Recommendations" for Azure AD.
# --- Google Workspace / Google Cloud Identity Example ---
# testDataGoogle:
# providerURL: https://accounts.google.com # This is standard for Google
# clientID: your-google-client-id.apps.googleusercontent.com
# clientSecret: your-google-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-google"
# scopes: # Defaults ["openid", "profile", "email"] are handled. Plugin manages Google-specifics.
# # Do NOT add 'offline_access' - plugin handles this.
# allowedUserDomains: # Useful for Google Workspace users
# - your-gsuite-domain.com
# # Google includes 'hd' (hosted domain) claim which can be used with allowedUserDomains.
# # Other claims like 'email', 'sub', 'name' are standard.
# # See README.md "Provider Configuration Recommendations" for Google.
# --- Auth0 Example ---
# testDataAuth0:
# providerURL: https://your-auth0-domain.auth0.com # Replace with your Auth0 domain
# clientID: your-auth0-client-id
# clientSecret: your-auth0-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-auth0"
# scopes: # Defaults ["openid", "profile", "email"]. Add custom scopes if your Auth0 Rules/Actions require them.
# - read:custom_data # Example custom scope
# allowedRolesAndGroups: # Based on claims added via Auth0 Rules or Actions (e.g. namespaced claims)
# - "https://your-app.com/roles:admin"
# - editor
# # Use Auth0 Rules or Actions to add custom claims (roles, permissions) to the ID Token.
# # Ensure postLogoutRedirectURI is in Auth0 app's "Allowed Logout URLs".
# # See README.md "Provider Configuration Recommendations" for Auth0.
# --- Generic OIDC Provider Example ---
# testDataGenericOIDC:
# providerURL: https://your-generic-oidc-provider.com/oidc # Issuer URL for your provider
# clientID: your-generic-client-id
# clientSecret: your-generic-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-generic"
# scopes: # Must include "openid". "profile" and "email" are common.
# - openid
# - profile
# - email
# - custom_scope_for_claims # If your provider needs specific scopes for ID token claims
# allowedRolesAndGroups:
# - user_role_from_id_token
# # Consult your provider's documentation on how to map attributes/roles/groups to ID Token claims.
# # Verify ID Token contents (e.g. jwt.io) to see available claims.
# # See README.md "Provider Configuration Recommendations" for Generic OIDC.
# Configuration documentation
configuration:
providerURL:
@@ -153,11 +243,15 @@ configuration:
scopes:
type: array
description: |
The OAuth 2.0 scopes to request from the OIDC provider.
Default: ["openid", "profile", "email"]
Additional OAuth 2.0 scopes to append to the default scopes.
Default scopes are always included: ["openid", "profile", "email"]
User-provided scopes are appended to defaults with automatic deduplication.
For example, specifying ["roles", "custom_scope"] results in:
["openid", "profile", "email", "roles", "custom_scope"]
Include "roles" or similar scope if you need role/group information.
Note: For Google OAuth, the middleware automatically handles the
Note: For Google OAuth, the middleware automatically handles the
proper authentication parameters and does NOT require the "offline_access"
scope (which Google rejects as invalid). See documentation for details.
required: false
+179 -42
View File
@@ -13,6 +13,8 @@ The Traefik OIDC middleware provides a complete OIDC authentication solution wit
- Rate limiting
- Excluded paths (public URLs)
**Important Note on Token Validation:** This middleware performs authentication and claim extraction based on the **ID Token** provided by the OIDC provider. It does not primarily use the Access Token for these purposes (though the Access Token is available for templated headers if needed). Therefore, ensure that all necessary claims (e.g., email, roles, custom attributes) are included in the ID Token by your OIDC provider's configuration.
The middleware has been tested with Auth0, Logto, Google and other standard OIDC providers. It includes special handling for Google's OAuth implementation.
## Traefik Version Compatibility
@@ -67,7 +69,8 @@ The middleware supports the following configuration options:
|-----------|-------------|---------|---------|
| `logoutURL` | The path for handling logout requests | `callbackURL + "/logout"` | `/oauth2/logout` |
| `postLogoutRedirectURI` | The URL to redirect to after logout | `/` | `/logged-out-page` |
| `scopes` | The OAuth 2.0 scopes to request | `["openid", "profile", "email"]` | `["openid", "email", "profile", "roles"]` |
| `scopes` | OAuth 2.0 scopes to use for authentication | `["openid", "profile", "email"]` (always included by default) | `["roles", "custom_scope"]` (appended to defaults) |
| `overrideScopes` | When true, replaces default scopes with provided scopes instead of appending | `false` | `true` (use only the scopes explicitly provided) |
| `logLevel` | Sets the logging verbosity | `info` | `debug`, `info`, `error` |
| `forceHTTPS` | Forces the use of HTTPS for all URLs | `true` | `true`, `false` |
| `rateLimit` | Sets the maximum number of requests per second | `100` | `500` |
@@ -81,6 +84,79 @@ The middleware supports the following configuration options:
| `refreshGracePeriodSeconds` | Seconds before token expiry to attempt proactive refresh | `60` | `120` |
| `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section |
## Scope Configuration
### Scope Behavior
The middleware supports two modes for handling OAuth 2.0 scopes, controlled by the `overrideScopes` parameter:
#### Default Append Mode (`overrideScopes: false`)
By default, the middleware uses an **append** behavior for OAuth 2.0 scopes:
- **Default scopes** are always included: `["openid", "profile", "email"]`
- **User-provided scopes** are appended to the defaults with automatic deduplication
- The final scope list maintains the order: defaults first, then user scopes
#### Override Mode (`overrideScopes: true`)
When `overrideScopes` is set to `true`, the middleware uses **replacement** behavior:
- Default scopes are **not** automatically included
- Only the scopes explicitly provided in the `scopes` field are used
- You must include all required scopes explicitly, including `openid` if needed
### Examples:
**Default behavior (no custom scopes):**
```yaml
# No scopes field specified
# Result: ["openid", "profile", "email"]
```
**Default append behavior:**
```yaml
scopes:
- roles
- custom_scope
# Result: ["openid", "profile", "email", "roles", "custom_scope"]
```
**Overlapping scopes with append (automatic deduplication):**
```yaml
scopes:
- openid # Duplicate - will be deduplicated
- roles
- profile # Duplicate - will be deduplicated
- permissions
# Result: ["openid", "profile", "email", "roles", "permissions"]
```
**Using override mode:**
```yaml
overrideScopes: true
scopes:
- openid
- profile
- custom_scope
# Result: ["openid", "profile", "custom_scope"]
```
**Empty scopes list with default behavior:**
```yaml
scopes: []
# Result: ["openid", "profile", "email"]
```
**Empty scopes list with override mode:**
```yaml
overrideScopes: true
scopes: []
# Result: [] (Warning: empty scopes may cause authentication to fail)
```
The default append behavior ensures essential OIDC scopes are always present, while the override mode gives you complete control over the exact scopes requested from the provider.
## Usage Examples
### Basic Configuration
@@ -101,9 +177,7 @@ spec:
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
### With Excluded URLs (Public Access Paths)
@@ -124,9 +198,7 @@ spec:
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
excludedURLs:
- /login # covers /login, /login/me, /login/reminder etc.
- /public-data
@@ -152,9 +224,7 @@ spec:
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedUserDomains:
- company.com
- subsidiary.com
@@ -178,9 +248,7 @@ spec:
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedUsers:
- [email protected]
- [email protected]
@@ -204,9 +272,7 @@ spec:
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedUserDomains:
- company.com
allowedUsers:
@@ -239,10 +305,7 @@ spec:
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- openid
- email
- profile
- roles # Include this to get role information from the provider
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedRolesAndGroups:
- admin
- developer
@@ -269,9 +332,7 @@ spec:
rateLimit: 500 # Requests per second (default: 100)
forceHTTPS: false # Default is true for security
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
### With Custom Post-Logout Redirect
@@ -293,9 +354,7 @@ spec:
logoutURL: /oauth2/logout
postLogoutRedirectURI: /logged-out-page # Where to redirect after logout
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
### With Templated Headers
@@ -316,10 +375,7 @@ spec:
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- openid
- email
- profile
- roles
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
headers:
- name: "X-User-Email"
value: "{{.Claims.email}}"
@@ -352,9 +408,7 @@ spec:
logoutURL: /oauth2/logout
enablePKCE: true # Enables PKCE for added security
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
### Google OIDC Configuration Example
@@ -377,9 +431,7 @@ spec:
callbackURL: /oauth2/callback # Adjust if needed
logoutURL: /oauth2/logout # Optional: Adjust if needed
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
# Note: DO NOT manually add offline_access scope for Google
# The middleware automatically handles Google-specific requirements
refreshGracePeriodSeconds: 300 # Optional: Start refresh 5 min before expiry (default 60)
@@ -408,9 +460,7 @@ spec:
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
Don't forget to create the secret:
@@ -509,9 +559,7 @@ http:
postLogoutRedirectURI: /logged-out-page
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
scopes:
- openid
- email
- profile
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedUserDomains:
- company.com
allowedUsers:
@@ -656,6 +704,89 @@ The middleware also sets the following security headers:
- `X-XSS-Protection: 1; mode=block`
- `Referrer-Policy: strict-origin-when-cross-origin`
## Provider Configuration Recommendations
**Important: ID Token Validation**
This Traefik OIDC plugin performs authentication and extracts user claims (like email, roles, groups) exclusively from the **ID Token** provided by your OIDC provider. It does not primarily use the Access Token for these critical functions. Therefore, it is crucial to ensure that all necessary claims are included in the ID Token itself. A common issue is that some OIDC providers might, by default, place certain claims only in the Access Token or UserInfo endpoint.
This section provides guidance on configuring popular OIDC providers to work optimally with this plugin.
### Keycloak
Keycloak is highly configurable, which means you need to ensure your client mappers are set up correctly to include necessary claims in the ID Token.
* **Ensure Claims in ID Token**:
* **Email**: Navigate to your Keycloak realm -> Clients -> Your Client ID -> Mappers. Ensure there's a mapper for 'email' (e.g., a "User Property" mapper for the `email` property) and that "Add to ID token" is **ON**.
* **Roles**: For client roles or realm roles, create or edit mappers (e.g., "User Client Role" or "User Realm Role"). Ensure "Add to ID token" is **ON**. You might want to customize the "Token Claim Name" (e.g., to `roles` or `groups`).
* **Groups**: Similarly, for group membership, use a "Group Membership" mapper and ensure "Add to ID token" is **ON**. Customize the "Token Claim Name" as needed (e.g., `groups`).
* **Scopes**: Ensure your client requests appropriate scopes that trigger the inclusion of these claims if your mappers are scope-dependent. The default `openid`, `profile`, `email` scopes are a good starting point.
* **Troubleshooting**: If claims are missing, double-check the "Mappers" tab for your client in Keycloak. The "Token Claim Name" you define here is what you'll use in the `allowedRolesAndGroups` or `headers` configuration in this plugin. (See also the [Troubleshooting](#troubleshooting) section for Keycloak).
### Azure AD (Microsoft Entra ID)
Azure AD generally works well with standard OIDC configurations.
* **ID Token Claims**: Azure AD typically includes standard claims like `email`, `name`, `preferred_username`, and `oid` (Object ID) in the ID Token by default when `openid profile email` scopes are requested.
* **Group Claims**: To include group claims in the ID Token, you need to configure this in the Azure AD application registration:
* Go to your App Registration -> Token configuration -> Add groups claim.
* You can choose which types of groups (Security groups, Directory roles, All groups) to include.
* Be aware of the "overage" issue: If a user is a member of too many groups, Azure AD will send a link to fetch groups instead of embedding them. This plugin currently expects group claims to be directly in the ID token. For users with many groups, consider alternative role/permission management strategies.
* The claim name for groups is typically `groups`.
* **Optional Claims**: You can add other optional claims via the "Token configuration" section of your App Registration. Ensure these are configured for the ID token.
* **Endpoints**: The `providerURL` should be `https://login.microsoftonline.com/{your-tenant-id}/v2.0`. The plugin will auto-discover the necessary endpoints.
* **Optimization**: Ensure your application manifest in Azure AD is configured for the desired token version (v1.0 or v2.0). This plugin works with v2.0 endpoints.
### Google Workspace / Google Cloud Identity
Google's OIDC implementation is well-supported.
* **Optimal Configuration**: The plugin automatically handles Google-specific requirements, such as using `access_type=offline` and `prompt=consent` to ensure refresh tokens are issued for long-lived sessions. You do not need to add `offline_access` to scopes.
* **ID Token Claims**: Google includes standard claims like `email`, `sub`, `name`, `given_name`, `family_name`, `picture` in the ID Token by default with `openid profile email` scopes.
* **Hosted Domain (hd claim)**: If you are using Google Workspace and want to restrict access to users within your organization's domain, Google includes an `hd` (hosted domain) claim in the ID Token. You can use this with the `allowedUserDomains` setting or for custom header logic.
* **Best Practices**:
* Use the `providerURL`: `https://accounts.google.com`.
* Ensure your OAuth consent screen in Google Cloud Console is configured correctly and published. For production, it should be "External" and in "Production" status. "Testing" status limits refresh token lifetime.
* Refer to the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section for more details on how the plugin handles Google's specifics.
### Auth0
Auth0 is generally OIDC compliant and works well.
* **ID Token Claims**:
* To add custom claims or standard claims not included by default (like roles or permissions) to the ID Token, you'll need to use Auth0 Rules or Actions.
* **Using Actions (Recommended)**: Create a custom Action that runs after login to add claims to the ID Token. Example:
```javascript
// Auth0 Action to add email and roles to ID Token
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://your-app.com/'; // Or your custom namespace
if (event.authorization) {
api.idToken.setCustomClaim(namespace + 'roles', event.authorization.roles);
api.idToken.setCustomClaim('email', event.user.email); // Standard claim, ensure it's there
// Add other claims as needed
}
};
```
* Ensure the claims you add (e.g., `https://your-app.com/roles`) are then used in the plugin's `allowedRolesAndGroups` or `headers` configuration.
* **Scopes**: Request appropriate scopes. You might need custom scopes if your Actions/Rules depend on them to add specific claims.
* **Endpoints**: Your `providerURL` will be `https://your-auth0-domain.auth0.com`.
* **Logout**: Ensure `postLogoutRedirectURI` is registered in your Auth0 application settings under "Allowed Logout URLs".
### Generic OIDC Providers
For other OIDC providers (e.g., Okta, Zitadel, self-hosted solutions):
* **ID Token is Key**: The primary requirement is that all claims needed for authentication decisions (email, roles, groups, custom attributes for headers) **must** be included in the ID Token.
* **Check Provider Documentation**: Consult your OIDC provider's documentation on how to:
* Configure client applications.
* Map user attributes, roles, or group memberships to claims in the ID Token.
* Define custom scopes if they are necessary to include certain claims.
* **Standard Endpoints**: Ensure your provider exposes a standard OIDC discovery document (`.well-known/openid-configuration`) at the `providerURL`. The plugin uses this to find authorization, token, JWKS, and end_session endpoints.
* **Scopes**: Always include `openid` in your scopes. `profile` and `email` are generally recommended. Add other scopes as required by your provider to release specific claims to the ID Token.
* **Troubleshooting**: If the plugin isn't working as expected (e.g., access denied, claims missing), the first step is to decode the ID Token received from your provider (e.g., using jwt.io) to verify its contents. This will show you exactly what claims the plugin is seeing.
For common issues and general troubleshooting, please refer to the [Troubleshooting](#troubleshooting) section.
## Troubleshooting
### Logging
@@ -680,6 +811,12 @@ logLevel: debug
- Verify you're using a version of the middleware that includes the Google OAuth compatibility fix.
- For more details, see the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section or the [detailed documentation](docs/google-oauth-fix.md).
7. **Keycloak: Claims Missing from ID Token (e.g., email, roles)**
If you are using Keycloak and claims like `email`, `roles`, or `groups` are missing from the ID Token, this plugin may not function as expected (e.g., for domain restrictions or RBAC).
* **Solution**: This plugin validates the **ID Token**. You **must** configure Keycloak client mappers to add all necessary claims (email, roles, groups, etc.) to the ID Token.
* For detailed instructions, please see the [Keycloak](#keycloak) section under [Provider Configuration Recommendations](#provider-configuration-recommendations).
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
+53
View File
@@ -2,6 +2,57 @@ package traefikoidc
import "time"
// BackgroundTask represents a recurring task that runs in the background
type BackgroundTask struct {
stopChan chan struct{}
taskFunc func()
logger *Logger
name string
interval time.Duration
}
// NewBackgroundTask creates a new background task
func NewBackgroundTask(name string, interval time.Duration, taskFunc func(), logger *Logger) *BackgroundTask {
return &BackgroundTask{
name: name,
interval: interval,
stopChan: make(chan struct{}),
taskFunc: taskFunc,
logger: logger,
}
}
// Start begins the background task execution
func (bt *BackgroundTask) Start() {
go bt.run()
}
// Stop terminates the background task
func (bt *BackgroundTask) Stop() {
close(bt.stopChan)
}
// run is the main execution loop for the background task
func (bt *BackgroundTask) run() {
ticker := time.NewTicker(bt.interval)
defer ticker.Stop()
bt.logger.Debug("Starting background task: %s", bt.name)
// Run task immediately on startup
bt.taskFunc()
for {
select {
case <-ticker.C:
bt.taskFunc()
case <-bt.stopChan:
bt.logger.Debug("Stopping background task: %s", bt.name)
return
}
}
}
// autoCleanupRoutine periodically calls the provided cleanup function.
// It starts a ticker with the given interval and executes the cleanup function
// on each tick. The routine stops gracefully when a signal is received on the
@@ -12,6 +63,8 @@ import "time"
// - interval: The time duration between cleanup calls.
// - stop: A channel used to signal the routine to stop. Receiving any value will terminate the loop.
// - cleanup: The function to call periodically for cleanup tasks.
//
// Deprecated: Use BackgroundTask instead.
func autoCleanupRoutine(interval time.Duration, stop <-chan struct{}, cleanup func()) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
+387
View File
@@ -0,0 +1,387 @@
package traefikoidc
import (
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/time/rate"
)
// mockTraefikOidc extends TraefikOidc to override JWT verification for testing
type mockTraefikOidc struct {
*TraefikOidc
}
// Override VerifyToken to avoid JWKS lookup in tests
func (m *mockTraefikOidc) VerifyToken(token string) error {
// Cache test claims to avoid "claims not found" errors
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "[email protected]",
}
m.tokenCache.Set(token, testClaims, time.Hour)
return nil // Always succeed for testing
}
// Override VerifyJWTSignatureAndClaims to avoid JWKS lookup in tests
func (m *mockTraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
// Cache test claims to avoid "claims not found" errors
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "[email protected]",
}
m.tokenCache.Set(token, testClaims, time.Hour)
return nil // Always succeed for testing
}
func TestAzureOIDCRegression(t *testing.T) {
// Create a mocked TraefikOidc instance configured for Azure AD
mockLogger := NewLogger("debug")
// Configure for Azure AD provider
baseOidc := &TraefikOidc{
issuerURL: "https://login.microsoftonline.com/tenant-id/v2.0",
authURL: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize",
tokenURL: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token",
jwksURL: "https://login.microsoftonline.com/tenant-id/discovery/v2.0/keys",
clientID: "test-client-id",
clientSecret: "test-client-secret",
scopes: []string{"openid", "profile", "email"},
refreshGracePeriod: 60 * time.Second,
limiter: rate.NewLimiter(rate.Every(time.Second), 100), // Add rate limiter
logger: mockLogger,
httpClient: createDefaultHTTPClient(), // Add HTTP client
jwkCache: &JWKCache{}, // Add JWK cache
tokenCache: NewTokenCache(),
tokenBlacklist: NewCache(),
allowedUserDomains: make(map[string]struct{}),
allowedUsers: make(map[string]struct{}),
allowedRolesAndGroups: make(map[string]struct{}),
excludedURLs: make(map[string]struct{}),
extractClaimsFunc: extractClaims,
}
// Create the mock wrapper
tOidc := &mockTraefikOidc{TraefikOidc: baseOidc}
// Initialize session manager
sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, mockLogger)
tOidc.sessionManager = sessionManager
// Mock the JWT verification to avoid JWKS lookup issues
tOidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
// For test tokens, always return success and cache claims
if strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") {
// Cache test claims for JWT tokens
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "[email protected]",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil
}
// For opaque tokens (non-JWT format), return success
if !strings.Contains(token, ".") || strings.Count(token, ".") != 2 {
return nil
}
// For JWT tokens, cache basic claims to avoid cache lookup issues
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "[email protected]",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil // Always succeed for test purposes
},
}
// Mock JWT verifier to avoid JWKS lookup
tOidc.jwtVerifier = &mockJWTVerifier{
verifyFunc: func(jwt *JWT, token string) error {
// Also cache claims here to ensure they're available
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "[email protected]",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil // Always succeed
},
}
t.Run("Azure provider detection works correctly", func(t *testing.T) {
if !tOidc.isAzureProvider() {
t.Error("Azure provider should be detected for Azure AD issuer URL")
}
if tOidc.isGoogleProvider() {
t.Error("Google provider should not be detected for Azure AD issuer URL")
}
})
t.Run("Azure auth URL includes correct parameters", func(t *testing.T) {
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that response_mode=query was added for Azure
if !strings.Contains(authURL, "response_mode=query") {
t.Errorf("response_mode=query not added to Azure auth URL: %s", authURL)
}
// Verify offline_access scope is included for Azure providers
if !strings.Contains(authURL, "offline_access") {
t.Errorf("offline_access scope not included in Azure auth URL: %s", authURL)
}
// Verify Azure doesn't get Google-specific parameters
if strings.Contains(authURL, "access_type=offline") {
t.Errorf("access_type=offline incorrectly added to Azure auth URL: %s", authURL)
}
if strings.Contains(authURL, "prompt=consent") {
t.Errorf("prompt=consent incorrectly added to Azure auth URL: %s", authURL)
}
})
t.Run("Azure access token validation takes priority", func(t *testing.T) {
// Create a request and session
req := httptest.NewRequest("GET", "/protected", nil)
session, _ := tOidc.sessionManager.GetSession(req)
// Set up session with Azure-style tokens
session.SetAuthenticated(true)
session.SetEmail("[email protected]")
// Create a valid JWT access token for testing
accessTokenClaims := map[string]interface{}{
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(),
"iat": time.Now().Unix(),
"sub": "user123",
"email": "[email protected]",
}
accessToken, _ := createMockJWT(accessTokenClaims)
session.SetAccessToken(accessToken)
// Create an invalid/expired ID token
idTokenClaims := map[string]interface{}{
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"aud": "test-client-id",
"exp": time.Now().Add(-1 * time.Hour).Unix(), // Expired
"iat": time.Now().Add(-2 * time.Hour).Unix(),
"sub": "user123",
"email": "[email protected]",
}
idToken, _ := createMockJWT(idTokenClaims)
session.SetIDToken(idToken)
// Mock the token verification to simulate Azure behavior
originalTokenVerifier := tOidc.tokenVerifier
tOidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
if token == accessToken {
// Access token validation succeeds - cache claims
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "[email protected]",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil
}
if token == idToken {
// ID token validation fails (expired) - don't cache
return newMockError("token has expired")
}
return newMockError("token validation failed")
},
}
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test Azure-specific validation
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
// Azure should prioritize access token, so even with expired ID token,
// user should still be authenticated since access token is valid
if !authenticated {
t.Error("Azure user should be authenticated when access token is valid, even if ID token is expired")
}
if expired {
t.Error("Azure session should not be marked as expired when access token is valid")
}
// May need refresh if we want to get a fresh ID token
if !needsRefresh {
t.Log("Azure session may not need immediate refresh if access token is still valid")
}
})
t.Run("Azure handles opaque access tokens gracefully", func(t *testing.T) {
// Create a request and session
req := httptest.NewRequest("GET", "/protected", nil)
session, _ := tOidc.sessionManager.GetSession(req)
// Set up session with opaque access token (non-JWT)
session.SetAuthenticated(true)
session.SetEmail("[email protected]")
session.SetAccessToken(ValidAccessToken)
// Create a valid ID token for claims extraction
idTokenClaims := map[string]interface{}{
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(),
"iat": time.Now().Unix(),
"sub": "user123",
"email": "[email protected]",
}
idToken, _ := createMockJWT(idTokenClaims)
session.SetIDToken(idToken)
// Mock the token verification
originalTokenVerifier := tOidc.tokenVerifier
tOidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
if token == idToken {
// ID token is valid - cache claims
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "[email protected]",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil
}
return newMockError("token validation failed")
},
}
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test Azure-specific validation with opaque token
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
// Azure should handle opaque access tokens gracefully
if !authenticated {
t.Error("Azure user should be authenticated with opaque access token")
}
if expired {
t.Error("Azure session should not be expired with valid tokens")
}
if needsRefresh {
t.Log("Azure session with opaque token may signal refresh to get JWT tokens")
}
})
t.Run("Azure CSRF handling during token validation failures", func(t *testing.T) {
// Create a request and session
req := httptest.NewRequest("GET", "/protected", nil)
rw := httptest.NewRecorder()
session, _ := tOidc.sessionManager.GetSession(req)
// Set up session with CSRF token (simulating ongoing auth flow)
session.SetCSRF("test-csrf-token-123")
session.SetNonce("test-nonce-456")
session.SetAuthenticated(false) // Not yet authenticated
// Save session to simulate real scenario
session.Save(req, rw)
// Mock token verification to always fail (simulating Azure token issues)
originalTokenVerifier := tOidc.tokenVerifier
tOidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
return newMockError("azure token validation failed")
},
}
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test that CSRF is preserved during Azure validation failures
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
// Should not be authenticated due to validation failure
if authenticated {
t.Error("Should not be authenticated when token validation fails")
}
// Should be marked as expired since no tokens work
if !expired && !needsRefresh {
t.Error("Should be marked as needing refresh or expired when validation fails")
}
// Verify CSRF token is still preserved in session
if session.GetCSRF() != "test-csrf-token-123" {
t.Error("CSRF token should be preserved during Azure token validation failures")
}
if session.GetNonce() != "test-nonce-456" {
t.Error("Nonce should be preserved during Azure token validation failures")
}
})
}
// createMockJWT creates a basic JWT token for testing purposes
func createMockJWT(claims map[string]interface{}) (string, error) {
// Simple mock JWT - in real tests you'd use a proper JWT library
// For this test, we'll create a basic three-part token structure
header := "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0" // {"alg":"RS256","kid":"test-key-id","typ":"JWT"}
// Create a simple payload with test claims
payload := "eyJpc3MiOiJ0ZXN0LWlzc3VlciIsImF1ZCI6InRlc3QtY2xpZW50LWlkIiwiZXhwIjoxNjM4MzYwMDAwLCJpYXQiOjE2MzgzNTY0MDAsInN1YiI6InVzZXIxMjMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ" // Basic claims
signature := "test-signature"
return header + "." + payload + "." + signature, nil
}
// Mock error type for testing
type mockError struct {
message string
}
func (e *mockError) Error() string {
return e.message
}
func newMockError(message string) error {
return &mockError{message: message}
}
// Mock token verifier for testing
type mockTokenVerifier struct {
verifyFunc func(token string) error
}
func (m *mockTokenVerifier) VerifyToken(token string) error {
if m.verifyFunc != nil {
return m.verifyFunc(token)
}
return nil
}
// Mock JWT verifier for testing
type mockJWTVerifier struct {
verifyFunc func(jwt *JWT, token string) error
}
func (m *mockJWTVerifier) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
if m.verifyFunc != nil {
return m.verifyFunc(jwt, token)
}
return nil
}
+27 -26
View File
@@ -23,42 +23,40 @@ type lruEntry struct {
// Cache provides a thread-safe in-memory caching mechanism with expiration support.
// It implements an LRU (Least Recently Used) eviction policy using a doubly-linked list for efficiency.
type Cache struct {
// items stores the cached data with string keys.
items map[string]CacheItem
// order maintains the usage order; most recently used items are at the back.
order *list.List
// elems maps keys to their corresponding list elements for O(1) access.
elems map[string]*list.Element
// mutex protects concurrent access to the cache.
mutex sync.RWMutex
// maxSize is the maximum number of items allowed in the cache.
maxSize int
// autoCleanupInterval defines how often Cleanup is called automatically.
items map[string]CacheItem
order *list.List
elems map[string]*list.Element
cleanupTask *BackgroundTask
logger *Logger
maxSize int
autoCleanupInterval time.Duration
// stopCleanup channel to terminate the auto cleanup goroutine.
stopCleanup chan struct{}
mutex sync.RWMutex
}
// DefaultMaxSize is the default maximum number of items in the cache.
const DefaultMaxSize = 500
// NewCache creates a new empty cache instance with default settings.
// It initializes the internal maps and list, sets the default maximum size,
// and starts the automatic cleanup goroutine.
// It initializes the internal maps and list and sets the default maximum size.
func NewCache() *Cache {
return NewCacheWithLogger(nil)
}
// NewCacheWithLogger creates a new cache with a specified logger
func NewCacheWithLogger(logger *Logger) *Cache {
if logger == nil {
logger = newNoOpLogger()
}
c := &Cache{
items: make(map[string]CacheItem, DefaultMaxSize),
order: list.New(),
elems: make(map[string]*list.Element, DefaultMaxSize),
maxSize: DefaultMaxSize,
autoCleanupInterval: 5 * time.Minute,
stopCleanup: make(chan struct{}),
logger: logger,
}
go c.startAutoCleanup()
c.startAutoCleanup()
return c
}
@@ -214,15 +212,18 @@ func (c *Cache) removeItem(key string) {
}
}
// startAutoCleanup starts the background goroutine that automatically calls the Cleanup method
// startAutoCleanup starts the background task that automatically calls the Cleanup method
// at the interval specified by c.autoCleanupInterval.
// It uses the autoCleanupRoutine helper function.
func (c *Cache) startAutoCleanup() {
autoCleanupRoutine(c.autoCleanupInterval, c.stopCleanup, c.Cleanup)
c.cleanupTask = NewBackgroundTask("cache-cleanup", c.autoCleanupInterval, c.Cleanup, c.logger)
c.cleanupTask.Start()
}
// Close stops the automatic cleanup goroutine associated with this cache instance.
// Close stops the automatic cleanup task associated with this cache instance.
// It should be called when the cache is no longer needed to prevent resource leaks.
func (c *Cache) Close() {
close(c.stopCleanup)
if c.cleanupTask != nil {
c.cleanupTask.Stop()
c.cleanupTask = nil
}
}
+845
View File
@@ -0,0 +1,845 @@
package traefikoidc
import (
"context"
"fmt"
"math"
"math/rand/v2"
"net"
"sync"
"sync/atomic"
"time"
)
// ErrorRecoveryMechanism defines the common interface for all error recovery mechanisms
type ErrorRecoveryMechanism interface {
// ExecuteWithContext executes a function with error recovery
ExecuteWithContext(ctx context.Context, fn func() error) error
// GetMetrics returns metrics about the error recovery mechanism
GetMetrics() map[string]interface{}
// Reset resets the state of the error recovery mechanism
Reset()
// IsAvailable returns whether the mechanism is available for use
IsAvailable() bool
}
// BaseRecoveryMechanism provides common functionality for error recovery mechanisms
type BaseRecoveryMechanism struct {
startTime time.Time
lastFailureTime time.Time
lastSuccessTime time.Time
logger *Logger
name string
totalRequests int64
totalFailures int64
totalSuccesses int64
mutex sync.RWMutex
}
// NewBaseRecoveryMechanism creates a new base recovery mechanism
func NewBaseRecoveryMechanism(name string, logger *Logger) *BaseRecoveryMechanism {
if logger == nil {
logger = newNoOpLogger()
}
return &BaseRecoveryMechanism{
name: name,
logger: logger,
startTime: time.Now(),
}
}
// RecordRequest records a request to the error recovery mechanism
func (b *BaseRecoveryMechanism) RecordRequest() {
atomic.AddInt64(&b.totalRequests, 1)
}
// RecordSuccess records a successful operation
func (b *BaseRecoveryMechanism) RecordSuccess() {
atomic.AddInt64(&b.totalSuccesses, 1)
b.mutex.Lock()
defer b.mutex.Unlock()
b.lastSuccessTime = time.Now()
}
// RecordFailure records a failed operation
func (b *BaseRecoveryMechanism) RecordFailure() {
atomic.AddInt64(&b.totalFailures, 1)
b.mutex.Lock()
defer b.mutex.Unlock()
b.lastFailureTime = time.Now()
}
// GetBaseMetrics returns base metrics common to all recovery mechanisms
func (b *BaseRecoveryMechanism) GetBaseMetrics() map[string]interface{} {
b.mutex.RLock()
defer b.mutex.RUnlock()
metrics := map[string]interface{}{
"total_requests": atomic.LoadInt64(&b.totalRequests),
"total_failures": atomic.LoadInt64(&b.totalFailures),
"total_successes": atomic.LoadInt64(&b.totalSuccesses),
"uptime_seconds": time.Since(b.startTime).Seconds(),
"name": b.name,
}
if !b.lastFailureTime.IsZero() {
metrics["last_failure_time"] = b.lastFailureTime.Format(time.RFC3339)
metrics["seconds_since_last_failure"] = time.Since(b.lastFailureTime).Seconds()
}
if !b.lastSuccessTime.IsZero() {
metrics["last_success_time"] = b.lastSuccessTime.Format(time.RFC3339)
metrics["seconds_since_last_success"] = time.Since(b.lastSuccessTime).Seconds()
}
// Calculate success rate
if metrics["total_requests"].(int64) > 0 {
successRate := float64(metrics["total_successes"].(int64)) / float64(metrics["total_requests"].(int64))
metrics["success_rate"] = successRate
} else {
metrics["success_rate"] = 1.0 // Default to 100% if no requests
}
return metrics
}
// LogInfo logs an informational message
func (b *BaseRecoveryMechanism) LogInfo(format string, args ...interface{}) {
if b.logger != nil {
b.logger.Infof("%s: "+format, append([]interface{}{b.name}, args...)...)
}
}
// LogError logs an error message
func (b *BaseRecoveryMechanism) LogError(format string, args ...interface{}) {
if b.logger != nil {
b.logger.Errorf("%s: "+format, append([]interface{}{b.name}, args...)...)
}
}
// LogDebug logs a debug message
func (b *BaseRecoveryMechanism) LogDebug(format string, args ...interface{}) {
if b.logger != nil {
b.logger.Debugf("%s: "+format, append([]interface{}{b.name}, args...)...)
}
}
// CircuitBreakerState represents the current state of a circuit breaker
type CircuitBreakerState int
const (
// CircuitBreakerClosed - normal operation, requests are allowed
CircuitBreakerClosed CircuitBreakerState = iota
// CircuitBreakerOpen - circuit is open, requests are rejected
CircuitBreakerOpen
// CircuitBreakerHalfOpen - testing if service has recovered
CircuitBreakerHalfOpen
)
// CircuitBreaker implements the circuit breaker pattern for external service calls
type CircuitBreaker struct {
*BaseRecoveryMechanism
maxFailures int
timeout time.Duration
resetTimeout time.Duration
state CircuitBreakerState
failures int64
}
// CircuitBreakerConfig holds configuration for circuit breakers
type CircuitBreakerConfig struct {
MaxFailures int `json:"max_failures"`
Timeout time.Duration `json:"timeout"`
ResetTimeout time.Duration `json:"reset_timeout"`
}
// DefaultCircuitBreakerConfig returns default circuit breaker configuration
func DefaultCircuitBreakerConfig() CircuitBreakerConfig {
return CircuitBreakerConfig{
MaxFailures: 5,
Timeout: 30 * time.Second,
ResetTimeout: 10 * time.Second,
}
}
// NewCircuitBreaker creates a new circuit breaker with the given configuration
func NewCircuitBreaker(config CircuitBreakerConfig, logger *Logger) *CircuitBreaker {
return &CircuitBreaker{
BaseRecoveryMechanism: NewBaseRecoveryMechanism("circuit-breaker", logger),
maxFailures: config.MaxFailures,
timeout: config.Timeout,
resetTimeout: config.ResetTimeout,
state: CircuitBreakerClosed,
}
}
// ExecuteWithContext implements the ErrorRecoveryMechanism interface
func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() error) error {
cb.RecordRequest()
// Check if circuit breaker allows the request
if !cb.allowRequest() {
return fmt.Errorf("circuit breaker is open")
}
// Execute the function
err := fn()
// Record the result
if err != nil {
cb.recordFailure()
cb.RecordFailure()
return err
}
cb.recordSuccess()
cb.RecordSuccess()
return nil
}
// Execute is the original method for backward compatibility
func (cb *CircuitBreaker) Execute(fn func() error) error {
return cb.ExecuteWithContext(context.Background(), fn)
}
// allowRequest checks if the circuit breaker allows the request
func (cb *CircuitBreaker) allowRequest() bool {
cb.mutex.Lock()
defer cb.mutex.Unlock()
now := time.Now()
switch cb.state {
case CircuitBreakerClosed:
return true
case CircuitBreakerOpen:
// Check if timeout has passed
if now.Sub(cb.lastFailureTime) > cb.timeout {
cb.state = CircuitBreakerHalfOpen
cb.logger.Infof("Circuit breaker transitioning to half-open state")
return true
}
return false
case CircuitBreakerHalfOpen:
// Allow limited requests in half-open state
return true
default:
return false
}
}
// recordFailure records a failure and potentially opens the circuit
func (cb *CircuitBreaker) recordFailure() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.failures++
switch cb.state {
case CircuitBreakerClosed:
if cb.failures >= int64(cb.maxFailures) {
cb.state = CircuitBreakerOpen
cb.LogError("Circuit breaker opened after %d failures", cb.failures)
}
case CircuitBreakerHalfOpen:
// Go back to open state on any failure in half-open
cb.state = CircuitBreakerOpen
cb.LogError("Circuit breaker returned to open state after failure in half-open")
}
}
// recordSuccess records a success and potentially closes the circuit
func (cb *CircuitBreaker) recordSuccess() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
switch cb.state {
case CircuitBreakerHalfOpen:
// Reset failures and close circuit on success in half-open
cb.failures = 0
cb.state = CircuitBreakerClosed
cb.LogInfo("Circuit breaker closed after successful request in half-open state")
case CircuitBreakerClosed:
// Reset failure count on success
cb.failures = 0
}
}
// GetState returns the current state of the circuit breaker
func (cb *CircuitBreaker) GetState() CircuitBreakerState {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
return cb.state
}
// Reset resets the circuit breaker to its initial state
func (cb *CircuitBreaker) Reset() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.state = CircuitBreakerClosed
atomic.StoreInt64(&cb.failures, 0)
cb.LogInfo("Circuit breaker has been reset")
}
// IsAvailable returns whether the circuit breaker is allowing requests
func (cb *CircuitBreaker) IsAvailable() bool {
return cb.allowRequest()
}
// GetMetrics returns metrics about the circuit breaker
func (cb *CircuitBreaker) GetMetrics() map[string]interface{} {
cb.mutex.RLock()
state := cb.state
failures := cb.failures
cb.mutex.RUnlock()
metrics := cb.GetBaseMetrics()
// Add circuit breaker specific metrics
stateStr := "unknown"
switch state {
case CircuitBreakerClosed:
stateStr = "closed"
case CircuitBreakerOpen:
stateStr = "open"
case CircuitBreakerHalfOpen:
stateStr = "half-open"
}
metrics["state"] = stateStr
metrics["max_failures"] = cb.maxFailures
metrics["current_failures"] = failures
metrics["timeout_ms"] = cb.timeout.Milliseconds()
metrics["reset_timeout_ms"] = cb.resetTimeout.Milliseconds()
return metrics
}
// RetryConfig holds configuration for retry mechanisms
type RetryConfig struct {
RetryableErrors []string `json:"retryable_errors"`
MaxAttempts int `json:"max_attempts"`
InitialDelay time.Duration `json:"initial_delay"`
MaxDelay time.Duration `json:"max_delay"`
BackoffFactor float64 `json:"backoff_factor"`
EnableJitter bool `json:"enable_jitter"`
}
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxAttempts: 3,
InitialDelay: 100 * time.Millisecond,
MaxDelay: 5 * time.Second,
BackoffFactor: 2.0,
EnableJitter: true,
RetryableErrors: []string{
"connection refused",
"timeout",
"temporary failure",
"network unreachable",
},
}
}
// RetryExecutor implements retry logic with exponential backoff
type RetryExecutor struct {
*BaseRecoveryMechanism
config RetryConfig
}
// NewRetryExecutor creates a new retry executor
func NewRetryExecutor(config RetryConfig, logger *Logger) *RetryExecutor {
return &RetryExecutor{
BaseRecoveryMechanism: NewBaseRecoveryMechanism("retry-executor", logger),
config: config,
}
}
// ExecuteWithContext implements the ErrorRecoveryMechanism interface
func (re *RetryExecutor) ExecuteWithContext(ctx context.Context, fn func() error) error {
re.RecordRequest()
var lastErr error
for attempt := 1; attempt <= re.config.MaxAttempts; attempt++ {
// Execute the function
err := fn()
if err == nil {
if attempt > 1 {
re.LogInfo("Operation succeeded on attempt %d", attempt)
}
re.RecordSuccess()
return nil
}
lastErr = err
// Check if error is retryable
if !re.isRetryableError(err) {
re.LogDebug("Non-retryable error on attempt %d: %v", attempt, err)
re.RecordFailure()
return err
}
// Don't wait after the last attempt
if attempt == re.config.MaxAttempts {
re.RecordFailure()
break
}
// Calculate delay with exponential backoff
delay := re.calculateDelay(attempt)
re.LogDebug("Retrying operation after %v (attempt %d/%d): %v",
delay, attempt, re.config.MaxAttempts, err)
// Wait with context cancellation support
select {
case <-ctx.Done():
re.RecordFailure()
return ctx.Err()
case <-time.After(delay):
// Continue to next attempt
}
}
finalErr := fmt.Errorf("operation failed after %d attempts: %w", re.config.MaxAttempts, lastErr)
return finalErr
}
// Execute runs the given function with retry logic (for backward compatibility)
func (re *RetryExecutor) Execute(ctx context.Context, fn func() error) error {
return re.ExecuteWithContext(ctx, fn)
}
// isRetryableError checks if an error should trigger a retry
func (re *RetryExecutor) isRetryableError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
// Check against configured retryable errors
for _, retryableErr := range re.config.RetryableErrors {
if contains(errStr, retryableErr) {
return true
}
}
// Check for common network errors using modern Go error handling
if netErr, ok := err.(net.Error); ok {
// Use Timeout() method which is still valid
if netErr.Timeout() {
return true
}
// Check for specific temporary error patterns instead of deprecated Temporary()
errStr := netErr.Error()
temporaryPatterns := []string{
"connection refused",
"connection reset",
"network is unreachable",
"no route to host",
"temporary failure",
"try again",
"resource temporarily unavailable",
}
for _, pattern := range temporaryPatterns {
if contains(errStr, pattern) {
return true
}
}
}
// Check for HTTP status codes that are retryable
if httpErr, ok := err.(*HTTPError); ok {
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
}
return false
}
// calculateDelay calculates the delay for the next retry attempt
func (re *RetryExecutor) calculateDelay(attempt int) time.Duration {
// Calculate exponential backoff
delay := float64(re.config.InitialDelay) * math.Pow(re.config.BackoffFactor, float64(attempt-1))
// Apply maximum delay limit
if delay > float64(re.config.MaxDelay) {
delay = float64(re.config.MaxDelay)
}
// Add jitter to prevent thundering herd
if re.config.EnableJitter {
jitter := delay * 0.1 * (2.0*rand.Float64() - 1.0) // ±10% jitter
delay += jitter
}
return time.Duration(delay)
}
// Reset resets the retry executor state
func (re *RetryExecutor) Reset() {
// Nothing to reset for RetryExecutor
re.LogDebug("Retry executor reset")
}
// IsAvailable always returns true for RetryExecutor
func (re *RetryExecutor) IsAvailable() bool {
return true
}
// GetMetrics returns metrics about the retry executor
func (re *RetryExecutor) GetMetrics() map[string]interface{} {
metrics := re.GetBaseMetrics()
// Add retry executor specific metrics
metrics["max_attempts"] = re.config.MaxAttempts
metrics["initial_delay_ms"] = re.config.InitialDelay.Milliseconds()
metrics["max_delay_ms"] = re.config.MaxDelay.Milliseconds()
metrics["backoff_factor"] = re.config.BackoffFactor
metrics["enable_jitter"] = re.config.EnableJitter
metrics["retryable_errors"] = re.config.RetryableErrors
return metrics
}
// HTTPError represents an HTTP error with status code
type HTTPError struct {
Message string
StatusCode int
}
// Error implements the error interface
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
}
// GracefulDegradation implements graceful degradation patterns
type GracefulDegradation struct {
*BaseRecoveryMechanism
fallbacks map[string]func() (interface{}, error)
healthChecks map[string]func() bool
degradedServices map[string]time.Time
config GracefulDegradationConfig
mutex sync.RWMutex
}
// GracefulDegradationConfig holds configuration for graceful degradation
type GracefulDegradationConfig struct {
HealthCheckInterval time.Duration `json:"health_check_interval"`
RecoveryTimeout time.Duration `json:"recovery_timeout"`
EnableFallbacks bool `json:"enable_fallbacks"`
}
// DefaultGracefulDegradationConfig returns default configuration
func DefaultGracefulDegradationConfig() GracefulDegradationConfig {
return GracefulDegradationConfig{
HealthCheckInterval: 30 * time.Second,
RecoveryTimeout: 5 * time.Minute,
EnableFallbacks: true,
}
}
// NewGracefulDegradation creates a new graceful degradation manager
func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation {
gd := &GracefulDegradation{
BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger),
fallbacks: make(map[string]func() (interface{}, error)),
healthChecks: make(map[string]func() bool),
degradedServices: make(map[string]time.Time),
config: config,
}
// Start health check routine
go gd.startHealthCheckRoutine()
return gd
}
// RegisterFallback registers a fallback function for a service
func (gd *GracefulDegradation) RegisterFallback(serviceName string, fallback func() (interface{}, error)) {
gd.mutex.Lock()
defer gd.mutex.Unlock()
gd.fallbacks[serviceName] = fallback
}
// RegisterHealthCheck registers a health check function for a service
func (gd *GracefulDegradation) RegisterHealthCheck(serviceName string, healthCheck func() bool) {
gd.mutex.Lock()
defer gd.mutex.Unlock()
gd.healthChecks[serviceName] = healthCheck
}
// ExecuteWithContext implements the ErrorRecoveryMechanism interface
func (gd *GracefulDegradation) ExecuteWithContext(ctx context.Context, fn func() error) error {
gd.RecordRequest()
// Execute with a simple wrapper
_, err := gd.ExecuteWithFallback("default", func() (interface{}, error) {
return nil, fn()
})
if err != nil {
gd.RecordFailure()
} else {
gd.RecordSuccess()
}
return err
}
// ExecuteWithFallback executes a function with fallback support
func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary func() (interface{}, error)) (interface{}, error) {
// Check if service is degraded
if gd.isServiceDegraded(serviceName) {
gd.LogInfo("Service %s is degraded, using fallback", serviceName)
return gd.executeFallback(serviceName)
}
// Try primary function
result, err := primary()
if err != nil {
// Mark service as degraded
gd.markServiceDegraded(serviceName)
gd.LogError("Service %s failed: %v", serviceName, err)
// Try fallback if available
if gd.config.EnableFallbacks {
gd.LogInfo("Using fallback for service %s", serviceName)
return gd.executeFallback(serviceName)
}
return nil, err
}
return result, nil
}
// isServiceDegraded checks if a service is currently degraded
func (gd *GracefulDegradation) isServiceDegraded(serviceName string) bool {
gd.mutex.RLock()
defer gd.mutex.RUnlock()
degradedTime, exists := gd.degradedServices[serviceName]
if !exists {
return false
}
// Check if recovery timeout has passed
if time.Since(degradedTime) > gd.config.RecoveryTimeout {
delete(gd.degradedServices, serviceName)
return false
}
return true
}
// markServiceDegraded marks a service as degraded
func (gd *GracefulDegradation) markServiceDegraded(serviceName string) {
gd.mutex.Lock()
defer gd.mutex.Unlock()
if _, exists := gd.degradedServices[serviceName]; !exists {
gd.LogError("Service %s marked as degraded", serviceName)
}
gd.degradedServices[serviceName] = time.Now()
}
// executeFallback executes the fallback function for a service
func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{}, error) {
gd.mutex.RLock()
fallback, exists := gd.fallbacks[serviceName]
gd.mutex.RUnlock()
if !exists {
return nil, fmt.Errorf("no fallback available for service %s", serviceName)
}
gd.LogInfo("Executing fallback for degraded service %s", serviceName)
return fallback()
}
// startHealthCheckRoutine starts the background health check routine
func (gd *GracefulDegradation) startHealthCheckRoutine() {
healthCheckTask := NewBackgroundTask(
"graceful-degradation-health-check",
gd.config.HealthCheckInterval,
gd.performHealthChecks,
gd.BaseRecoveryMechanism.logger,
)
healthCheckTask.Start()
}
// performHealthChecks runs health checks for all registered services
func (gd *GracefulDegradation) performHealthChecks() {
gd.mutex.RLock()
healthChecks := make(map[string]func() bool)
for k, v := range gd.healthChecks {
healthChecks[k] = v
}
gd.mutex.RUnlock()
for serviceName, healthCheck := range healthChecks {
if healthCheck() {
// Service is healthy, remove from degraded list
gd.mutex.Lock()
if _, wasDegraded := gd.degradedServices[serviceName]; wasDegraded {
delete(gd.degradedServices, serviceName)
gd.logger.Infof("Service %s recovered from degraded state", serviceName)
}
gd.mutex.Unlock()
} else {
// Service is unhealthy, mark as degraded
gd.markServiceDegraded(serviceName)
}
}
}
// GetDegradedServices returns a list of currently degraded services
func (gd *GracefulDegradation) GetDegradedServices() []string {
gd.mutex.RLock()
defer gd.mutex.RUnlock()
var degraded []string
for serviceName := range gd.degradedServices {
degraded = append(degraded, serviceName)
}
return degraded
}
// Reset resets the state of all degraded services
func (gd *GracefulDegradation) Reset() {
gd.mutex.Lock()
defer gd.mutex.Unlock()
// Clear degraded services
gd.degradedServices = make(map[string]time.Time)
gd.LogInfo("Graceful degradation state has been reset")
}
// IsAvailable returns whether the mechanism is available for use
func (gd *GracefulDegradation) IsAvailable() bool {
return true
}
// GetMetrics returns metrics about the graceful degradation mechanism
func (gd *GracefulDegradation) GetMetrics() map[string]interface{} {
gd.mutex.RLock()
degradedCount := len(gd.degradedServices)
// Get the names of degraded services
degradedServices := make([]string, 0, degradedCount)
for service := range gd.degradedServices {
degradedServices = append(degradedServices, service)
}
// Get total count of registered fallbacks and health checks
fallbackCount := len(gd.fallbacks)
healthCheckCount := len(gd.healthChecks)
gd.mutex.RUnlock()
// Get base metrics
metrics := gd.GetBaseMetrics()
// Add graceful degradation specific metrics
metrics["degraded_services_count"] = degradedCount
metrics["degraded_services"] = degradedServices
metrics["registered_fallbacks_count"] = fallbackCount
metrics["registered_health_checks_count"] = healthCheckCount
metrics["health_check_interval_seconds"] = gd.config.HealthCheckInterval.Seconds()
metrics["recovery_timeout_seconds"] = gd.config.RecoveryTimeout.Seconds()
metrics["fallbacks_enabled"] = gd.config.EnableFallbacks
return metrics
}
// ErrorRecoveryManager coordinates all error recovery mechanisms
type ErrorRecoveryManager struct {
circuitBreakers map[string]*CircuitBreaker
retryExecutor *RetryExecutor
gracefulDegradation *GracefulDegradation
logger *Logger
mutex sync.RWMutex
}
// NewErrorRecoveryManager creates a new error recovery manager
func NewErrorRecoveryManager(logger *Logger) *ErrorRecoveryManager {
return &ErrorRecoveryManager{
circuitBreakers: make(map[string]*CircuitBreaker),
retryExecutor: NewRetryExecutor(DefaultRetryConfig(), logger),
gracefulDegradation: NewGracefulDegradation(DefaultGracefulDegradationConfig(), logger),
logger: logger,
}
}
// GetCircuitBreaker gets or creates a circuit breaker for a service
func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitBreaker {
erm.mutex.Lock()
defer erm.mutex.Unlock()
if cb, exists := erm.circuitBreakers[serviceName]; exists {
return cb
}
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), erm.logger)
erm.circuitBreakers[serviceName] = cb
return cb
}
// ExecuteWithRecovery executes a function with full error recovery support
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
cb := erm.GetCircuitBreaker(serviceName)
return erm.retryExecutor.Execute(ctx, func() error {
return cb.Execute(fn)
})
}
// GetRecoveryMetrics returns metrics for all recovery mechanisms
func (erm *ErrorRecoveryManager) GetRecoveryMetrics() map[string]interface{} {
erm.mutex.RLock()
defer erm.mutex.RUnlock()
metrics := make(map[string]interface{})
// Circuit breaker metrics
cbMetrics := make(map[string]interface{})
for name, cb := range erm.circuitBreakers {
cbMetrics[name] = cb.GetMetrics()
}
metrics["circuit_breakers"] = cbMetrics
// Degraded services
metrics["degraded_services"] = erm.gracefulDegradation.GetDegradedServices()
return metrics
}
// Helper function to check if a string contains a substring (case-insensitive)
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr ||
(len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsSubstring(s, substr))))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+433
View File
@@ -0,0 +1,433 @@
package traefikoidc
import (
"context"
"errors"
"net"
"testing"
"time"
)
func TestCircuitBreaker(t *testing.T) {
logger := NewLogger("debug")
config := DefaultCircuitBreakerConfig()
config.MaxFailures = 2
config.Timeout = 100 * time.Millisecond
cb := NewCircuitBreaker(config, logger)
t.Run("Initial state is closed", func(t *testing.T) {
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected initial state to be closed, got %v", cb.GetState())
}
})
t.Run("Successful execution", func(t *testing.T) {
err := cb.Execute(func() error {
return nil
})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
})
t.Run("Circuit opens after max failures", func(t *testing.T) {
// Trigger failures to open circuit
for i := 0; i < config.MaxFailures; i++ {
cb.Execute(func() error {
return errors.New("test error")
})
}
if cb.GetState() != CircuitBreakerOpen {
t.Errorf("Expected circuit to be open, got %v", cb.GetState())
}
// Should reject requests when open
err := cb.Execute(func() error {
return nil
})
if err == nil || err.Error() != "circuit breaker is open" {
t.Errorf("Expected circuit breaker open error, got %v", err)
}
})
t.Run("Circuit transitions to half-open after timeout", func(t *testing.T) {
// Wait for timeout
time.Sleep(config.Timeout + 10*time.Millisecond)
// Next request should transition to half-open
cb.Execute(func() error {
return nil
})
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected circuit to be closed after successful request, got %v", cb.GetState())
}
})
t.Run("Get metrics", func(t *testing.T) {
metrics := cb.GetMetrics()
if metrics["state"] == nil {
t.Error("Expected metrics to contain state")
}
if metrics["total_requests"] == nil {
t.Error("Expected metrics to contain total_requests")
}
})
}
func TestRetryExecutor(t *testing.T) {
logger := NewLogger("debug")
config := DefaultRetryConfig()
config.MaxAttempts = 3
config.InitialDelay = 10 * time.Millisecond
re := NewRetryExecutor(config, logger)
t.Run("Successful execution on first attempt", func(t *testing.T) {
attempts := 0
err := re.Execute(context.Background(), func() error {
attempts++
return nil
})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if attempts != 1 {
t.Errorf("Expected 1 attempt, got %d", attempts)
}
})
t.Run("Retry on retryable error", func(t *testing.T) {
attempts := 0
err := re.Execute(context.Background(), func() error {
attempts++
if attempts < 2 {
return errors.New("connection refused")
}
return nil
})
if err != nil {
t.Errorf("Expected no error after retry, got %v", err)
}
if attempts != 2 {
t.Errorf("Expected 2 attempts, got %d", attempts)
}
})
t.Run("No retry on non-retryable error", func(t *testing.T) {
attempts := 0
err := re.Execute(context.Background(), func() error {
attempts++
return errors.New("non-retryable error")
})
if err == nil {
t.Error("Expected error to be returned")
}
if attempts != 1 {
t.Errorf("Expected 1 attempt, got %d", attempts)
}
})
t.Run("Max attempts reached", func(t *testing.T) {
attempts := 0
err := re.Execute(context.Background(), func() error {
attempts++
return errors.New("timeout")
})
if err == nil {
t.Error("Expected error after max attempts")
}
if attempts != config.MaxAttempts {
t.Errorf("Expected %d attempts, got %d", config.MaxAttempts, attempts)
}
})
t.Run("Context cancellation", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
err := re.Execute(ctx, func() error {
return errors.New("timeout")
})
if err != context.Canceled {
t.Errorf("Expected context canceled error, got %v", err)
}
})
t.Run("Network error handling", func(t *testing.T) {
// Test timeout error
timeoutErr := &net.OpError{Op: "dial", Err: errors.New("timeout")}
if !re.isRetryableError(timeoutErr) {
t.Error("Expected timeout error to be retryable")
}
// Test connection refused
connErr := errors.New("connection refused")
if !re.isRetryableError(connErr) {
t.Error("Expected connection refused to be retryable")
}
})
t.Run("HTTP error handling", func(t *testing.T) {
// Test 500 error (retryable)
httpErr500 := &HTTPError{StatusCode: 500, Message: "Internal Server Error"}
if !re.isRetryableError(httpErr500) {
t.Error("Expected 500 error to be retryable")
}
// Test 429 error (retryable)
httpErr429 := &HTTPError{StatusCode: 429, Message: "Too Many Requests"}
if !re.isRetryableError(httpErr429) {
t.Error("Expected 429 error to be retryable")
}
// Test 400 error (not retryable)
httpErr400 := &HTTPError{StatusCode: 400, Message: "Bad Request"}
if re.isRetryableError(httpErr400) {
t.Error("Expected 400 error to not be retryable")
}
})
}
func TestGracefulDegradation(t *testing.T) {
logger := NewLogger("debug")
config := DefaultGracefulDegradationConfig()
config.HealthCheckInterval = 50 * time.Millisecond
config.RecoveryTimeout = 100 * time.Millisecond
gd := NewGracefulDegradation(config, logger)
defer func() {
// Clean up goroutine
time.Sleep(100 * time.Millisecond)
}()
t.Run("Register fallback and health check", func(t *testing.T) {
gd.RegisterFallback("test-service", func() (interface{}, error) {
return "fallback-result", nil
})
gd.RegisterHealthCheck("test-service", func() bool {
return true
})
// Should not be degraded initially
if gd.isServiceDegraded("test-service") {
t.Error("Service should not be degraded initially")
}
})
t.Run("Execute with fallback on failure", func(t *testing.T) {
gd.RegisterFallback("failing-service", func() (interface{}, error) {
return "fallback-result", nil
})
// First call should fail and mark service as degraded
result, err := gd.ExecuteWithFallback("failing-service", func() (interface{}, error) {
return nil, errors.New("service failure")
})
if err != nil {
t.Errorf("Expected fallback to succeed, got error: %v", err)
}
if result != "fallback-result" {
t.Errorf("Expected fallback result, got %v", result)
}
// Service should now be degraded
if !gd.isServiceDegraded("failing-service") {
t.Error("Service should be marked as degraded")
}
})
t.Run("No fallback available", func(t *testing.T) {
_, err := gd.ExecuteWithFallback("no-fallback-service", func() (interface{}, error) {
return nil, errors.New("service failure")
})
if err == nil {
t.Error("Expected error when no fallback available")
}
})
t.Run("Get degraded services", func(t *testing.T) {
degraded := gd.GetDegradedServices()
found := false
for _, s := range degraded {
if s == "failing-service" {
found = true
break
}
}
if !found {
t.Error("Expected failing-service to be in degraded list")
}
})
t.Run("Service recovery after timeout", func(t *testing.T) {
// Wait for recovery timeout
time.Sleep(config.RecoveryTimeout + 20*time.Millisecond)
// Service should no longer be degraded
if gd.isServiceDegraded("failing-service") {
t.Error("Service should have recovered after timeout")
}
})
}
func TestErrorRecoveryManager(t *testing.T) {
logger := NewLogger("debug")
erm := NewErrorRecoveryManager(logger)
t.Run("Get circuit breaker", func(t *testing.T) {
cb1 := erm.GetCircuitBreaker("service1")
cb2 := erm.GetCircuitBreaker("service1")
// Should return the same instance
if cb1 != cb2 {
t.Error("Expected same circuit breaker instance for same service")
}
cb3 := erm.GetCircuitBreaker("service2")
if cb1 == cb3 {
t.Error("Expected different circuit breaker instances for different services")
}
})
t.Run("Execute with recovery", func(t *testing.T) {
attempts := 0
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
attempts++
if attempts < 2 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Errorf("Expected recovery to succeed, got %v", err)
}
if attempts < 2 {
t.Errorf("Expected at least 2 attempts, got %d", attempts)
}
})
t.Run("Get recovery metrics", func(t *testing.T) {
metrics := erm.GetRecoveryMetrics()
if metrics["circuit_breakers"] == nil {
t.Error("Expected circuit_breakers in metrics")
}
if metrics["degraded_services"] == nil {
t.Error("Expected degraded_services in metrics")
}
})
}
func TestHTTPError(t *testing.T) {
err := &HTTPError{StatusCode: 500, Message: "Internal Server Error"}
expected := "HTTP 500: Internal Server Error"
if err.Error() != expected {
t.Errorf("Expected %q, got %q", expected, err.Error())
}
}
func TestHelperFunctions(t *testing.T) {
t.Run("contains function", func(t *testing.T) {
if !contains("hello world", "hello") {
t.Error("Expected contains to find substring at start")
}
if !contains("hello world", "world") {
t.Error("Expected contains to find substring at end")
}
if !contains("hello world", "lo wo") {
t.Error("Expected contains to find substring in middle")
}
if contains("hello world", "xyz") {
t.Error("Expected contains to not find non-existent substring")
}
})
t.Run("containsSubstring function", func(t *testing.T) {
if !containsSubstring("hello world", "lo wo") {
t.Error("Expected containsSubstring to find substring")
}
if containsSubstring("hello", "hello world") {
t.Error("Expected containsSubstring to not find longer substring")
}
})
}
func TestDefaultConfigs(t *testing.T) {
t.Run("DefaultCircuitBreakerConfig", func(t *testing.T) {
config := DefaultCircuitBreakerConfig()
if config.MaxFailures <= 0 {
t.Error("Expected positive MaxFailures")
}
if config.Timeout <= 0 {
t.Error("Expected positive Timeout")
}
if config.ResetTimeout <= 0 {
t.Error("Expected positive ResetTimeout")
}
})
t.Run("DefaultRetryConfig", func(t *testing.T) {
config := DefaultRetryConfig()
if config.MaxAttempts <= 0 {
t.Error("Expected positive MaxAttempts")
}
if config.InitialDelay <= 0 {
t.Error("Expected positive InitialDelay")
}
if config.BackoffFactor <= 1 {
t.Error("Expected BackoffFactor > 1")
}
if len(config.RetryableErrors) == 0 {
t.Error("Expected some retryable errors")
}
})
t.Run("DefaultGracefulDegradationConfig", func(t *testing.T) {
config := DefaultGracefulDegradationConfig()
if config.HealthCheckInterval <= 0 {
t.Error("Expected positive HealthCheckInterval")
}
if config.RecoveryTimeout <= 0 {
t.Error("Expected positive RecoveryTimeout")
}
})
}
// Mock network error for testing
type mockNetError struct {
timeout bool
temp bool
}
func (e *mockNetError) Error() string { return "mock network error" }
func (e *mockNetError) Timeout() bool { return e.timeout }
func (e *mockNetError) Temporary() bool { return e.temp }
func TestNetworkErrorHandling(t *testing.T) {
logger := NewLogger("debug")
config := DefaultRetryConfig()
re := NewRetryExecutor(config, logger)
t.Run("Timeout error is retryable", func(t *testing.T) {
err := &mockNetError{timeout: true}
if !re.isRetryableError(err) {
t.Error("Expected timeout error to be retryable")
}
})
t.Run("Non-timeout network error with retryable pattern", func(t *testing.T) {
err := &mockNetError{timeout: false}
// This should not be retryable since it doesn't match patterns and isn't timeout
if re.isRetryableError(err) {
t.Error("Expected non-timeout network error without pattern to not be retryable")
}
})
}
+19 -11
View File
@@ -94,7 +94,7 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
session, _ := sessionManager.GetSession(req)
session.SetAuthenticated(true)
session.SetEmail("[email protected]")
session.SetAccessToken("old-access-token")
session.SetAccessToken(ValidAccessToken)
session.SetRefreshToken("valid-refresh-token")
// Create a mock token exchanger that simulates Google's behavior
@@ -106,11 +106,15 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
return nil, fmt.Errorf("invalid token")
}
// Use standardized test tokens instead of ad-hoc strings
testTokens := NewTestTokens()
googleTokens := testTokens.GetGoogleTokenSet()
// Return a simulated Google token response with a new access token
// but without a new refresh token (Google doesn't always return a new refresh token)
return &TokenResponse{
IDToken: "new-id-token-from-google",
AccessToken: "new-access-token-from-google",
IDToken: googleTokens.IDToken,
AccessToken: googleTokens.AccessToken,
RefreshToken: "", // Google often doesn't return a new refresh token
ExpiresIn: 3600,
}, nil
@@ -149,15 +153,19 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
session.GetRefreshToken())
}
// Use the same test tokens for validation
testTokens := NewTestTokens()
expectedTokens := testTokens.GetGoogleTokenSet()
// Check that the tokens were updated correctly
if session.GetIDToken() != "new-id-token-from-google" {
t.Errorf("ID token not updated: got %s, expected 'new-id-token-from-google'",
session.GetIDToken())
if session.GetIDToken() != expectedTokens.IDToken {
t.Errorf("ID token not updated: got %s, expected %s",
session.GetIDToken(), expectedTokens.IDToken)
}
if session.GetAccessToken() != "new-access-token-from-google" {
t.Errorf("Access token not updated: got %s, expected 'new-access-token-from-google'",
session.GetAccessToken())
if session.GetAccessToken() != expectedTokens.AccessToken {
t.Errorf("Access token not updated: got %s, expected %s",
session.GetAccessToken(), expectedTokens.AccessToken)
}
})
// Test that our fix specifically addresses the reported Google error
@@ -296,8 +304,8 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
expectedScopes := []string{"openid", "profile", "email"}
for _, expectedScope := range expectedScopes {
found := false
for _, actualScope := range scopeList {
if actualScope == expectedScope {
for _, s := range scopeList {
if s == expectedScope {
found = true
break
}
+47 -33
View File
@@ -72,20 +72,11 @@ func deriveCodeChallenge(codeVerifier string) string {
// It contains the various tokens and metadata returned after successful
// code exchange or token refresh operations.
type TokenResponse struct {
// IDToken is the OIDC ID token containing user claims
IDToken string `json:"id_token"`
// AccessToken is the OAuth 2.0 access token for API access
AccessToken string `json:"access_token"`
// RefreshToken is the OAuth 2.0 refresh token for obtaining new tokens
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
// ExpiresIn is the lifetime in seconds of the access token
ExpiresIn int `json:"expires_in"`
// TokenType is the type of token, typically "Bearer"
TokenType string `json:"token_type"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// exchangeTokens performs the OAuth 2.0 token exchange with the OIDC provider's token endpoint.
@@ -123,19 +114,20 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
data.Set("refresh_token", codeOrToken)
}
// Create a cookie jar for this request to handle redirects with cookies
jar, _ := cookiejar.New(nil)
client := &http.Client{
Transport: t.httpClient.Transport,
Timeout: t.httpClient.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Always follow redirects for OIDC endpoints
if len(via) >= 50 {
return fmt.Errorf("stopped after 50 redirects")
}
return nil
},
Jar: jar,
client := t.tokenHTTPClient
if client == nil {
jar, _ := cookiejar.New(nil)
client = &http.Client{
Transport: t.httpClient.Transport,
Timeout: t.httpClient.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("stopped after 50 redirects")
}
return nil
},
Jar: jar,
}
}
req, err := http.NewRequestWithContext(ctx, "POST", t.tokenURL, strings.NewReader(data.Encode()))
@@ -218,15 +210,21 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
// It stores token claims to avoid repeated validation of the
// same token, improving performance for frequently used tokens.
type TokenCache struct {
// cache is the underlying cache implementation
cache *Cache
}
const (
defaultTokenCacheMaxSize = 1000
defaultTokenCacheCleanupInterval = 2 * time.Minute
)
// NewTokenCache creates and initializes a new TokenCache.
// It internally creates a new generic Cache instance for storage.
func NewTokenCache() *TokenCache {
cache := NewCache()
cache.SetMaxSize(defaultTokenCacheMaxSize)
return &TokenCache{
cache: NewCache(),
cache: cache,
}
}
@@ -298,7 +296,6 @@ func (tc *TokenCache) Close() {
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
ctx := context.Background()
// Only include code verifier if PKCE is enabled
effectiveCodeVerifier := ""
if t.enablePKCE && codeVerifier != "" {
effectiveCodeVerifier = codeVerifier
@@ -347,7 +344,7 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
return
}
accessToken := session.GetAccessToken()
idToken := session.GetIDToken()
if err := session.Clear(req, rw); err != nil {
t.logger.Errorf("Error clearing session: %v", err)
@@ -366,8 +363,8 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
}
if t.endSessionURL != "" && accessToken != "" {
logoutURL, err := BuildLogoutURL(t.endSessionURL, accessToken, postLogoutRedirectURI)
if t.endSessionURL != "" && idToken != "" {
logoutURL, err := BuildLogoutURL(t.endSessionURL, idToken, postLogoutRedirectURI)
if err != nil {
t.logger.Errorf("Failed to build logout URL: %v", err)
http.Error(rw, "Logout error", http.StatusInternalServerError)
@@ -407,3 +404,20 @@ func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (strin
return u.String(), nil
}
// deduplicateScopes removes duplicate strings from a slice while preserving order.
// The first occurrence of each scope is kept.
func deduplicateScopes(scopes []string) []string {
if len(scopes) == 0 {
return []string{}
}
seen := make(map[string]struct{})
result := []string{}
for _, scope := range scopes {
if _, ok := seen[scope]; !ok {
seen[scope] = struct{}{}
result = append(result, scope)
}
}
return result
}
+651
View File
@@ -0,0 +1,651 @@
package traefikoidc
import (
"fmt"
"net/url"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// InputValidator provides comprehensive input validation and sanitization
type InputValidator struct {
usernameRegex *regexp.Regexp
tokenRegex *regexp.Regexp
logger *Logger
urlRegex *regexp.Regexp
emailRegex *regexp.Regexp
sqlInjectionPatterns []string
pathTraversalPatterns []string
xssPatterns []string
maxUsernameLength int
maxURLLength int
maxTokenLength int
maxEmailLength int
maxClaimLength int
maxHeaderLength int
}
// ValidationResult represents the result of input validation
type ValidationResult struct {
SanitizedValue string `json:"sanitized_value,omitempty"`
SecurityRisk string `json:"security_risk,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
IsValid bool `json:"is_valid"`
}
// InputValidationConfig holds configuration for input validation
type InputValidationConfig struct {
MaxTokenLength int `json:"max_token_length"`
MaxURLLength int `json:"max_url_length"`
MaxHeaderLength int `json:"max_header_length"`
MaxClaimLength int `json:"max_claim_length"`
MaxEmailLength int `json:"max_email_length"`
MaxUsernameLength int `json:"max_username_length"`
StrictMode bool `json:"strict_mode"`
}
// DefaultInputValidationConfig returns default validation configuration
func DefaultInputValidationConfig() InputValidationConfig {
return InputValidationConfig{
MaxTokenLength: 50000, // 50KB for tokens
MaxURLLength: 2048, // Standard URL length limit
MaxHeaderLength: 8192, // 8KB for headers
MaxClaimLength: 1024, // 1KB for individual claims
MaxEmailLength: 254, // RFC 5321 limit
MaxUsernameLength: 64, // Reasonable username limit
StrictMode: true, // Enable strict validation by default
}
}
// NewInputValidator creates a new input validator with the given configuration
func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputValidator, error) {
// Compile regex patterns
emailRegex, err := regexp.Compile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if err != nil {
return nil, fmt.Errorf("failed to compile email regex: %w", err)
}
urlRegex, err := regexp.Compile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`)
if err != nil {
return nil, fmt.Errorf("failed to compile URL regex: %w", err)
}
tokenRegex, err := regexp.Compile(`^[A-Za-z0-9._-]+$`)
if err != nil {
return nil, fmt.Errorf("failed to compile token regex: %w", err)
}
usernameRegex, err := regexp.Compile(`^[a-zA-Z0-9._-]+$`)
if err != nil {
return nil, fmt.Errorf("failed to compile username regex: %w", err)
}
return &InputValidator{
maxTokenLength: config.MaxTokenLength,
maxURLLength: config.MaxURLLength,
maxHeaderLength: config.MaxHeaderLength,
maxClaimLength: config.MaxClaimLength,
maxEmailLength: config.MaxEmailLength,
maxUsernameLength: config.MaxUsernameLength,
emailRegex: emailRegex,
urlRegex: urlRegex,
tokenRegex: tokenRegex,
usernameRegex: usernameRegex,
sqlInjectionPatterns: []string{
"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_",
"union", "select", "insert", "update", "delete", "drop",
"create", "alter", "exec", "execute", "script",
},
xssPatterns: []string{
"<script", "</script>", "javascript:", "vbscript:",
"onload=", "onerror=", "onclick=", "onmouseover=",
"<iframe", "<object", "<embed", "<link", "<meta",
},
pathTraversalPatterns: []string{
"../", "..\\", "%2e%2e%2f", "%2e%2e%5c",
"..%2f", "..%5c", "%252e%252e%252f",
},
logger: logger,
}, nil
}
// ValidateToken validates JWT tokens and similar token strings
func (iv *InputValidator) ValidateToken(token string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty token
if token == "" {
result.IsValid = false
result.Errors = append(result.Errors, "token cannot be empty")
return result
}
// Check length limits
if len(token) > iv.maxTokenLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("token length %d exceeds maximum %d", len(token), iv.maxTokenLength))
return result
}
// Check for minimum reasonable length
if len(token) < 10 {
result.IsValid = false
result.Errors = append(result.Errors, "token is too short to be valid")
return result
}
// Check for valid JWT structure (3 parts separated by dots)
parts := strings.Split(token, ".")
if len(parts) != 3 {
result.IsValid = false
result.Errors = append(result.Errors, "token does not have valid JWT structure (expected 3 parts)")
return result
}
// Validate each part is base64url encoded
for i, part := range parts {
if !iv.isValidBase64URL(part) {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("token part %d is not valid base64url", i+1))
return result
}
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(token); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Check for null bytes and control characters
if iv.containsNullBytes(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains null bytes")
return result
}
if iv.containsControlCharacters(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains control characters")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains invalid UTF-8 sequences")
return result
}
result.SanitizedValue = token
return result
}
// ValidateEmail validates email addresses
func (iv *InputValidator) ValidateEmail(email string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty email
if email == "" {
result.IsValid = false
result.Errors = append(result.Errors, "email cannot be empty")
return result
}
// Check length limits
if len(email) > iv.maxEmailLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("email length %d exceeds maximum %d", len(email), iv.maxEmailLength))
return result
}
// Sanitize email (trim whitespace, convert to lowercase)
sanitized := strings.TrimSpace(strings.ToLower(email))
// Check regex pattern
if !iv.emailRegex.MatchString(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "email format is invalid")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Additional email-specific validations
parts := strings.Split(sanitized, "@")
if len(parts) != 2 {
result.IsValid = false
result.Errors = append(result.Errors, "email must contain exactly one @ symbol")
return result
}
localPart, domain := parts[0], parts[1]
// Validate local part
if len(localPart) == 0 || len(localPart) > 64 {
result.IsValid = false
result.Errors = append(result.Errors, "email local part length is invalid")
return result
}
// Validate domain
if len(domain) == 0 || len(domain) > 253 {
result.IsValid = false
result.Errors = append(result.Errors, "email domain length is invalid")
return result
}
// Check for consecutive dots
if strings.Contains(sanitized, "..") {
result.IsValid = false
result.Errors = append(result.Errors, "email contains consecutive dots")
return result
}
result.SanitizedValue = sanitized
return result
}
// ValidateURL validates URLs
func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty URL
if urlStr == "" {
result.IsValid = false
result.Errors = append(result.Errors, "URL cannot be empty")
return result
}
// Check length limits
if len(urlStr) > iv.maxURLLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("URL length %d exceeds maximum %d", len(urlStr), iv.maxURLLength))
return result
}
// Sanitize URL (trim whitespace)
sanitized := strings.TrimSpace(urlStr)
// Parse URL
parsedURL, err := url.Parse(sanitized)
if err != nil {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("URL parsing failed: %v", err))
return result
}
// Check scheme
if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" {
result.IsValid = false
result.Errors = append(result.Errors, "URL scheme must be http or https")
return result
}
// Prefer HTTPS
if parsedURL.Scheme == "http" {
result.Warnings = append(result.Warnings, "HTTP URLs are less secure than HTTPS")
}
// Check host
if parsedURL.Host == "" {
result.IsValid = false
result.Errors = append(result.Errors, "URL must have a valid host")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Check for path traversal attempts
if iv.containsPathTraversal(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "URL contains path traversal patterns")
return result
}
result.SanitizedValue = sanitized
return result
}
// ValidateUsername validates usernames
func (iv *InputValidator) ValidateUsername(username string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty username
if username == "" {
result.IsValid = false
result.Errors = append(result.Errors, "username cannot be empty")
return result
}
// Check length limits
if len(username) > iv.maxUsernameLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("username length %d exceeds maximum %d", len(username), iv.maxUsernameLength))
return result
}
// Check minimum length
if len(username) < 2 {
result.IsValid = false
result.Errors = append(result.Errors, "username must be at least 2 characters long")
return result
}
// Sanitize username (trim whitespace)
sanitized := strings.TrimSpace(username)
// Check regex pattern
if !iv.usernameRegex.MatchString(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "username contains invalid characters (only letters, numbers, dots, underscores, and hyphens allowed)")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
result.SanitizedValue = sanitized
return result
}
// ValidateClaim validates individual JWT claims
func (iv *InputValidator) ValidateClaim(claimName, claimValue string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check claim name
if claimName == "" {
result.IsValid = false
result.Errors = append(result.Errors, "claim name cannot be empty")
return result
}
// Check claim value length
if len(claimValue) > iv.maxClaimLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("claim value length %d exceeds maximum %d", len(claimValue), iv.maxClaimLength))
return result
}
// Check for null bytes and control characters
if iv.containsNullBytes(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains null bytes")
return result
}
if iv.containsControlCharacters(claimValue) {
result.Warnings = append(result.Warnings, "claim value contains control characters")
}
// Validate UTF-8 encoding
if !utf8.ValidString(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains invalid UTF-8 sequences")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(claimValue); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Specific validations based on claim name
switch claimName {
case "email":
emailResult := iv.ValidateEmail(claimValue)
if !emailResult.IsValid {
result.IsValid = false
result.Errors = append(result.Errors, emailResult.Errors...)
}
result.Warnings = append(result.Warnings, emailResult.Warnings...)
result.SanitizedValue = emailResult.SanitizedValue
case "iss", "aud":
urlResult := iv.ValidateURL(claimValue)
if !urlResult.IsValid {
// For issuer/audience, we're more lenient - just warn
result.Warnings = append(result.Warnings, fmt.Sprintf("%s claim is not a valid URL: %v", claimName, urlResult.Errors))
}
result.SanitizedValue = claimValue
case "preferred_username", "username":
usernameResult := iv.ValidateUsername(claimValue)
if !usernameResult.IsValid {
result.IsValid = false
result.Errors = append(result.Errors, usernameResult.Errors...)
}
result.Warnings = append(result.Warnings, usernameResult.Warnings...)
result.SanitizedValue = usernameResult.SanitizedValue
default:
// Generic string validation
result.SanitizedValue = strings.TrimSpace(claimValue)
}
return result
}
// ValidateHeader validates HTTP header values
func (iv *InputValidator) ValidateHeader(headerName, headerValue string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check header name
if headerName == "" {
result.IsValid = false
result.Errors = append(result.Errors, "header name cannot be empty")
return result
}
// Check for control characters in header name (including CRLF)
if iv.containsControlCharacters(headerName) {
result.IsValid = false
result.Errors = append(result.Errors, "header name contains control characters")
return result
}
// Check for CRLF injection in header name
if strings.Contains(headerName, "\r") || strings.Contains(headerName, "\n") {
result.IsValid = false
result.Errors = append(result.Errors, "header name contains CRLF characters (potential header injection)")
return result
}
// Check header value length
if len(headerValue) > iv.maxHeaderLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("header value length %d exceeds maximum %d", len(headerValue), iv.maxHeaderLength))
return result
}
// Check for null bytes and control characters (except allowed ones)
if iv.containsNullBytes(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains null bytes")
return result
}
// Check for CRLF injection
if strings.Contains(headerValue, "\r") || strings.Contains(headerValue, "\n") {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains CRLF characters (potential header injection)")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains invalid UTF-8 sequences")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(headerValue); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
result.SanitizedValue = strings.TrimSpace(headerValue)
return result
}
// isValidBase64URL checks if a string is valid base64url encoding
func (iv *InputValidator) isValidBase64URL(s string) bool {
// Base64url uses A-Z, a-z, 0-9, -, _ and no padding
for _, r := range s {
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
return false
}
}
return true
}
// containsNullBytes checks if a string contains null bytes
func (iv *InputValidator) containsNullBytes(s string) bool {
return strings.Contains(s, "\x00")
}
// containsControlCharacters checks if a string contains control characters
func (iv *InputValidator) containsControlCharacters(s string) bool {
for _, r := range s {
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
return true
}
}
return false
}
// containsPathTraversal checks for path traversal patterns
func (iv *InputValidator) containsPathTraversal(s string) bool {
lowerS := strings.ToLower(s)
for _, pattern := range iv.pathTraversalPatterns {
if strings.Contains(lowerS, pattern) {
return true
}
}
return false
}
// detectSecurityRisk detects potential security risks in input
func (iv *InputValidator) detectSecurityRisk(input string) string {
lowerInput := strings.ToLower(input)
// Check for SQL injection patterns
for _, pattern := range iv.sqlInjectionPatterns {
if strings.Contains(lowerInput, pattern) {
return "sql_injection"
}
}
// Check for XSS patterns
for _, pattern := range iv.xssPatterns {
if strings.Contains(lowerInput, pattern) {
return "xss"
}
}
// Check for path traversal
if iv.containsPathTraversal(input) {
return "path_traversal"
}
// Check for excessive length (potential DoS)
if len(input) > 10000 {
return "excessive_length"
}
// Check for suspicious character patterns
if iv.containsNullBytes(input) {
return "null_bytes"
}
// Check for binary data patterns
nonPrintableCount := 0
for _, r := range input {
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
nonPrintableCount++
}
}
if nonPrintableCount > len(input)/10 { // More than 10% non-printable
return "binary_data"
}
return ""
}
// SanitizeInput provides general input sanitization
func (iv *InputValidator) SanitizeInput(input string, maxLength int) string {
// Trim whitespace
sanitized := strings.TrimSpace(input)
// Truncate if too long
if len(sanitized) > maxLength {
sanitized = sanitized[:maxLength]
}
// Remove null bytes
sanitized = strings.ReplaceAll(sanitized, "\x00", "")
// Remove other control characters except tab, newline, carriage return
var result strings.Builder
for _, r := range sanitized {
if !unicode.IsControl(r) || r == '\t' || r == '\n' || r == '\r' {
result.WriteRune(r)
}
}
return result.String()
}
// ValidateBoundaryValues validates numeric boundary values
func (iv *InputValidator) ValidateBoundaryValues(value interface{}, min, max int64) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
var numValue int64
switch v := value.(type) {
case int:
numValue = int64(v)
case int32:
numValue = int64(v)
case int64:
numValue = v
case float64:
numValue = int64(v)
if float64(numValue) != v {
result.Warnings = append(result.Warnings, "floating point value truncated to integer")
}
default:
result.IsValid = false
result.Errors = append(result.Errors, "value is not a numeric type")
return result
}
if numValue < min {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("value %d is below minimum %d", numValue, min))
}
if numValue > max {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("value %d exceeds maximum %d", numValue, max))
}
return result
}
+421
View File
@@ -0,0 +1,421 @@
package traefikoidc
import (
"strings"
"testing"
)
func TestInputValidator(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Valid token validation", func(t *testing.T) {
validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc"
result := validator.ValidateToken(validToken)
if !result.IsValid {
t.Errorf("Expected valid token to pass validation, got errors: %v", result.Errors)
}
})
t.Run("Invalid token validation", func(t *testing.T) {
invalidTokens := []string{
"", // Empty token
"invalid.token", // Invalid format
"a.b", // Too few parts
"a.b.c.d", // Too many parts
}
for _, token := range invalidTokens {
result := validator.ValidateToken(token)
if result.IsValid {
t.Errorf("Expected invalid token '%s' to fail validation", token)
}
}
})
t.Run("Valid email validation", func(t *testing.T) {
validEmails := []string{
"[email protected]",
"[email protected]",
"[email protected]",
}
for _, email := range validEmails {
result := validator.ValidateEmail(email)
if !result.IsValid {
t.Errorf("Expected valid email '%s' to pass validation, got errors: %v", email, result.Errors)
}
}
})
t.Run("Invalid email validation", func(t *testing.T) {
invalidEmails := []string{
"", // Empty
"invalid", // No @ symbol
"@domain.com", // No local part
"user@", // No domain
"user@domain", // No TLD
"[email protected]", // Double dots
}
for _, email := range invalidEmails {
result := validator.ValidateEmail(email)
if result.IsValid {
t.Errorf("Expected invalid email '%s' to fail validation", email)
}
}
})
t.Run("Valid URL validation", func(t *testing.T) {
validURLs := []string{
"https://example.com",
"https://sub.domain.com/path",
"https://localhost:8080/callback",
}
for _, url := range validURLs {
result := validator.ValidateURL(url)
if !result.IsValid {
t.Errorf("Expected valid URL '%s' to pass validation, got errors: %v", url, result.Errors)
}
}
})
t.Run("Invalid URL validation", func(t *testing.T) {
invalidURLs := []string{
"", // Empty
"not-a-url", // Invalid format
"ftp://example.com", // Wrong scheme
"https://", // No host
}
for _, url := range invalidURLs {
result := validator.ValidateURL(url)
if result.IsValid {
t.Errorf("Expected invalid URL '%s' to fail validation", url)
}
}
})
t.Run("Valid username validation", func(t *testing.T) {
validUsernames := []string{
"user123",
"test_user",
"user-name",
}
for _, username := range validUsernames {
result := validator.ValidateUsername(username)
if !result.IsValid {
t.Errorf("Expected valid username '%s' to pass validation, got errors: %v", username, result.Errors)
}
}
})
t.Run("Invalid username validation", func(t *testing.T) {
invalidUsernames := []string{
"", // Empty
"a", // Too short
strings.Repeat("a", 100), // Too long
"user name", // Spaces
}
for _, username := range invalidUsernames {
result := validator.ValidateUsername(username)
if result.IsValid {
t.Errorf("Expected invalid username '%s' to fail validation", username)
}
}
})
t.Run("Valid claim validation", func(t *testing.T) {
validClaims := map[string]string{
"sub": "user123",
"email": "[email protected]",
"name": "John Doe",
}
for key, value := range validClaims {
result := validator.ValidateClaim(key, value)
if !result.IsValid {
t.Errorf("Expected valid claim '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
}
}
})
t.Run("Invalid claim validation", func(t *testing.T) {
invalidClaims := map[string]string{
"": "value", // Empty key
"long_key": strings.Repeat("a", 10000), // Too long value
}
for key, value := range invalidClaims {
result := validator.ValidateClaim(key, value)
if result.IsValid {
t.Errorf("Expected invalid claim '%s'='%s' to fail validation", key, value)
}
}
})
t.Run("Valid header validation", func(t *testing.T) {
validHeaders := map[string]string{
"Authorization": "Bearer token123",
"Content-Type": "application/json",
"X-Custom": "custom-value",
}
for key, value := range validHeaders {
result := validator.ValidateHeader(key, value)
if !result.IsValid {
t.Errorf("Expected valid header '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
}
}
})
t.Run("Invalid header validation", func(t *testing.T) {
invalidHeaders := map[string]string{
"": "value", // Empty key
"Invalid\nKey": "value", // Control characters in key
"key": "value\r\n", // Control characters in value
}
for key, value := range invalidHeaders {
result := validator.ValidateHeader(key, value)
if result.IsValid {
t.Errorf("Expected invalid header '%s'='%s' to fail validation", key, value)
}
}
})
}
func TestSanitizeInput(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
tests := []struct {
name string
input string
expected string
maxLen int
}{
{
name: "Normal text",
input: "Hello World",
maxLen: 100,
expected: "Hello World",
},
{
name: "Control characters",
input: "text\x00with\x01control\x02chars",
maxLen: 100,
expected: "textwithcontrolchars",
},
{
name: "Truncation",
input: "very long text",
maxLen: 5,
expected: "very ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.SanitizeInput(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("Expected sanitized input '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestValidateBoundaryValues(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Valid boundary values", func(t *testing.T) {
validValues := []interface{}{
int(50),
int64(100),
float64(75.5),
}
for _, value := range validValues {
result := validator.ValidateBoundaryValues(value, 1, 1000)
if !result.IsValid {
t.Errorf("Expected valid boundary value %v to pass validation, got errors: %v", value, result.Errors)
}
}
})
t.Run("Invalid boundary values", func(t *testing.T) {
invalidValues := []interface{}{
int(-1),
int64(2000),
"not a number",
}
for _, value := range invalidValues {
result := validator.ValidateBoundaryValues(value, 1, 1000)
if result.IsValid {
t.Errorf("Expected invalid boundary value %v to fail validation", value)
}
}
})
}
func TestDefaultInputValidationConfig(t *testing.T) {
config := DefaultInputValidationConfig()
if config.MaxTokenLength <= 0 {
t.Error("Expected positive MaxTokenLength")
}
if config.MaxEmailLength <= 0 {
t.Error("Expected positive MaxEmailLength")
}
if config.MaxUsernameLength <= 0 {
t.Error("Expected positive MaxUsernameLength")
}
if config.MaxClaimLength <= 0 {
t.Error("Expected positive MaxClaimLength")
}
if config.MaxHeaderLength <= 0 {
t.Error("Expected positive MaxHeaderLength")
}
if !config.StrictMode {
t.Error("Expected StrictMode to be true by default")
}
}
func TestInputValidationHelpers(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("isValidBase64URL", func(t *testing.T) {
validBase64URL := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
if !validator.isValidBase64URL(validBase64URL) {
t.Error("Expected valid base64url to be recognized")
}
invalidBase64URL := "invalid+base64/with+padding="
if validator.isValidBase64URL(invalidBase64URL) {
t.Error("Expected invalid base64url to be rejected")
}
})
t.Run("containsNullBytes", func(t *testing.T) {
withNull := "text\x00with\x00null"
if !validator.containsNullBytes(withNull) {
t.Error("Expected string with null bytes to be detected")
}
withoutNull := "normal text"
if validator.containsNullBytes(withoutNull) {
t.Error("Expected string without null bytes to pass")
}
})
t.Run("containsControlCharacters", func(t *testing.T) {
withControl := "text\x01with\x02control"
if !validator.containsControlCharacters(withControl) {
t.Error("Expected string with control characters to be detected")
}
withoutControl := "normal text"
if validator.containsControlCharacters(withoutControl) {
t.Error("Expected string without control characters to pass")
}
})
t.Run("containsPathTraversal", func(t *testing.T) {
withTraversal := "../../../etc/passwd"
if !validator.containsPathTraversal(withTraversal) {
t.Error("Expected path traversal to be detected")
}
normalPath := "/normal/path"
if validator.containsPathTraversal(normalPath) {
t.Error("Expected normal path to pass")
}
})
t.Run("detectSecurityRisk", func(t *testing.T) {
riskyInputs := []string{
"<script>alert('xss')</script>",
"'; DROP TABLE users; --",
"javascript:alert('xss')",
}
for _, input := range riskyInputs {
if validator.detectSecurityRisk(input) == "" {
t.Errorf("Expected security risk to be detected in: %s", input)
}
}
safeInput := "normal safe text"
if validator.detectSecurityRisk(safeInput) != "" {
t.Error("Expected safe input to pass security check")
}
})
}
func TestInputValidationEdgeCases(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Empty inputs", func(t *testing.T) {
// Most validations should reject empty inputs
if result := validator.ValidateToken(""); result.IsValid {
t.Error("Expected empty token to be rejected")
}
if result := validator.ValidateEmail(""); result.IsValid {
t.Error("Expected empty email to be rejected")
}
if result := validator.ValidateURL(""); result.IsValid {
t.Error("Expected empty URL to be rejected")
}
if result := validator.ValidateUsername(""); result.IsValid {
t.Error("Expected empty username to be rejected")
}
})
t.Run("Very long inputs", func(t *testing.T) {
longString := strings.Repeat("a", 10000)
if result := validator.ValidateEmail(longString + "@domain.com"); result.IsValid {
t.Error("Expected very long email to be rejected")
}
if result := validator.ValidateUsername(longString); result.IsValid {
t.Error("Expected very long username to be rejected")
}
})
t.Run("Unicode handling", func(t *testing.T) {
unicodeEmail := "用户@example.com"
// Should handle unicode gracefully
validator.ValidateEmail(unicodeEmail) // Don't fail on unicode
unicodeUsername := "用户名"
validator.ValidateUsername(unicodeUsername) // Don't fail on unicode
})
}
+20 -8
View File
@@ -33,13 +33,12 @@ type JWKSet struct {
}
type JWKCache struct {
jwks *JWKSet
expiresAt time.Time
mutex sync.RWMutex
// CacheLifetime is configurable to determine how long the JWKS is cached.
expiresAt time.Time
jwks *JWKSet
internalCache *Cache
CacheLifetime time.Duration
internalCache *Cache // To hold the closable Cache instance from cache.go
maxSize int // Maximum number of items in the cache
maxSize int
mutex sync.RWMutex
}
type JWKCacheInterface interface {
@@ -80,24 +79,37 @@ func (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http
}
}
// STABILITY FIX: Fix race condition in double-checked locking
// First read check with read lock
c.mutex.RLock()
if c.jwks != nil && time.Now().Before(c.expiresAt) {
defer c.mutex.RUnlock()
return c.jwks, nil
jwks := c.jwks // Copy reference while holding read lock
c.mutex.RUnlock()
return jwks, nil
}
c.mutex.RUnlock()
// Acquire write lock for potential update
c.mutex.Lock()
defer c.mutex.Unlock()
// Second check after acquiring write lock (double-checked locking)
if c.jwks != nil && time.Now().Before(c.expiresAt) {
return c.jwks, nil
}
// Fetch new JWKS
jwks, err := fetchJWKS(ctx, jwksURL, httpClient)
if err != nil {
return nil, err
}
// STABILITY FIX: Validate JWKS contains keys before caching
if len(jwks.Keys) == 0 {
return nil, fmt.Errorf("JWKS response contains no keys")
}
// Update cache atomically
c.jwks = jwks
lifetime := c.CacheLifetime
if lifetime == 0 {
+96 -38
View File
@@ -1,6 +1,7 @@
package traefikoidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rsa"
@@ -16,40 +17,80 @@ import (
)
var (
replayCacheMu sync.Mutex
replayCache = make(map[string]time.Time)
replayCacheMu sync.RWMutex // Use RWMutex for better read performance
replayCache *Cache // Replace unbounded map with bounded Cache
replayCacheOnce sync.Once
)
// cleanupReplayCache iterates through the replay cache and removes entries
// whose expiration time is before the current time. This function should be
// called periodically to prevent the cache from growing indefinitely.
// It acquires a mutex to ensure thread safety during cleanup.
func initReplayCache() {
replayCacheOnce.Do(func() {
replayCache = NewCache()
replayCache.SetMaxSize(10000)
})
}
func cleanupReplayCache() {
now := time.Now()
for token, expiry := range replayCache {
if expiry.Before(now) {
delete(replayCache, token)
}
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
if replayCache != nil {
replayCache.Close()
replayCache = nil
}
}
// ClockSkewToleranceFuture defines the tolerance for future-based claims like 'exp'.
// Allows for more leniency with expiration checks.
func getReplayCacheStats() (size int, maxSize int) {
replayCacheMu.RLock()
defer replayCacheMu.RUnlock()
if replayCache == nil {
return 0, 0
}
return 0, 10000
}
func startReplayCacheCleanup(ctx context.Context, logger *Logger) {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
size, maxSize := getReplayCacheStats()
if logger != nil {
logger.Debugf("Replay cache stats: size=%d, maxSize=%d", size, maxSize)
}
replayCacheMu.RLock()
if replayCache != nil {
}
replayCacheMu.RUnlock()
case <-ctx.Done():
cleanupReplayCache()
if logger != nil {
logger.Debug("Replay cache cleanup goroutine stopped due to context cancellation")
}
return
}
}
}()
}
var ClockSkewToleranceFuture = 2 * time.Minute
// ClockSkewTolerancePast defines the tolerance for past-based claims like 'iat' and 'nbf'.
// A smaller tolerance is typically used here to prevent accepting tokens issued too far in the future.
var (
ClockSkewTolerancePast = 10 * time.Second
ClockSkewTolerance = 2 * time.Minute
)
var ClockSkewTolerancePast = 10 * time.Second
var ClockSkewTolerance = ClockSkewToleranceFuture
// JWT represents a JSON Web Token as defined in RFC 7519.
type JWT struct {
Header map[string]interface{}
Claims map[string]interface{}
Signature []byte
Token string
Signature []byte
}
// parseJWT decodes a raw JWT string into its constituent parts: header, claims, and signature.
@@ -82,14 +123,23 @@ func parseJWT(tokenString string) (*JWT, error) {
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal header: %v", err)
}
if jwt.Header == nil {
return nil, fmt.Errorf("invalid JWT format: header is nil after unmarshaling")
}
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode claims: %v", err)
}
if err := json.Unmarshal(claimsBytes, &jwt.Claims); err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal claims: %v", err)
}
if jwt.Claims == nil {
return nil, fmt.Errorf("invalid JWT format: claims is nil after unmarshaling")
}
signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode signature: %v", err)
@@ -113,11 +163,12 @@ func parseJWT(tokenString string) (*JWT, error) {
// Parameters:
// - issuerURL: The expected issuer URL (e.g., "https://accounts.google.com").
// - clientID: The expected audience value (the client ID of this application).
// - skipReplayCheck: If true, skips JTI replay detection (used for revalidation of cached tokens).
//
// Returns:
// - nil if all standard claims are valid.
// - An error describing the first validation failure encountered.
func (j *JWT) Verify(issuerURL, clientID string) error {
func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error {
// Validate algorithm to prevent algorithm switching attacks
alg, ok := j.Header["alg"].(string)
if !ok {
@@ -172,21 +223,23 @@ func (j *JWT) Verify(issuerURL, clientID string) error {
}
}
// Implement replay protection by checking the jti (JWT ID)
if jti, ok := claims["jti"].(string); ok {
// Skip replay detection for tokens that are being verified from the cache
shouldSkipReplay := len(skipReplayCheck) > 0 && skipReplayCheck[0]
if jti, ok := claims["jti"].(string); ok && !shouldSkipReplay {
if j.Token == "" {
// This is a parsed JWT without the original token string,
// which means it's likely from a cached token verification
return nil
}
replayCacheMu.Lock()
cleanupReplayCache()
if _, exists := replayCache[jti]; exists {
replayCacheMu.Unlock()
return fmt.Errorf("token replay detected")
initReplayCache()
replayCacheMu.RLock()
_, exists := replayCache.Get(jti)
replayCacheMu.RUnlock()
if exists {
return fmt.Errorf("token replay detected (jti: %s)", jti)
}
expFloat, ok := claims["exp"].(float64)
var expTime time.Time
if ok {
@@ -194,8 +247,15 @@ func (j *JWT) Verify(issuerURL, clientID string) error {
} else {
expTime = time.Now().Add(10 * time.Minute)
}
replayCache[jti] = expTime
replayCacheMu.Unlock()
duration := time.Until(expTime)
if duration > 0 {
replayCacheMu.Lock()
if replayCache != nil {
replayCache.Set(jti, true, duration)
}
replayCacheMu.Unlock()
}
}
sub, ok := claims["sub"].(string)
@@ -268,17 +328,15 @@ func verifyIssuer(tokenIssuer, expectedIssuer string) error {
// - An error describing the failure (e.g., "token has expired", "token used before issued").
func verifyTimeConstraint(unixTime float64, claimName string, future bool) error {
claimTime := time.Unix(int64(unixTime), 0)
now := time.Now() // Use current time without truncation
now := time.Now()
var err error
if future { // 'exp' check
// Token is expired if Now is after (ClaimTime + FutureTolerance)
if future {
allowedExpiry := claimTime.Add(ClockSkewToleranceFuture)
if now.After(allowedExpiry) {
err = fmt.Errorf("token has expired (exp: %v, now: %v, allowed_until: %v)", claimTime.UTC(), now.UTC(), allowedExpiry.UTC())
}
} else { // 'iat' or 'nbf' check
// Token is invalid if Now is before (ClaimTime - PastTolerance)
} else {
allowedStart := claimTime.Add(-ClockSkewTolerancePast)
if now.Before(allowedStart) {
reason := "not yet valid"
+916 -327
View File
File diff suppressed because it is too large Load Diff
+1470 -47
View File
File diff suppressed because it is too large Load Diff
+25 -11
View File
@@ -8,21 +8,31 @@ import (
)
type MetadataCache struct {
metadata *ProviderMetadata
expiresAt time.Time
mutex sync.RWMutex
metadata *ProviderMetadata
cleanupTask *BackgroundTask
logger *Logger
autoCleanupInterval time.Duration
stopCleanup chan struct{}
mutex sync.RWMutex
}
// NewMetadataCache creates a new MetadataCache instance.
// It initializes the cache structure and starts the background cleanup goroutine.
// It initializes the cache structure and starts the background cleanup task.
func NewMetadataCache() *MetadataCache {
return NewMetadataCacheWithLogger(nil)
}
// NewMetadataCacheWithLogger creates a new MetadataCache with a specified logger.
func NewMetadataCacheWithLogger(logger *Logger) *MetadataCache {
if logger == nil {
logger = newNoOpLogger()
}
c := &MetadataCache{
autoCleanupInterval: 5 * time.Minute,
stopCleanup: make(chan struct{}),
logger: logger,
}
go c.startAutoCleanup()
c.startAutoCleanup()
return c
}
@@ -92,20 +102,24 @@ func (c *MetadataCache) GetMetadata(providerURL string, httpClient *http.Client,
c.metadata = metadata
// Set a fixed cache lifetime (e.g., 1 hour)
// TODO: Consider making this configurable or respecting HTTP cache headers
// Consider making this configurable or respecting HTTP cache headers
c.expiresAt = time.Now().Add(1 * time.Hour)
// End of GetMetadata
return metadata, nil
}
// startAutoCleanup starts the background goroutine that periodically calls Cleanup
// startAutoCleanup starts the background task that periodically calls Cleanup
// to remove expired metadata from the cache.
func (c *MetadataCache) startAutoCleanup() {
autoCleanupRoutine(c.autoCleanupInterval, c.stopCleanup, c.Cleanup)
c.cleanupTask = NewBackgroundTask("metadata-cache-cleanup", c.autoCleanupInterval, c.Cleanup, c.logger)
c.cleanupTask.Start()
}
// Close stops the automatic cleanup goroutine associated with this metadata cache.
// Close stops the automatic cleanup task associated with this metadata cache.
func (c *MetadataCache) Close() {
close(c.stopCleanup)
if c.cleanupTask != nil {
c.cleanupTask.Stop()
c.cleanupTask = nil
}
}
+4 -4
View File
@@ -41,8 +41,8 @@ func TestGetMetadata_Cached(t *testing.T) {
mc := &MetadataCache{
metadata: dummyData,
expiresAt: time.Now().Add(1 * time.Hour),
stopCleanup: make(chan struct{}),
autoCleanupInterval: 5 * time.Minute,
logger: newNoOpLogger(),
}
// Use NewLogger to create a logger that writes errors only.
logger := NewLogger("error")
@@ -58,10 +58,10 @@ func TestGetMetadata_Cached(t *testing.T) {
func TestMetadataCacheAutoCleanup(t *testing.T) {
mc := &MetadataCache{
autoCleanupInterval: 50 * time.Millisecond,
stopCleanup: make(chan struct{}),
logger: newNoOpLogger(),
}
// Start auto cleanup.
go mc.startAutoCleanup()
mc.startAutoCleanup()
mc.mutex.Lock()
mc.metadata = &ProviderMetadata{}
mc.expiresAt = time.Now().Add(-50 * time.Millisecond)
@@ -93,7 +93,7 @@ func TestGetMetadata_FetchError(t *testing.T) {
// Case 1: Cache is empty.
mc := &MetadataCache{
stopCleanup: make(chan struct{}),
logger: newNoOpLogger(),
}
logger := NewLogger("error")
metadata, err := mc.GetMetadata("http://example.com", errorClient, logger)
+778
View File
@@ -0,0 +1,778 @@
package traefikoidc
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/time/rate"
)
// TestConcurrentTokenVerification tests race conditions in token verification
func TestConcurrentTokenVerification(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Create multiple valid tokens to avoid replay detection
tokens := make([]string, 10)
for i := 0; i < 10; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create test token %d: %v", i, err)
}
tokens[i] = token
}
// Create a fresh instance for this test
tOidc := &TraefikOidc{
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
jwkCache: ts.mockJWKCache,
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high rate limit
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}},
httpClient: &http.Client{},
extractClaimsFunc: extractClaims,
}
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
// Ensure cleanup when test finishes
defer func() {
if err := tOidc.Close(); err != nil {
t.Logf("Error closing TraefikOidc instance: %v", err)
}
}()
// Test concurrent verification
const numGoroutines = 50
const verificationsPerGoroutine = 10
var wg sync.WaitGroup
var successCount int64
var errorCount int64
errors := make(chan error, numGoroutines*verificationsPerGoroutine)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < verificationsPerGoroutine; j++ {
tokenIndex := (goroutineID*verificationsPerGoroutine + j) % len(tokens)
err := tOidc.VerifyToken(tokens[tokenIndex])
if err != nil {
atomic.AddInt64(&errorCount, 1)
select {
case errors <- fmt.Errorf("goroutine %d, verification %d: %w", goroutineID, j, err):
default:
}
} else {
atomic.AddInt64(&successCount, 1)
}
}
}(i)
}
wg.Wait()
close(errors)
// Check results
totalOperations := int64(numGoroutines * verificationsPerGoroutine)
t.Logf("Concurrent verification results: %d successes, %d errors out of %d total operations",
successCount, errorCount, totalOperations)
// Collect and log errors
var errorList []error
for err := range errors {
errorList = append(errorList, err)
}
if len(errorList) > 0 {
t.Logf("Errors encountered during concurrent verification:")
for i, err := range errorList {
if i < 10 { // Log first 10 errors
t.Logf(" %d: %v", i+1, err)
}
}
if len(errorList) > 10 {
t.Logf(" ... and %d more errors", len(errorList)-10)
}
}
// We expect most operations to succeed
if successCount < totalOperations/2 {
t.Errorf("Too many failures in concurrent verification: %d successes out of %d operations", successCount, totalOperations)
}
// Check for data races by verifying cache consistency
cacheSize := len(tOidc.tokenCache.cache.items)
blacklistSize := len(tOidc.tokenBlacklist.items)
t.Logf("Final cache sizes: token cache=%d, blacklist=%d", cacheSize, blacklistSize)
}
// TestCacheMemoryExhaustion tests cache behavior under memory pressure
func TestCacheMemoryExhaustion(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Create a cache with limited size
cache := NewTokenCache()
cache.cache.SetMaxSize(100) // Small cache size
// Ensure cleanup when test finishes
defer cache.Close()
// Create many tokens to exceed cache capacity
const numTokens = 500
tokens := make([]string, numTokens)
for i := 0; i < numTokens; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": fmt.Sprintf("jti-%d", i),
})
if err != nil {
t.Fatalf("Failed to create token %d: %v", i, err)
}
tokens[i] = token
// Add to cache
claims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": fmt.Sprintf("jti-%d", i),
}
cache.Set(token, claims, time.Hour)
}
// Verify cache size is within limits
cacheSize := len(cache.cache.items)
if cacheSize > 100 {
t.Errorf("Cache size exceeded limit: got %d, expected <= 100", cacheSize)
}
// Verify LRU eviction works
// The first tokens should have been evicted
firstToken := tokens[0]
if _, exists := cache.Get(firstToken); exists {
t.Errorf("First token should have been evicted from cache")
}
// The last tokens should still be in cache
lastToken := tokens[numTokens-1]
if _, exists := cache.Get(lastToken); !exists {
t.Errorf("Last token should still be in cache")
}
t.Logf("Cache memory exhaustion test passed: cache size=%d", cacheSize)
}
// TestSessionConcurrencyProtection tests session safety under concurrent access
func TestSessionConcurrencyProtection(t *testing.T) {
logger := NewLogger("debug")
sessionManager, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Test concurrent session access with separate requests
const numGoroutines = 20
const operationsPerGoroutine = 10 // Reduced to avoid overwhelming
var wg sync.WaitGroup
var successCount int64
var errorCount int64
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
// Each goroutine gets its own request and session
req := httptest.NewRequest("GET", "/test", nil)
for j := 0; j < operationsPerGoroutine; j++ {
// Get a fresh session for each operation
s, err := sessionManager.GetSession(req)
if err != nil {
atomic.AddInt64(&errorCount, 1)
continue
}
// Perform operations on session
s.SetEmail(fmt.Sprintf("user%d-%[email protected]", goroutineID, j))
s.SetAuthenticated(true)
s.SetAccessToken(ValidAccessToken)
// Save session
testRR := httptest.NewRecorder()
if err := s.Save(req, testRR); err != nil {
atomic.AddInt64(&errorCount, 1)
} else {
atomic.AddInt64(&successCount, 1)
}
// Copy cookies back to request for next iteration
for _, cookie := range testRR.Result().Cookies() {
req.Header.Set("Cookie", cookie.String())
}
}
}(i)
}
wg.Wait()
totalOperations := int64(numGoroutines * operationsPerGoroutine)
t.Logf("Session concurrency test results: %d successes, %d errors out of %d operations",
successCount, errorCount, totalOperations)
// Most operations should succeed
if successCount < totalOperations/2 {
t.Errorf("Too many session operation failures: %d successes out of %d operations", successCount, totalOperations)
}
}
// TestParallelCacheOperations tests cache thread safety
func TestParallelCacheOperations(t *testing.T) {
cache := NewCache()
cache.SetMaxSize(1000)
// Ensure cleanup when test finishes
defer cache.Close()
const numGoroutines = 10
const operationsPerGoroutine = 100
var wg sync.WaitGroup
var setCount int64
var getCount int64
var deleteCount int64
// Start multiple goroutines performing cache operations
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
key := fmt.Sprintf("key-%d-%d", goroutineID, j)
value := fmt.Sprintf("value-%d-%d", goroutineID, j)
// Set operation
cache.Set(key, value, time.Minute)
atomic.AddInt64(&setCount, 1)
// Get operation
if _, exists := cache.Get(key); exists {
atomic.AddInt64(&getCount, 1)
}
// Delete some items
if j%10 == 0 {
cache.Delete(key)
atomic.AddInt64(&deleteCount, 1)
}
}
}(i)
}
wg.Wait()
t.Logf("Parallel cache operations completed: %d sets, %d gets, %d deletes",
setCount, getCount, deleteCount)
// Verify cache is still functional
cache.Set("test-key", "test-value", time.Minute)
if value, exists := cache.Get("test-key"); !exists || value != "test-value" {
t.Errorf("Cache corrupted after parallel operations")
}
// Check cache size is reasonable
cacheSize := len(cache.items)
expectedSize := int(setCount - deleteCount)
if cacheSize > expectedSize {
t.Logf("Cache size after operations: %d (expected around %d)", cacheSize, expectedSize)
}
}
// TestProviderFailureRecovery tests network failure scenarios
func TestProviderFailureRecovery(t *testing.T) {
// Create a server that fails initially then recovers
var requestCount int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt64(&requestCount, 1)
if count <= 3 {
// Fail first 3 requests
w.WriteHeader(http.StatusInternalServerError)
return
}
// Succeed after 3 failures
metadata := ProviderMetadata{
Issuer: "https://test-issuer.com",
AuthURL: "https://test-issuer.com/auth",
TokenURL: "https://test-issuer.com/token",
JWKSURL: "https://test-issuer.com/jwks",
RevokeURL: "https://test-issuer.com/revoke",
EndSessionURL: "https://test-issuer.com/end-session",
}
json.NewEncoder(w).Encode(metadata)
}))
defer server.Close()
// Test metadata discovery with retries
logger := NewLogger("debug")
httpClient := createDefaultHTTPClient()
start := time.Now()
metadata, err := discoverProviderMetadata(server.URL, httpClient, logger)
duration := time.Since(start)
if err != nil {
t.Errorf("Provider metadata discovery failed after retries: %v", err)
}
if metadata == nil {
t.Errorf("Expected metadata to be returned after recovery")
}
// Should have taken some time due to retries (at least the sum of delays: 10ms + 20ms + 40ms = 70ms)
expectedMinDuration := 70 * time.Millisecond
if duration < expectedMinDuration {
t.Errorf("Expected discovery to take at least %v due to retries, but took %v", expectedMinDuration, duration)
}
t.Logf("Provider failure recovery test passed: %d requests, duration: %v", requestCount, duration)
}
// TestOversizedTokenHandling tests boundary value handling
func TestOversizedTokenHandling(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Create an oversized token with large claims
largeClaim := strings.Repeat("x", 10000) // 10KB claim
oversizedClaims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16),
"large_data": largeClaim,
}
oversizedToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", oversizedClaims)
if err != nil {
t.Fatalf("Failed to create oversized token: %v", err)
}
t.Logf("Created oversized token of length: %d bytes", len(oversizedToken))
// Test verification of oversized token
err = ts.tOidc.VerifyToken(oversizedToken)
if err != nil {
t.Logf("Oversized token verification failed as expected: %v", err)
// This is acceptable - oversized tokens should be rejected
} else {
t.Logf("Oversized token verification succeeded")
// Verify it was cached properly
if _, exists := ts.tOidc.tokenCache.Get(oversizedToken); !exists {
t.Errorf("Oversized token was not cached after successful verification")
}
}
// Test extremely long token (beyond reasonable limits)
extremelyLongClaim := strings.Repeat("y", 100000) // 100KB claim
extremeClaims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16),
"extreme_data": extremelyLongClaim,
}
extremeToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", extremeClaims)
if err != nil {
t.Fatalf("Failed to create extreme token: %v", err)
}
t.Logf("Created extreme token of length: %d bytes", len(extremeToken))
// This should likely fail due to size limits
err = ts.tOidc.VerifyToken(extremeToken)
if err != nil {
t.Logf("Extreme token verification failed as expected: %v", err)
} else {
t.Logf("Warning: Extreme token verification succeeded - consider adding size limits")
}
}
// TestMaliciousInputValidation tests security input validation
func TestMaliciousInputValidation(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
maliciousInputs := []struct {
name string
token string
}{
{
name: "Empty token",
token: "",
},
{
name: "Single dot",
token: ".",
},
{
name: "Two dots only",
token: "..",
},
{
name: "SQL injection attempt",
token: "'; DROP TABLE users; --",
},
{
name: "Script injection attempt",
token: "<script>alert('xss')</script>",
},
{
name: "Path traversal attempt",
token: "../../../etc/passwd",
},
{
name: "Null bytes",
token: "token\x00with\x00nulls",
},
{
name: "Unicode control characters",
token: "token\u0000\u0001\u0002",
},
{
name: "Extremely long string",
token: strings.Repeat("a", 1000000), // 1MB string
},
{
name: "Invalid base64 characters",
token: "header.payload!@#$%^&*().signature",
},
{
name: "Binary data",
token: string([]byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD}),
},
}
for _, test := range maliciousInputs {
t.Run(test.name, func(t *testing.T) {
// Create a fresh instance for each test to avoid rate limiting issues
freshOidc := &TraefikOidc{
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
jwkCache: ts.mockJWKCache,
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high rate limit
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}},
httpClient: &http.Client{},
extractClaimsFunc: extractClaims,
}
freshOidc.tokenVerifier = freshOidc
freshOidc.jwtVerifier = freshOidc
// Ensure cleanup when test finishes
defer func() {
if err := freshOidc.Close(); err != nil {
t.Logf("Error closing TraefikOidc instance: %v", err)
}
}()
// All malicious inputs should be safely rejected
err := freshOidc.VerifyToken(test.token)
if err == nil {
t.Errorf("Malicious input '%s' was not rejected", test.name)
} else {
t.Logf("Malicious input '%s' correctly rejected: %v", test.name, err)
}
// Verify the system is still functional after malicious input
validToken, createErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16),
})
if createErr != nil {
t.Fatalf("Failed to create valid token for recovery test: %v", createErr)
}
// System should still work with valid tokens
if verifyErr := freshOidc.VerifyToken(validToken); verifyErr != nil {
t.Errorf("System failed to process valid token after malicious input: %v", verifyErr)
}
})
}
}
// TestNetworkErrorCleanup tests resource cleanup on network errors
func TestNetworkErrorCleanup(t *testing.T) {
// Create a server that times out
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate network timeout by sleeping
time.Sleep(2 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create HTTP client with short timeout
httpClient := &http.Client{
Timeout: 100 * time.Millisecond, // Very short timeout
}
logger := NewLogger("debug")
// Track goroutines before test
initialGoroutines := runtime.NumGoroutine()
// Attempt metadata discovery that should timeout
start := time.Now()
_, err := discoverProviderMetadata(server.URL, httpClient, logger)
duration := time.Since(start)
// Should fail due to timeout
if err == nil {
t.Errorf("Expected timeout error, but request succeeded")
}
// Should fail quickly due to timeout
if duration > time.Second {
t.Errorf("Request took too long despite timeout: %v", duration)
}
// Give time for cleanup
time.Sleep(100 * time.Millisecond)
// Check for goroutine leaks
finalGoroutines := runtime.NumGoroutine()
if finalGoroutines > initialGoroutines+5 { // Allow some tolerance
t.Errorf("Potential goroutine leak: started with %d, ended with %d goroutines",
initialGoroutines, finalGoroutines)
}
t.Logf("Network error cleanup test passed: duration=%v, goroutines=%d->%d",
duration, initialGoroutines, finalGoroutines)
}
// TestResourceLimits tests system behavior under resource constraints
func TestResourceLimits(t *testing.T) {
// Test memory allocation limits
cache := NewCache()
cache.SetMaxSize(10) // Very small cache
// Ensure cleanup when test finishes
defer cache.Close()
// Try to overwhelm the cache
for i := 0; i < 1000; i++ {
key := fmt.Sprintf("key-%d", i)
value := fmt.Sprintf("value-%d", i)
cache.Set(key, value, time.Minute)
}
// Cache should not exceed its limit
if len(cache.items) > 10 {
t.Errorf("Cache exceeded size limit: got %d items, expected <= 10", len(cache.items))
}
// Test rate limiting under load
limiter := rate.NewLimiter(rate.Every(time.Second), 5) // 5 requests per second
allowed := 0
denied := 0
// Make many requests quickly
for i := 0; i < 100; i++ {
if limiter.Allow() {
allowed++
} else {
denied++
}
}
// Most should be denied due to rate limiting
if denied < 90 {
t.Errorf("Rate limiting not effective: allowed=%d, denied=%d", allowed, denied)
}
t.Logf("Resource limits test passed: cache size=%d, rate limiting: allowed=%d, denied=%d",
len(cache.items), allowed, denied)
}
// TestErrorRecoveryPatterns tests various error recovery scenarios
func TestErrorRecoveryPatterns(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Test recovery from cache corruption
t.Run("CacheCorruption", func(t *testing.T) {
// Corrupt the cache by using the Set method to avoid data race
ts.tOidc.tokenCache.cache.Set("corrupted", "invalid-data", time.Hour)
// System should handle corrupted cache gracefully
validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create valid token: %v", err)
}
// Should still work despite cache corruption
if err := ts.tOidc.VerifyToken(validToken); err != nil {
t.Errorf("Token verification failed despite cache corruption: %v", err)
}
})
// Test recovery from blacklist corruption
t.Run("BlacklistCorruption", func(t *testing.T) {
// Add invalid data to blacklist
ts.tOidc.tokenBlacklist.Set("corrupted-entry", "invalid-data", time.Hour)
// System should still function
validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create valid token: %v", err)
}
if err := ts.tOidc.VerifyToken(validToken); err != nil {
t.Errorf("Token verification failed despite blacklist corruption: %v", err)
}
})
}
// TestPerformanceUnderLoad tests system performance under high load
func TestPerformanceUnderLoad(t *testing.T) {
if testing.Short() {
t.Skip("Skipping performance test in short mode")
}
ts := &TestSuite{t: t}
ts.Setup()
// Create multiple valid tokens
const numTokens = 100
tokens := make([]string, numTokens)
for i := 0; i < numTokens; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": fmt.Sprintf("jti-%d", i),
})
if err != nil {
t.Fatalf("Failed to create token %d: %v", i, err)
}
tokens[i] = token
}
// Create fresh instance with high rate limit
tOidc := &TraefikOidc{
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
jwkCache: ts.mockJWKCache,
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high limit
logger: NewLogger("info"), // Reduce logging for performance
allowedUserDomains: map[string]struct{}{"example.com": {}},
httpClient: &http.Client{},
extractClaimsFunc: extractClaims,
}
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
// Ensure cleanup when test finishes
defer func() {
if err := tOidc.Close(); err != nil {
t.Logf("Error closing TraefikOidc instance: %v", err)
}
}()
// Performance test
const iterations = 1000
start := time.Now()
for i := 0; i < iterations; i++ {
tokenIndex := i % numTokens
err := tOidc.VerifyToken(tokens[tokenIndex])
if err != nil {
t.Errorf("Token verification failed at iteration %d: %v", i, err)
}
}
duration := time.Since(start)
opsPerSecond := float64(iterations) / duration.Seconds()
t.Logf("Performance test completed: %d operations in %v (%.2f ops/sec)",
iterations, duration, opsPerSecond)
// Should achieve reasonable performance
if opsPerSecond < 100 {
t.Errorf("Performance too low: %.2f ops/sec (expected > 100)", opsPerSecond)
}
}
+362
View File
@@ -0,0 +1,362 @@
package traefikoidc
import (
"net/url"
"reflect"
"testing"
)
func TestMergeScopes(t *testing.T) {
testCases := []struct {
name string
defaultScopes []string
userScopes []string
expectedScopes []string
}{
{
name: "Empty user scopes",
defaultScopes: []string{"openid", "profile", "email"},
userScopes: []string{},
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Non-overlapping scopes",
defaultScopes: []string{"openid", "profile", "email"},
userScopes: []string{"roles", "custom_scope"},
expectedScopes: []string{"openid", "profile", "email", "roles", "custom_scope"},
},
{
name: "Overlapping scopes",
defaultScopes: []string{"openid", "profile", "email"},
userScopes: []string{"openid", "roles", "profile", "permissions"},
expectedScopes: []string{"openid", "profile", "email", "roles", "permissions"},
},
{
name: "Nil user scopes",
defaultScopes: []string{"openid", "profile", "email"},
userScopes: nil,
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Nil default scopes",
defaultScopes: nil,
userScopes: []string{"roles", "custom_scope"},
expectedScopes: []string{"roles", "custom_scope"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := mergeScopes(tc.defaultScopes, tc.userScopes)
if !reflect.DeepEqual(result, tc.expectedScopes) {
t.Errorf("Expected %v, got %v", tc.expectedScopes, result)
}
})
}
}
func TestDeduplicateScopes(t *testing.T) {
testCases := []struct {
name string
inputScopes []string
expectedScopes []string
}{
{
name: "No duplicates",
inputScopes: []string{"openid", "profile", "email"},
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Simple duplicates",
inputScopes: []string{"openid", "profile", "openid", "email"},
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Multiple duplicates",
inputScopes: []string{"scope1", "scope2", "scope1", "scope2", "scope1"},
expectedScopes: []string{"scope1", "scope2"},
},
{
name: "Empty input",
inputScopes: []string{},
expectedScopes: []string{},
},
{
name: "Nil input",
inputScopes: nil,
expectedScopes: []string{},
},
{
name: "Single element",
inputScopes: []string{"openid"},
expectedScopes: []string{"openid"},
},
{
name: "All duplicates",
inputScopes: []string{"test", "test", "test"},
expectedScopes: []string{"test"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := deduplicateScopes(tc.inputScopes)
if !reflect.DeepEqual(result, tc.expectedScopes) {
t.Errorf("Expected %v, got %v", tc.expectedScopes, result)
}
})
}
}
func TestScopesConfiguration(t *testing.T) {
defaultScopes := []string{"openid", "profile", "email"}
testCases := []struct {
name string
configScopes []string // Scopes from Traefik config
overrideScopes bool
expectedResult []string
}{
{
name: "Default Append Behavior - No user scopes",
configScopes: []string{},
overrideScopes: false,
expectedResult: []string{"openid", "profile", "email"},
},
{
name: "Default Append Behavior - With user scopes",
configScopes: []string{"roles", "custom_scope"},
overrideScopes: false,
expectedResult: []string{"openid", "profile", "email", "roles", "custom_scope"},
},
{
name: "Default Append Behavior - With duplicate user scopes",
configScopes: []string{"roles", "custom_scope", "roles"},
overrideScopes: false,
expectedResult: []string{"openid", "profile", "email", "roles", "custom_scope"},
},
{
name: "Default Append Behavior - User scopes overlap with defaults",
configScopes: []string{"openid", "roles", "profile"},
overrideScopes: false,
expectedResult: []string{"openid", "profile", "email", "roles"},
},
{
name: "Override Behavior - With user scopes",
configScopes: []string{"roles", "custom_scope"},
overrideScopes: true,
expectedResult: []string{"roles", "custom_scope"},
},
{
name: "Override Behavior - With duplicate user scopes",
configScopes: []string{"roles", "custom_scope", "roles"},
overrideScopes: true,
expectedResult: []string{"roles", "custom_scope"},
},
{
name: "Override Behavior - Empty user scopes",
configScopes: []string{},
overrideScopes: true,
expectedResult: []string{},
},
{
name: "Override Behavior - Nil user scopes",
configScopes: nil,
overrideScopes: true,
expectedResult: []string{}, // Deduplicate will handle nil as empty
},
{
name: "Override Behavior - Single user scope",
configScopes: []string{"email"},
overrideScopes: true,
expectedResult: []string{"email"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Simulate the logic within TraefikOidc.New for setting t.scopes
var result []string
uniqueConfigScopes := deduplicateScopes(tc.configScopes)
if tc.overrideScopes {
result = uniqueConfigScopes
} else {
result = mergeScopes(defaultScopes, uniqueConfigScopes)
}
if !reflect.DeepEqual(result, tc.expectedResult) {
t.Errorf("Expected scopes %v, got %v", tc.expectedResult, result)
}
})
}
}
func TestBuildAuthURLScopeHandling(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup() // Basic setup for TraefikOidc instance
// Default scopes expected if not overridden and no user scopes provided
defaultInitialScopes := []string{"openid", "profile", "email"}
testCases := []struct {
name string
configScopes []string // Scopes from Traefik config
overrideScopes bool
isGoogle bool
isAzure bool
expectedScopeString string // Expected final scope string in the auth URL
expectedParams map[string]string
}{
{
name: "Deduplication: Default append, duplicate in user scopes",
configScopes: []string{"openid", "custom", "profile", "custom"},
overrideScopes: false,
expectedScopeString: "openid profile email custom offline_access",
},
{
name: "Deduplication: Override, duplicate in user scopes",
configScopes: []string{"openid", "custom", "profile", "custom"},
overrideScopes: true,
expectedScopeString: "openid custom profile", // offline_access not added
},
{
name: "Override True: No automatic offline_access",
configScopes: []string{"scope1", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 scope2",
},
{
name: "Override True: User includes offline_access",
configScopes: []string{"scope1", "offline_access", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 offline_access scope2",
},
{
name: "Override False: Automatic offline_access added",
configScopes: []string{"scope1", "scope2"},
overrideScopes: false,
expectedScopeString: "openid profile email scope1 scope2 offline_access",
},
{
name: "Override False: User includes offline_access (deduplicated)",
configScopes: []string{"scope1", "offline_access", "scope2"},
overrideScopes: false,
expectedScopeString: "openid profile email scope1 offline_access scope2",
},
{
name: "Integration: Duplicate scopes in config, override true",
configScopes: []string{"scope1", "scope1", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 scope2",
},
{
name: "Integration: No auto offline_access with override true",
configScopes: []string{"scope1", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 scope2",
},
{
name: "Integration: Duplicates and no auto offline_access with override true",
configScopes: []string{"scope1", "scope1", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 scope2",
},
{
name: "Integration: Google provider, override false, no user scopes",
configScopes: []string{},
overrideScopes: false,
isGoogle: true,
expectedScopeString: "openid profile email", // Google uses access_type=offline param
expectedParams: map[string]string{"access_type": "offline", "prompt": "consent"},
},
{
name: "Integration: Google provider, override true, user scopes",
configScopes: []string{"custom1", "custom2"},
overrideScopes: true,
isGoogle: true,
expectedScopeString: "custom1 custom2", // Google uses access_type=offline param
expectedParams: map[string]string{"access_type": "offline", "prompt": "consent"},
},
{
name: "Integration: Azure provider, override false, no user scopes",
configScopes: []string{},
overrideScopes: false,
isAzure: true,
expectedScopeString: "openid profile email offline_access", // Azure adds offline_access scope
expectedParams: map[string]string{"response_mode": "query"},
},
{
name: "Integration: Azure provider, override true, user scopes without offline_access",
configScopes: []string{"custom1", "custom2"},
overrideScopes: true,
isAzure: true,
expectedScopeString: "custom1 custom2", // Azure respects override
expectedParams: map[string]string{"response_mode": "query"},
},
{
name: "Integration: Azure provider, override true, user scopes with offline_access",
configScopes: []string{"custom1", "offline_access"},
overrideScopes: true,
isAzure: true,
expectedScopeString: "custom1 offline_access", // Azure respects override
expectedParams: map[string]string{"response_mode": "query"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Simulate the TraefikOidc instance's scope initialization
var initializedScopes []string
uniqueConfigScopes := deduplicateScopes(tc.configScopes)
if tc.overrideScopes {
initializedScopes = uniqueConfigScopes
} else {
initializedScopes = mergeScopes(defaultInitialScopes, uniqueConfigScopes)
}
// Create a new TraefikOidc instance for this test case
// to ensure proper isolation of 'scopes' and 'overrideScopes' fields.
// We use parts of the TestSuite's tOidc for common setup like logger, clientID etc.
// but override the scope-related fields.
testOidc := &TraefikOidc{
clientID: ts.tOidc.clientID,
logger: ts.tOidc.logger,
scopes: initializedScopes, // Use scopes processed as New() would
overrideScopes: tc.overrideScopes,
// Set other necessary fields for buildAuthURL to function
authURL: "https://provider.com/auth", // Dummy authURL
issuerURL: "https://provider.com", // Dummy issuerURL
httpClient: ts.tOidc.httpClient, // Reuse from TestSuite
}
originalIssuerURL := testOidc.issuerURL
if tc.isGoogle {
testOidc.issuerURL = "https://accounts.google.com"
} else if tc.isAzure {
testOidc.issuerURL = "https://login.microsoftonline.com/common"
}
authURLString := testOidc.buildAuthURL("http://localhost/callback", "state", "nonce", "challenge")
parsedURL, err := url.Parse(authURLString)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
query := parsedURL.Query()
actualScopeString := query.Get("scope")
if actualScopeString != tc.expectedScopeString {
t.Errorf("Expected scope string %q, got %q", tc.expectedScopeString, actualScopeString)
}
if tc.expectedParams != nil {
for k, v := range tc.expectedParams {
if query.Get(k) != v {
t.Errorf("Expected param %s=%s, got %s", k, v, query.Get(k))
}
}
}
testOidc.issuerURL = originalIssuerURL // Restore
})
}
}
+403 -49
View File
@@ -389,8 +389,8 @@ func TestMissingClaims(t *testing.T) {
// Test cases for missing claims
testCases := []struct {
name string
omittedClaims []string
expectedError string
omittedClaims []string
}{
{
name: "Missing Issuer",
@@ -479,8 +479,8 @@ func TestSessionFixationAttack(t *testing.T) {
// Set up the attacker's session with malicious data
attackerSession.SetAuthenticated(true)
attackerSession.SetEmail("[email protected]")
attackerSession.SetIDToken("fake-id-token")
attackerSession.SetAccessToken("fake-access-token")
attackerSession.SetIDToken(ValidIDToken)
attackerSession.SetAccessToken(ValidAccessToken)
// Save the session to get cookies
if err := attackerSession.Save(req, resp); err != nil {
@@ -510,6 +510,31 @@ func TestSessionFixationAttack(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
// Create keys for JWT verification
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
rsaPublicKey := &rsaPrivateKey.PublicKey
// Create JWK
jwk := JWK{
Kty: "RSA",
Kid: "test-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), // 65537 in bytes
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
// Create mock JWK cache
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
// Create the TraefikOidc middleware
tOidc := &TraefikOidc{
next: nextHandler,
@@ -519,6 +544,8 @@ func TestSessionFixationAttack(t *testing.T) {
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
jwkCache: mockJWKCache,
jwksURL: "https://test-jwks-url.com",
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
@@ -528,7 +555,13 @@ func TestSessionFixationAttack(t *testing.T) {
httpClient: &http.Client{},
initComplete: make(chan struct{}),
sessionManager: sm,
extractClaimsFunc: extractClaims,
}
// Set up the token verifier and JWT verifier
tOidc.jwtVerifier = tOidc
tOidc.tokenVerifier = tOidc
close(tOidc.initComplete)
// Now create a victim's request with the attacker's cookies
@@ -581,9 +614,186 @@ func TestSessionFixationAttack(t *testing.T) {
// TestCSRFProtection tests the plugin's CSRF protection mechanisms
// TestCSRFProtection tests CSRF protection in POST requests
func TestCSRFProtection(t *testing.T) {
// Simply pass this test since we're focusing on the token and JTI checks
// The original CSRF test causes problems with nil pointer access
t.Skip("Skipping CSRF test to focus on token security")
logger := NewLogger("debug")
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Test case 1: Valid CSRF token should succeed
t.Run("Valid CSRF token", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://example.com/protected", nil)
resp := httptest.NewRecorder()
// Create a session and set CSRF token
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
csrfToken := "valid-csrf-token-12345"
session.SetCSRF(csrfToken)
if err := session.Save(req, resp); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from response
cookies := resp.Result().Cookies()
// Create new request with CSRF token in header and cookies
req = httptest.NewRequest("POST", "http://example.com/protected", nil)
req.Header.Set("X-CSRF-Token", csrfToken)
for _, cookie := range cookies {
req.AddCookie(cookie)
}
// Get session again to verify CSRF
session, err = sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session with cookies: %v", err)
}
sessionCSRF := session.GetCSRF()
if sessionCSRF != csrfToken {
t.Errorf("CSRF token mismatch: expected %s, got %s", csrfToken, sessionCSRF)
}
// Verify CSRF token matches
headerCSRF := req.Header.Get("X-CSRF-Token")
if headerCSRF != sessionCSRF {
t.Errorf("CSRF validation failed: header token %s != session token %s", headerCSRF, sessionCSRF)
}
})
// Test case 2: Missing CSRF token should fail
t.Run("Missing CSRF token", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://example.com/protected", nil)
resp := httptest.NewRecorder()
// Create a session with CSRF token
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
csrfToken := "expected-csrf-token-67890"
session.SetCSRF(csrfToken)
if err := session.Save(req, resp); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from response
cookies := resp.Result().Cookies()
// Create new request WITHOUT CSRF token in header but with cookies
req = httptest.NewRequest("POST", "http://example.com/protected", nil)
// Intentionally NOT setting X-CSRF-Token header
for _, cookie := range cookies {
req.AddCookie(cookie)
}
// Get session to verify CSRF exists
session, err = sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session with cookies: %v", err)
}
sessionCSRF := session.GetCSRF()
headerCSRF := req.Header.Get("X-CSRF-Token")
// This should fail - no CSRF token in header
if headerCSRF == sessionCSRF && headerCSRF != "" {
t.Errorf("CSRF protection failed: request without CSRF token was accepted")
}
if headerCSRF == "" && sessionCSRF != "" {
t.Logf("CSRF protection working: missing header token, session has %s", sessionCSRF)
}
})
// Test case 3: Invalid CSRF token should fail
t.Run("Invalid CSRF token", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://example.com/protected", nil)
resp := httptest.NewRecorder()
// Create a session with CSRF token
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
csrfToken := "valid-csrf-token-abcdef"
session.SetCSRF(csrfToken)
if err := session.Save(req, resp); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from response
cookies := resp.Result().Cookies()
// Create new request with WRONG CSRF token in header
req = httptest.NewRequest("POST", "http://example.com/protected", nil)
req.Header.Set("X-CSRF-Token", "wrong-csrf-token-xyz")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
// Get session to verify CSRF
session, err = sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session with cookies: %v", err)
}
sessionCSRF := session.GetCSRF()
headerCSRF := req.Header.Get("X-CSRF-Token")
// This should fail - wrong CSRF token
if headerCSRF == sessionCSRF {
t.Errorf("CSRF protection failed: request with wrong CSRF token was accepted")
}
if headerCSRF != sessionCSRF {
t.Logf("CSRF protection working: header token %s != session token %s", headerCSRF, sessionCSRF)
}
})
// Test case 4: CSRF token generation and validation
t.Run("CSRF token generation", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/login", nil)
resp := httptest.NewRecorder()
// Create a session
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Generate and set CSRF token
csrfToken := generateRandomString(32)
if len(csrfToken) != 32 {
t.Errorf("CSRF token length incorrect: expected 32, got %d", len(csrfToken))
}
session.SetCSRF(csrfToken)
if err := session.Save(req, resp); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Verify token was stored
storedToken := session.GetCSRF()
if storedToken != csrfToken {
t.Errorf("CSRF token storage failed: expected %s, got %s", csrfToken, storedToken)
}
// Verify token is not empty and has reasonable entropy
if storedToken == "" {
t.Error("CSRF token is empty")
}
if len(storedToken) < 16 {
t.Errorf("CSRF token too short: %d characters", len(storedToken))
}
})
}
// TestTokenBlacklisting tests the token blacklisting mechanism
@@ -676,79 +886,124 @@ func TestTokenBlacklisting(t *testing.T) {
// TestDifferentSigningAlgorithms tests that the plugin properly handles different signing algorithms
func TestDifferentSigningAlgorithms(t *testing.T) {
// Skip this test as the current implementation only supports RS256
// and rate limiting in tests causes issues with multiple algorithm tests
t.Skip("Skipping different signing algorithms test as implementation only supports RS256")
ts := &TestSuite{t: t}
ts.Setup()
// Test cases for different algorithms
// Test cases for different algorithms - the implementation actually supports multiple algorithms
testCases := []struct {
name string
algorithm string
keyType string
shouldSucceed bool
}{
// RSA algorithms
{"RS256 Algorithm", "RS256", "RSA", true},
// Currently, only RS256 is supported in our implementation
// Other algorithms are left commented out to document what could be supported
// {"RS384 Algorithm", "RS384", "RSA", true},
// {"RS512 Algorithm", "RS512", "RSA", true},
// {"PS256 Algorithm", "PS256", "RSA", true},
// {"PS384 Algorithm", "PS384", "RSA", true},
// {"PS512 Algorithm", "PS512", "RSA", true},
// {"ES256 Algorithm", "ES256", "EC", true},
// {"ES384 Algorithm", "ES384", "EC", true},
// {"ES512 Algorithm", "ES512", "EC", true},
{"RS384 Algorithm", "RS384", "RSA", true},
{"RS512 Algorithm", "RS512", "RSA", true},
{"PS256 Algorithm", "PS256", "RSA", true},
{"PS384 Algorithm", "PS384", "RSA", true},
{"PS512 Algorithm", "PS512", "RSA", true},
// EC algorithms
{"ES256 Algorithm", "ES256", "EC", true},
{"ES384 Algorithm", "ES384", "EC", true},
{"ES512 Algorithm", "ES512", "EC", true},
// Unsupported algorithms
{"HS256 Algorithm", "HS256", "RSA", false},
// {"HS384 Algorithm", "HS384", "RSA", false},
// {"HS512 Algorithm", "HS512", "RSA", false},
}
// Define standard claims
standardClaims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16),
{"HS384 Algorithm", "HS384", "RSA", false},
{"HS512 Algorithm", "HS512", "RSA", false},
{"None Algorithm", "none", "RSA", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Define standard claims with unique JTI for each test
standardClaims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16), // Generate unique JTI for each test
}
var jwtToken string
var err error
// Use appropriate key type
// Use appropriate key type and create corresponding JWK
if tc.keyType == "RSA" {
// Update the RSA JWK to support the current algorithm
rsaJWK := JWK{
Kty: "RSA",
Kid: "test-key-id",
Alg: tc.algorithm, // Use the algorithm being tested
N: base64.RawURLEncoding.EncodeToString(ts.rsaPrivateKey.PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), // 65537 in bytes
}
// Update the mock JWK cache with the correct algorithm
ts.mockJWKCache.JWKS = &JWKSet{
Keys: []JWK{rsaJWK},
}
jwtToken, err = createTestJWT(ts.rsaPrivateKey, tc.algorithm, "test-key-id", standardClaims)
if err != nil {
if !tc.shouldSucceed {
t.Logf("Expected failure creating JWT with %s algorithm: %v", tc.algorithm, err)
return // This is expected for unsupported algorithms
}
t.Fatalf("Failed to create JWT with %s algorithm: %v", tc.algorithm, err)
}
} else if tc.keyType == "EC" {
// We need to create an EC key
if ts.ecPrivateKey == nil {
ts.ecPrivateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("Failed to generate EC key: %v", err)
}
// Generate EC key for the specific curve
var curve elliptic.Curve
switch tc.algorithm {
case "ES256":
curve = elliptic.P256()
case "ES384":
curve = elliptic.P384()
case "ES512":
curve = elliptic.P521()
default:
t.Fatalf("Unsupported EC algorithm: %s", tc.algorithm)
}
ecPrivateKey, err := ecdsa.GenerateKey(curve, rand.Reader)
if err != nil {
t.Fatalf("Failed to generate EC key for %s: %v", tc.algorithm, err)
}
// Create EC JWK for this test
ecJWK := createECJWK(ecPrivateKey, tc.algorithm, "test-ec-key-id")
// Replace the JWK cache entirely with just the EC key for this test
ts.mockJWKCache.JWKS = &JWKSet{
Keys: []JWK{ecJWK},
}
// Ensure rate limiter is initialized for EC tests
if ts.tOidc.limiter == nil {
ts.tOidc.limiter = rate.NewLimiter(rate.Every(time.Second), 10)
}
jwtToken, err = createTestJWTWithECKey(ecPrivateKey, tc.algorithm, "test-ec-key-id", standardClaims)
if err != nil {
t.Fatalf("Failed to create JWT with %s algorithm: %v", tc.algorithm, err)
}
jwtToken, err = createTestJWTWithECKey(ts.ecPrivateKey, tc.algorithm, "test-key-id", standardClaims)
} else {
t.Fatalf("Unsupported key type: %s", tc.keyType)
}
if err != nil {
t.Fatalf("Failed to create JWT with %s algorithm: %v", tc.algorithm, err)
}
// Verify the token
err = ts.tOidc.VerifyToken(jwtToken)
if tc.shouldSucceed {
if err != nil {
t.Errorf("Verification with %s failed: %v", tc.algorithm, err)
} else {
t.Logf("Successfully verified token with %s algorithm", tc.algorithm)
}
} else {
if err == nil {
@@ -757,6 +1012,8 @@ func TestDifferentSigningAlgorithms(t *testing.T) {
// Check that the error message indicates unsupported algorithm
if !strings.Contains(err.Error(), "unsupported algorithm") {
t.Errorf("Expected unsupported algorithm error for %s, but got: %v", tc.algorithm, err)
} else {
t.Logf("Correctly rejected unsupported algorithm %s: %v", tc.algorithm, err)
}
}
}
@@ -801,7 +1058,20 @@ func createTestJWTWithECKey(privateKey *ecdsa.PrivateKey, alg, kid string, claim
if err != nil {
return "", fmt.Errorf("failed to sign with ES256: %v", err)
}
signature = append(r.Bytes(), s.Bytes()...)
// For ES256, each coordinate should be 32 bytes (256 bits / 8)
rBytes := r.Bytes()
sBytes := s.Bytes()
if len(rBytes) < 32 {
padded := make([]byte, 32)
copy(padded[32-len(rBytes):], rBytes)
rBytes = padded
}
if len(sBytes) < 32 {
padded := make([]byte, 32)
copy(padded[32-len(sBytes):], sBytes)
sBytes = padded
}
signature = append(rBytes, sBytes...)
case "ES384":
h := crypto.SHA384.New()
h.Write([]byte(signingInput))
@@ -810,7 +1080,27 @@ func createTestJWTWithECKey(privateKey *ecdsa.PrivateKey, alg, kid string, claim
if err != nil {
return "", fmt.Errorf("failed to sign with ES384: %v", err)
}
signature = append(r.Bytes(), s.Bytes()...)
// For ES384 (P-384), each coordinate should be 48 bytes (384 bits / 8)
rBytes := r.Bytes()
sBytes := s.Bytes()
// Pad to exactly 48 bytes each
if len(rBytes) < 48 {
padded := make([]byte, 48)
copy(padded[48-len(rBytes):], rBytes)
rBytes = padded
} else if len(rBytes) > 48 {
// Truncate if too long (shouldn't happen with P-384)
rBytes = rBytes[len(rBytes)-48:]
}
if len(sBytes) < 48 {
padded := make([]byte, 48)
copy(padded[48-len(sBytes):], sBytes)
sBytes = padded
} else if len(sBytes) > 48 {
// Truncate if too long (shouldn't happen with P-384)
sBytes = sBytes[len(sBytes)-48:]
}
signature = append(rBytes, sBytes...)
case "ES512":
h := crypto.SHA512.New()
h.Write([]byte(signingInput))
@@ -819,7 +1109,27 @@ func createTestJWTWithECKey(privateKey *ecdsa.PrivateKey, alg, kid string, claim
if err != nil {
return "", fmt.Errorf("failed to sign with ES512: %v", err)
}
signature = append(r.Bytes(), s.Bytes()...)
// For ES512 (P-521), each coordinate should be 66 bytes (521 bits / 8 = 65.125, rounded up to 66)
rBytes := r.Bytes()
sBytes := s.Bytes()
// Pad to 66 bytes each
if len(rBytes) < 66 {
padded := make([]byte, 66)
copy(padded[66-len(rBytes):], rBytes)
rBytes = padded
} else if len(rBytes) > 66 {
// Truncate if too long (shouldn't happen with P-521)
rBytes = rBytes[len(rBytes)-66:]
}
if len(sBytes) < 66 {
padded := make([]byte, 66)
copy(padded[66-len(sBytes):], sBytes)
sBytes = padded
} else if len(sBytes) > 66 {
// Truncate if too long (shouldn't happen with P-521)
sBytes = sBytes[len(sBytes)-66:]
}
signature = append(rBytes, sBytes...)
default:
return "", fmt.Errorf("unsupported EC algorithm: %s", alg)
}
@@ -831,6 +1141,50 @@ func createTestJWTWithECKey(privateKey *ecdsa.PrivateKey, alg, kid string, claim
return signingInput + "." + signatureBase64, nil
}
// createECJWK creates a JWK from an EC private key
func createECJWK(privateKey *ecdsa.PrivateKey, alg, kid string) JWK {
// Get the curve name
var crv string
switch privateKey.Curve {
case elliptic.P256():
crv = "P-256"
case elliptic.P384():
crv = "P-384"
case elliptic.P521():
crv = "P-521"
default:
panic("unsupported curve")
}
// Get the key size for coordinate encoding
keySize := (privateKey.Curve.Params().BitSize + 7) / 8
// Encode X and Y coordinates
xBytes := privateKey.PublicKey.X.Bytes()
yBytes := privateKey.PublicKey.Y.Bytes()
// Pad to the correct length
if len(xBytes) < keySize {
padded := make([]byte, keySize)
copy(padded[keySize-len(xBytes):], xBytes)
xBytes = padded
}
if len(yBytes) < keySize {
padded := make([]byte, keySize)
copy(padded[keySize-len(yBytes):], yBytes)
yBytes = padded
}
return JWK{
Kty: "EC",
Kid: kid,
Alg: alg,
Crv: crv,
X: base64.RawURLEncoding.EncodeToString(xBytes),
Y: base64.RawURLEncoding.EncodeToString(yBytes),
}
}
// TestMalformedTokens tests the plugin's handling of malformed tokens
func TestMalformedTokens(t *testing.T) {
ts := &TestSuite{t: t}
+566
View File
@@ -0,0 +1,566 @@
package traefikoidc
import (
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)
// SecurityEventType represents different types of security events
type SecurityEventType string
const (
// AuthFailure represents an authentication failure event
AuthFailure SecurityEventType = "authentication_failure"
// TokenValidFailure represents a token validation failure event
TokenValidFailure SecurityEventType = "token_validation_failure"
// RateLimitHit represents a rate limit hit event
RateLimitHit SecurityEventType = "rate_limit_hit"
// SuspiciousActivity represents a suspicious activity event
SuspiciousActivity SecurityEventType = "suspicious_activity"
)
// DefaultSeverity returns the default severity level for a security event type
func (t SecurityEventType) DefaultSeverity() string {
switch t {
case AuthFailure:
return "medium"
case TokenValidFailure:
return "medium"
case RateLimitHit:
return "low"
case SuspiciousActivity:
return "high"
default:
return "medium"
}
}
// IPFailureType returns the IP failure tracking type for a security event type
func (t SecurityEventType) IPFailureType() string {
switch t {
case AuthFailure:
return "auth_failure"
case TokenValidFailure:
return "token_failure"
case SuspiciousActivity:
return "suspicious"
default:
return "general"
}
}
// SecurityEvent represents a security-related event that should be logged and monitored
type SecurityEvent struct {
Timestamp time.Time `json:"timestamp"`
Details map[string]interface{} `json:"details,omitempty"`
Type string `json:"type"`
Severity string `json:"severity"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
RequestPath string `json:"request_path"`
Message string `json:"message"`
}
// SecurityMonitor tracks security events and suspicious activity patterns
type SecurityMonitor struct {
ipFailures map[string]*IPFailureTracker
patternDetector *SuspiciousPatternDetector
logger *Logger
eventHandlers []SecurityEventHandler
config SecurityMonitorConfig
ipMutex sync.RWMutex
}
// IPFailureTracker tracks failures for a specific IP address
type IPFailureTracker struct {
LastFailure time.Time
FirstFailure time.Time
BlockedUntil time.Time
FailureTypes map[string]int64
FailureCount int64
mutex sync.RWMutex
IsBlocked bool
}
// SuspiciousPatternDetector identifies patterns that may indicate attacks
type SuspiciousPatternDetector struct {
recentEvents []SecurityEvent
shortWindow time.Duration
mediumWindow time.Duration
longWindow time.Duration
rapidFailureThreshold int
distributedAttackThreshold int
persistentAttackThreshold int
eventsMutex sync.RWMutex
}
// SecurityEventHandler defines the interface for handling security events
type SecurityEventHandler interface {
HandleSecurityEvent(event SecurityEvent)
}
// SecurityMonitorConfig contains configuration for the security monitor
type SecurityMonitorConfig struct {
MaxFailuresPerIP int `json:"max_failures_per_ip"`
FailureWindowMinutes int `json:"failure_window_minutes"`
BlockDurationMinutes int `json:"block_duration_minutes"`
RapidFailureThreshold int `json:"rapid_failure_threshold"`
CleanupIntervalMinutes int `json:"cleanup_interval_minutes"`
RetentionHours int `json:"retention_hours"`
EnablePatternDetection bool `json:"enable_pattern_detection"`
EnableDetailedLogging bool `json:"enable_detailed_logging"`
LogSuspiciousOnly bool `json:"log_suspicious_only"`
}
// DefaultSecurityMonitorConfig returns a default configuration
func DefaultSecurityMonitorConfig() SecurityMonitorConfig {
return SecurityMonitorConfig{
MaxFailuresPerIP: 10,
FailureWindowMinutes: 15,
BlockDurationMinutes: 60,
EnablePatternDetection: true,
RapidFailureThreshold: 5,
EnableDetailedLogging: true,
LogSuspiciousOnly: false,
CleanupIntervalMinutes: 30,
RetentionHours: 24,
}
}
// cleanupTask holds the BackgroundTask for security cleanup
var cleanupTask *BackgroundTask
// NewSecurityMonitor creates a new security monitor instance
func NewSecurityMonitor(config SecurityMonitorConfig, logger *Logger) *SecurityMonitor {
sm := &SecurityMonitor{
ipFailures: make(map[string]*IPFailureTracker),
eventHandlers: make([]SecurityEventHandler, 0),
config: config,
logger: logger,
patternDetector: NewSuspiciousPatternDetector(),
}
// Start cleanup routine
sm.startCleanupRoutine()
return sm
}
// NewSuspiciousPatternDetector creates a new pattern detector
func NewSuspiciousPatternDetector() *SuspiciousPatternDetector {
return &SuspiciousPatternDetector{
shortWindow: 1 * time.Minute,
mediumWindow: 5 * time.Minute,
longWindow: 15 * time.Minute,
rapidFailureThreshold: 5,
distributedAttackThreshold: 20,
persistentAttackThreshold: 50,
recentEvents: make([]SecurityEvent, 0),
}
}
// RecordSecurityEvent is a generic method to record any type of security event
func (sm *SecurityMonitor) RecordSecurityEvent(
eventType SecurityEventType,
clientIP, userAgent, requestPath string,
message string,
details map[string]interface{},
trackIPFailure bool) {
// Create event with default values for the event type
event := SecurityEvent{
Type: string(eventType),
Severity: eventType.DefaultSeverity(),
Timestamp: time.Now(),
ClientIP: clientIP,
UserAgent: userAgent,
RequestPath: requestPath,
Message: message,
Details: details,
}
// Track IP failures if requested
if trackIPFailure {
sm.recordIPFailure(clientIP, eventType.IPFailureType())
}
// Process the event
sm.processSecurityEvent(event)
}
// RecordAuthenticationFailure records an authentication failure event
func (sm *SecurityMonitor) RecordAuthenticationFailure(clientIP, userAgent, requestPath, reason string, details map[string]interface{}) {
if details == nil {
details = make(map[string]interface{})
}
details["reason"] = reason
sm.RecordSecurityEvent(
AuthFailure,
clientIP,
userAgent,
requestPath,
fmt.Sprintf("Authentication failed: %s", reason),
details,
true,
)
}
// RecordTokenValidationFailure records a token validation failure
func (sm *SecurityMonitor) RecordTokenValidationFailure(clientIP, userAgent, requestPath, reason string, tokenPrefix string) {
details := map[string]interface{}{
"reason": reason,
}
if tokenPrefix != "" {
details["token_prefix"] = tokenPrefix
}
sm.RecordSecurityEvent(
TokenValidFailure,
clientIP,
userAgent,
requestPath,
fmt.Sprintf("Token validation failed: %s", reason),
details,
true,
)
}
// RecordRateLimitHit records when rate limiting is triggered
func (sm *SecurityMonitor) RecordRateLimitHit(clientIP, userAgent, requestPath string) {
details := map[string]interface{}{
"limit_type": "token_verification",
}
sm.RecordSecurityEvent(
RateLimitHit,
clientIP,
userAgent,
requestPath,
"Rate limit exceeded",
details,
true, // Track IP failure for rate limiting
)
}
// RecordSuspiciousActivity records suspicious activity that doesn't fit other categories
func (sm *SecurityMonitor) RecordSuspiciousActivity(clientIP, userAgent, requestPath, activityType, description string, details map[string]interface{}) {
if details == nil {
details = make(map[string]interface{})
}
details["activity_type"] = activityType
sm.RecordSecurityEvent(
SuspiciousActivity,
clientIP,
userAgent,
requestPath,
fmt.Sprintf("Suspicious activity detected: %s - %s", activityType, description),
details,
true,
)
}
// recordIPFailure tracks failures for a specific IP address
func (sm *SecurityMonitor) recordIPFailure(clientIP, failureType string) {
sm.ipMutex.Lock()
defer sm.ipMutex.Unlock()
tracker, exists := sm.ipFailures[clientIP]
if !exists {
tracker = &IPFailureTracker{
FailureTypes: make(map[string]int64),
FirstFailure: time.Now(),
}
sm.ipFailures[clientIP] = tracker
}
tracker.mutex.Lock()
defer tracker.mutex.Unlock()
tracker.FailureCount++
tracker.LastFailure = time.Now()
tracker.FailureTypes[failureType]++
// Check if IP should be blocked
windowStart := time.Now().Add(-time.Duration(sm.config.FailureWindowMinutes) * time.Minute)
if tracker.FirstFailure.After(windowStart) && tracker.FailureCount >= int64(sm.config.MaxFailuresPerIP) {
if !tracker.IsBlocked {
tracker.IsBlocked = true
tracker.BlockedUntil = time.Now().Add(time.Duration(sm.config.BlockDurationMinutes) * time.Minute)
sm.logger.Errorf("IP %s blocked due to %d failures (types: %v)", clientIP, tracker.FailureCount, tracker.FailureTypes)
// Record blocking event
blockEvent := SecurityEvent{
Type: "ip_blocked",
Severity: "high",
Timestamp: time.Now(),
ClientIP: clientIP,
Message: fmt.Sprintf("IP blocked due to %d failures in %d minutes", tracker.FailureCount, sm.config.FailureWindowMinutes),
Details: map[string]interface{}{
"failure_count": tracker.FailureCount,
"failure_types": tracker.FailureTypes,
"blocked_until": tracker.BlockedUntil,
},
}
sm.processSecurityEvent(blockEvent)
}
}
}
// IsIPBlocked checks if an IP address is currently blocked
func (sm *SecurityMonitor) IsIPBlocked(clientIP string) bool {
sm.ipMutex.RLock()
defer sm.ipMutex.RUnlock()
tracker, exists := sm.ipFailures[clientIP]
if !exists {
return false
}
tracker.mutex.RLock()
defer tracker.mutex.RUnlock()
if tracker.IsBlocked && time.Now().Before(tracker.BlockedUntil) {
return true
}
// Unblock if time has passed
if tracker.IsBlocked && time.Now().After(tracker.BlockedUntil) {
tracker.IsBlocked = false
sm.logger.Infof("IP %s automatically unblocked", clientIP)
}
return false
}
// processSecurityEvent processes a security event through all handlers and pattern detection
func (sm *SecurityMonitor) processSecurityEvent(event SecurityEvent) {
// Add to pattern detector
if sm.config.EnablePatternDetection {
sm.patternDetector.AddEvent(event)
// Check for suspicious patterns
if patterns := sm.patternDetector.DetectSuspiciousPatterns(); len(patterns) > 0 {
for _, pattern := range patterns {
sm.logger.Errorf("Suspicious pattern detected: %s", pattern)
patternEvent := SecurityEvent{
Type: "suspicious_pattern",
Severity: "high",
Timestamp: time.Now(),
Message: fmt.Sprintf("Suspicious pattern detected: %s", pattern),
Details: map[string]interface{}{
"pattern_type": pattern,
"trigger_event": event,
},
}
sm.handleSecurityEvent(patternEvent)
}
}
}
sm.handleSecurityEvent(event)
}
// handleSecurityEvent sends the event to all registered handlers
func (sm *SecurityMonitor) handleSecurityEvent(event SecurityEvent) {
// Log the event
if sm.config.EnableDetailedLogging && (!sm.config.LogSuspiciousOnly || event.Severity == "high") {
sm.logger.Infof("Security Event [%s/%s]: %s (IP: %s, Path: %s)",
event.Type, event.Severity, event.Message, event.ClientIP, event.RequestPath)
}
// Send to all handlers
for _, handler := range sm.eventHandlers {
go handler.HandleSecurityEvent(event)
}
}
// AddEventHandler adds a security event handler
func (sm *SecurityMonitor) AddEventHandler(handler SecurityEventHandler) {
sm.eventHandlers = append(sm.eventHandlers, handler)
}
// GetSecurityMetrics returns minimal security metrics
// This is kept for API compatibility but doesn't collect actual metrics
func (sm *SecurityMonitor) GetSecurityMetrics() map[string]interface{} {
return map[string]interface{}{
"tracked_ips": 0,
}
}
// AddEvent adds an event to the pattern detector
func (spd *SuspiciousPatternDetector) AddEvent(event SecurityEvent) {
spd.eventsMutex.Lock()
defer spd.eventsMutex.Unlock()
spd.recentEvents = append(spd.recentEvents, event)
// Clean old events
cutoff := time.Now().Add(-spd.longWindow)
var filteredEvents []SecurityEvent
for _, e := range spd.recentEvents {
if e.Timestamp.After(cutoff) {
filteredEvents = append(filteredEvents, e)
}
}
spd.recentEvents = filteredEvents
}
// DetectSuspiciousPatterns analyzes recent events for suspicious patterns
func (spd *SuspiciousPatternDetector) DetectSuspiciousPatterns() []string {
spd.eventsMutex.RLock()
defer spd.eventsMutex.RUnlock()
var patterns []string
now := time.Now()
// Check for rapid failures from single IP
ipCounts := make(map[string]int)
shortWindowStart := now.Add(-spd.shortWindow)
for _, event := range spd.recentEvents {
if event.Timestamp.After(shortWindowStart) &&
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
ipCounts[event.ClientIP]++
}
}
for ip, count := range ipCounts {
if count >= spd.rapidFailureThreshold {
patterns = append(patterns, fmt.Sprintf("rapid_failures_from_ip_%s", ip))
}
}
// Check for distributed attack (many IPs failing)
mediumWindowStart := now.Add(-spd.mediumWindow)
uniqueFailingIPs := make(map[string]bool)
for _, event := range spd.recentEvents {
if event.Timestamp.After(mediumWindowStart) &&
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
uniqueFailingIPs[event.ClientIP] = true
}
}
if len(uniqueFailingIPs) >= spd.distributedAttackThreshold {
patterns = append(patterns, "distributed_attack_pattern")
}
// Check for persistent attack
longWindowStart := now.Add(-spd.longWindow)
persistentFailures := 0
for _, event := range spd.recentEvents {
if event.Timestamp.After(longWindowStart) &&
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
persistentFailures++
}
}
if persistentFailures >= spd.persistentAttackThreshold {
patterns = append(patterns, "persistent_attack_pattern")
}
return patterns
}
// startCleanupRoutine starts the background cleanup routine
func (sm *SecurityMonitor) startCleanupRoutine() {
// Use BackgroundTask abstraction for consistent management
cleanupTask = NewBackgroundTask(
"security-monitor-cleanup",
time.Duration(sm.config.CleanupIntervalMinutes)*time.Minute,
sm.cleanup,
sm.logger)
cleanupTask.Start()
}
// StopCleanupRoutine stops the background cleanup routine
func (sm *SecurityMonitor) StopCleanupRoutine() {
if cleanupTask != nil {
cleanupTask.Stop()
cleanupTask = nil
}
}
// cleanup removes old tracking data
func (sm *SecurityMonitor) cleanup() {
sm.ipMutex.Lock()
defer sm.ipMutex.Unlock()
cutoff := time.Now().Add(-time.Duration(sm.config.RetentionHours) * time.Hour)
for ip, tracker := range sm.ipFailures {
tracker.mutex.RLock()
shouldRemove := tracker.LastFailure.Before(cutoff) && !tracker.IsBlocked
tracker.mutex.RUnlock()
if shouldRemove {
delete(sm.ipFailures, ip)
}
}
sm.logger.Debugf("Security monitor cleanup completed, tracking %d IPs", len(sm.ipFailures))
}
// ExtractClientIP extracts the client IP from the request, considering proxy headers
func ExtractClientIP(r *http.Request) string {
// Check X-Real-IP header first (highest priority)
if xri := r.Header.Get("X-Real-IP"); xri != "" {
if net.ParseIP(xri) != nil {
return xri
}
}
// Check X-Forwarded-For header second
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first IP in the chain
ips := strings.Split(xff, ",")
if len(ips) > 0 {
ip := strings.TrimSpace(ips[0])
if net.ParseIP(ip) != nil {
return ip
}
}
}
// Fall back to RemoteAddr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// LoggingSecurityEventHandler logs security events to the standard logger
type LoggingSecurityEventHandler struct {
logger *Logger
}
// NewLoggingSecurityEventHandler creates a new logging event handler
func NewLoggingSecurityEventHandler(logger *Logger) *LoggingSecurityEventHandler {
return &LoggingSecurityEventHandler{logger: logger}
}
// HandleSecurityEvent implements SecurityEventHandler
func (h *LoggingSecurityEventHandler) HandleSecurityEvent(event SecurityEvent) {
switch event.Severity {
case "high":
h.logger.Errorf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
case "medium":
h.logger.Errorf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
case "low":
h.logger.Infof("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
default:
h.logger.Debugf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
}
}
// Note: MetricsSecurityEventHandler has been removed as part of metrics cleanup
+285
View File
@@ -0,0 +1,285 @@
package traefikoidc
import (
"net/http/httptest"
"strconv"
"testing"
"time"
)
func TestSecurityMonitor(t *testing.T) {
config := DefaultSecurityMonitorConfig()
config.MaxFailuresPerIP = 3
config.BlockDurationMinutes = 1 // 1 minute for testing
config.CleanupIntervalMinutes = 1
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
defer func() {
// Allow cleanup goroutine to finish
time.Sleep(150 * time.Millisecond)
}()
t.Run("Record authentication failure", func(t *testing.T) {
monitor.RecordAuthenticationFailure("192.168.1.1", "test-agent", "/login", "invalid credentials", nil)
// Should not be blocked after first failure
if monitor.IsIPBlocked("192.168.1.1") {
t.Error("IP should not be blocked after first failure")
}
})
t.Run("IP blocked after max failures", func(t *testing.T) {
// Record multiple failures
for i := 0; i < config.MaxFailuresPerIP; i++ {
monitor.RecordAuthenticationFailure("192.168.1.2", "test-agent", "/login", "invalid credentials", nil)
}
// Should be blocked now
if !monitor.IsIPBlocked("192.168.1.2") {
t.Error("IP should be blocked after max failures")
}
})
t.Run("Token validation failure", func(t *testing.T) {
// Just verify the method doesn't panic
monitor.RecordTokenValidationFailure("192.168.1.3", "test-agent", "/api", "invalid token", "abc123")
})
t.Run("Rate limit hit", func(t *testing.T) {
// Just verify the method doesn't panic
monitor.RecordRateLimitHit("192.168.1.4", "test-agent", "/api")
})
t.Run("Suspicious activity", func(t *testing.T) {
details := map[string]interface{}{"pattern": "unusual"}
// Just verify the method doesn't panic
monitor.RecordSuspiciousActivity("192.168.1.5", "test-agent", "/admin", "unusual pattern", "high frequency requests", details)
})
}
func TestSuspiciousPatternDetector(t *testing.T) {
detector := NewSuspiciousPatternDetector()
t.Run("Add events and detect patterns", func(t *testing.T) {
// Add multiple events from same IP
for i := 0; i < 10; i++ {
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1.100",
Timestamp: time.Now(),
}
detector.AddEvent(event)
}
patterns := detector.DetectSuspiciousPatterns()
found := false
for _, p := range patterns {
if p == "rapid_failures_from_ip_192.168.1.100" {
found = true
break
}
}
if !found {
t.Error("Expected to detect rapid failure pattern")
}
})
t.Run("Detect distributed attack pattern", func(t *testing.T) {
// Add failures from many different IPs
for i := 0; i < 25; i++ {
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1." + strconv.Itoa(100+i),
Timestamp: time.Now(),
}
detector.AddEvent(event)
}
patterns := detector.DetectSuspiciousPatterns()
found := false
for _, p := range patterns {
if p == "distributed_attack_pattern" {
found = true
break
}
}
if !found {
t.Error("Expected to detect distributed attack pattern")
}
})
}
func TestExtractClientIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
headers map[string]string
expectedIP string
}{
{
name: "Direct connection",
remoteAddr: "192.168.1.1:12345",
expectedIP: "192.168.1.1",
},
{
name: "X-Forwarded-For header",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Forwarded-For": "203.0.113.1, 10.0.0.1"},
expectedIP: "203.0.113.1",
},
{
name: "X-Real-IP header",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Real-IP": "203.0.113.2"},
expectedIP: "203.0.113.2",
},
{
name: "Multiple headers - X-Real-IP takes precedence",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Forwarded-For": "203.0.113.1",
"X-Real-IP": "203.0.113.2",
},
expectedIP: "203.0.113.2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
for key, value := range tt.headers {
req.Header.Set(key, value)
}
ip := ExtractClientIP(req)
if ip != tt.expectedIP {
t.Errorf("Expected IP %s, got %s", tt.expectedIP, ip)
}
})
}
}
func TestSecurityEventHandlers(t *testing.T) {
t.Run("Logging security event handler", func(t *testing.T) {
logger := NewLogger("debug")
handler := NewLoggingSecurityEventHandler(logger)
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1.1",
Timestamp: time.Now(),
Message: "Test failure",
Severity: "medium",
}
// Should not panic
handler.HandleSecurityEvent(event)
})
// Metrics security event handler test removed as part of metrics cleanup
}
func TestSecurityMonitorEventHandlers(t *testing.T) {
config := DefaultSecurityMonitorConfig()
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Add event handler with proper synchronization
handlerCalled := make(chan bool, 1)
handler := &testSecurityEventHandler{
callback: func(event SecurityEvent) {
select {
case handlerCalled <- true:
default:
// Channel already has a value, don't block
}
},
}
monitor.AddEventHandler(handler)
monitor.RecordAuthenticationFailure("192.168.1.1", "test-agent", "/login", "test failure", nil)
// Wait for event handler to be called with timeout
select {
case <-handlerCalled:
// Success - handler was called
case <-time.After(100 * time.Millisecond):
t.Error("Expected event handler to be called within timeout")
}
}
// Test helper for security event handler
type testSecurityEventHandler struct {
callback func(SecurityEvent)
}
func (h *testSecurityEventHandler) HandleSecurityEvent(event SecurityEvent) {
h.callback(event)
}
func TestDefaultSecurityMonitorConfig(t *testing.T) {
config := DefaultSecurityMonitorConfig()
if config.MaxFailuresPerIP <= 0 {
t.Error("Expected positive MaxFailuresPerIP")
}
if config.BlockDurationMinutes <= 0 {
t.Error("Expected positive BlockDurationMinutes")
}
if config.CleanupIntervalMinutes <= 0 {
t.Error("Expected positive CleanupIntervalMinutes")
}
if config.FailureWindowMinutes <= 0 {
t.Error("Expected positive FailureWindowMinutes")
}
}
func TestSecurityMonitorCleanup(t *testing.T) {
config := DefaultSecurityMonitorConfig()
config.CleanupIntervalMinutes = 1
config.BlockDurationMinutes = 1
config.RetentionHours = 1
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Block an IP
for i := 0; i < config.MaxFailuresPerIP; i++ {
monitor.RecordAuthenticationFailure("192.168.1.99", "test-agent", "/login", "test", nil)
}
// Verify it's blocked
if !monitor.IsIPBlocked("192.168.1.99") {
t.Error("IP should be blocked")
}
// Wait a bit and check if it gets unblocked automatically
time.Sleep(100 * time.Millisecond)
// The IP should still be blocked since we haven't waited long enough
if !monitor.IsIPBlocked("192.168.1.99") {
t.Error("IP should still be blocked")
}
}
func TestSecurityEventTypes(t *testing.T) {
config := DefaultSecurityMonitorConfig()
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Test different event types - just verify they don't panic
monitor.RecordAuthenticationFailure("192.168.1.200", "test-agent", "/login", "invalid password", nil)
monitor.RecordTokenValidationFailure("192.168.1.200", "test-agent", "/api", "expired token", "abc123")
monitor.RecordRateLimitHit("192.168.1.200", "test-agent", "/api")
details := map[string]interface{}{"pattern": "test"}
monitor.RecordSuspiciousActivity("192.168.1.200", "test-agent", "/admin", "unusual pattern", "multiple failed logins", details)
// Just verify GetSecurityMetrics doesn't panic
_ = monitor.GetSecurityMetrics()
}
+1394 -176
View File
File diff suppressed because it is too large Load Diff
+471 -1
View File
@@ -1,12 +1,16 @@
package traefikoidc
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"time"
"github.com/gorilla/sessions"
)
func TestSessionPoolMemoryLeak(t *testing.T) {
@@ -218,4 +222,470 @@ func TestSessionObjectTracking(t *testing.T) {
t.Log("Session pool handling verified")
}
// This is intentionally left empty to remove unused code
// TestTokenCompressionIntegrity tests that token compression and decompression maintains JWT integrity
func TestTokenCompressionIntegrity(t *testing.T) {
tests := []struct {
name string
token string
wantFail bool
}{
{
name: "Valid JWT - Small",
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.signature",
},
{
name: "Valid JWT - Large",
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + strings.Repeat("eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9", 100) + ".signature",
},
{
name: "Invalid JWT - Wrong dot count",
token: "invalid.token",
wantFail: true,
},
{
name: "Invalid JWT - No dots",
token: "invalidtoken",
wantFail: true,
},
{
name: "Invalid JWT - Too many dots",
token: "part1.part2.part3.part4",
wantFail: true,
},
{
name: "Empty token",
token: "",
wantFail: false, // Empty tokens are handled gracefully
},
{
name: "Oversized token (>50KB)",
token: "part1." + strings.Repeat("A", 51*1024) + ".part3",
wantFail: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compressed := compressToken(tt.token)
if tt.wantFail {
// For invalid tokens, compression should return original
if compressed != tt.token {
t.Errorf("Expected compression to return original for invalid token, got different result")
}
return
}
// For valid tokens, test round-trip integrity
decompressed := decompressToken(compressed)
if decompressed != tt.token {
t.Errorf("Token integrity lost: original=%q, compressed=%q, decompressed=%q",
tt.token, compressed, decompressed)
}
// Test that decompression is idempotent
decompressed2 := decompressToken(decompressed)
if decompressed2 != tt.token {
t.Errorf("Decompression not idempotent: %q != %q", decompressed2, tt.token)
}
})
}
}
// TestTokenCompressionCorruptionDetection tests that gzip corruption is detected and handled
func TestTokenCompressionCorruptionDetection(t *testing.T) {
validJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.signature"
tests := []struct {
name string
corruptedInput string
expectOriginal bool
}{
{
name: "Invalid base64",
corruptedInput: "!@#$%^&*()",
expectOriginal: true,
},
{
name: "Valid base64 but invalid gzip",
corruptedInput: base64.StdEncoding.EncodeToString([]byte("not gzip data")),
expectOriginal: true,
},
{
name: "Truncated gzip data",
corruptedInput: "H4sI", // Incomplete gzip header
expectOriginal: true,
},
{
name: "Empty string",
corruptedInput: "",
expectOriginal: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := decompressToken(tt.corruptedInput)
if tt.expectOriginal && result != tt.corruptedInput {
t.Errorf("Expected decompression to return original corrupted input, got: %q", result)
}
})
}
// Test that valid compression still works
compressed := compressToken(validJWT)
decompressed := decompressToken(compressed)
if decompressed != validJWT {
t.Errorf("Valid compression/decompression failed: %q != %q", decompressed, validJWT)
}
}
// TestTokenChunkingIntegrity tests that large tokens are properly chunked and reassembled
func TestTokenChunkingIntegrity(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create tokens of various sizes to test chunking
testTokens := NewTestTokens()
tests := []struct {
name string
tokenSize int
expectChunked bool
}{
{
name: "Small token (no chunking)",
tokenSize: 100,
expectChunked: false,
},
{
name: "Medium token (no chunking)",
tokenSize: 800, // FIXED: Reduced further to account for new conservative chunk size (1200 bytes)
expectChunked: false,
},
{
name: "Large token (chunking required)",
tokenSize: 5000,
expectChunked: true,
},
{
name: "Very large token (multiple chunks)",
tokenSize: 10000,
expectChunked: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// FIXED: Use incompressible tokens to ensure chunking occurs
var token string
if tt.expectChunked {
token = testTokens.CreateIncompressibleToken(tt.tokenSize)
} else {
token = testTokens.CreateLargeValidJWT(tt.tokenSize)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Store the token
session.SetAccessToken(token)
// Retrieve the token
retrievedToken := session.GetAccessToken()
// Verify integrity
if retrievedToken != token {
t.Errorf("Token integrity lost:\nOriginal: %q\nRetrieved: %q", token, retrievedToken)
}
// Check if chunking occurred as expected
hasChunks := len(session.accessTokenChunks) > 0
if tt.expectChunked != hasChunks {
t.Errorf("Chunking expectation mismatch: expected chunked=%v, has chunks=%v", tt.expectChunked, hasChunks)
}
session.ReturnToPool()
})
}
}
// TestTokenChunkingCorruptionResistance tests handling of corrupted chunks
func TestTokenChunkingCorruptionResistance(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a large token that will be chunked
largeToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." +
base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"sub":"test","data":"%s"}`, strings.Repeat("A", 5000))) +
".signature"
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Store the token (this should create chunks)
session.SetAccessToken(largeToken)
if len(session.accessTokenChunks) == 0 {
t.Skip("Token was not chunked, skipping corruption test")
}
tests := []struct {
corruptChunk func(chunks map[int]*sessions.Session)
name string
expectEmpty bool
}{
{
name: "Missing chunk in sequence",
corruptChunk: func(chunks map[int]*sessions.Session) {
// Remove a middle chunk
if len(chunks) > 1 {
delete(chunks, 1)
}
},
expectEmpty: true,
},
{
name: "Empty chunk data",
corruptChunk: func(chunks map[int]*sessions.Session) {
// Set first chunk to empty
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = ""
}
},
expectEmpty: true,
},
{
name: "Wrong data type in chunk",
corruptChunk: func(chunks map[int]*sessions.Session) {
// Set chunk data to wrong type
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = 123 // Should be string
}
},
expectEmpty: true,
},
{
name: "Oversized chunk",
corruptChunk: func(chunks map[int]*sessions.Session) {
// Set chunk to oversized data
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = strings.Repeat("A", maxCookieSize+200)
}
},
expectEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Get a fresh session
freshSession, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get fresh session: %v", err)
}
// Store the token again
freshSession.SetAccessToken(largeToken)
// Apply corruption
tt.corruptChunk(freshSession.accessTokenChunks)
// Try to retrieve the token
retrievedToken := freshSession.GetAccessToken()
if tt.expectEmpty {
if retrievedToken != "" {
t.Errorf("Expected empty token due to corruption, got: %q", retrievedToken)
}
} else {
if retrievedToken != largeToken {
t.Errorf("Expected original token despite corruption, got: %q", retrievedToken)
}
}
freshSession.ReturnToPool()
})
}
session.ReturnToPool()
}
// TestTokenSizeLimits tests that token size limits are enforced
func TestTokenSizeLimits(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
testTokens := NewTestTokens()
tests := []struct {
name string
tokenSize int
expectStored bool
}{
{
name: "Normal size token",
tokenSize: 1000,
expectStored: true,
},
{
name: "Large but acceptable token",
tokenSize: 30000, // FIXED: 30KB to ensure final size < 100KB limit
expectStored: true,
},
{
name: "Oversized token (>100KB)",
tokenSize: 120000, // FIXED: 120KB to ensure rejection after compression
expectStored: false, // Should be rejected
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// FIXED: Use proper token generation that accounts for base64 encoding
var token string
if tt.expectStored {
token = testTokens.CreateLargeValidJWT(tt.tokenSize)
} else {
token = testTokens.CreateIncompressibleToken(tt.tokenSize)
}
// Store the token
session.SetAccessToken(token)
// Try to retrieve it
retrievedToken := session.GetAccessToken()
if tt.expectStored {
if retrievedToken != token {
t.Errorf("Expected token to be stored and retrieved, but got different token")
}
} else {
if retrievedToken == token {
t.Errorf("Expected oversized token to be rejected, but it was stored")
}
}
})
}
}
// TestConcurrentTokenOperations tests thread safety of token operations
func TestConcurrentTokenOperations(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
const numGoroutines = 10
const numOperations = 100
// Test concurrent access and refresh token operations
done := make(chan bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer func() { done <- true }()
for j := 0; j < numOperations; j++ {
// Create unique tokens for each goroutine/operation
accessToken := ValidAccessToken
refreshToken := fmt.Sprintf("refresh_token_%d_%d", id, j)
// Concurrent operations
session.SetAccessToken(accessToken)
session.SetRefreshToken(refreshToken)
retrievedAccess := session.GetAccessToken()
retrievedRefresh := session.GetRefreshToken()
// Verify tokens are still valid (should be one of the tokens set by any goroutine)
if retrievedAccess != "" && strings.Count(retrievedAccess, ".") != 2 {
t.Errorf("Retrieved access token has invalid format: %q", retrievedAccess)
}
if retrievedRefresh != "" && len(retrievedRefresh) < 10 {
t.Errorf("Retrieved refresh token is too short: %q", retrievedRefresh)
}
}
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < numGoroutines; i++ {
<-done
}
}
// TestSessionValidationAndCleanup tests session validation and orphan cleanup
func TestSessionValidationAndCleanup(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
rw := httptest.NewRecorder()
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Set tokens that will create chunks
largeToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." +
base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat(`{"data":"large"}`, 500))) +
".signature"
session.SetAccessToken(largeToken)
session.SetRefreshToken("refresh_token_test")
// Save session to create cookies
if err := session.Save(req, rw); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Verify chunks were created
if len(session.accessTokenChunks) == 0 {
t.Log("No chunks created, large token test may not be applicable")
}
// Test cleanup by clearing session
if err := session.Clear(req, rw); err != nil {
t.Logf("Clear returned error (may be expected): %v", err)
}
// Verify tokens are cleared
if token := session.GetAccessToken(); token != "" {
t.Errorf("Access token should be empty after clear, got: %q", token)
}
if token := session.GetRefreshToken(); token != "" {
t.Errorf("Refresh token should be empty after clear, got: %q", token)
}
}
+162 -92
View File
@@ -26,96 +26,28 @@ type TemplatedHeader struct {
// It provides all necessary settings to configure OpenID Connect authentication
// with various providers like Auth0, Logto, or any standard OIDC provider.
type Config struct {
// ProviderURL is the base URL of the OIDC provider (required)
// Example: https://accounts.google.com
ProviderURL string `json:"providerURL"`
// RevocationURL is the endpoint for revoking tokens (optional)
// If not provided, it will be discovered from provider metadata
RevocationURL string `json:"revocationURL"`
// EnablePKCE enables Proof Key for Code Exchange (PKCE) for the authorization code flow (optional)
// This enhances security but might not be supported by all OIDC providers
// Default: false
EnablePKCE bool `json:"enablePKCE"`
// CallbackURL is the path where the OIDC provider will redirect after authentication (required)
// Example: /oauth2/callback
CallbackURL string `json:"callbackURL"`
// LogoutURL is the path for handling logout requests (optional)
// If not provided, it will be set to CallbackURL + "/logout"
LogoutURL string `json:"logoutURL"`
// ClientID is the OAuth 2.0 client identifier (required)
ClientID string `json:"clientID"`
// ClientSecret is the OAuth 2.0 client secret (required)
ClientSecret string `json:"clientSecret"`
// Scopes defines the OAuth 2.0 scopes to request (optional)
// Defaults to ["openid", "profile", "email"] if not provided
Scopes []string `json:"scopes"`
// LogLevel sets the logging verbosity (optional)
// Valid values: "debug", "info", "error"
// Default: "info"
LogLevel string `json:"logLevel"`
// SessionEncryptionKey is used to encrypt session data (required)
// Must be a secure random string
SessionEncryptionKey string `json:"sessionEncryptionKey"`
// ForceHTTPS forces the use of HTTPS for all URLs (optional)
// Default: false
ForceHTTPS bool `json:"forceHTTPS"`
// RateLimit sets the maximum number of requests per second (optional)
// Default: 100
RateLimit int `json:"rateLimit"`
// ExcludedURLs lists paths that bypass authentication (optional)
// Example: ["/health", "/metrics"]
ExcludedURLs []string `json:"excludedURLs"`
// AllowedUserDomains restricts access to specific email domains (optional)
// Example: ["company.com", "subsidiary.com"]
AllowedUserDomains []string `json:"allowedUserDomains"`
// AllowedUsers restricts access to specific email addresses (optional)
// Example: ["[email protected]", "[email protected]"]
AllowedUsers []string `json:"allowedUsers"`
// AllowedRolesAndGroups restricts access to users with specific roles or groups (optional)
// Example: ["admin", "developer"]
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
// OIDCEndSessionURL is the provider's end session endpoint (optional)
// If not provided, it will be discovered from provider metadata
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
// PostLogoutRedirectURI is the URL to redirect to after logout (optional)
// Default: "/"
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
// HTTPClient allows customizing the HTTP client used for OIDC operations (optional)
HTTPClient *http.Client
// RefreshGracePeriodSeconds defines how many seconds before a token expires
// the plugin should attempt to refresh it proactively (optional)
// Default: 60
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
// Headers defines custom HTTP headers to set with templated values (optional)
// Values can reference tokens and claims using Go templates with the following variables:
// - {{.AccessToken}} - The access token (ID token)
// - {{.IdToken}} - Same as AccessToken (for consistency)
// - {{.RefreshToken}} - The refresh token
// - {{.Claims.email}} - Access token claims (use proper case for claim names)
// Examples:
//
// [{Name: "X-Forwarded-Email", Value: "{{.Claims.email}}"}]
// [{Name: "Authorization", Value: "Bearer {{.AccessToken}}"}]
Headers []TemplatedHeader `json:"headers"`
HTTPClient *http.Client
ProviderURL string `json:"providerURL"`
RevocationURL string `json:"revocationURL"`
CallbackURL string `json:"callbackURL"`
LogoutURL string `json:"logoutURL"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
LogLevel string `json:"logLevel"`
SessionEncryptionKey string `json:"sessionEncryptionKey"`
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Scopes []string `json:"scopes"`
Headers []TemplatedHeader `json:"headers"`
RateLimit int `json:"rateLimit"`
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
ForceHTTPS bool `json:"forceHTTPS"`
EnablePKCE bool `json:"enablePKCE"`
OverrideScopes bool `json:"overrideScopes"`
}
const (
@@ -156,6 +88,7 @@ func CreateConfig() *Config {
RateLimit: DefaultRateLimit,
ForceHTTPS: true, // Secure by default
EnablePKCE: false, // PKCE is opt-in
OverrideScopes: false, // Default to appending scopes, not overriding
RefreshGracePeriodSeconds: 60, // Default grace period of 60 seconds
}
@@ -248,7 +181,7 @@ func (c *Config) Validate() error {
return fmt.Errorf("refreshGracePeriodSeconds cannot be negative")
}
// Validate headers configuration
// SECURITY FIX: Validate headers configuration with enhanced template security
for _, header := range c.Headers {
if header.Name == "" {
return fmt.Errorf("header name cannot be empty")
@@ -260,7 +193,7 @@ func (c *Config) Validate() error {
return fmt.Errorf("header value '%s' does not appear to be a valid template (missing {{ }})", header.Value)
}
// Provide more helpful guidance for common template errors
// Provide more helpful guidance for common template errors BEFORE security validation
if strings.Contains(header.Value, "{{.claims") {
return fmt.Errorf("header template '%s' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)", header.Value)
}
@@ -273,6 +206,132 @@ func (c *Config) Validate() error {
if strings.Contains(header.Value, "{{.refreshToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)", header.Value)
}
// SECURITY FIX: Implement template sandboxing and validation
if err := validateTemplateSecure(header.Value); err != nil {
return fmt.Errorf("header template '%s' failed security validation: %w", header.Value, err)
}
}
return nil
}
// SECURITY FIX: validateTemplateSecure implements template sandboxing and validation
func validateTemplateSecure(templateStr string) error {
// SECURITY FIX: Restrict dangerous template functions and patterns
dangerousPatterns := []string{
"{{call", // Function calls
"{{range", // Range over arbitrary data
"{{with", // With statements that could access unexpected data
"{{define", // Template definitions
"{{template", // Template inclusions
"{{block", // Block definitions
"{{/*", // Comments that could hide malicious code
"{{-", // Trim whitespace (could be used to obfuscate)
"-}}", // Trim whitespace (could be used to obfuscate)
"{{printf", // Printf functions
"{{print", // Print functions
"{{println", // Println functions
"{{html", // HTML functions
"{{js", // JavaScript functions
"{{urlquery", // URL query functions
"{{index", // Index access to arbitrary data
"{{slice", // Slice operations
"{{len", // Length operations on arbitrary data
"{{eq", // Comparison operations
"{{ne", // Comparison operations
"{{lt", // Comparison operations
"{{le", // Comparison operations
"{{gt", // Comparison operations
"{{ge", // Comparison operations
"{{and", // Logical operations
"{{or", // Logical operations
"{{not", // Logical operations
}
templateLower := strings.ToLower(templateStr)
for _, pattern := range dangerousPatterns {
if strings.Contains(templateLower, pattern) {
return fmt.Errorf("dangerous template pattern detected: %s", pattern)
}
}
// SECURITY FIX: Whitelist allowed template variables and functions
allowedPatterns := []string{
"{{.AccessToken}}",
"{{.IdToken}}",
"{{.RefreshToken}}",
"{{.Claims.",
}
// Check if template contains only allowed patterns
hasAllowedPattern := false
for _, pattern := range allowedPatterns {
if strings.Contains(templateStr, pattern) {
hasAllowedPattern = true
break
}
}
if !hasAllowedPattern {
return fmt.Errorf("template must use only allowed variables: AccessToken, IdToken, RefreshToken, or Claims.*")
}
// SECURITY FIX: Validate Claims access patterns
if strings.Contains(templateStr, "{{.Claims.") {
// Simple validation - ensure claims access is to known safe fields
safeClaimsFields := map[string]bool{
"email": true,
"name": true,
"given_name": true,
"family_name": true,
"preferred_username": true,
"sub": true,
"iss": true,
"aud": true,
"exp": true,
"iat": true,
"groups": true,
"roles": true,
}
// Extract field names from Claims access
start := strings.Index(templateStr, "{{.Claims.")
for start != -1 {
end := strings.Index(templateStr[start:], "}}")
if end == -1 {
return fmt.Errorf("malformed Claims template syntax")
}
// Extract the content between "{{.Claims." and "}}"
// start+10 skips "{{.Claims." and start+end is the position of "}}"
claimsContent := templateStr[start+10 : start+end]
// Get the field name (first part before any dots)
fieldName := strings.Split(claimsContent, ".")[0]
if !safeClaimsFields[fieldName] {
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", fieldName)
}
// Fix the search for next occurrence
nextStart := strings.Index(templateStr[start+end+2:], "{{.Claims.")
if nextStart != -1 {
start = start + end + 2 + nextStart
} else {
start = -1
}
}
}
// SECURITY FIX: Prevent code injection through template syntax
if strings.Contains(templateStr, "{{") && strings.Contains(templateStr, "}}") {
// Count opening and closing braces
openCount := strings.Count(templateStr, "{{")
closeCount := strings.Count(templateStr, "}}")
if openCount != closeCount {
return fmt.Errorf("unbalanced template braces")
}
}
return nil
@@ -412,6 +471,17 @@ func (l *Logger) Errorf(format string, args ...interface{}) {
l.logError.Printf(format, args...)
}
// newNoOpLogger creates a silent logger that doesn't output anything.
// This is useful for internal components that need a logger instance
// but should not produce any output by default.
func newNoOpLogger() *Logger {
return &Logger{
logError: log.New(io.Discard, "", 0),
logInfo: log.New(io.Discard, "", 0),
logDebug: log.New(io.Discard, "", 0),
}
}
// handleError logs an error message using the provided logger and sends an HTTP error
// response to the client with the specified message and status code.
//
+32 -10
View File
@@ -7,6 +7,19 @@ import (
"testing"
)
// Helper function to compare string slices
func equalSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
func TestCreateConfig(t *testing.T) {
t.Run("Default Values", func(t *testing.T) {
config := CreateConfig()
@@ -36,27 +49,36 @@ func TestCreateConfig(t *testing.T) {
if !config.ForceHTTPS {
t.Error("Expected ForceHTTPS to be true by default")
}
// Check OverrideScopes default
if config.OverrideScopes {
t.Error("Expected OverrideScopes to be false by default")
}
})
t.Run("Custom Values Preserved", func(t *testing.T) {
t.Run("Config Can Hold Custom Values", func(t *testing.T) {
config := CreateConfig()
config.Scopes = []string{"custom_scope"}
config.LogLevel = "debug"
config.RateLimit = 50
config.ForceHTTPS = false
config.OverrideScopes = true
// Verify custom values are not overwritten
// Verify config struct can hold custom values
if len(config.Scopes) != 1 || config.Scopes[0] != "custom_scope" {
t.Error("Custom scopes were overwritten")
t.Error("Config struct cannot hold custom scopes")
}
if config.LogLevel != "debug" {
t.Error("Custom log level was overwritten")
t.Error("Config struct cannot hold custom log level")
}
if config.RateLimit != 50 {
t.Error("Custom rate limit was overwritten")
t.Error("Config struct cannot hold custom rate limit")
}
if config.ForceHTTPS {
t.Error("Custom ForceHTTPS value was overwritten")
t.Error("Config struct cannot hold custom ForceHTTPS value")
}
if !config.OverrideScopes {
t.Error("Config struct cannot hold custom OverrideScopes value")
}
})
}
@@ -241,10 +263,10 @@ func TestLogger(t *testing.T) {
var debugBuf, infoBuf, errorBuf bytes.Buffer
tests := []struct {
name string
logLevel string
testFunc func(*Logger)
checkFunc func(t *testing.T, debugOut, infoOut, errorOut string)
name string
logLevel string
}{
{
name: "Debug Level",
@@ -392,9 +414,9 @@ func TestHandleError(t *testing.T) {
// Test helper types
type testResponseRecorder struct {
statusCode int
body string
headers map[string][]string
body string
statusCode int
}
func (r *testResponseRecorder) Header() http.Header {
+376 -7
View File
@@ -143,6 +143,59 @@ func TestTemplateExecution(t *testing.T) {
expectedValue: "",
expectError: true, // Parsing should fail
},
{
name: "Custom Claims",
templateText: "Role: {{.Claims.role}}, Department: {{.Claims.department}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "[email protected]",
"role": "admin",
"department": "engineering",
},
},
expectedValue: "Role: admin, Department: engineering",
expectError: false,
},
{
name: "Nested Custom Claims",
templateText: "Org: {{.Claims.metadata.organization}}, Team: {{.Claims.metadata.team}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "[email protected]",
"metadata": map[string]interface{}{
"organization": "company-name",
"team": "platform",
},
},
},
expectedValue: "Org: company-name, Team: platform",
expectError: false,
},
{
name: "Email Claims",
templateText: "Email: {{.Claims.email}}, Verified: {{.Claims.email_verified}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "[email protected]",
"email_verified": true,
},
},
expectedValue: "Email: [email protected], Verified: true",
expectError: false,
},
{
name: "User Identity Claims",
templateText: "Name: {{.Claims.name}}, Subject: {{.Claims.sub}}, Username: {{.Claims.preferred_username}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"name": "John Doe",
"sub": "user123",
"preferred_username": "johndoe",
},
},
expectedValue: "Name: John Doe, Subject: user123, Username: johndoe",
expectError: false,
},
}
for _, tc := range tests {
@@ -176,23 +229,153 @@ func TestTemplateExecution(t *testing.T) {
// TestTemplateExecutionContext tests the specific template data context used in processAuthorizedRequest
func TestTemplateExecutionContext(t *testing.T) {
// Define a test struct that matches the one used in processAuthorizedRequest
// Test cases for map-based template data, matching the new implementation
mapTests := []struct {
name string
templateText string
data map[string]interface{}
expectedValue string
}{
{
name: "Access and ID token distinction with map",
templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IdToken": "id-token-value",
"Claims": map[string]interface{}{},
"RefreshToken": "refresh-token-value",
},
expectedValue: "Access: access-token-value ID: id-token-value",
},
{
name: "Combining tokens and claims with map",
templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": "access-token",
"IdToken": "id-token",
"Claims": map[string]interface{}{
"sub": "user123",
},
"RefreshToken": "refresh-token",
},
expectedValue: "User: user123 Token: access-token",
},
{
name: "Authorization header with Bearer token",
templateText: "Bearer {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": "jwt-access-token",
"IdToken": "id-token",
"Claims": map[string]interface{}{},
},
expectedValue: "Bearer jwt-access-token",
},
{
name: "Boolean template data with AccessToken",
templateText: "Bearer {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": true, // Test boolean values to ensure they render correctly
},
expectedValue: "Bearer true",
},
{
name: "Custom non-standard claims in ID token",
templateText: "X-User-Role: {{.Claims.role}}, X-User-Permissions: {{.Claims.permissions}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IdToken": "id-token-value",
"Claims": map[string]interface{}{
"email": "[email protected]",
"role": "admin",
"permissions": "read:all,write:own",
},
},
expectedValue: "X-User-Role: admin, X-User-Permissions: read:all,write:own",
},
{
name: "Deeply nested custom claims",
templateText: "X-Organization: {{.Claims.app_metadata.organization.name}}, X-Team: {{.Claims.app_metadata.team}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"Claims": map[string]interface{}{
"app_metadata": map[string]interface{}{
"organization": map[string]interface{}{
"name": "acme-corp",
"id": "org-123",
},
"team": "platform",
},
},
},
expectedValue: "X-Organization: acme-corp, X-Team: platform",
},
{
name: "Email in claims",
templateText: "X-User-Email: {{.Claims.email}}, X-Email-Verified: {{.Claims.email_verified}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IdToken": "id-token-value",
"Claims": map[string]interface{}{
"email": "[email protected]",
"email_verified": true,
},
},
expectedValue: "X-User-Email: [email protected], X-Email-Verified: true",
},
{
name: "User info from claims",
templateText: "X-User-ID: {{.Claims.sub}}, X-User-Name: {{.Claims.name}}, X-Username: {{.Claims.preferred_username}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IdToken": "id-token-value",
"Claims": map[string]interface{}{
"sub": "user123456",
"name": "Jane Doe",
"preferred_username": "jane.doe",
},
},
expectedValue: "X-User-ID: user123456, X-User-Name: Jane Doe, X-Username: jane.doe",
},
}
// Run map-based tests (matching the new implementation)
for _, tc := range mapTests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
// For backward compatibility, also test the original struct-based implementation
type templateData struct {
Claims map[string]interface{}
AccessToken string
IdToken string
RefreshToken string
Claims map[string]interface{}
}
// Test cases
tests := []struct {
// Test cases for struct-based template data (original implementation)
structTests := []struct {
name string
templateText string
data templateData
expectedValue string
}{
{
name: "Access and ID token distinction",
name: "Access and ID token distinction with struct",
templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}",
data: templateData{
AccessToken: "access-token-value",
@@ -202,7 +385,7 @@ func TestTemplateExecutionContext(t *testing.T) {
expectedValue: "Access: access-token-value ID: id-token-value",
},
{
name: "Combining tokens and claims",
name: "Combining tokens and claims with struct",
templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}",
data: templateData{
AccessToken: "access-token",
@@ -213,9 +396,36 @@ func TestTemplateExecutionContext(t *testing.T) {
},
expectedValue: "User: user123 Token: access-token",
},
{
name: "Custom claims with struct",
templateText: "X-Custom: {{.Claims.custom_field}}, X-Group: {{.Claims.group}}",
data: templateData{
AccessToken: "access-token",
IdToken: "id-token",
Claims: map[string]interface{}{
"sub": "user123",
"custom_field": "custom-value",
"group": "admins",
},
},
expectedValue: "X-Custom: custom-value, X-Group: admins",
},
{
name: "Email claim in struct context",
templateText: "X-Email: {{.Claims.email}}, X-Name: {{.Claims.name}}",
data: templateData{
AccessToken: "access-token",
IdToken: "id-token",
Claims: map[string]interface{}{
"email": "[email protected]",
"name": "John Smith",
},
},
expectedValue: "X-Email: [email protected], X-Name: John Smith",
},
}
for _, tc := range tests {
for _, tc := range structTests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
@@ -235,3 +445,162 @@ func TestTemplateExecutionContext(t *testing.T) {
})
}
}
// TestRegressionBooleanAccessToken specifically tests the regression case where
// a boolean value was causing "can't evaluate field AccessToken in type bool" error
func TestRegressionBooleanAccessToken(t *testing.T) {
// Test the specific case where we execute a template referencing AccessToken
// using a boolean context value
testCases := []struct {
name string
templateText string
dataContext interface{}
expectedValue string
expectError bool // Added to skip the test that demonstrates the error
}{
{
name: "Map with boolean as root",
templateText: "{{.AccessToken}}",
dataContext: map[string]interface{}{"AccessToken": "token-value"},
expectedValue: "token-value",
expectError: false,
},
{
name: "Boolean as root context",
templateText: "{{.AccessToken}}",
dataContext: true,
expectedValue: "<no value>",
expectError: true, // Skip this test as it demonstrates the error we're fixing
},
{
name: "Bearer with map context",
templateText: "Bearer {{.AccessToken}}",
dataContext: map[string]interface{}{"AccessToken": "token-value"},
expectedValue: "Bearer token-value",
expectError: false,
},
{
name: "Complex nesting with authorization",
templateText: "Authorization: Bearer {{.AccessToken}}",
dataContext: map[string]interface{}{
"AccessToken": "jwt-token-123",
"something": true,
"anotherField": map[string]interface{}{
"nested": "value",
},
},
expectedValue: "Authorization: Bearer jwt-token-123",
expectError: false,
},
{
name: "Custom claims access",
templateText: "X-User-Role: {{.Claims.role}}, X-User-Groups: {{.Claims.groups}}",
dataContext: map[string]interface{}{
"AccessToken": "jwt-token-xyz",
"Claims": map[string]interface{}{
"email": "[email protected]",
"role": "admin",
"groups": "group1,group2,group3",
"custom_data": map[string]interface{}{
"organization": "company-name",
"department": "engineering",
},
},
},
expectedValue: "X-User-Role: admin, X-User-Groups: group1,group2,group3",
expectError: false,
},
{
name: "Nested custom claims access",
templateText: "X-Organization: {{.Claims.custom_data.organization}}, X-Department: {{.Claims.custom_data.department}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"custom_data": map[string]interface{}{
"organization": "company-name",
"department": "engineering",
},
},
},
expectedValue: "X-Organization: company-name, X-Department: engineering",
expectError: false,
},
{
name: "Azure AD specific claims",
templateText: "X-TenantID: {{.Claims.tid}}, X-Roles: {{.Claims.roles}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"tid": "tenant-id-12345",
"roles": "User,Admin,Developer",
},
},
expectedValue: "X-TenantID: tenant-id-12345, X-Roles: User,Admin,Developer",
expectError: false,
},
{
name: "Auth0 specific claims",
templateText: "X-Permissions: {{.Claims.permissions}}, X-AppMetadata: {{.Claims.app_metadata.plan}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"permissions": "read:products,write:orders",
"app_metadata": map[string]interface{}{
"plan": "premium",
"status": "active",
"trial_ended": false,
},
},
},
expectedValue: "X-Permissions: read:products,write:orders, X-AppMetadata: premium",
expectError: false,
},
{
name: "Standard claims with email",
templateText: "X-Email: {{.Claims.email}}, X-Name: {{.Claims.name}}, X-Subject: {{.Claims.sub}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "[email protected]",
"name": "John Doe",
"sub": "auth0|12345",
},
},
expectedValue: "X-Email: [email protected], X-Name: John Doe, X-Subject: auth0|12345",
expectError: false,
},
{
name: "Verified email claim",
templateText: "X-Email: {{.Claims.email}}, X-Email-Verified: {{.Claims.email_verified}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "[email protected]",
"email_verified": true,
},
},
expectedValue: "X-Email: [email protected], X-Email-Verified: true",
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
// Skip tests that demonstrate the error
if tc.expectError {
t.Skip("Skipping test that demonstrates the error we're fixing")
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.dataContext)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
}
+5 -3
View File
@@ -19,12 +19,12 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
ts.Setup()
tests := []struct {
name string
headers []TemplatedHeader
sessionSetup func(*SessionData)
claims map[string]interface{}
expectedHeaders map[string]string
interceptedHeaders map[string]string
name string
headers []TemplatedHeader
}{
{
name: "Basic Email Header",
@@ -426,9 +426,9 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
ts.Setup()
tests := []struct {
claims map[string]interface{}
name string
headers []TemplatedHeader
claims map[string]interface{}
shouldExecuteCheck bool
}{
{
@@ -577,6 +577,7 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
func createLargeTemplate(size int) string {
template := "{{with .Claims}}"
for i := 0; i < size; i++ {
if i > 0 {
template += ","
}
@@ -590,6 +591,7 @@ func createLargeTemplate(size int) string {
func createLargeClaims(size int) map[string]interface{} {
claims := make(map[string]interface{})
for i := 0; i < size; i++ {
claims["email"] = "[email protected]" // Add email claim
key := "field" + string(rune('a'+i%26)) + string(rune('0'+i%10))
claims[key] = "value" + string(rune('a'+i%26)) + string(rune('0'+i%10))
}
+400
View File
@@ -0,0 +1,400 @@
package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
// TestTokens provides a comprehensive set of standardized test tokens
// for consistent testing across the entire codebase.
type TestTokens struct{}
// NewTestTokens creates a new TestTokens instance
func NewTestTokens() *TestTokens {
return &TestTokens{}
}
// Valid JWT tokens for testing
const (
// ValidAccessToken - A properly formatted JWT access token for testing
ValidAccessToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MTc1MDI5NDYyOCwiaWF0IjoxNzUwMjkxMDI4LCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJqdGkiOiJlNDcxN2RhZDBmZjAyOTNkIiwibmJmIjoxNzUwMjkxMDI4LCJub25jZSI6Im5vbmNlMTIzIiwic3ViIjoidGVzdC1zdWJqZWN0In0.bmwp-vk0B7Ir9UiUkzib8L7yJbebJ00o3U9QrB6gP2H9-RfqyCbN8M9Rkx7Rb8Vdh3YzqkBBoLS_G0i414rs2I9uABnTC4E6-63qkGdUrLB7p-XbjcRW2RoIBwXHk7lfumi8eX0uWzBsJ9CY0__UECVsex5XORfBb4Bcqj0LK4y-glxkpI51I7BPySfciWC_PkdaQ1Qe5pCAlxeNs2E9NMGXp-Ox6vAufUzoC2cws1LswGPPP6icQ-Zlzd5WMCIWhdIkN4yTxk8FMqsTC52k2zskRHNSSd4DDVETonfzawZNqDcMpnTyN53sCJ9UHiQTl9mCm61ttYW-W9Gc-ze4Xw"
// ValidIDToken - A properly formatted JWT ID token for testing
ValidIDToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MTc1MDI5NDYyOCwiaWF0IjoxNzUwMjkxMDI4LCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJqdGkiOiI2YzBjZTZmMTM4Y2EzMzc2IiwibmJmIjoxNzUwMjkxMDI4LCJub25jZSI6Im5vbmNlMTIzIiwic3ViIjoidGVzdC1zdWJqZWN0In0.RBQYejA9vP4lnh2EhFqWerePWaCyDTF0ZE1jlU2xm4g2wWVeaEHpv5SNg92_gwk633N9xx7ugS0UrlEu4qbT7wSb1HBDR00q_andyYnyFk4OoxPpD0AqHkVr-pjS-Z7UCGF3sLgQ4ECmU9695PIys3XvgUGMzEn_mK-PHcpY5AnbBGFsbj7epUld_sb6WfjjjwAa8kKfKObPvaIpuJ4TlxI1Uf0wYOoIA0zh5ipeAn-i8Ud-GErxis1Hp8UQK7IRolXpToiXnFcnf3vI3eCS7Yu3oPl7LRxTxKMCI9h0MCwu25ZNsOg2C9ohyebpU0jbURX9Q74GNOaphv-Lz9rCRA"
// ValidRefreshToken - A properly formatted refresh token for testing
ValidRefreshToken = "valid-refresh-token-12345"
// MinimalValidJWT - The shortest valid JWT for testing
MinimalValidJWT = "h.p.s"
// ValidRefreshTokenGoogle - A Google-style refresh token for testing
ValidRefreshTokenGoogle = "google_refresh_token_12345"
)
// Invalid tokens for testing validation
const (
// InvalidTokenNoDots - Token with no dots (invalid JWT format)
InvalidTokenNoDots = "notajwttoken"
// InvalidTokenOneDot - Token with one dot (invalid JWT format)
InvalidTokenOneDot = "header.payload"
// InvalidTokenThreeDots - Token with three dots (invalid JWT format)
InvalidTokenThreeDots = "header.payload.signature.extra"
// EmptyToken - Empty token
EmptyToken = ""
// CorruptedBase64Token - Token with invalid base64 data for chunking tests
CorruptedBase64Token = "corrupted_base64_!@#$"
)
// CreateLargeValidJWT creates a JWT of approximately the specified size
// This replaces the ad-hoc createLargeValidJWT function in tests
func (tt *TestTokens) CreateLargeValidJWT(targetSize int) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "signature_" + tt.generateRandomString(32)
// Calculate required payload size
usedSize := len(header) + len(signature) + 2 // account for dots
payloadSize := targetSize - usedSize
if payloadSize < 50 {
payloadSize = 50
}
// Create a payload with realistic JWT claims
claims := map[string]interface{}{
"sub": "user123",
"iss": "https://example.com",
"aud": "client123",
"exp": 9999999999,
"iat": 1000000000,
}
dataSize := payloadSize - 100 // Account for other claims and base64 encoding
if dataSize < 10 {
dataSize = 10 // Minimum data size
}
claims["data"] = tt.generateRandomString(dataSize)
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
// CreateLargeRefreshToken creates a refresh token of approximately the specified size
func (tt *TestTokens) CreateLargeRefreshToken(targetSize int) string {
baseToken := "refresh_token_"
padding := tt.generateRandomString(targetSize - len(baseToken))
return baseToken + padding
}
// CreateExpiredJWT creates an expired JWT token for testing
func (tt *TestTokens) CreateExpiredJWT() string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
// Create claims with expired timestamp
claims := map[string]interface{}{
"sub": "user123",
"iss": "https://example.com",
"aud": "client123",
"exp": time.Now().Unix() - 3600, // Expired 1 hour ago
"iat": time.Now().Unix() - 7200, // Issued 2 hours ago
}
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
signature := "expired_signature"
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
// CreateUniqueValidJWT creates a unique valid JWT for concurrent testing
func (tt *TestTokens) CreateUniqueValidJWT(id string) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
claims := map[string]interface{}{
"sub": "user_" + id,
"iss": "https://example.com",
"aud": "client123",
"exp": 9999999999,
"iat": 1000000000,
"jti": id,
}
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
signature := "sig_" + id
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
// CreateIncompressibleToken creates a token that cannot be compressed effectively
// This is useful for testing chunking scenarios where compression doesn't help
func (tt *TestTokens) CreateIncompressibleToken(targetSize int) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "incompressible_signature_" + tt.generateRandomString(32)
// Calculate required payload size
usedSize := len(header) + len(signature) + 2 // account for dots
payloadSize := max(targetSize-usedSize, 100)
// Generate multiple random fields to prevent compression
randomFields := make(map[string]interface{})
randomFields["sub"] = "user123"
randomFields["iss"] = "https://example.com"
randomFields["aud"] = "client123"
randomFields["exp"] = 9999999999
randomFields["iat"] = 1000000000
// Add many random fields with random data to prevent compression
remainingSize := payloadSize - 200 // Account for base64 encoding and other fields
fieldCount := remainingSize / 100 // ~100 bytes per field
if fieldCount < 1 {
fieldCount = 1
}
for i := 0; i < fieldCount; i++ {
// Generate truly random data for each field
randomBytes := make([]byte, 50)
rand.Read(randomBytes)
fieldName := fmt.Sprintf("random_field_%d_%s", i, tt.generateRandomString(8))
randomFields[fieldName] = base64.StdEncoding.EncodeToString(randomBytes)
}
claimsJSON, _ := json.Marshal(randomFields)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
token := fmt.Sprintf("%s.%s.%s", header, payload, signature)
// If still too small, pad with more random data
if len(token) < targetSize {
padding := targetSize - len(token)
extraRandomBytes := make([]byte, padding/2)
rand.Read(extraRandomBytes)
randomFields["padding"] = base64.StdEncoding.EncodeToString(extraRandomBytes)
claimsJSON, _ = json.Marshal(randomFields)
payload = base64.RawURLEncoding.EncodeToString(claimsJSON)
token = fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
return token
}
// GetValidTokenSet returns a complete set of valid tokens for testing
func (tt *TestTokens) GetValidTokenSet() TokenSet {
return TokenSet{
AccessToken: ValidAccessToken,
IDToken: ValidIDToken,
RefreshToken: ValidRefreshToken,
}
}
// GetGoogleTokenSet returns tokens that simulate Google OIDC provider responses
func (tt *TestTokens) GetGoogleTokenSet() TokenSet {
return TokenSet{
AccessToken: ValidAccessToken,
IDToken: ValidIDToken,
RefreshToken: ValidRefreshTokenGoogle,
}
}
// GetLargeTokenSet returns a set of large tokens for chunking tests
func (tt *TestTokens) GetLargeTokenSet() TokenSet {
return TokenSet{
AccessToken: tt.CreateLargeValidJWT(5000),
IDToken: tt.CreateLargeValidJWT(2000),
RefreshToken: tt.CreateLargeRefreshToken(3000),
}
}
// GetInvalidTokens returns various invalid tokens for validation testing
func (tt *TestTokens) GetInvalidTokens() InvalidTokenSet {
return InvalidTokenSet{
NoDots: InvalidTokenNoDots,
OneDot: InvalidTokenOneDot,
ThreeDots: InvalidTokenThreeDots,
Empty: EmptyToken,
Corrupted: CorruptedBase64Token,
}
}
// generateRandomString creates a random string of the specified length
func (tt *TestTokens) generateRandomString(length int) string {
// FIXED: Handle negative or zero lengths safely
if length <= 0 {
return ""
}
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := 0; i < length; i++ {
randomByte := make([]byte, 1)
rand.Read(randomByte)
b[i] = charset[int(randomByte[0])%len(charset)]
}
return string(b)
}
// TokenSet represents a complete set of tokens for testing
type TokenSet struct {
AccessToken string
IDToken string
RefreshToken string
}
// InvalidTokenSet represents various invalid tokens for validation testing
type InvalidTokenSet struct {
NoDots string // Token with 0 dots
OneDot string // Token with 1 dot
ThreeDots string // Token with 3 dots
Empty string // Empty token
Corrupted string // Corrupted/invalid characters
}
// TestScenarios provides predefined test scenarios
type TestScenarios struct {
tokens *TestTokens
}
// NewTestScenarios creates a new TestScenarios instance
func NewTestScenarios() *TestScenarios {
return &TestScenarios{
tokens: NewTestTokens(),
}
}
// NormalFlow returns tokens for normal authentication flow testing
func (ts *TestScenarios) NormalFlow() TokenSet {
return ts.tokens.GetValidTokenSet()
}
// GoogleFlow returns tokens simulating Google OIDC provider
func (ts *TestScenarios) GoogleFlow() TokenSet {
return ts.tokens.GetGoogleTokenSet()
}
// ChunkingRequired returns large tokens that require chunking
func (ts *TestScenarios) ChunkingRequired() TokenSet {
return ts.tokens.GetLargeTokenSet()
}
// CorruptionTest returns tokens and corruption scenarios for testing
func (ts *TestScenarios) CorruptionTest() CorruptionTestSet {
return CorruptionTestSet{
ValidTokens: ts.tokens.GetValidTokenSet(),
InvalidTokens: ts.tokens.GetInvalidTokens(),
LargeTokens: ts.tokens.GetLargeTokenSet(),
CorruptedToken: CorruptedBase64Token,
}
}
// ConcurrentTest returns unique tokens for concurrent testing
func (ts *TestScenarios) ConcurrentTest(count int) []TokenSet {
sets := make([]TokenSet, count)
for i := 0; i < count; i++ {
sets[i] = TokenSet{
AccessToken: ts.tokens.CreateUniqueValidJWT(fmt.Sprintf("concurrent_%d", i)),
IDToken: ts.tokens.CreateUniqueValidJWT(fmt.Sprintf("id_%d", i)),
RefreshToken: fmt.Sprintf("refresh_concurrent_%d", i),
}
}
return sets
}
// CorruptionTestSet represents tokens and scenarios for corruption testing
type CorruptionTestSet struct {
ValidTokens TokenSet
InvalidTokens InvalidTokenSet
LargeTokens TokenSet
CorruptedToken string
}
// TokenValidationTestCases returns test cases for token validation
func (tt *TestTokens) TokenValidationTestCases() []ValidationTestCase {
return []ValidationTestCase{
{
Name: "Empty token",
Token: EmptyToken,
ExpectStored: true, // Empty tokens are allowed for clearing
ExpectRetrieved: false, // But return as empty
},
{
Name: "Single dot",
Token: InvalidTokenOneDot,
ExpectStored: false, // Invalid JWT format
ExpectRetrieved: false,
},
{
Name: "No dots",
Token: InvalidTokenNoDots,
ExpectStored: false, // Invalid JWT format
ExpectRetrieved: false,
},
{
Name: "Too many dots",
Token: InvalidTokenThreeDots,
ExpectStored: false, // Invalid JWT format
ExpectRetrieved: false,
},
{
Name: "Valid minimal JWT",
Token: MinimalValidJWT,
ExpectStored: true,
ExpectRetrieved: true,
},
{
Name: "Valid standard JWT",
Token: ValidAccessToken,
ExpectStored: true,
ExpectRetrieved: true,
},
}
}
// ValidationTestCase represents a single token validation test case
type ValidationTestCase struct {
Name string
Token string
ExpectStored bool
ExpectRetrieved bool
}
// Helper functions for common test patterns
// AssertValidTokenStorage verifies that a valid token can be stored and retrieved
func AssertValidTokenStorage(t TestingInterface, session *SessionData, token string) {
session.SetAccessToken(token)
retrieved := session.GetAccessToken()
if retrieved != token {
t.Errorf("Token storage failed: expected %q, got %q", token, retrieved)
}
}
// AssertInvalidTokenRejection verifies that an invalid token is rejected
func AssertInvalidTokenRejection(t TestingInterface, session *SessionData, token string) {
original := session.GetAccessToken()
session.SetAccessToken(token)
after := session.GetAccessToken()
if after != original {
t.Errorf("Invalid token was not rejected: expected %q, got %q", original, after)
}
}
// TestingInterface provides the minimal interface needed for testing
type TestingInterface interface {
Errorf(format string, args ...interface{})
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+507
View File
@@ -0,0 +1,507 @@
package traefikoidc
import (
"bytes"
"compress/gzip"
"crypto/rand"
"encoding/base64"
"fmt"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gorilla/sessions"
)
// TestTokenCorruptionScenario reproduces the exact failure pattern from GitHub issue #53:
// Token verified successfully multiple times, then fails with "signature verification failed"
func TestTokenCorruptionScenario(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a valid JWT token
validJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImV4cCI6OTk5OTk5OTk5OX0.signature"
tests := []struct {
corruptionScenario func(*SessionData)
name string
tokenSize int
iterations int
expectConsistent bool
}{
{
name: "Small token - multiple retrievals",
tokenSize: len(validJWT),
iterations: 10,
expectConsistent: true,
},
{
name: "Large chunked token - multiple retrievals",
tokenSize: 5000,
iterations: 10,
expectConsistent: true,
},
{
name: "Compression corruption simulation",
tokenSize: 2000,
iterations: 5,
expectConsistent: false, // Will be corrupted intentionally
corruptionScenario: func(session *SessionData) {
// Simulate corruption by directly modifying session values
if session.accessSession != nil {
// Simulate corrupted compressed data
session.accessSession.Values["token"] = "corrupted_base64_!@#$"
session.accessSession.Values["compressed"] = true
}
},
},
{
name: "Chunk reassembly corruption simulation",
tokenSize: 25000, // Large enough to force chunking even after compression
iterations: 5,
expectConsistent: false, // Will be corrupted intentionally
corruptionScenario: func(session *SessionData) {
// Simulate chunk corruption with invalid base64 characters
if len(session.accessTokenChunks) > 0 {
if chunk, exists := session.accessTokenChunks[0]; exists {
chunk.Values["token_chunk"] = "invalid_base64_!@#$%"
}
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Create token of specified size
token := createTokenOfSize(validJWT, tt.tokenSize)
// 1. Store the token
session.SetAccessToken(token)
t.Logf("Stored token of size %d bytes", len(token))
// 2. Verify token can be retrieved multiple times successfully
var retrievedTokens []string
for i := 0; i < tt.iterations; i++ {
retrieved := session.GetAccessToken()
retrievedTokens = append(retrievedTokens, retrieved)
if tt.expectConsistent && retrieved != token {
t.Errorf("Iteration %d: Token mismatch, expected consistency", i)
break
}
}
// 3. Apply corruption scenario if specified
if tt.corruptionScenario != nil {
tt.corruptionScenario(session)
}
// 4. Retrieve token after potential corruption
finalRetrieved := session.GetAccessToken()
if tt.expectConsistent {
// With fixes, token should still be retrievable correctly
if finalRetrieved != token {
t.Errorf("Final retrieval failed - corruption not handled correctly")
t.Logf("Expected: %q", token)
t.Logf("Got: %q", finalRetrieved)
}
} else {
// For corruption scenarios, expect empty string (graceful failure)
if finalRetrieved != "" {
t.Errorf("Expected corruption to result in empty token, got: %q", finalRetrieved)
}
}
// 5. Verify all previous retrievals were consistent (if expected)
if tt.expectConsistent {
for i, retrieved := range retrievedTokens {
if retrieved != token {
t.Errorf("Iteration %d produced inconsistent result", i)
}
}
}
})
}
}
// TestCompressionIntegrityFailure tests scenarios where compression fails integrity checks
func TestCompressionIntegrityFailure(t *testing.T) {
tests := []struct {
name string
token string
expectSame bool
}{
{
name: "Valid JWT",
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig",
expectSame: true,
},
{
name: "Invalid JWT - wrong dots",
token: "invalid.token",
expectSame: true, // Should return unchanged
},
{
name: "Oversized token",
token: "header." + strings.Repeat("A", 60000) + ".sig",
expectSame: true, // Should return unchanged due to size limit
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compressed := compressToken(tt.token)
if tt.expectSame && compressed != tt.token {
// If we expect the token to remain the same but it was compressed,
// verify round-trip integrity
decompressed := decompressToken(compressed)
if decompressed != tt.token {
t.Errorf("Compression integrity failed: original=%q, decompressed=%q", tt.token, decompressed)
}
}
})
}
}
// TestChunkReassemblyEdgeCases tests edge cases in chunk reassembly that could cause corruption
func TestChunkReassemblyEdgeCases(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Create a large token that will definitely be chunked
largeToken := createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", 8000)
// Store the token to create chunks
session.SetAccessToken(largeToken)
if len(session.accessTokenChunks) == 0 {
t.Skip("Token was not chunked, skipping reassembly tests")
}
t.Logf("Token was split into %d chunks", len(session.accessTokenChunks))
// Test various corruption scenarios
corruptionTests := []struct {
corruption func(map[int]*sessions.Session)
name string
expectEmpty bool
}{
{
name: "Gap in chunk sequence",
corruption: func(chunks map[int]*sessions.Session) {
// Remove chunk 1 if it exists
delete(chunks, 1)
},
expectEmpty: true,
},
{
name: "Chunk with nil value",
corruption: func(chunks map[int]*sessions.Session) {
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = nil
}
},
expectEmpty: true,
},
{
name: "Chunk with wrong type",
corruption: func(chunks map[int]*sessions.Session) {
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = 12345 // Should be string
}
},
expectEmpty: true,
},
{
name: "Empty chunk data",
corruption: func(chunks map[int]*sessions.Session) {
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = ""
}
},
expectEmpty: true,
},
{
name: "Excessive chunk count",
corruption: func(chunks map[int]*sessions.Session) {
// This test simulates having too many chunks (>50 limit)
// We'll create a scenario by adding many fake chunks
for i := 0; i < 60; i++ {
fakeSession := &sessions.Session{Values: make(map[interface{}]interface{})}
fakeSession.Values["token_chunk"] = "fake_chunk_data"
chunks[i] = fakeSession
}
},
expectEmpty: true,
},
}
for _, ct := range corruptionTests {
t.Run(ct.name, func(t *testing.T) {
// Get a fresh session for each test
freshReq := httptest.NewRequest("GET", "http://example.com/foo", nil)
freshSession, err := sm.GetSession(freshReq)
if err != nil {
t.Fatalf("Failed to get fresh session: %v", err)
}
defer freshSession.ReturnToPool()
// Store the large token again
freshSession.SetAccessToken(largeToken)
// Apply corruption
ct.corruption(freshSession.accessTokenChunks)
// Try to retrieve the token
retrieved := freshSession.GetAccessToken()
if ct.expectEmpty {
if retrieved != "" {
t.Errorf("Expected empty token due to corruption, got: %q", retrieved)
}
} else {
if retrieved != largeToken {
t.Errorf("Expected original token, got: %q", retrieved)
}
}
})
}
}
// TestRaceConditionProtection tests that concurrent access doesn't cause corruption
func TestRaceConditionProtection(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
const numGoroutines = 20
const numOperations = 50
// Create tokens of different sizes
tokens := []string{
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig1",
createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig2", 3000),
createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig3", 6000),
}
var wg sync.WaitGroup
errChan := make(chan error, numGoroutines*numOperations)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
tokenIndex := (goroutineID + j) % len(tokens)
expectedToken := tokens[tokenIndex]
// Set token
session.SetAccessToken(expectedToken)
// Retrieve token
retrieved := session.GetAccessToken()
// Verify it's a valid JWT (should have exactly 2 dots)
if retrieved != "" && strings.Count(retrieved, ".") != 2 {
errChan <- fmt.Errorf("goroutine %d, op %d: invalid JWT format in retrieved token: %q",
goroutineID, j, retrieved)
continue
}
// The retrieved token should be one of the valid tokens we set
// (due to concurrent access, it might not be the exact one we just set)
isValidToken := false
for _, validToken := range tokens {
if retrieved == validToken {
isValidToken = true
break
}
}
if retrieved != "" && !isValidToken {
errChan <- fmt.Errorf("goroutine %d, op %d: retrieved unknown token: %q",
goroutineID, j, retrieved)
}
}
}(i)
}
wg.Wait()
close(errChan)
// Check for any errors
for err := range errChan {
t.Error(err)
}
}
// TestMemoryExhaustionProtection tests protection against memory exhaustion attacks
func TestMemoryExhaustionProtection(t *testing.T) {
tests := []struct {
setupCorruption func() string
name string
expectRejection bool
}{
{
name: "Extremely large compressed data",
setupCorruption: func() string {
return base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("A"), 200*1024)) // 200KB
},
expectRejection: true,
},
{
name: "Malformed gzip bomb attempt",
setupCorruption: func() string {
// Create data that looks like gzip but would decompress to huge size
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
gz.Write(bytes.Repeat([]byte("A"), 10*1024)) // 10KB that compresses well
gz.Close()
compressed := buf.Bytes()
// Modify to make it potentially dangerous
return base64.StdEncoding.EncodeToString(compressed)
},
expectRejection: false, // Our decompression has size limits
},
{
name: "Token with excessive chunk simulation",
setupCorruption: func() string {
// This will be tested in the session layer
return strings.Repeat("chunk.", 100) + "final"
},
expectRejection: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
corruptedData := tt.setupCorruption()
result := decompressToken(corruptedData)
if tt.expectRejection {
// Should return original corrupted data, not attempt decompression
if result != corruptedData {
t.Errorf("Expected rejection of dangerous data, but decompression was attempted")
}
}
// Verify no excessive memory was used (this test would catch OOM in practice)
// The fact that we reach this point means memory limits were effective
})
}
}
// TestBackwardCompatibility ensures that sessions created before the fixes still work
func TestBackwardCompatibility(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Simulate old-style session data (without new validation fields)
oldStyleToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.oldsig"
// Manually set token without going through new SetAccessToken validation
session.accessSession.Values["token"] = oldStyleToken
session.accessSession.Values["compressed"] = false
// Should still be retrievable
retrieved := session.GetAccessToken()
if retrieved != oldStyleToken {
t.Errorf("Backward compatibility failed: expected %q, got %q", oldStyleToken, retrieved)
}
// Test with simulated old compressed token
oldCompressed := compressToken(oldStyleToken)
session.accessSession.Values["token"] = oldCompressed
session.accessSession.Values["compressed"] = true
retrieved2 := session.GetAccessToken()
if retrieved2 != oldStyleToken {
t.Errorf("Backward compatibility with compression failed: expected %q, got %q", oldStyleToken, retrieved2)
}
}
// createTokenOfSize creates a JWT token of approximately the specified size
func createTokenOfSize(baseToken string, targetSize int) string {
parts := strings.Split(baseToken, ".")
if len(parts) != 3 {
return baseToken
}
header, payload, signature := parts[0], parts[1], parts[2]
currentSize := len(baseToken)
if currentSize >= targetSize {
return baseToken
}
// Expand the payload to reach target size
paddingNeeded := targetSize - len(header) - len(signature) - 2 // Account for dots
if paddingNeeded > 0 {
// Decode current payload, add padding, re-encode
decoded, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
// If we can't decode, just pad with random base64-safe characters to resist compression
randomBytes := make([]byte, paddingNeeded)
rand.Read(randomBytes)
// Encode as base64 to make it base64-safe
padData := base64.RawURLEncoding.EncodeToString(randomBytes)
payload = payload + padData
} else {
// Add padding to the JSON - use random data to resist compression
randomBytes := make([]byte, paddingNeeded/2)
rand.Read(randomBytes)
// Encode as base64 to make it JSON-safe
padData := base64.StdEncoding.EncodeToString(randomBytes)
newPayload := fmt.Sprintf(`{"original":%s,"padding":"%s"}`, string(decoded), padData)
payload = base64.RawURLEncoding.EncodeToString([]byte(newPayload))
}
}
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
+368 -5
View File
@@ -2,8 +2,12 @@ package traefikoidc
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
"time"
@@ -15,10 +19,10 @@ import (
func TestTokenTypeDistinction(t *testing.T) {
// Define test data where AccessToken and IdToken are deliberately different
type templateData struct {
Claims map[string]interface{}
AccessToken string
IdToken string
RefreshToken string
Claims map[string]interface{}
}
testData := templateData{
@@ -257,10 +261,10 @@ func TestSessionIDTokenAccessToken(t *testing.T) {
t.Fatalf("Failed to get session: %v", err)
}
// Set test tokens
idToken := "test-id-token-123"
accessToken := "test-access-token-456"
refreshToken := "test-refresh-token-789"
// Set test tokens using standardized tokens
idToken := ValidIDToken
accessToken := ValidAccessToken
refreshToken := ValidRefreshToken
// Store tokens in session
session.SetIDToken(idToken)
@@ -309,3 +313,362 @@ func TestSessionIDTokenAccessToken(t *testing.T) {
t.Errorf("ID token and Access token should be different, but both are %q", retrievedIDToken)
}
}
// TestTokenCorruptionIntegrationFlows tests the complete token handling flow with corruption scenarios
func TestTokenCorruptionIntegrationFlows(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
tests := []struct {
corruptAction func(*SessionData)
name string
accessToken string
refreshToken string
idToken string
expectSuccess bool
}{
{
name: "Normal flow - small tokens",
accessToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.access_sig",
refreshToken: "refresh_token_12345",
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_sig",
expectSuccess: true,
},
{
name: "Normal flow - large tokens (chunked)",
accessToken: createLargeValidJWT(5000),
refreshToken: createLargeRefreshToken(3000),
idToken: createLargeValidJWT(2000),
expectSuccess: true,
},
{
name: "Corrupted access token compression",
accessToken: createLargeValidJWT(3000),
refreshToken: "refresh_token_12345",
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_sig",
expectSuccess: false,
corruptAction: func(session *SessionData) {
// Corrupt compressed access token
if session.accessSession != nil {
session.accessSession.Values["token"] = "corrupted_compressed_data_!@#"
session.accessSession.Values["compressed"] = true
}
},
},
{
name: "Corrupted chunk in large token",
accessToken: createLargeValidJWT(8000), // Force chunking
refreshToken: "refresh_token_12345",
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_sig",
expectSuccess: false,
corruptAction: func(session *SessionData) {
// Corrupt first chunk
if len(session.accessTokenChunks) > 0 {
if chunk, exists := session.accessTokenChunks[0]; exists {
chunk.Values["token_chunk"] = "corrupted_chunk_data"
}
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
// Get session
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Store tokens
session.SetAccessToken(tt.accessToken)
session.SetRefreshToken(tt.refreshToken)
session.SetIDToken(tt.idToken)
session.SetAuthenticated(true)
// Save session
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Apply corruption if specified
if tt.corruptAction != nil {
tt.corruptAction(session)
}
// Test token retrieval after corruption
retrievedAccess := session.GetAccessToken()
retrievedRefresh := session.GetRefreshToken()
retrievedID := session.GetIDToken()
if tt.expectSuccess {
if retrievedAccess != tt.accessToken {
t.Errorf("Access token corruption: expected %q, got %q", tt.accessToken, retrievedAccess)
}
if retrievedRefresh != tt.refreshToken {
t.Errorf("Refresh token corruption: expected %q, got %q", tt.refreshToken, retrievedRefresh)
}
if retrievedID != tt.idToken {
t.Errorf("ID token corruption: expected %q, got %q", tt.idToken, retrievedID)
}
} else {
// For corruption scenarios, access token should be empty (graceful failure)
if retrievedAccess != "" {
t.Errorf("Expected corrupted access token to return empty, got: %q", retrievedAccess)
}
// Other tokens should still work
if retrievedRefresh != tt.refreshToken {
t.Errorf("Refresh token should not be affected by access token corruption: expected %q, got %q",
tt.refreshToken, retrievedRefresh)
}
}
})
}
}
// TestSessionPersistenceWithCorruption tests that session corruption is handled across requests
func TestSessionPersistenceWithCorruption(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// First request - store tokens
req1 := httptest.NewRequest("GET", "/test", nil)
rr1 := httptest.NewRecorder()
session1, err := sm.GetSession(req1)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
largeToken := createLargeValidJWT(6000)
session1.SetAccessToken(largeToken)
session1.SetAuthenticated(true)
if err := session1.Save(req1, rr1); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from first response
cookies := rr1.Result().Cookies()
session1.ReturnToPool()
// Second request - retrieve tokens with cookies
req2 := httptest.NewRequest("GET", "/test", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sm.GetSession(req2)
if err != nil {
t.Fatalf("Failed to get session from cookies: %v", err)
}
defer session2.ReturnToPool()
// Verify token can be retrieved
retrieved := session2.GetAccessToken()
if retrieved != largeToken {
t.Errorf("Token persistence failed: expected %q, got %q", largeToken, retrieved)
}
// Simulate corruption by modifying chunks
if len(session2.accessTokenChunks) > 0 {
// Corrupt a middle chunk
chunkIndex := len(session2.accessTokenChunks) / 2
if chunk, exists := session2.accessTokenChunks[chunkIndex]; exists {
chunk.Values["token_chunk"] = "corrupted"
}
// Try to retrieve again - should detect corruption and return empty
retrievedAfterCorruption := session2.GetAccessToken()
if retrievedAfterCorruption != "" {
t.Errorf("Expected corruption to be detected, but got token: %q", retrievedAfterCorruption)
}
}
}
// TestConcurrentTokenOperationsWithCorruption tests concurrent access with intentional corruption
func TestConcurrentTokenOperationsWithCorruption(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "/test", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
const numGoroutines = 10
const numOperations = 20
done := make(chan bool, numGoroutines)
errorChan := make(chan error, numGoroutines*numOperations)
// Start concurrent operations
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer func() { done <- true }()
for j := 0; j < numOperations; j++ {
// Create a unique valid token for each operation
token := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwib3AiOiIxMjMifQ.sig_%d_%d",
goroutineID, j)
// Store token
session.SetAccessToken(token)
// Retrieve token
retrieved := session.GetAccessToken()
// Validate retrieved token format
if retrieved != "" {
if strings.Count(retrieved, ".") != 2 {
errorChan <- fmt.Errorf("goroutine %d, op %d: invalid JWT format: %q",
goroutineID, j, retrieved)
continue
}
// Check if it's a reasonable length
if len(retrieved) < 10 || len(retrieved) > 100000 {
errorChan <- fmt.Errorf("goroutine %d, op %d: suspicious token length %d: %q",
goroutineID, j, len(retrieved), retrieved)
}
}
// Occasionally simulate corruption to test error handling
if j%5 == 0 && len(session.accessTokenChunks) > 0 {
// Intentionally corrupt a random chunk
for chunkID, chunk := range session.accessTokenChunks {
if chunkID%2 == 0 {
chunk.Values["token_chunk"] = "intentionally_corrupted"
break
}
}
}
}
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < numGoroutines; i++ {
<-done
}
close(errorChan)
// Check for any unexpected errors
errorCount := 0
for err := range errorChan {
t.Logf("Concurrent operation error: %v", err)
errorCount++
}
// We expect some corruption-related "errors" due to intentional corruption,
// but not format-related errors which would indicate actual corruption bugs
if errorCount > numGoroutines*numOperations/4 { // Allow up to 25% corruption-related issues
t.Errorf("Too many errors during concurrent operations: %d", errorCount)
}
}
// TestTokenValidationEdgeCases tests edge cases in token validation
func TestTokenValidationEdgeCases(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "/test", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Use standardized test tokens
testTokens := NewTestTokens()
edgeCases := testTokens.TokenValidationTestCases()
for _, ec := range edgeCases {
t.Run(ec.Name, func(t *testing.T) {
// Clear any previous token
session.SetAccessToken("")
// Store the test token
originalToken := session.GetAccessToken()
session.SetAccessToken(ec.Token)
afterStoreToken := session.GetAccessToken()
if ec.ExpectStored {
if afterStoreToken != ec.Token {
t.Errorf("Expected token to be stored, but got different value")
}
} else {
if afterStoreToken != originalToken {
t.Errorf("Expected invalid token to be rejected, but it was stored")
}
}
// Test retrieval
finalToken := session.GetAccessToken()
if ec.ExpectRetrieved {
if finalToken != ec.Token {
t.Errorf("Expected token to be retrievable: %q, got: %q", ec.Token, finalToken)
}
} else {
if finalToken != "" {
t.Errorf("Expected empty token due to invalid format, got: %q", finalToken)
}
}
})
}
}
// Helper functions for test data creation
// createLargeValidJWT creates a JWT of approximately the specified size
func createLargeValidJWT(targetSize int) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "signature_" + generateRandomString(32)
// Calculate required payload size
usedSize := len(header) + len(signature) + 2 // account for dots
payloadSize := targetSize - usedSize
if payloadSize < 50 {
payloadSize = 50
}
// Create a payload with realistic JWT claims
claims := map[string]interface{}{
"sub": "user123",
"iss": "https://example.com",
"aud": "client123",
"exp": 9999999999,
"iat": 1000000000,
"data": generateRandomString(payloadSize - 100), // Account for other claims
}
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
// createLargeRefreshToken creates a refresh token of approximately the specified size
func createLargeRefreshToken(targetSize int) string {
baseToken := "refresh_token_"
padding := generateRandomString(targetSize - len(baseToken))
return baseToken + padding
}