Compare commits

..
Author SHA1 Message Date
lukaszraczylo f88886a5df Merge main into 0.8.0-redis: resolve conflicts
Merged the following features from main:
- Dynamic Client Registration (RFC 7591)
- Sharded cache and CPU spike prevention
- Documentation updates (CNAME, index.html)

Resolved conflicts in:
- settings.go: Combined Redis config with Dynamic Client Registration config
- universal_cache_singleton.go: Merged Redis backend support with consolidated cleanup

Both features now work together:
- Redis distributed caching with circuit breaker and health checks
- Consolidated cleanup goroutine (reduces from 7 to 1)
- SkipAutoCleanup flag honored for all cache types
2025-11-30 02:03:13 +00:00
lukaszraczyloandGitHub 5fcbd54955 Add sharded cache and prevention of CPU spikes / locks (#96)
* Add sharded cache and prevention of CPU spikes / locks

* Add dynamic client registration with oidc provider

* Fix race condition introduced during the sharded cache implementation.

* Add page for traefikoidc.
2025-11-30 01:41:12 +00:00
lukaszraczyloandGitHub e70cd1907c Create CNAME 2025-11-30 01:28:07 +00:00
lukaszraczyloGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
e56e90f40b Potential fix for code scanning alert no. 10: Size computation for allocation may overflow
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-11-13 00:11:18 +00:00
lukaszraczylo e453a15922 Add ability to set cookie max age - issue #91 2025-11-12 12:46:41 +00:00
lukaszraczylo ccc6f76b0e fixup! Add ability to set cookie prefix for session cookies ( #87 ) 2025-11-12 12:30:04 +00:00
lukaszraczylo 16a93b5ca8 Add ability to set cookie prefix for session cookies ( #87 ) 2025-11-12 12:27:53 +00:00
43 changed files with 4755 additions and 317 deletions
+8 -15
View File
@@ -211,11 +211,6 @@ jobs:
echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT
echo "Total Coverage: $COVERAGE%"
# Get per-package coverage
echo "## Coverage by Package" >> coverage_report.md
echo "" >> coverage_report.md
go tool cover -func=coverage.out | grep -v "total:" | awk '{print "- " $1 ": " $3}' >> coverage_report.md || true
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
@@ -230,21 +225,19 @@ jobs:
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const coverage = '${{ steps.coverage.outputs.coverage }}';
let coverageReport = '';
try {
coverageReport = fs.readFileSync('coverage_report.md', 'utf8');
} catch (e) {
coverageReport = 'Coverage details not available';
}
const threshold = 70;
const coverageNum = parseFloat(coverage);
const emoji = coverageNum >= threshold ? '✅' : '⚠️';
const status = coverageNum >= threshold ? 'meets' : 'below';
const body = `## ${emoji} Test Coverage Report\n\n**Total Coverage:** ${coverage}%\n**Threshold:** ${threshold}%\n\n${coverageReport}`;
const body = `## ${emoji} Test Coverage Report
| Metric | Value |
|--------|-------|
| **Total Coverage** | ${coverage}% |
| **Threshold** | ${threshold}% |
| **Status** | ${emoji} Coverage ${status} threshold |`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
+82 -7
View File
@@ -109,6 +109,8 @@ testData:
oidcEndSessionURL: https://accounts.google.com/logout # Provider's end session endpoint
enablePKCE: false # Enables PKCE (Proof Key for Code Exchange) for additional security
cookieDomain: "" # Explicit domain for session cookies (e.g., ".example.com" for multi-subdomain setups)
cookiePrefix: "" # Custom prefix for cookie names (e.g., "_oidc_myapp_" for session isolation between middleware instances)
sessionMaxAge: 86400 # Maximum session age in seconds (default: 86400 = 24 hours, 0 = use default)
overrideScopes: false # When true, replaces default scopes instead of appending (default: false)
refreshGracePeriodSeconds: 60 # Seconds before token expiry to attempt proactive refresh (default: 60)
@@ -636,28 +638,101 @@ configuration:
cookieDomain:
type: string
description: |
Explicit domain for session cookies. This is important for multi-subdomain setups
Explicit domain for session cookies. This is important for multi-subdomain setups
and reverse proxy deployments to ensure consistent cookie handling.
When set, all session cookies will use this domain. When not set, the domain
is auto-detected from the request headers (X-Forwarded-Host or Host).
Use a leading dot for subdomain-wide cookies (e.g., ".example.com" allows
cookies to be shared between app.example.com, api.example.com, etc.).
Use a specific domain for host-only cookies (e.g., "app.example.com" restricts
cookies to that exact domain).
This setting is crucial to prevent authentication issues like "CSRF token missing
in session" errors that can occur when cookies are created with inconsistent domains.
Examples:
- ".example.com" - Allows all subdomains to share cookies
- "app.example.com" - Restricts cookies to this specific host
Default: "" (auto-detected from request headers)
required: false
cookiePrefix:
type: string
description: |
Custom prefix for session cookie names. This is essential for running multiple
middleware instances with different authorization requirements on the same domain.
By default, all middleware instances use the same cookie names (_oidc_raczylo_m,
_oidc_raczylo_a, etc.), which means they share session state. When you have
multiple instances with different access restrictions (e.g., one for general users
and one for admins), this session sharing can lead to authorization bypass issues.
Setting a unique cookiePrefix for each middleware instance ensures complete
session isolation, preventing users authenticated via one middleware from
automatically gaining access to routes protected by a different middleware.
The prefix is prepended to all session cookie names:
- Main session cookie: {prefix}m
- Access token cookie: {prefix}a
- Refresh token cookie: {prefix}r
- ID token cookie: {prefix}id
Examples:
- "_oidc_userauth_" - For general user authentication middleware
- "_oidc_adminauth_" - For admin-only authentication middleware
- "_oidc_api_" - For API-specific authentication middleware
Security Note: Use different cookie prefixes AND different sessionEncryptionKey
values for each middleware instance to ensure complete isolation.
Default: "_oidc_raczylo_" (standard prefix for backward compatibility)
See: https://github.com/lukaszraczylo/traefikoidc/issues/87
required: false
sessionMaxAge:
type: integer
description: |
Maximum session age in seconds before requiring re-authentication.
This setting controls how long a user's authentication session remains valid
before they must authenticate again through the OIDC provider. The session
age is tracked from the initial authentication time (created_at).
When a session exceeds this age:
- The session is cleared and invalidated
- The user is redirected to re-authenticate
- All session cookies are removed
Use Cases:
- High-security applications: Use shorter durations (e.g., 3600 = 1 hour)
- Standard applications: Default 24 hours balances security and UX
- Long-lived sessions: Extend for applications accessed infrequently
(e.g., 604800 = 7 days, 2592000 = 30 days)
Security Considerations:
- Shorter sessions provide better security but require more frequent logins
- Longer sessions improve user experience but increase security risk
- Consider your application's security requirements and user access patterns
- This is independent of token refresh - tokens can be refreshed during the session
Common Values:
- 3600 (1 hour) - High security applications
- 28800 (8 hours) - Working day session
- 86400 (24 hours) - Default, balances security and convenience
- 604800 (7 days) - Weekly session for less frequently accessed apps
- 2592000 (30 days) - Monthly session for infrequently used applications
Default: 86400 (24 hours)
Minimum: 0 (uses default of 24 hours)
See: https://github.com/lukaszraczylo/traefikoidc/issues/91
required: false
overrideScopes:
type: boolean
description: |
+201
View File
@@ -8,6 +8,7 @@ The Traefik OIDC middleware provides a complete OIDC authentication solution wit
- **Universal provider support**: Works with 9+ OIDC providers including Google, Azure AD, Auth0, Okta, Keycloak, AWS Cognito, GitLab, and more
- **Automatic provider detection**: Automatically detects and configures provider-specific settings
- **Dynamic Client Registration (RFC 7591)**: Automatic client registration with OIDC providers without manual pre-registration
- **Automatic scope filtering**: Intelligently filters OAuth scopes based on provider capabilities declared in OIDC discovery documents, preventing authentication failures with unsupported scopes
- **Security headers**: Comprehensive security headers with CORS, CSP, HSTS, and custom profiles
- **Domain restrictions**: Limit access to specific email domains or individual users
@@ -128,6 +129,8 @@ The middleware supports the following configuration options:
| `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
| `refreshGracePeriodSeconds` | Seconds before token expiry to attempt proactive refresh | `60` | `120` |
| `cookieDomain` | Explicit domain for session cookies (important for multi-subdomain setups) | auto-detected | `.example.com`, `app.example.com` |
| `cookiePrefix` | Custom prefix for session cookie names (for isolating multiple middleware instances) | `_oidc_raczylo_` | `_oidc_userauth_`, `_oidc_admin_` |
| `sessionMaxAge` | Maximum session age in seconds before requiring re-authentication | `86400` (24 hours) | `3600` (1 hour), `604800` (7 days) |
| `audience` | Custom audience for access token validation (for Auth0 custom APIs, etc.) | `clientID` | `https://my-api.example.com` |
| `strictAudienceValidation` | Reject sessions with access token audience mismatch (prevents token confusion attacks) | `false` | `true` |
| `allowOpaqueTokens` | Enable opaque (non-JWT) access token support via RFC 7662 introspection | `false` | `true` |
@@ -707,6 +710,123 @@ See [Redis Cache Documentation](docs/REDIS_CACHE.md) for:
- Troubleshooting guide
- Migration from memory-only cache
## Dynamic Client Registration (RFC 7591)
The middleware supports **OIDC Dynamic Client Registration** (RFC 7591), allowing automatic client registration with OIDC providers without manual pre-registration. This is useful for:
- **Multi-tenant deployments**: Automatically register clients per tenant
- **Development environments**: Quick setup without manual OAuth app creation
- **Self-service integrations**: Allow applications to self-register
### How It Works
1. When enabled, the middleware discovers the `registration_endpoint` from the provider's `.well-known/openid-configuration`
2. If no `clientID` is configured, it automatically registers a new client with the provider
3. The registered `client_id` and `client_secret` are cached and optionally persisted to a file
4. Subsequent requests use the registered credentials
### Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-dynamic-registration
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://your-oidc-provider.com
# clientID and clientSecret are NOT required when using DCR
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
callbackURL: /oauth2/callback
dynamicClientRegistration:
enabled: true
# Optional: Initial access token for protected registration endpoints
initialAccessToken: "your-initial-access-token"
# Optional: Override the registration endpoint (auto-discovered by default)
registrationEndpoint: "https://your-provider.com/register"
# Optional: Persist credentials to file for reuse across restarts
persistCredentials: true
credentialsFile: "/tmp/oidc-client-credentials.json"
# Client metadata for registration
clientMetadata:
redirect_uris:
- "https://your-app.com/oauth2/callback"
client_name: "My Application"
application_type: "web"
grant_types:
- "authorization_code"
- "refresh_token"
response_types:
- "code"
token_endpoint_auth_method: "client_secret_basic"
contacts:
- "[email protected]"
```
### DCR Configuration Parameters
| Parameter | Description | Required | Default |
|-----------|-------------|----------|---------|
| `enabled` | Enable dynamic client registration | Yes | `false` |
| `initialAccessToken` | Bearer token for protected registration endpoints | No | - |
| `registrationEndpoint` | Override auto-discovered registration endpoint | No | From discovery |
| `persistCredentials` | Save registered credentials to file | No | `false` |
| `credentialsFile` | Path to store/load credentials | No | `/tmp/oidc-client-credentials.json` |
| `clientMetadata.redirect_uris` | **REQUIRED** - Redirect URIs for OAuth flow | Yes | - |
| `clientMetadata.client_name` | Human-readable client name | No | - |
| `clientMetadata.application_type` | `web` or `native` | No | `web` |
| `clientMetadata.grant_types` | OAuth grant types | No | `["authorization_code", "refresh_token"]` |
| `clientMetadata.response_types` | OAuth response types | No | `["code"]` |
| `clientMetadata.token_endpoint_auth_method` | Authentication method | No | `client_secret_basic` |
| `clientMetadata.contacts` | Contact email addresses | No | - |
| `clientMetadata.logo_uri` | URL to client logo | No | - |
| `clientMetadata.client_uri` | URL to client homepage | No | - |
| `clientMetadata.policy_uri` | URL to privacy policy | No | - |
| `clientMetadata.tos_uri` | URL to terms of service | No | - |
| `clientMetadata.scope` | Space-separated scopes | No | - |
### Provider Support
DCR support varies by provider:
| Provider | DCR Support | Notes |
|----------|-------------|-------|
| Keycloak | ✅ Full | Enable in realm settings |
| Auth0 | ✅ Full | Requires Management API token |
| Okta | ✅ Full | Enable Dynamic Client Registration |
| Azure AD | ⚠️ Limited | App Registration API instead |
| Google | ❌ No | Manual registration required |
| AWS Cognito | ❌ No | Manual registration required |
### Security Considerations
1. **HTTPS Required**: Registration endpoints must use HTTPS (except localhost for development)
2. **Initial Access Token**: Recommended for production to prevent unauthorized registrations
3. **Credential Persistence**: If enabled, ensure the credentials file has appropriate permissions (0600)
4. **Secret Expiration**: Monitor `client_secret_expires_at` and handle rotation if needed
### Example: Keycloak with DCR
```yaml
dynamicClientRegistration:
enabled: true
clientMetadata:
redirect_uris:
- "https://myapp.example.com/oauth2/callback"
client_name: "My App - Production"
application_type: "web"
grant_types:
- "authorization_code"
- "refresh_token"
```
## Usage Examples
### Basic Configuration
@@ -885,6 +1005,87 @@ spec:
**Important**: The `cookieDomain` parameter is crucial when running behind a reverse proxy or when your application serves multiple subdomains. Without it, cookies may be created with inconsistent domains, leading to authentication issues like "CSRF token missing in session" errors.
### With Multiple Middleware Instances (Session Isolation)
When running multiple middleware instances with different authorization requirements (e.g., one for general users and one for admins), you must use different `cookiePrefix` values to prevent session sharing between instances:
```yaml
# Middleware for general user authentication
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-userauth
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://auth.example.com
clientID: your-client-id
clientSecret: your-client-secret
sessionEncryptionKey: user-key-at-least-32-bytes-long
callbackURL: /oauth2/callback
cookiePrefix: "_oidc_userauth_" # Unique prefix for this instance
---
# Middleware for admin authentication with stricter requirements
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-adminauth
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://auth.example.com
clientID: your-client-id
clientSecret: your-client-secret
sessionEncryptionKey: admin-key-at-least-32-bytes-long # Different encryption key
callbackURL: /oauth2/admin/callback # Different callback URL
cookiePrefix: "_oidc_adminauth_" # Different prefix for isolation
allowedUsers: # Restricted to specific admin users
- [email protected]
- [email protected]
```
**Security Note**: When running multiple instances, ensure you use:
1. **Different `cookiePrefix`** values to prevent cookie name collisions
2. **Different `sessionEncryptionKey`** values for complete session isolation
3. **Different `callbackURL`** paths to avoid routing conflicts
This configuration prevents authorization bypass issues where a user authenticated via the general middleware could access admin-protected routes. See [issue #87](https://github.com/lukaszraczylo/traefikoidc/issues/87) for more details.
### With Extended Session Duration
For applications that users access infrequently (weekly or monthly), you can extend the session duration beyond the default 24 hours to reduce authentication friction:
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-long-session
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://auth.example.com
clientID: your-client-id
clientSecret: your-client-secret
sessionEncryptionKey: your-key-at-least-32-bytes-long
callbackURL: /oauth2/callback
sessionMaxAge: 604800 # 7 days (in seconds)
# Other common values:
# 259200 - 3 days
# 604800 - 7 days
# 1209600 - 14 days
# 2592000 - 30 days
```
**Security Note**: Longer session durations improve user experience but increase security risk. Consider your application's security requirements:
- **High-security apps**: Use shorter sessions (3600 = 1 hour)
- **Standard apps**: Default 24 hours balances security and UX
- **Low-frequency access apps**: Extend to 7-30 days for better UX
See [issue #91](https://github.com/lukaszraczylo/traefikoidc/issues/91) for more details.
### With Custom Logging and Rate Limiting
```yaml
+1 -1
View File
@@ -838,7 +838,7 @@ func TestAudienceEndToEndScenario(t *testing.T) {
}
logger := NewLogger("debug")
sm, err := NewSessionManager(strings.Repeat("a", MinSessionEncryptionKeyLength), false, "", logger)
sm, err := NewSessionManager(strings.Repeat("a", MinSessionEncryptionKeyLength), false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
+1 -1
View File
@@ -79,7 +79,7 @@ func TestAzureOIDCRegression(t *testing.T) {
tOidc := &mockTraefikOidc{TraefikOidc: baseOidc}
// Initialize session manager
sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, "", mockLogger)
sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, "", "", 0, mockLogger)
tOidc.sessionManager = sessionManager
// Mock the JWT verification to avoid JWKS lookup issues
+83
View File
@@ -69,6 +69,89 @@ type Config struct {
HTTPClient *http.Client `json:"-"`
CookieDomain string `json:"cookieDomain"`
SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"`
// Dynamic Client Registration (RFC 7591) configuration
DynamicClientRegistration *DynamicClientRegistrationConfig `json:"dynamicClientRegistration,omitempty"`
}
// DynamicClientRegistrationConfig configures OIDC Dynamic Client Registration (RFC 7591)
type DynamicClientRegistrationConfig struct {
// Enabled enables automatic client registration with the OIDC provider
Enabled bool `json:"enabled"`
// InitialAccessToken is an optional bearer token for protected registration endpoints
// Some providers require this token to authorize new client registrations
InitialAccessToken string `json:"initialAccessToken,omitempty"`
// RegistrationEndpoint overrides the endpoint discovered from provider metadata
// If empty, uses the registration_endpoint from .well-known/openid-configuration
RegistrationEndpoint string `json:"registrationEndpoint,omitempty"`
// ClientMetadata contains the client metadata to register
ClientMetadata *ClientRegistrationMetadata `json:"clientMetadata,omitempty"`
// PersistCredentials determines whether to save registered credentials to a file
// This allows reusing the same client_id/client_secret across restarts
PersistCredentials bool `json:"persistCredentials"`
// CredentialsFile is the path to store/load registered client credentials
// Defaults to "/tmp/oidc-client-credentials.json" if not specified
CredentialsFile string `json:"credentialsFile,omitempty"`
}
// ClientRegistrationMetadata contains client metadata for dynamic registration (RFC 7591)
type ClientRegistrationMetadata struct {
// RedirectURIs is REQUIRED - array of redirect URIs for authorization
RedirectURIs []string `json:"redirect_uris"`
// ResponseTypes specifies OAuth 2.0 response types (default: ["code"])
ResponseTypes []string `json:"response_types,omitempty"`
// GrantTypes specifies OAuth 2.0 grant types (default: ["authorization_code"])
GrantTypes []string `json:"grant_types,omitempty"`
// ApplicationType is either "web" (default) or "native"
ApplicationType string `json:"application_type,omitempty"`
// Contacts is an array of email addresses for responsible parties
Contacts []string `json:"contacts,omitempty"`
// ClientName is a human-readable name for the client
ClientName string `json:"client_name,omitempty"`
// LogoURI is a URL pointing to a logo for the client
LogoURI string `json:"logo_uri,omitempty"`
// ClientURI is a URL of the home page of the client
ClientURI string `json:"client_uri,omitempty"`
// PolicyURI is a URL pointing to the client's privacy policy
PolicyURI string `json:"policy_uri,omitempty"`
// TOSURI is a URL pointing to the client's terms of service
TOSURI string `json:"tos_uri,omitempty"`
// JWKSURI is a URL for the client's JSON Web Key Set
JWKSURI string `json:"jwks_uri,omitempty"`
// SubjectType is "pairwise" or "public" (provider-specific)
SubjectType string `json:"subject_type,omitempty"`
// TokenEndpointAuthMethod specifies how the client authenticates at token endpoint
// Values: "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
// DefaultMaxAge is the default maximum authentication age in seconds
DefaultMaxAge int `json:"default_max_age,omitempty"`
// RequireAuthTime specifies whether auth_time claim is required in ID token
RequireAuthTime bool `json:"require_auth_time,omitempty"`
// DefaultACRValues specifies default ACR values
DefaultACRValues []string `json:"default_acr_values,omitempty"`
// Scope is a space-separated list of scopes (alternative to config.Scopes)
Scope string `json:"scope,omitempty"`
}
// HeaderConfig represents header template configuration
+6 -5
View File
@@ -69,11 +69,12 @@ type SessionConfig struct {
MaxChunks int `json:"maxChunks" yaml:"maxChunks"`
// Cookie settings
Domain string `json:"domain" yaml:"domain"`
Path string `json:"path" yaml:"path"`
Secure bool `json:"secure" yaml:"secure"`
HttpOnly bool `json:"httpOnly" yaml:"httpOnly"`
SameSite string `json:"sameSite" yaml:"sameSite"`
Domain string `json:"domain" yaml:"domain"`
Path string `json:"path" yaml:"path"`
Secure bool `json:"secure" yaml:"secure"`
HttpOnly bool `json:"httpOnly" yaml:"httpOnly"`
SameSite string `json:"sameSite" yaml:"sameSite"`
CookiePrefix string `json:"cookiePrefix" yaml:"cookiePrefix"` // Prefix for cookie names (e.g., "_oidc_myapp_")
// Storage settings
StorageType string `json:"storageType" yaml:"storageType"` // "memory", "redis", "cookie"
+8 -8
View File
@@ -18,7 +18,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
// Test that CSRF tokens persist through the authentication flow
t.Run("CSRF_Token_Persists_After_Selective_Clear", func(t *testing.T) {
// Create a session manager
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
// Create initial request
@@ -90,7 +90,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
// Test that marking session as dirty forces save
t.Run("Mark_Dirty_Forces_Session_Save", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
@@ -126,7 +126,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
// Test Azure-specific session handling
t.Run("Azure_Session_Cookie_Configuration", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
// Simulate Azure callback scenario
@@ -158,7 +158,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
// Test session continuity through auth flow
t.Run("Session_Continuity_Through_Auth_Flow", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
// Step 1: Initial request
@@ -199,7 +199,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
// Test large token handling doesn't affect CSRF
t.Run("Large_Tokens_Dont_Affect_CSRF", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
@@ -262,7 +262,7 @@ func TestAuthFlowWithoutExternalDependencies(t *testing.T) {
// We can't fully initialize TraefikOidc without network access,
// but we can test the session management directly
sessionManager, err := NewSessionManager(plugin.SessionEncryptionKey, plugin.ForceHTTPS, "", NewLogger(plugin.LogLevel))
sessionManager, err := NewSessionManager(plugin.SessionEncryptionKey, plugin.ForceHTTPS, "", "", 0, NewLogger(plugin.LogLevel))
require.NoError(t, err)
t.Run("Session_Created_On_Protected_Request", func(t *testing.T) {
@@ -291,7 +291,7 @@ func TestAuthFlowWithoutExternalDependencies(t *testing.T) {
// TestRegressionLoginLoop specifically tests the fix for issue #53
func TestRegressionLoginLoop(t *testing.T) {
// This test verifies that the specific changes made to fix the login loop work correctly
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
// Simulate the exact flow that was causing the login loop
@@ -392,7 +392,7 @@ func TestRegressionLoginLoop(t *testing.T) {
// TestCSRFValidationTiming tests timing-sensitive CSRF validation scenarios
func TestCSRFValidationTiming(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
t.Run("Rapid_Redirect_Maintains_CSRF", func(t *testing.T) {
+1
View File
@@ -0,0 +1 @@
traefikoidc.raczylo.com
+831
View File
@@ -0,0 +1,831 @@
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Traefik OIDC - OpenID Connect Authentication Middleware</title>
<meta
name="description"
content="Production-ready OIDC authentication middleware for Traefik. Supports Google, Azure AD, Auth0, Okta, Keycloak, and more. Drop-in replacement for oauth2-proxy."
/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
body { font-family: "Inter", sans-serif; }
code, pre { font-family: "JetBrains Mono", monospace; }
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
.animate-float { animation: float 3s ease-in-out infinite; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(17, 24, 39, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-text {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .gradient-text {
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 50%, #22d3ee 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.1); }
.dark .shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4); }
html { scroll-behavior: smooth; }
</style>
<script>
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 theme-transition">
<!-- Navigation -->
<nav class="fixed w-full glass shadow-modern z-50 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex justify-between h-16 items-center">
<a href="#" class="flex items-center hover:opacity-80 transition-opacity duration-300 gap-2">
<i class="fas fa-shield-alt text-2xl text-blue-500"></i>
<span class="text-xl font-bold gradient-text">Traefik OIDC</span>
</a>
<div class="hidden md:flex space-x-6">
<a href="#features" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Features</a>
<a href="#providers" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Providers</a>
<a href="#installation" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Installation</a>
<a href="#configuration" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Configuration</a>
<a href="#security" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Security</a>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
<i class="fas fa-moon dark:hidden text-xl"></i>
<i class="fas fa-sun hidden dark:inline text-xl"></i>
</button>
<a href="https://github.com/lukaszraczylo/traefikoidc" target="_blank" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="View on GitHub">
<i class="fab fa-github text-xl"></i>
</a>
<button id="mobile-menu-toggle" class="md:hidden text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle menu">
<i class="fas fa-bars text-xl" id="menu-open-icon"></i>
<i class="fas fa-times text-xl hidden" id="menu-close-icon"></i>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 space-y-1 bg-white dark:bg-gray-800">
<a href="#features" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Features</a>
<a href="#providers" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Providers</a>
<a href="#installation" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Installation</a>
<a href="#configuration" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Configuration</a>
<a href="#security" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Security</a>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="relative pt-24 sm:pt-32 pb-12 sm:pb-20 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-blue-50 via-purple-50 to-cyan-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 theme-transition"></div>
<div class="absolute top-0 -left-4 w-72 h-72 bg-blue-300 dark:bg-blue-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float"></div>
<div class="absolute top-0 -right-4 w-72 h-72 bg-purple-300 dark:bg-purple-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 1s;"></div>
<div class="absolute -bottom-8 left-20 w-72 h-72 bg-cyan-300 dark:bg-cyan-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 2s;"></div>
<div class="relative max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center">
<div class="mb-6 sm:mb-8 flex justify-center animate-fade-in-up">
<div class="w-24 h-24 sm:w-32 sm:h-32 rounded-2xl bg-gradient-to-br from-blue-500 via-purple-500 to-cyan-500 flex items-center justify-center shadow-xl animate-float">
<i class="fas fa-shield-alt text-white text-4xl sm:text-5xl"></i>
</div>
</div>
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6 leading-tight animate-fade-in-up" style="animation-delay: 0.1s;">
OpenID Connect for<br /><span class="gradient-text">Traefik</span>
</h1>
<p class="text-base sm:text-lg md:text-xl text-gray-600 dark:text-gray-300 mb-8 sm:mb-10 max-w-2xl mx-auto leading-relaxed px-4 animate-fade-in-up" style="animation-delay: 0.2s;">
Production-ready OIDC authentication middleware. Drop-in replacement for oauth2-proxy and forward-auth with support for 9+ identity providers.
</p>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-8 sm:mb-12 px-4 animate-fade-in-up" style="animation-delay: 0.3s;">
<a href="#installation" class="group relative bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
<span class="relative z-10">Get Started</span>
</a>
<a href="https://github.com/lukaszraczylo/traefikoidc" class="group glass hover:shadow-lg text-gray-900 dark:text-gray-100 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
<i class="fab fa-github mr-2"></i>View on GitHub
</a>
</div>
<div class="flex flex-wrap justify-center gap-2 sm:gap-4 text-sm px-4 animate-fade-in-up" style="animation-delay: 0.4s;">
<img src="https://img.shields.io/github/v/release/lukaszraczylo/traefikoidc" alt="Version" class="h-5" />
<img src="https://img.shields.io/github/license/lukaszraczylo/traefikoidc" alt="License" class="h-5" />
<img src="https://goreportcard.com/badge/github.com/lukaszraczylo/traefikoidc" alt="Go Report" class="h-5" />
<img src="https://codecov.io/gh/lukaszraczylo/traefikoidc/branch/main/graph/badge.svg" alt="Coverage" class="h-5" />
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Features</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Enterprise-grade authentication for your Traefik deployments</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-globe text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Universal Provider Support</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Works with Google, Azure AD, Auth0, Okta, Keycloak, Cognito, GitLab, and any OIDC-compliant provider</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-magic text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Auto-Detection</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Automatically detects and configures provider-specific settings from OIDC discovery</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-cyan-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-user-plus text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Dynamic Registration</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration for automatic client setup without manual configuration</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-filter text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Automatic Scope Filtering</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Intelligently filters OAuth scopes based on provider capabilities from discovery documents</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-shield-alt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Security Headers</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Comprehensive security headers including CORS, CSP, HSTS, and customizable profiles</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-rose-500 to-rose-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-user-lock text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Domain & User Restrictions</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Limit access to specific email domains, individual users, or role-based groups</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-users-cog text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Role-Based Access</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Restrict access based on roles and groups from OIDC claims</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-sync-alt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Automatic Token Refresh</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Secure session handling with proactive token refresh before expiry</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-tachometer-alt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Rate Limiting</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Built-in protection against brute force attacks with configurable limits</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-code text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Custom Headers</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Template-based headers using OIDC claims and tokens for downstream services</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-slate-500 to-slate-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-key text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">PKCE Support</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Proof Key for Code Exchange for enhanced security in authorization code flow</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-memory text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Memory Management</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Bounded caches with LRU eviction, automatic cleanup, and zero goroutine leaks</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Providers Section -->
<section id="providers" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Supported Providers</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Works with all major identity providers out of the box</p>
</div>
<!-- Provider Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-red-500 to-yellow-500 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-google text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Google</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-microsoft text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Azure AD</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<span class="text-white font-bold text-sm">A0</span>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Auth0</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<span class="text-white font-bold text-sm">OK</span>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Okta</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-red-600 to-red-700 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<span class="text-white font-bold text-sm">KC</span>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Keycloak</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-aws text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">AWS Cognito</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-orange-600 to-orange-700 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-gitlab text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">GitLab</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-github text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">GitHub</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">OAuth 2.0</p>
</div>
</div>
<!-- Comparison Table -->
<div class="glass rounded-xl overflow-hidden shadow-modern">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<tr>
<th class="px-4 py-3 text-left font-semibold">Feature</th>
<th class="px-4 py-3 text-center font-semibold">Google</th>
<th class="px-4 py-3 text-center font-semibold">Azure AD</th>
<th class="px-4 py-3 text-center font-semibold">Auth0</th>
<th class="px-4 py-3 text-center font-semibold">Okta</th>
<th class="px-4 py-3 text-center font-semibold">Keycloak</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">ID Tokens</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Refresh Tokens</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Auto-Configuration</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Custom Claims</td>
<td class="px-4 py-3 text-center text-gray-400">Limited</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Group/Role Claims</td>
<td class="px-4 py-3 text-center text-gray-400">Limited</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Self-Hosted</td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Installation Section -->
<section id="installation" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Installation</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Get started in under 5 minutes</p>
</div>
<div class="max-w-3xl mx-auto space-y-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<span class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold mr-3">1</span>
Enable the Plugin
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Add to your Traefik static configuration:</p>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code># traefik.yml
experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.7.10</code></pre>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<span class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold mr-3">2</span>
Configure the Middleware
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Create your middleware configuration:</p>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code># dynamic/middleware.yml
http:
middlewares:
oidc-auth:
plugin:
traefikoidc:
providerURL: "https://accounts.google.com"
clientID: "your-client-id"
clientSecret: "your-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-byte-secret-key-here!!"
scopes:
- "openid"
- "profile"
- "email"</code></pre>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<span class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold mr-3">3</span>
Apply to Your Routes
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Use the middleware on your services:</p>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code># dynamic/routers.yml
http:
routers:
my-secure-app:
rule: "Host(`app.example.com`)"
service: my-service
middlewares:
- oidc-auth
tls:
certResolver: letsencrypt</code></pre>
</div>
</div>
</div>
</section>
<!-- Configuration Section -->
<section id="configuration" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Configuration</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Flexible options for any deployment scenario</p>
</div>
<div class="max-w-4xl mx-auto space-y-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Required Parameters</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Parameter</th>
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Description</th>
</tr>
</thead>
<tbody class="text-gray-600 dark:text-gray-400">
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">providerURL</code></td>
<td class="py-2 px-3">Base URL of your OIDC provider</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientID</code></td>
<td class="py-2 px-3">OAuth 2.0 client identifier</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientSecret</code></td>
<td class="py-2 px-3">OAuth 2.0 client secret</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">sessionEncryptionKey</code></td>
<td class="py-2 px-3">32+ byte key for session encryption</td>
</tr>
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">callbackURL</code></td>
<td class="py-2 px-3">OAuth callback path (e.g., /oauth2/callback)</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Popular Optional Parameters</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Parameter</th>
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Default</th>
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Description</th>
</tr>
</thead>
<tbody class="text-gray-600 dark:text-gray-400">
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">forceHTTPS</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Required for TLS termination at load balancer</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">allowedUserDomains</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">Restrict to specific email domains</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">allowedRolesAndGroups</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">Restrict to users with specific roles</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">excludedURLs</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">Paths that bypass authentication</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">enablePKCE</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Enable PKCE for enhanced security</td>
</tr>
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">rateLimit</code></td>
<td class="py-2 px-3">100</td>
<td class="py-2 px-3">Maximum requests per second</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Example: Google Workspace with Domain Restriction</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>http:
middlewares:
google-oidc:
plugin:
traefikoidc:
providerURL: "https://accounts.google.com"
clientID: "1234567890.apps.googleusercontent.com"
clientSecret: "your-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-byte-encryption-key!!"
allowedUserDomains:
- "yourcompany.com"
- "subsidiary.com"
excludedURLs:
- "/health"
- "/metrics"
- "/api/public"
forceHTTPS: true
logLevel: "info"</code></pre>
</div>
</div>
</div>
</section>
<!-- Security Section -->
<section id="security" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Security First</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Built with enterprise security requirements in mind</p>
</div>
<div class="max-w-4xl mx-auto">
<div class="grid md:grid-cols-2 gap-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-lock mr-2 text-blue-500"></i>
Token Security
</h3>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; JWT signature verification with JWK rotation</li>
<li>&#8226; Replay attack detection via JTI claims</li>
<li>&#8226; Strict audience and issuer validation</li>
<li>&#8226; Automatic token refresh before expiry</li>
<li>&#8226; Token revocation on logout</li>
</ul>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-shield-alt mr-2 text-purple-500"></i>
Session Security
</h3>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; AES-256-GCM encrypted session cookies</li>
<li>&#8226; CSRF protection with state parameter</li>
<li>&#8226; Secure, HttpOnly, SameSite cookies</li>
<li>&#8226; Configurable session timeouts</li>
<li>&#8226; Bounded session cache with LRU eviction</li>
</ul>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-globe mr-2 text-cyan-500"></i>
Security Headers
</h3>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; Content Security Policy (CSP)</li>
<li>&#8226; HTTP Strict Transport Security (HSTS)</li>
<li>&#8226; X-Frame-Options, X-Content-Type-Options</li>
<li>&#8226; CORS configuration</li>
<li>&#8226; Customizable header profiles</li>
</ul>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-tachometer-alt mr-2 text-emerald-500"></i>
Rate Limiting
</h3>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; Configurable request rate limits</li>
<li>&#8226; Protection against brute force attacks</li>
<li>&#8226; Per-client rate limiting</li>
<li>&#8226; Graceful handling of limit exceeded</li>
<li>&#8226; Customizable response codes</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<!-- Why Choose Section -->
<section class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Why Choose Traefik OIDC?</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">A better alternative to oauth2-proxy and forward-auth</p>
</div>
<div class="max-w-4xl mx-auto">
<div class="glass rounded-xl overflow-hidden shadow-modern">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<tr>
<th class="px-4 py-3 text-left font-semibold">Feature</th>
<th class="px-4 py-3 text-center font-semibold">Traefik OIDC</th>
<th class="px-4 py-3 text-center font-semibold">oauth2-proxy</th>
<th class="px-4 py-3 text-center font-semibold">forward-auth</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Native Plugin</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">No Extra Service</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Auto Provider Detection</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Dynamic Client Registration</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Automatic Scope Filtering</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Built-in Security Headers</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Template Headers</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Memory Efficient</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span> <span class="text-xs text-gray-500">LRU caches</span></td>
<td class="px-4 py-3 text-center"><span class="text-gray-400">Varies</span></td>
<td class="px-4 py-3 text-center"><span class="text-gray-400">Varies</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="py-12 sm:py-16 md:py-20 bg-gradient-to-r from-blue-600 to-purple-600">
<div class="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-4">Ready to Secure Your Applications?</h2>
<p class="text-lg text-blue-100 mb-8 max-w-2xl mx-auto">
Get started with Traefik OIDC in minutes. Full documentation and examples available on GitHub.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="https://github.com/lukaszraczylo/traefikoidc" class="bg-white hover:bg-gray-100 text-blue-600 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
<i class="fab fa-github mr-2"></i>View on GitHub
</a>
<a href="https://github.com/lukaszraczylo/traefikoidc/blob/main/README.md" class="bg-transparent border-2 border-white hover:bg-white/10 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
<i class="fas fa-book mr-2"></i>Documentation
</a>
</div>
</div>
</section>
<!-- Footer -->
<footer class="py-8 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-2">
<i class="fas fa-shield-alt text-xl text-blue-500"></i>
<span class="text-xl font-bold gradient-text">Traefik OIDC</span>
</div>
<div class="flex items-center space-x-6">
<a href="https://github.com/lukaszraczylo/traefikoidc" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
<i class="fab fa-github text-xl"></i>
</a>
<a href="https://github.com/lukaszraczylo/traefikoidc/issues" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">Issues</a>
<a href="https://github.com/lukaszraczylo/traefikoidc/releases" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">Releases</a>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">Apache 2.0 License</p>
</div>
</div>
</footer>
<script>
// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', function() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
});
// Mobile menu toggle
document.getElementById('mobile-menu-toggle').addEventListener('click', function() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-open-icon');
const closeIcon = document.getElementById('menu-close-icon');
menu.classList.toggle('hidden');
openIcon.classList.toggle('hidden');
closeIcon.classList.toggle('hidden');
});
// Close mobile menu on link click
document.querySelectorAll('#mobile-menu a').forEach(link => {
link.addEventListener('click', function() {
document.getElementById('mobile-menu').classList.add('hidden');
document.getElementById('menu-open-icon').classList.remove('hidden');
document.getElementById('menu-close-icon').classList.add('hidden');
});
});
</script>
</body>
</html>
+550
View File
@@ -0,0 +1,550 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik
package traefikoidc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
)
// ClientRegistrationResponse represents the response from a successful client registration (RFC 7591)
type ClientRegistrationResponse struct {
// Required fields
ClientID string `json:"client_id"`
// Conditional - only for confidential clients
ClientSecret string `json:"client_secret,omitempty"`
// Optional - for managing registration
RegistrationAccessToken string `json:"registration_access_token,omitempty"`
RegistrationClientURI string `json:"registration_client_uri,omitempty"`
// Expiration
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
// Echo back of registered metadata
RedirectURIs []string `json:"redirect_uris,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ApplicationType string `json:"application_type,omitempty"`
Contacts []string `json:"contacts,omitempty"`
ClientName string `json:"client_name,omitempty"`
LogoURI string `json:"logo_uri,omitempty"`
ClientURI string `json:"client_uri,omitempty"`
PolicyURI string `json:"policy_uri,omitempty"`
TOSURI string `json:"tos_uri,omitempty"`
JWKSURI string `json:"jwks_uri,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
Scope string `json:"scope,omitempty"`
}
// ClientRegistrationError represents an error response from client registration (RFC 7591)
type ClientRegistrationError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// DynamicClientRegistrar handles OIDC Dynamic Client Registration (RFC 7591)
type DynamicClientRegistrar struct {
httpClient *http.Client
logger *Logger
config *DynamicClientRegistrationConfig
providerURL string
// Cached registration response
mu sync.RWMutex
registrationResponse *ClientRegistrationResponse
}
// NewDynamicClientRegistrar creates a new dynamic client registrar
func NewDynamicClientRegistrar(
httpClient *http.Client,
logger *Logger,
dcrConfig *DynamicClientRegistrationConfig,
providerURL string,
) *DynamicClientRegistrar {
if logger == nil {
logger = GetSingletonNoOpLogger()
}
return &DynamicClientRegistrar{
httpClient: httpClient,
logger: logger,
config: dcrConfig,
providerURL: providerURL,
}
}
// RegisterClient performs dynamic client registration with the OIDC provider
// It first attempts to load existing credentials from a file if persistence is enabled,
// then registers a new client if no valid credentials exist.
func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registrationEndpoint string) (*ClientRegistrationResponse, error) {
if r.config == nil || !r.config.Enabled {
return nil, fmt.Errorf("dynamic client registration is not enabled")
}
// Try to load existing credentials if persistence is enabled
if r.config.PersistCredentials {
if resp, err := r.loadCredentials(); err == nil && resp != nil {
// Check if credentials are still valid (not expired)
if r.areCredentialsValid(resp) {
r.logger.Info("Loaded existing client credentials from file")
r.mu.Lock()
r.registrationResponse = resp
r.mu.Unlock()
return resp, nil
}
r.logger.Info("Existing credentials expired or invalid, registering new client")
}
}
// Determine registration endpoint
endpoint := registrationEndpoint
if r.config.RegistrationEndpoint != "" {
endpoint = r.config.RegistrationEndpoint
}
if endpoint == "" {
return nil, fmt.Errorf("no registration endpoint available: provider does not support dynamic client registration or endpoint not configured")
}
// Validate the endpoint URL
if !strings.HasPrefix(endpoint, "https://") {
// Allow http only for localhost/development
if !strings.HasPrefix(endpoint, "http://localhost") && !strings.HasPrefix(endpoint, "http://127.0.0.1") {
return nil, fmt.Errorf("registration endpoint must use HTTPS for security")
}
r.logger.Infof("Warning: using insecure HTTP for registration endpoint (development only): %s", endpoint)
}
// Build registration request
reqBody, err := r.buildRegistrationRequest()
if err != nil {
return nil, fmt.Errorf("failed to build registration request: %w", err)
}
r.logger.Debugf("Registering client at endpoint: %s", endpoint)
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create registration request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Add Initial Access Token if provided
if r.config.InitialAccessToken != "" {
req.Header.Set("Authorization", "Bearer "+r.config.InitialAccessToken)
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("registration request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
if err != nil {
return nil, fmt.Errorf("failed to read registration response: %w", err)
}
// Handle error responses
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return nil, fmt.Errorf("registration failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return nil, fmt.Errorf("registration failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var regResp ClientRegistrationResponse
if err := json.Unmarshal(body, &regResp); err != nil {
return nil, fmt.Errorf("failed to parse registration response: %w", err)
}
// Validate response
if regResp.ClientID == "" {
return nil, fmt.Errorf("registration response missing client_id")
}
r.logger.Infof("Successfully registered client with ID: %s", regResp.ClientID)
// Cache the response
r.mu.Lock()
r.registrationResponse = &regResp
r.mu.Unlock()
// Persist credentials if enabled
if r.config.PersistCredentials {
if err := r.saveCredentials(&regResp); err != nil {
r.logger.Errorf("Failed to persist client credentials: %v", err)
// Don't fail registration if persistence fails
}
}
return &regResp, nil
}
// buildRegistrationRequest creates the JSON request body for client registration
func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
metadata := r.config.ClientMetadata
if metadata == nil {
metadata = &ClientRegistrationMetadata{}
}
// Build request object
reqData := make(map[string]interface{})
// Required: redirect_uris
if len(metadata.RedirectURIs) > 0 {
reqData["redirect_uris"] = metadata.RedirectURIs
} else {
return nil, fmt.Errorf("redirect_uris is required for client registration")
}
// Optional fields - only include if set
if len(metadata.ResponseTypes) > 0 {
reqData["response_types"] = metadata.ResponseTypes
} else {
// Default to authorization code flow
reqData["response_types"] = []string{"code"}
}
if len(metadata.GrantTypes) > 0 {
reqData["grant_types"] = metadata.GrantTypes
} else {
// Default grant types for authorization code flow
reqData["grant_types"] = []string{"authorization_code", "refresh_token"}
}
if metadata.ApplicationType != "" {
reqData["application_type"] = metadata.ApplicationType
}
if len(metadata.Contacts) > 0 {
reqData["contacts"] = metadata.Contacts
}
if metadata.ClientName != "" {
reqData["client_name"] = metadata.ClientName
}
if metadata.LogoURI != "" {
reqData["logo_uri"] = metadata.LogoURI
}
if metadata.ClientURI != "" {
reqData["client_uri"] = metadata.ClientURI
}
if metadata.PolicyURI != "" {
reqData["policy_uri"] = metadata.PolicyURI
}
if metadata.TOSURI != "" {
reqData["tos_uri"] = metadata.TOSURI
}
if metadata.JWKSURI != "" {
reqData["jwks_uri"] = metadata.JWKSURI
}
if metadata.SubjectType != "" {
reqData["subject_type"] = metadata.SubjectType
}
if metadata.TokenEndpointAuthMethod != "" {
reqData["token_endpoint_auth_method"] = metadata.TokenEndpointAuthMethod
} else {
// Default to client_secret_basic for confidential clients
reqData["token_endpoint_auth_method"] = "client_secret_basic"
}
if metadata.DefaultMaxAge > 0 {
reqData["default_max_age"] = metadata.DefaultMaxAge
}
if metadata.RequireAuthTime {
reqData["require_auth_time"] = metadata.RequireAuthTime
}
if len(metadata.DefaultACRValues) > 0 {
reqData["default_acr_values"] = metadata.DefaultACRValues
}
if metadata.Scope != "" {
reqData["scope"] = metadata.Scope
}
return json.Marshal(reqData)
}
// GetCachedResponse returns the cached registration response
func (r *DynamicClientRegistrar) GetCachedResponse() *ClientRegistrationResponse {
r.mu.RLock()
defer r.mu.RUnlock()
return r.registrationResponse
}
// areCredentialsValid checks if the cached credentials are still valid
func (r *DynamicClientRegistrar) areCredentialsValid(resp *ClientRegistrationResponse) bool {
if resp == nil || resp.ClientID == "" {
return false
}
// Check if secret has expired
if resp.ClientSecretExpiresAt > 0 {
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
// Add 5 minute buffer before expiration
if time.Now().Add(5 * time.Minute).After(expiresAt) {
return false
}
}
return true
}
// credentialsFilePath returns the path for storing credentials
func (r *DynamicClientRegistrar) credentialsFilePath() string {
if r.config.CredentialsFile != "" {
return r.config.CredentialsFile
}
return "/tmp/oidc-client-credentials.json"
}
// saveCredentials persists client credentials to a file
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
filePath := r.credentialsFilePath()
data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal credentials: %w", err)
}
// Write with restrictive permissions (owner read/write only)
if err := os.WriteFile(filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write credentials file: %w", err)
}
r.logger.Debugf("Saved client credentials to %s", filePath)
return nil
}
// loadCredentials loads client credentials from a file
func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse, error) {
filePath := r.credentialsFilePath()
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No credentials file exists
}
return nil, fmt.Errorf("failed to read credentials file: %w", err)
}
var resp ClientRegistrationResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("failed to parse credentials file: %w", err)
}
return &resp, nil
}
// UpdateClientRegistration updates an existing client registration using RFC 7592
// This requires the registration_client_uri and registration_access_token from the original registration
func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return nil, fmt.Errorf("no existing registration to update")
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
}
// Build update request
reqBody, err := r.buildRegistrationRequest()
if err != nil {
return nil, fmt.Errorf("failed to build update request: %w", err)
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodPut, cachedResp.RegistrationClientURI, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create update request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("update request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read update response: %w", err)
}
// Handle error responses
if resp.StatusCode != http.StatusOK {
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return nil, fmt.Errorf("update failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return nil, fmt.Errorf("update failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var regResp ClientRegistrationResponse
if err := json.Unmarshal(body, &regResp); err != nil {
return nil, fmt.Errorf("failed to parse update response: %w", err)
}
// Update cache
r.mu.Lock()
r.registrationResponse = &regResp
r.mu.Unlock()
// Persist updated credentials if enabled
if r.config.PersistCredentials {
if err := r.saveCredentials(&regResp); err != nil {
r.logger.Errorf("Failed to persist updated credentials: %v", err)
}
}
r.logger.Infof("Successfully updated client registration for client ID: %s", regResp.ClientID)
return &regResp, nil
}
// ReadClientRegistration reads the current client registration using RFC 7592
func (r *DynamicClientRegistrar) ReadClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return nil, fmt.Errorf("no existing registration to read")
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cachedResp.RegistrationClientURI, nil)
if err != nil {
return nil, fmt.Errorf("failed to create read request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("read request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Handle error responses
if resp.StatusCode != http.StatusOK {
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return nil, fmt.Errorf("read failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return nil, fmt.Errorf("read failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var regResp ClientRegistrationResponse
if err := json.Unmarshal(body, &regResp); err != nil {
return nil, fmt.Errorf("failed to parse read response: %w", err)
}
return &regResp, nil
}
// DeleteClientRegistration deletes the client registration using RFC 7592
func (r *DynamicClientRegistrar) DeleteClientRegistration(ctx context.Context) error {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return fmt.Errorf("no existing registration to delete")
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, cachedResp.RegistrationClientURI, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete request failed: %w", err)
}
defer resp.Body.Close()
// Handle error responses (204 No Content is success)
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return fmt.Errorf("delete failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return fmt.Errorf("delete failed with status %d: %s", resp.StatusCode, string(body))
}
// Clear cache
r.mu.Lock()
r.registrationResponse = nil
r.mu.Unlock()
// Remove credentials file if persistence is enabled
if r.config.PersistCredentials {
filePath := r.credentialsFilePath()
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
r.logger.Errorf("Failed to remove credentials file: %v", err)
}
}
r.logger.Info("Successfully deleted client registration")
return nil
}
File diff suppressed because it is too large Load Diff
+42 -10
View File
@@ -146,6 +146,9 @@ func (p *SharedTransportPool) ReleaseTransport(transport *http.Transport) {
}
// cleanupIdleTransports periodically cleans up unused transports
// Uses two-phase cleanup to minimize lock contention:
// 1. Find candidates while holding read lock
// 2. Remove and close transports with minimal lock duration
func (p *SharedTransportPool) cleanupIdleTransports(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
@@ -155,17 +158,46 @@ func (p *SharedTransportPool) cleanupIdleTransports(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
p.mu.Lock()
now := time.Now()
for transportKey, shared := range p.transports {
// Clean up transports not used for 2 minutes with no references
if shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
shared.transport.CloseIdleConnections()
delete(p.transports, transportKey)
// SECURITY FIX: Decrement client count when removing transport
atomic.AddInt32(&p.clientCount, -1)
}
p.performCleanup()
}
}
}
// performCleanup does the actual cleanup with optimized locking
func (p *SharedTransportPool) performCleanup() {
now := time.Now()
// Phase 1: Find candidates while holding read lock (fast)
p.mu.RLock()
candidates := make([]string, 0)
for transportKey, shared := range p.transports {
// Clean up transports not used for 2 minutes with no references
if shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
candidates = append(candidates, transportKey)
}
}
p.mu.RUnlock()
if len(candidates) == 0 {
return
}
// Phase 2: Remove and close each candidate individually
// This minimizes lock contention and allows concurrent access
for _, key := range candidates {
p.mu.Lock()
shared, exists := p.transports[key]
if exists && shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
// Remove from map first (releases memory)
delete(p.transports, key)
atomic.AddInt32(&p.clientCount, -1)
p.mu.Unlock()
// Close idle connections outside the lock (can be slow)
if shared.transport != nil {
shared.transport.CloseIdleConnections()
}
} else {
p.mu.Unlock()
}
}
+12
View File
@@ -255,6 +255,18 @@ func (c *RedisConn) Do(command string, args ...string) (interface{}, error) {
if argsLen < 0 || argsLen > maxSafeArgs {
return nil, errors.New("too many arguments")
}
const maxTotalArgBytes = 64 << 20 // 64 MiB max total size
totalBytes := len(command)
for _, s := range args {
// Protect against possible overflow
if len(s) > maxTotalArgBytes-totalBytes {
return nil, errors.New("arguments too large (would overflow maximum allowed total size)")
}
totalBytes += len(s)
if totalBytes > maxTotalArgBytes {
return nil, errors.New("total argument size exceeds maximum allowed")
}
}
cmdArgs := make([]string, 0, argsLen+1)
cmdArgs = append(cmdArgs, command)
cmdArgs = append(cmdArgs, args...)
+91 -27
View File
@@ -18,13 +18,16 @@ import (
"github.com/lukaszraczylo/traefikoidc/internal/pool"
)
// Replay attack protection cache and synchronization primitives.
// Replay attack protection cache using sharded design for reduced lock contention.
// This cache tracks JWT IDs (jti claims) to prevent token reuse attacks.
// Under high load (500+ req/sec), the sharded design reduces contention significantly.
var (
// replayCacheMu protects access to the replay cache instance
// replayCacheMu protects access to the replay cache instance (only used for initialization)
replayCacheMu sync.RWMutex
// replayCache stores JWT IDs with expiration to prevent replay attacks
// replayCache stores JWT IDs with expiration to prevent replay attacks (legacy interface)
replayCache CacheInterface
// shardedReplayCache is the new high-performance sharded cache for replay detection
shardedReplayCache *ShardedCache
// replayCacheOnce ensures the replay cache is initialized only once
replayCacheOnce sync.Once
// replayCacheCleanupWG waits for cleanup goroutine to finish
@@ -36,10 +39,20 @@ var (
)
// initReplayCache initializes the JWT replay protection cache with bounded size.
// Uses a sharded cache design with 64 shards for reduced lock contention under high load.
// The cache is bounded to 10,000 entries to prevent unbounded memory growth.
// This function uses sync.Once to ensure thread-safe single initialization.
func initReplayCache() {
replayCacheOnce.Do(func() {
// Hold mutex during initialization to synchronize with cleanup goroutine
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
// Create sharded cache with 64 shards for reduced contention
// Under 500 req/sec, this reduces lock contention by ~64x compared to single mutex
shardedReplayCache = NewShardedCache(64, 10000)
// Also initialize legacy cache for backward compatibility
replayCache = NewCache()
replayCache.SetMaxSize(10000)
})
@@ -65,18 +78,32 @@ func cleanupReplayCache() {
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
// Clear sharded cache
if shardedReplayCache != nil {
shardedReplayCache.Clear()
shardedReplayCache = nil
}
// Clear legacy cache
if replayCache != nil {
replayCache.Close()
replayCache = nil
replayCacheOnce = sync.Once{}
}
replayCacheOnce = sync.Once{}
}
// getReplayCacheStats returns statistics about the replay cache state.
// Returns:
// - size: Current number of entries in the cache (currently always 0 due to interface limitations)
// - size: Current number of entries in the cache
// - maxSize: Maximum allowed entries (10,000)
func getReplayCacheStats() (size int, maxSize int) {
// Use sharded cache if available (no mutex needed due to internal sharding)
if shardedReplayCache != nil {
return shardedReplayCache.Size(), 10000
}
// Fall back to legacy cache
replayCacheMu.RLock()
defer replayCacheMu.RUnlock()
@@ -98,16 +125,31 @@ func startReplayCacheCleanup(ctx context.Context, logger *Logger) {
// Define the cleanup task function
cleanupFunc := func() {
// Use mutex to safely access cache pointers - this prevents race with initReplayCache
replayCacheMu.RLock()
shardedCache := shardedReplayCache
legacyCache := replayCache
replayCacheMu.RUnlock()
// Only proceed if caches have been initialized
if shardedCache == nil && legacyCache == nil {
return
}
size, maxSize := getReplayCacheStats()
if logger != nil {
logger.Debugf("Replay cache stats: size=%d, maxSize=%d", size, maxSize)
}
replayCacheMu.RLock()
if replayCache != nil {
replayCache.Cleanup()
// Clean up sharded cache
if shardedCache != nil {
shardedCache.Cleanup()
}
// Also clean up legacy cache for backward compatibility
if legacyCache != nil {
legacyCache.Cleanup()
}
replayCacheMu.RUnlock()
}
// Create or get singleton cleanup task
@@ -323,29 +365,51 @@ func (j *JWT) Verify(issuerURL, expectedAudience string, skipReplayCheck ...bool
if jtiOk && !shouldSkipReplay && jtiValue != "" {
initReplayCache()
replayCacheMu.RLock()
_, exists := replayCache.Get(jtiValue)
replayCacheMu.RUnlock()
// Use sharded cache for replay detection - no global mutex needed
// This reduces lock contention by ~64x under high load
if shardedReplayCache != nil {
if shardedReplayCache.Exists(jtiValue) {
return fmt.Errorf("token replay detected (jti: %s)", jtiValue)
}
if exists {
return fmt.Errorf("token replay detected (jti: %s)", jtiValue)
}
expFloat, ok := claims["exp"].(float64)
var expTime time.Time
if ok {
expTime = time.Unix(int64(expFloat), 0)
} else {
expTime = time.Now().Add(10 * time.Minute)
}
expFloat, ok := claims["exp"].(float64)
var expTime time.Time
if ok {
expTime = time.Unix(int64(expFloat), 0)
duration := time.Until(expTime)
if duration > 0 {
shardedReplayCache.Set(jtiValue, true, duration)
}
} else {
expTime = time.Now().Add(10 * time.Minute)
}
// Fall back to legacy cache with mutex (should rarely happen)
replayCacheMu.RLock()
_, exists := replayCache.Get(jtiValue)
replayCacheMu.RUnlock()
duration := time.Until(expTime)
if duration > 0 {
replayCacheMu.Lock()
if replayCache != nil {
replayCache.Set(jtiValue, true, duration)
if exists {
return fmt.Errorf("token replay detected (jti: %s)", jtiValue)
}
expFloat, ok := claims["exp"].(float64)
var expTime time.Time
if ok {
expTime = time.Unix(int64(expFloat), 0)
} else {
expTime = time.Now().Add(10 * time.Minute)
}
duration := time.Until(expTime)
if duration > 0 {
replayCacheMu.Lock()
if replayCache != nil {
replayCache.Set(jtiValue, true, duration)
}
replayCacheMu.Unlock()
}
replayCacheMu.Unlock()
}
}
+71 -3
View File
@@ -217,6 +217,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
suppressDiagnosticLogs: isTestMode(),
securityHeadersApplier: config.GetSecurityHeadersApplier(),
scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
dcrConfig: config.DynamicClientRegistration,
}
// Log audience configuration
@@ -226,7 +227,9 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
t.logger.Debugf("No custom audience specified, using clientID as audience: %s", t.clientID)
}
t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, t.logger) // Safe to ignore: session manager creation with fallback to defaults
// Convert sessionMaxAge from seconds to duration (0 will use default 24 hours)
sessionMaxAge := time.Duration(config.SessionMaxAge) * time.Second
t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, config.CookiePrefix, sessionMaxAge, t.logger) // Safe to ignore: session manager creation with fallback to defaults
t.errorRecoveryManager = NewErrorRecoveryManager(t.logger)
// Initialize token resilience manager with default configuration
@@ -373,12 +376,13 @@ func (t *TraefikOidc) initializeMetadata(providerURL string) {
// updateMetadataEndpoints updates internal endpoint URLs with discovered metadata.
// It sets the authorization URL, token URL, JWKS URL, issuer URL, revocation URL,
// end session URL, and introspection URL based on the provider's metadata.
// end session URL, introspection URL, and registration URL based on the provider's metadata.
// If Dynamic Client Registration is enabled and no ClientID is configured, it will
// automatically register the client with the provider.
// Parameters:
// - metadata: A pointer to the ProviderMetadata struct containing the discovered endpoints.
func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
t.metadataMu.Lock()
defer t.metadataMu.Unlock()
t.jwksURL = metadata.JWKSURL
t.scopesSupported = metadata.ScopesSupported // Store supported scopes from discovery
@@ -388,6 +392,9 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
t.revocationURL = metadata.RevokeURL
t.endSessionURL = metadata.EndSessionURL
t.introspectionURL = metadata.IntrospectionURL // OAuth 2.0 Token Introspection endpoint (RFC 7662)
t.registrationURL = metadata.RegistrationURL // OIDC Dynamic Client Registration endpoint (RFC 7591)
t.metadataMu.Unlock()
// Log introspection endpoint availability for opaque token support
if t.introspectionURL != "" {
@@ -398,6 +405,67 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
} else if t.allowOpaqueTokens || t.requireTokenIntrospection {
t.logger.Infof("⚠️ Opaque tokens enabled but no introspection endpoint available from provider")
}
// Log registration endpoint availability
if t.registrationURL != "" {
t.logger.Debugf("Dynamic client registration endpoint discovered: %s", t.registrationURL)
}
// Perform Dynamic Client Registration if enabled and ClientID is not set
if t.dcrConfig != nil && t.dcrConfig.Enabled && t.clientID == "" {
t.performDynamicClientRegistration()
}
}
// performDynamicClientRegistration performs automatic client registration with the OIDC provider
func (t *TraefikOidc) performDynamicClientRegistration() {
t.logger.Info("Dynamic Client Registration enabled - registering client with provider")
// Initialize the DCR registrar if not already done
if t.dynamicClientRegistrar == nil {
t.dynamicClientRegistrar = NewDynamicClientRegistrar(
t.httpClient,
t.logger,
t.dcrConfig,
t.providerURL,
)
}
// Get registration endpoint (from metadata or config override)
registrationEndpoint := t.registrationURL
if t.dcrConfig.RegistrationEndpoint != "" {
registrationEndpoint = t.dcrConfig.RegistrationEndpoint
}
// Perform registration
ctx, cancel := context.WithTimeout(t.ctx, 30*time.Second)
defer cancel()
resp, err := t.dynamicClientRegistrar.RegisterClient(ctx, registrationEndpoint)
if err != nil {
t.logger.Errorf("Dynamic Client Registration failed: %v", err)
return
}
// Update client credentials from registration response
t.metadataMu.Lock()
t.clientID = resp.ClientID
t.clientSecret = resp.ClientSecret
if t.audience == "" {
t.audience = resp.ClientID // Default audience to client ID
}
t.metadataMu.Unlock()
t.logger.Infof("Dynamic Client Registration successful - client_id: %s", resp.ClientID)
// Log additional registration details
if resp.ClientSecretExpiresAt > 0 {
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
t.logger.Infof("Client secret expires at: %s", expiresAt.Format(time.RFC3339))
}
if resp.RegistrationClientURI != "" {
t.logger.Debugf("Registration management URI: %s", resp.RegistrationClientURI)
}
}
// startMetadataRefresh starts a background goroutine that periodically refreshes provider metadata.
+1 -1
View File
@@ -539,7 +539,7 @@ func (m *MockSessionData) Clear(r *http.Request, w http.ResponseWriter) error {
// Helper function to create a test session manager
func createTestSessionManager(t *testing.T) *SessionManager {
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
+12 -18
View File
@@ -104,7 +104,7 @@ func (ts *TestSuite) Setup() {
}
logger := NewLogger("info")
ts.sessionManager, _ = NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
ts.sessionManager, _ = NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", "", 0, logger)
// Create WaitGroup for the OIDC instance
goroutineWG := &sync.WaitGroup{}
@@ -1274,7 +1274,7 @@ func TestHandleCallback(t *testing.T) {
ts.tOidc.tokenBlacklist = NewCache() // Use generic cache for blacklist
logger := NewLogger("info")
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", "", 0, logger)
// Create a new instance for each test to avoid state carryover
instanceExtractClaimsFunc := tc.extractClaimsFunc
@@ -1663,7 +1663,7 @@ func TestHandleLogout(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := NewLogger("info")
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", "", 0, logger)
tOidc := &TraefikOidc{
revocationURL: mockRevocationServer.URL,
endSessionURL: tc.endSessionURL,
@@ -1966,7 +1966,7 @@ func TestHandleExpiredToken(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := NewLogger("info")
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", "", 0, logger)
tOidc := &TraefikOidc{
sessionManager: sessionManager,
@@ -3216,10 +3216,8 @@ func TestAuthenticationFlowReplayDetection(t *testing.T) {
t.Fatalf("Initial authentication should succeed: %v", err)
}
// Verify JTI is in cache
replayCacheMu.Lock()
_, exists := replayCache.Get(jti)
replayCacheMu.Unlock()
// Verify JTI is in cache (use shardedReplayCache which is the actual cache used)
exists := shardedReplayCache.Exists(jti)
if !exists {
t.Error("JTI should be added to replay cache during initial authentication")
}
@@ -3400,14 +3398,12 @@ func TestConcurrentTokenValidation(t *testing.T) {
t.Errorf("Expected no errors in concurrent validation, got %d errors: %v", len(errors), errors)
}
// Verify all JTIs are in cache
replayCacheMu.Lock()
// Verify all JTIs are in cache (use shardedReplayCache which is the actual cache used)
for i, jti := range jtis {
if _, exists := replayCache.Get(jti); !exists {
if !shardedReplayCache.Exists(jti) {
t.Errorf("JTI %d (%s) should be in replay cache", i, jti)
}
}
replayCacheMu.Unlock()
}
// TestJTIBlacklistBehavior tests the JTI blacklist cache management
@@ -3460,9 +3456,8 @@ func TestJTIBlacklistBehavior(t *testing.T) {
{
name: "JTI exists in blacklist after verification",
action: func() error {
replayCacheMu.RLock()
defer replayCacheMu.RUnlock()
if _, exists := replayCache.Get(jti); !exists {
// Use shardedReplayCache which is the actual cache used
if !shardedReplayCache.Exists(jti) {
return fmt.Errorf("JTI not found in blacklist cache")
}
return nil
@@ -3569,9 +3564,8 @@ func TestSessionBasedTokenRevalidation(t *testing.T) {
}
// Check replay cache
replayCacheMu.Lock()
_, inReplayCache := replayCache.Get(jti)
replayCacheMu.Unlock()
// Use shardedReplayCache which is the actual cache used
inReplayCache := shardedReplayCache.Exists(jti)
if !inReplayCache {
t.Error("JTI should be in replay cache")
}
+10
View File
@@ -253,6 +253,8 @@ func TestMemoryLeakConsolidated(t *testing.T) {
"test-encryption-key-32-bytes-long-enough",
false,
"",
"",
0,
tf.logger,
)
if err != nil {
@@ -293,6 +295,8 @@ func TestMemoryLeakConsolidated(t *testing.T) {
"test-encryption-key-32-bytes-long-enough",
false,
"",
"",
0,
tf.logger,
)
return err
@@ -695,6 +699,8 @@ func BenchmarkMemoryUsage(b *testing.B) {
"test-encryption-key-32-bytes-long-enough",
false,
"",
"",
0,
NewLogger("error"),
)
// No Cleanup method, defer not needed
@@ -774,6 +780,8 @@ func TestGoroutineLeaks(t *testing.T) {
"test-encryption-key-32-bytes-long-enough",
false,
"",
"",
0,
NewLogger("error"),
)
require.NoError(t, err)
@@ -863,6 +871,8 @@ func TestMemoryThresholds(t *testing.T) {
"test-encryption-key-32-bytes-long-enough",
false,
"",
"",
0,
NewLogger("error"),
)
+2 -2
View File
@@ -65,7 +65,7 @@ func TestMemoryTestOrchestrator(t *testing.T) {
mto := NewMemoryTestOrchestrator(config, logger)
// Test registering a component
sessionManager, err := NewSessionManager("test-key-32-chars-long-for-testing", false, "", logger)
sessionManager, err := NewSessionManager("test-key-32-chars-long-for-testing", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -111,7 +111,7 @@ func TestComponentProfilers(t *testing.T) {
logger := NewLogger("debug")
// Test Session Pool Profiler
sessionManager, err := NewSessionManager("test-key-32-chars-long-for-testing", false, "", logger)
sessionManager, err := NewSessionManager("test-key-32-chars-long-for-testing", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
+90 -10
View File
@@ -40,6 +40,13 @@ type RefreshCoordinator struct {
// Cleanup goroutine control
stopChan chan struct{}
wg sync.WaitGroup
// delayedCleanupQueue stores items to be cleaned up after delay
// Uses a timer-based approach instead of spawning goroutines per cleanup
delayedCleanupQueue chan delayedCleanupItem
// cleanupTimerPool reuses timers to avoid goroutine-per-cleanup
cleanupTimerMu sync.Mutex
cleanupTimers map[string]*time.Timer
}
// RefreshCoordinatorConfig configures the refresh coordinator behavior
@@ -131,6 +138,12 @@ type RefreshMetrics struct {
currentInFlightRefreshes int32
}
// delayedCleanupItem represents an item scheduled for delayed cleanup
type delayedCleanupItem struct {
tokenHash string
cleanupAt time.Time
}
// RefreshCircuitBreaker implements a circuit breaker specifically for refresh operations
type RefreshCircuitBreaker struct {
state int32 // 0=closed, 1=open, 2=half-open
@@ -161,6 +174,8 @@ func NewRefreshCoordinator(config RefreshCoordinatorConfig, logger *Logger) *Ref
metrics: &RefreshMetrics{},
logger: logger,
stopChan: make(chan struct{}),
delayedCleanupQueue: make(chan delayedCleanupItem, 1000), // Buffered channel for cleanup items
cleanupTimers: make(map[string]*time.Timer),
circuitBreaker: &RefreshCircuitBreaker{
config: RefreshCircuitBreakerConfig{
MaxFailures: 3,
@@ -174,6 +189,10 @@ func NewRefreshCoordinator(config RefreshCoordinatorConfig, logger *Logger) *Ref
rc.wg.Add(1)
go rc.cleanupRoutine()
// Start delayed cleanup processor (single goroutine processes all cleanup timers)
rc.wg.Add(1)
go rc.processDelayedCleanups()
return rc
}
@@ -313,16 +332,9 @@ func (rc *RefreshCoordinator) executeRefreshAsync(
// Signal completion to all waiters
close(operation.done)
// Clean up operation after a configurable delay to allow waiters to read result
go func() {
if rc.config.DeduplicationCleanupDelay > 0 {
time.Sleep(rc.config.DeduplicationCleanupDelay)
}
rc.refreshMutex.Lock()
delete(rc.inFlightRefreshes, tokenHash)
rc.refreshMutex.Unlock()
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1)
}()
// Schedule delayed cleanup using timer instead of spawning a goroutine
// This prevents goroutine explosion under high load
rc.scheduleDelayedCleanup(tokenHash)
}()
// Create timeout context
@@ -369,6 +381,65 @@ func (rc *RefreshCoordinator) executeRefreshAsync(
}
}
// scheduleDelayedCleanup schedules a cleanup using a timer instead of spawning a goroutine
// This prevents goroutine explosion under high load (500+ req/sec)
func (rc *RefreshCoordinator) scheduleDelayedCleanup(tokenHash string) {
delay := rc.config.DeduplicationCleanupDelay
if delay <= 0 {
// Immediate cleanup
rc.performCleanup(tokenHash)
return
}
// Use time.AfterFunc which is more efficient than spawning a goroutine with Sleep
// time.AfterFunc uses the runtime's timer heap which is much more efficient
rc.cleanupTimerMu.Lock()
// Cancel any existing timer for this hash (shouldn't happen, but just in case)
if existingTimer, exists := rc.cleanupTimers[tokenHash]; exists {
existingTimer.Stop()
}
rc.cleanupTimers[tokenHash] = time.AfterFunc(delay, func() {
rc.performCleanup(tokenHash)
// Remove timer from map
rc.cleanupTimerMu.Lock()
delete(rc.cleanupTimers, tokenHash)
rc.cleanupTimerMu.Unlock()
})
rc.cleanupTimerMu.Unlock()
}
// performCleanup removes the operation from the in-flight map
func (rc *RefreshCoordinator) performCleanup(tokenHash string) {
rc.refreshMutex.Lock()
delete(rc.inFlightRefreshes, tokenHash)
rc.refreshMutex.Unlock()
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1)
}
// processDelayedCleanups processes delayed cleanup requests from the queue
// This is a single goroutine that handles all delayed cleanups
func (rc *RefreshCoordinator) processDelayedCleanups() {
defer rc.wg.Done()
for {
select {
case item := <-rc.delayedCleanupQueue:
// Wait until cleanup time
waitDuration := time.Until(item.cleanupAt)
if waitDuration > 0 {
select {
case <-time.After(waitDuration):
case <-rc.stopChan:
return
}
}
rc.performCleanup(item.tokenHash)
case <-rc.stopChan:
return
}
}
}
// isInCooldown checks if a session is in cooldown after recording an attempt
func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
rc.attemptsMutex.Lock()
@@ -516,6 +587,15 @@ func (rc *RefreshCoordinator) GetMetrics() map[string]interface{} {
// Shutdown gracefully shuts down the coordinator
func (rc *RefreshCoordinator) Shutdown() {
close(rc.stopChan)
// Cancel all pending cleanup timers
rc.cleanupTimerMu.Lock()
for _, timer := range rc.cleanupTimers {
timer.Stop()
}
rc.cleanupTimers = make(map[string]*time.Timer)
rc.cleanupTimerMu.Unlock()
rc.wg.Wait()
}
+95
View File
@@ -671,3 +671,98 @@ func TestCleanupRoutine(t *testing.T) {
t.Errorf("Expected 0 sessions after cleanup, got %d", finalCount)
}
}
// TestNoGoroutineExplosionWithTimers verifies that timer-based cleanup doesn't cause goroutine explosion
// This was the original issue: spawning a goroutine per refresh to sleep and cleanup
func TestNoGoroutineExplosionWithTimers(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultRefreshCoordinatorConfig()
config.DeduplicationCleanupDelay = 100 * time.Millisecond // Non-zero delay
config.MaxConcurrentRefreshes = 100 // Allow many concurrent
config.MaxRefreshAttempts = 10000 // Don't rate limit
coordinator := NewRefreshCoordinator(config, logger)
defer coordinator.Shutdown()
// Record initial goroutines (allow settling time)
time.Sleep(50 * time.Millisecond)
runtime.GC()
initialGoroutines := runtime.NumGoroutine()
t.Logf("Initial goroutines: %d", initialGoroutines)
// Submit many refresh operations rapidly
const numRefreshes = 500
var wg sync.WaitGroup
wg.Add(numRefreshes)
refreshFunc := func() (*TokenResponse, error) {
return &TokenResponse{AccessToken: "token"}, nil
}
for i := 0; i < numRefreshes; i++ {
go func(id int) {
defer wg.Done()
ctx := context.Background()
_, _ = coordinator.CoordinateRefresh(
ctx,
fmt.Sprintf("session_%d", id),
fmt.Sprintf("token_%d", id),
refreshFunc,
)
}(i)
}
wg.Wait()
// Measure goroutines immediately after all operations complete
// With the old approach, we'd have ~500 sleeping goroutines
// With the new timer approach, we should have much fewer
currentGoroutines := runtime.NumGoroutine()
t.Logf("Goroutines after %d refresh operations: %d", numRefreshes, currentGoroutines)
// Check timer count
coordinator.cleanupTimerMu.Lock()
timerCount := len(coordinator.cleanupTimers)
coordinator.cleanupTimerMu.Unlock()
t.Logf("Active cleanup timers: %d", timerCount)
// With timer-based cleanup, goroutine increase should be minimal
// Timers don't create goroutines - they use the runtime timer heap
goroutineIncrease := currentGoroutines - initialGoroutines
// Allow for some goroutine overhead (test framework, etc)
// With the old approach, we'd see ~500 goroutines
// With the new approach, we should see <50 (much smaller)
maxAcceptableIncrease := 100 // Very generous limit
if goroutineIncrease > maxAcceptableIncrease {
t.Errorf("Goroutine explosion detected: started with %d, now have %d (increase of %d)",
initialGoroutines, currentGoroutines, goroutineIncrease)
}
// Wait for timers to fire and cleanup
time.Sleep(config.DeduplicationCleanupDelay + 50*time.Millisecond)
// Verify timers were cleaned up
coordinator.cleanupTimerMu.Lock()
remainingTimers := len(coordinator.cleanupTimers)
coordinator.cleanupTimerMu.Unlock()
// Most timers should have fired and been removed
if remainingTimers > 10 {
t.Errorf("Too many cleanup timers remaining: %d", remainingTimers)
}
// Verify goroutines returned to near initial
runtime.GC()
time.Sleep(50 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
t.Logf("Final goroutines: %d", finalGoroutines)
// Should be close to initial (within tolerance)
finalIncrease := finalGoroutines - initialGoroutines
if finalIncrease > 20 {
t.Errorf("Goroutine leak detected: started with %d, ended with %d (increase of %d)",
initialGoroutines, finalGoroutines, finalIncrease)
}
}
+3 -3
View File
@@ -30,7 +30,7 @@ func testIssue53CSRFRegression(t *testing.T) {
// 3. Session cookies must be properly configured for HTTPS
// 4. CSRF token must persist through the OAuth flow
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", traefikoidc.NewLogger("debug"))
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug"))
require.NoError(t, err)
// Step 1: Initial request to protected resource
@@ -116,7 +116,7 @@ func testIssue53CSRFRegression(t *testing.T) {
// testIssue53ReverseProxyHTTPS tests HTTPS detection in reverse proxy setups
func testIssue53ReverseProxyHTTPS(t *testing.T) {
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", traefikoidc.NewLogger("debug"))
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug"))
require.NoError(t, err)
// Create authenticated session with Azure tokens
@@ -200,7 +200,7 @@ func testIssue53SameSiteCookies(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", traefikoidc.NewLogger("debug"))
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://internal/test", nil)
+2 -2
View File
@@ -468,7 +468,7 @@ func TestSessionFixationAttack(t *testing.T) {
tc := newTestCleanup(t)
logger := NewLogger("debug")
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -625,7 +625,7 @@ func TestSessionFixationAttack(t *testing.T) {
// TestCSRFProtection tests CSRF protection in POST requests
func TestCSRFProtection(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
+29 -13
View File
@@ -237,6 +237,8 @@ type SessionManager struct {
logger *Logger
chunkManager *ChunkManager
cookieDomain string
cookiePrefix string // Prefix for cookie names (default: "_oidc_raczylo_")
sessionMaxAge time.Duration // Maximum session age (default: 24 hours)
cleanupMutex sync.RWMutex
forceHTTPS bool
cleanupDone bool
@@ -256,26 +258,40 @@ type SessionManager struct {
// - encryptionKey: The key for encrypting session cookies (minimum 32 bytes).
// - forceHTTPS: Whether to force HTTPS-only cookies regardless of request scheme.
// - cookieDomain: The domain for session cookies (empty for auto-detection).
// - cookiePrefix: Prefix for session cookie names (empty for default "_oidc_raczylo_").
// - sessionMaxAge: Maximum session age duration (0 for default 24 hours).
// - logger: Logger instance for debug and error logging.
//
// Returns:
// - The configured SessionManager instance.
// - An error if the encryption key does not meet minimum length requirements.
func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, logger *Logger) (*SessionManager, error) {
func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, cookiePrefix string, sessionMaxAge time.Duration, logger *Logger) (*SessionManager, error) {
if len(encryptionKey) < minEncryptionKeyLength {
return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength)
}
// Set default cookie prefix if not provided
if cookiePrefix == "" {
cookiePrefix = "_oidc_raczylo_"
}
// Set default session max age if not provided (24 hours for backward compatibility)
if sessionMaxAge == 0 {
sessionMaxAge = absoluteSessionTimeout
}
ctx, cancel := context.WithCancel(context.Background())
sm := &SessionManager{
store: sessions.NewCookieStore([]byte(encryptionKey)),
forceHTTPS: forceHTTPS,
cookieDomain: cookieDomain,
logger: logger,
chunkManager: NewChunkManager(logger),
ctx: ctx,
cancel: cancel,
store: sessions.NewCookieStore([]byte(encryptionKey)),
forceHTTPS: forceHTTPS,
cookieDomain: cookieDomain,
cookiePrefix: cookiePrefix,
sessionMaxAge: sessionMaxAge,
logger: logger,
chunkManager: NewChunkManager(logger),
ctx: ctx,
cancel: cancel,
}
// Initialize global memory monitoring (singleton)
@@ -607,7 +623,7 @@ func (sm *SessionManager) GetSessionMetrics() map[string]interface{} {
metrics := make(map[string]interface{})
metrics["session_manager_type"] = "CookieStore"
metrics["force_https"] = sm.forceHTTPS
metrics["absolute_timeout_hours"] = absoluteSessionTimeout.Hours()
metrics["absolute_timeout_hours"] = sm.sessionMaxAge.Hours()
metrics["max_cookie_size"] = maxCookieSize
metrics["max_browser_cookie_size"] = maxBrowserCookieSize
@@ -641,7 +657,7 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h
userAgent := r.Header.Get("User-Agent")
if userAgent == "" {
sm.logger.Debugf("Request from %s missing User-Agent header", r.RemoteAddr)
options.MaxAge = int((absoluteSessionTimeout / 2).Seconds())
options.MaxAge = int((sm.sessionMaxAge / 2).Seconds())
}
if r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sm.forceHTTPS {
@@ -691,7 +707,7 @@ func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
HttpOnly: true,
Secure: isSecure || sm.forceHTTPS,
SameSite: http.SameSiteLaxMode,
MaxAge: int(absoluteSessionTimeout.Seconds()),
MaxAge: int(sm.sessionMaxAge.Seconds()),
Path: "/",
Domain: sm.cookieDomain,
}
@@ -821,7 +837,7 @@ func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
}
if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok {
if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout {
if time.Since(time.Unix(createdAt, 0)) > sm.sessionMaxAge {
_ = sessionData.Clear(r, nil) // Safe to ignore: session is being invalidated
return handleError(fmt.Errorf("session timeout"), "session expired")
}
@@ -1130,7 +1146,7 @@ func (sd *SessionData) getAuthenticatedUnsafe() bool {
if !ok {
return false
}
return time.Since(time.Unix(createdAt, 0)) <= absoluteSessionTimeout
return time.Since(time.Unix(createdAt, 0)) <= sd.manager.sessionMaxAge
}
// SetAuthenticated sets the authentication status and manages session security.
+130
View File
@@ -0,0 +1,130 @@
package core
import (
"testing"
)
// TestCookiePrefix tests that custom cookie prefixes work correctly
func TestCookiePrefix(t *testing.T) {
tests := []struct {
name string
cookiePrefix string
wantMain string
wantAccess string
wantRefresh string
wantID string
}{
{
name: "Default prefix",
cookiePrefix: "",
wantMain: "_oidc_raczylo_m",
wantAccess: "_oidc_raczylo_a",
wantRefresh: "_oidc_raczylo_r",
wantID: "_oidc_raczylo_id",
},
{
name: "Custom prefix",
cookiePrefix: "_oidc_myapp_",
wantMain: "_oidc_myapp_m",
wantAccess: "_oidc_myapp_a",
wantRefresh: "_oidc_myapp_r",
wantID: "_oidc_myapp_id",
},
{
name: "Custom prefix without underscore suffix",
cookiePrefix: "myapp",
wantMain: "myappm",
wantAccess: "myappa",
wantRefresh: "myappr",
wantID: "myappid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager(
"0123456789abcdef0123456789abcdef0123456789abcdef",
false,
"",
tt.cookiePrefix,
0,
logger,
chunkManager,
)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Test cookie names
if got := sm.MainCookieName(); got != tt.wantMain {
t.Errorf("MainCookieName() = %q, want %q", got, tt.wantMain)
}
if got := sm.AccessTokenCookie(); got != tt.wantAccess {
t.Errorf("AccessTokenCookie() = %q, want %q", got, tt.wantAccess)
}
if got := sm.RefreshTokenCookie(); got != tt.wantRefresh {
t.Errorf("RefreshTokenCookie() = %q, want %q", got, tt.wantRefresh)
}
if got := sm.IDTokenCookie(); got != tt.wantID {
t.Errorf("IDTokenCookie() = %q, want %q", got, tt.wantID)
}
})
}
}
// TestMultipleInstancesWithDifferentPrefixes tests that multiple session managers
// with different prefixes can coexist (addresses issue #87)
func TestMultipleInstancesWithDifferentPrefixes(t *testing.T) {
logger := &MockLogger{}
chunkManager1 := &MockChunkManager{}
chunkManager2 := &MockChunkManager{}
// Create two session managers with different prefixes
sm1, err := NewSessionManager(
"0123456789abcdef0123456789abcdef0123456789abcdef",
false,
"example.com",
"_oidc_app1_",
0,
logger,
chunkManager1,
)
if err != nil {
t.Fatalf("Failed to create session manager 1: %v", err)
}
sm2, err := NewSessionManager(
"fedcba9876543210fedcba9876543210fedcba9876543210", // Different encryption key
false,
"example.com",
"_oidc_app2_",
0,
logger,
chunkManager2,
)
if err != nil {
t.Fatalf("Failed to create session manager 2: %v", err)
}
// Verify they have different cookie names
if sm1.MainCookieName() == sm2.MainCookieName() {
t.Error("Expected different main cookie names for different instances")
}
// Verify cookie name patterns
expectedPrefix1 := "_oidc_app1_"
expectedPrefix2 := "_oidc_app2_"
if sm1.MainCookieName() != expectedPrefix1+"m" {
t.Errorf("Expected main cookie name %s, got %s", expectedPrefix1+"m", sm1.MainCookieName())
}
if sm2.MainCookieName() != expectedPrefix2+"m" {
t.Errorf("Expected main cookie name %s, got %s", expectedPrefix2+"m", sm2.MainCookieName())
}
t.Log("✓ Session isolation verified: Different cookie prefixes prevent session sharing")
}
+38 -17
View File
@@ -18,14 +18,16 @@ const (
// SessionManager handles session creation, management and cleanup
type SessionManager struct {
sessionPool sync.Pool
store sessions.Store
logger Logger
chunkManager ChunkManager
cookieDomain string
cleanupMutex sync.RWMutex
forceHTTPS bool
cleanupDone bool
sessionPool sync.Pool
store sessions.Store
logger Logger
chunkManager ChunkManager
cookieDomain string
cookiePrefix string // Prefix for cookie names (default: "_oidc_raczylo_")
sessionMaxAge time.Duration // Maximum session age (default: 24 hours)
cleanupMutex sync.RWMutex
forceHTTPS bool
cleanupDone bool
}
// Logger interface for dependency injection
@@ -69,17 +71,29 @@ type SessionData interface {
// NewSessionManager creates a new SessionManager instance with secure defaults.
// It initializes the cookie store with encryption, sets up session pooling,
// and configures chunk management for large tokens.
func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, logger Logger, chunkManager ChunkManager) (*SessionManager, error) {
func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, cookiePrefix string, sessionMaxAge time.Duration, logger Logger, chunkManager ChunkManager) (*SessionManager, error) {
if len(encryptionKey) < minEncryptionKeyLength {
return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength)
}
// Set default cookie prefix if not provided
if cookiePrefix == "" {
cookiePrefix = "_oidc_raczylo_"
}
// Set default session max age if not provided (24 hours for backward compatibility)
if sessionMaxAge == 0 {
sessionMaxAge = absoluteSessionTimeout
}
sm := &SessionManager{
store: sessions.NewCookieStore([]byte(encryptionKey)),
forceHTTPS: forceHTTPS,
cookieDomain: cookieDomain,
logger: logger,
chunkManager: chunkManager,
store: sessions.NewCookieStore([]byte(encryptionKey)),
forceHTTPS: forceHTTPS,
cookieDomain: cookieDomain,
cookiePrefix: cookiePrefix,
sessionMaxAge: sessionMaxAge,
logger: logger,
chunkManager: chunkManager,
}
sm.sessionPool.New = func() interface{} {
@@ -114,7 +128,7 @@ func (sm *SessionManager) initializeSession(sessionData SessionData, r *http.Req
sessionData.SetManager(sm)
// Load session data from cookies
session, err := sm.store.Get(r, MainCookieName())
session, err := sm.store.Get(r, sm.MainCookieName())
if err != nil {
sm.logger.Debugf("Error getting main session: %v", err)
return nil // Not a fatal error, will create new session
@@ -315,14 +329,21 @@ func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
return &sessions.Options{
Path: "/",
Domain: sm.cookieDomain,
MaxAge: int(absoluteSessionTimeout.Seconds()),
MaxAge: int(sm.sessionMaxAge.Seconds()),
Secure: isSecure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}
// Cookie name functions
// Cookie name methods - these now use the configurable prefix
func (sm *SessionManager) MainCookieName() string { return sm.cookiePrefix + "m" }
func (sm *SessionManager) AccessTokenCookie() string { return sm.cookiePrefix + "a" }
func (sm *SessionManager) RefreshTokenCookie() string { return sm.cookiePrefix + "r" }
func (sm *SessionManager) IDTokenCookie() string { return sm.cookiePrefix + "id" }
// Package-level functions for backward compatibility (use default prefix)
// These are deprecated and will be removed in a future version
func MainCookieName() string { return "_oidc_raczylo_m" }
func AccessTokenCookie() string { return "_oidc_raczylo_a" }
func RefreshTokenCookie() string { return "_oidc_raczylo_r" }
+14 -14
View File
@@ -165,7 +165,7 @@ func TestSessionManagerCreation(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager(tt.encryptionKey, false, "", logger, chunkManager)
sm, err := NewSessionManager(tt.encryptionKey, false, "", "", 0, logger, chunkManager)
if tt.expectError {
if err == nil {
@@ -200,7 +200,7 @@ func TestSessionManagerCreation(t *testing.T) {
func TestSessionManagerPoolBehavior(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger, chunkManager)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -291,7 +291,7 @@ func TestSessionManagerPoolBehavior(t *testing.T) {
func TestSessionManagerErrorHandling(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger, chunkManager)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -390,7 +390,7 @@ func TestSessionManagerCleanup(t *testing.T) {
logger := &MockLogger{}
mockChunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger, mockChunkManager)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, mockChunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -458,7 +458,7 @@ func TestSessionManagerHTTPSBehavior(t *testing.T) {
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef",
tt.forceHTTPS, "", logger, chunkManager)
tt.forceHTTPS, "", "", 0, logger, chunkManager)
if tt.expectError {
if err == nil {
@@ -520,7 +520,7 @@ func TestSessionManagerCookieDomain(t *testing.T) {
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef",
false, tt.cookieDomain, logger, chunkManager)
false, tt.cookieDomain, "", 0, logger, chunkManager)
if err != nil {
t.Errorf("Unexpected error for %s: %v", tt.description, err)
@@ -549,7 +549,7 @@ func BenchmarkSessionManagerCreation(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
sm, err := NewSessionManager(encryptionKey, false, "", logger, chunkManager)
sm, err := NewSessionManager(encryptionKey, false, "", "", 0, logger, chunkManager)
if err != nil {
b.Fatalf("Failed to create session manager: %v", err)
}
@@ -561,7 +561,7 @@ func BenchmarkSessionManagerCreation(b *testing.B) {
func BenchmarkSessionManagerGetSession(b *testing.B) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger, chunkManager)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager)
if err != nil {
b.Fatalf("Failed to create session manager: %v", err)
}
@@ -599,7 +599,7 @@ func minInt(a, b int) int {
func TestValidateSessionHealth(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger, chunkManager)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -660,7 +660,7 @@ func TestValidateSessionHealth(t *testing.T) {
func TestValidateTokenFormat(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger, chunkManager)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -727,7 +727,7 @@ func TestValidateTokenFormat(t *testing.T) {
func TestDetectSessionTampering(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger, chunkManager)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -812,7 +812,7 @@ func TestGetSessionMetrics(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef",
tt.forceHTTPS, tt.cookieDomain, logger, chunkManager)
tt.forceHTTPS, tt.cookieDomain, "", 0, logger, chunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -898,7 +898,7 @@ func TestShouldUseSecureCookies(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef",
tt.forceHTTPS, "", logger, chunkManager)
tt.forceHTTPS, "", "", 0, logger, chunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -940,7 +940,7 @@ func TestGetSessionOptions(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef",
false, tt.cookieDomain, logger, chunkManager)
false, tt.cookieDomain, "", 0, logger, chunkManager)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
+78 -68
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/base64"
"fmt"
"runtime"
"strings"
"sync"
"sync/atomic"
@@ -196,26 +195,23 @@ func (cm *ChunkManager) performPeriodicCleanup() {
cm.CleanupExpiredSessions()
// Force garbage collection if memory usage is high
var m runtime.MemStats
runtime.ReadMemStats(&m)
// Track memory stats for monitoring but DO NOT force GC
// Forced GC causes significant CPU spikes every cleanup interval
// Let the Go runtime handle GC scheduling efficiently
currentSessions := atomic.LoadInt64(&cm.peakSessions)
allocatedBytes := atomic.LoadInt64(&cm.bytesAllocated)
if allocatedBytes > 10*1024*1024 || currentSessions > int64(cm.maxSessions/2) {
runtime.GC()
if cm.logger != nil {
cm.logger.Debugf("Forced GC: sessions=%d, allocated=%d bytes",
currentSessions, allocatedBytes)
}
}
duration := time.Since(startTime)
atomic.AddInt64(&cm.cleanupCount, 1)
if cm.logger != nil && duration > 100*time.Millisecond {
cm.logger.Debugf("Chunk manager cleanup took %v", duration)
if cm.logger != nil {
if duration > 100*time.Millisecond {
cm.logger.Debugf("Chunk manager cleanup took %v (sessions=%d, allocated=%d bytes)",
duration, currentSessions, allocatedBytes)
} else if duration > 10*time.Millisecond {
cm.logger.Debugf("Chunk manager cleanup: sessions=%d, allocated=%d bytes, duration=%v",
currentSessions, allocatedBytes, duration)
}
}
}
@@ -1129,6 +1125,7 @@ func (cm *ChunkManager) CleanupExpiredSessions(force ...bool) {
}
// enforceSessionLimit removes oldest sessions when limit is exceeded
// Uses partial sort (O(n) for finding k smallest) instead of full sort (O(n log n))
func (cm *ChunkManager) enforceSessionLimit() {
currentLocal := len(cm.sessionMap)
currentGlobal := atomic.LoadInt64(&globalSessionCount)
@@ -1153,37 +1150,20 @@ func (cm *ChunkManager) enforceSessionLimit() {
return
}
// Find oldest sessions to remove
type sessionAge struct {
key string
lastUsed time.Time
}
sessions := make([]sessionAge, 0, len(cm.sessionMap))
for key, entry := range cm.sessionMap {
sessions = append(sessions, sessionAge{key: key, lastUsed: entry.LastUsed})
}
// Sort by last used time (oldest first)
for i := 0; i < len(sessions)-1; i++ {
for j := i + 1; j < len(sessions); j++ {
if sessions[i].lastUsed.After(sessions[j].lastUsed) {
sessions[i], sessions[j] = sessions[j], sessions[i]
}
}
}
// Remove excess sessions and track memory - CRITICAL FIX: More aggressive
// Calculate how many sessions to remove
excessCount := currentLocal - targetCapacity
if excessCount < 0 {
excessCount = 0
if excessCount <= 0 {
return
}
// Use partial selection instead of full sort for better performance
// For finding k oldest sessions, we only need O(n) operations
keysToRemove := cm.findOldestSessions(excessCount)
totalBytesFreed := int64(0)
removedCount := int64(0)
for i := 0; i < excessCount && i < len(sessions); i++ {
key := sessions[i].key
for _, key := range keysToRemove {
if entry, exists := cm.sessionMap[key]; exists {
totalBytesFreed += entry.SizeEstimate
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
@@ -1198,7 +1178,56 @@ func (cm *ChunkManager) enforceSessionLimit() {
}
cm.logger.Infof("Enforced session limit: removed %d excess sessions, freed %d bytes",
excessCount, totalBytesFreed)
len(keysToRemove), totalBytesFreed)
}
// findOldestSessions returns keys of the k oldest sessions efficiently
// Uses a simple approach: find the kth oldest timestamp, then collect all older entries
func (cm *ChunkManager) findOldestSessions(k int) []string {
if k <= 0 || len(cm.sessionMap) == 0 {
return nil
}
if k >= len(cm.sessionMap) {
// Remove all sessions
keys := make([]string, 0, len(cm.sessionMap))
for key := range cm.sessionMap {
keys = append(keys, key)
}
return keys
}
// Collect all timestamps with keys
type sessionAge struct {
key string
lastUsed time.Time
}
sessions := make([]sessionAge, 0, len(cm.sessionMap))
for key, entry := range cm.sessionMap {
sessions = append(sessions, sessionAge{key: key, lastUsed: entry.LastUsed})
}
// Partial sort: get the k smallest elements using selection
// This is O(n*k) which is better than O(n log n) when k << n
for i := 0; i < k; i++ {
minIdx := i
for j := i + 1; j < len(sessions); j++ {
if sessions[j].lastUsed.Before(sessions[minIdx].lastUsed) {
minIdx = j
}
}
if minIdx != i {
sessions[i], sessions[minIdx] = sessions[minIdx], sessions[i]
}
}
// Return the k oldest
result := make([]string, k)
for i := 0; i < k; i++ {
result[i] = sessions[i].key
}
return result
}
// CanCreateSession checks if a new session can be created within limits
@@ -1260,29 +1289,12 @@ func (cm *ChunkManager) EmergencyCleanup() {
// If still over 80% capacity, remove oldest sessions more aggressively
targetCapacity := int(float64(cm.maxSessions) * 0.8)
if len(cm.sessionMap) > targetCapacity {
type sessionAge struct {
key string
lastUsed time.Time
}
sessions := make([]sessionAge, 0, len(cm.sessionMap))
for key, entry := range cm.sessionMap {
sessions = append(sessions, sessionAge{key: key, lastUsed: entry.LastUsed})
}
// Sort by last used time (oldest first)
for i := 0; i < len(sessions)-1; i++ {
for j := i + 1; j < len(sessions); j++ {
if sessions[i].lastUsed.After(sessions[j].lastUsed) {
sessions[i], sessions[j] = sessions[j], sessions[i]
}
}
}
// Remove sessions until we reach target capacity
excessCount := len(cm.sessionMap) - targetCapacity
for i := 0; i < excessCount && i < len(sessions); i++ {
key := sessions[i].key
// Use efficient partial sort to find oldest sessions
keysToRemove := cm.findOldestSessions(excessCount)
for _, key := range keysToRemove {
if entry, exists := cm.sessionMap[key]; exists {
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
}
@@ -1295,11 +1307,9 @@ func (cm *ChunkManager) EmergencyCleanup() {
cm.logger.Infof("Emergency cleanup completed: removed %d sessions, %d remaining",
removed, len(cm.sessionMap))
// Log memory stats after emergency cleanup
var m runtime.MemStats
runtime.ReadMemStats(&m)
cm.logger.Infof("Memory after emergency cleanup - Heap: %.1fMB, Sessions: %d, Tracked bytes: %d",
float64(m.HeapAlloc)/(1024*1024), len(cm.sessionMap), atomic.LoadInt64(&cm.bytesAllocated))
// Log memory stats after emergency cleanup (read only, no forced GC)
cm.logger.Infof("Sessions after emergency cleanup: %d, Tracked bytes: %d",
len(cm.sessionMap), atomic.LoadInt64(&cm.bytesAllocated))
}
// GetSessionCount returns the current number of active sessions (for monitoring)
+3 -3
View File
@@ -11,7 +11,7 @@ import (
// TestSetCodeVerifier_NoChange tests the branch where the code verifier value doesn't change
func TestSetCodeVerifier_NoChange(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -52,7 +52,7 @@ func TestSetCodeVerifier_NoChange(t *testing.T) {
// TestClearTokenChunks_EmptyChunks tests the branch where the chunks map is empty
func TestClearTokenChunks_EmptyChunks(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -90,7 +90,7 @@ func TestClearTokenChunks_EmptyChunks(t *testing.T) {
// TestClearTokenChunks_WithSessions tests the branch where the chunks map contains actual sessions
func TestClearTokenChunks_WithSessions(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
+16 -16
View File
@@ -84,7 +84,7 @@ func TestSessionPoolMemoryLeak(t *testing.T) {
}
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -107,7 +107,7 @@ func TestSessionPoolMemoryLeak(t *testing.T) {
session.ReturnToPool()
case "Error path in GetSession":
badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, "", logger)
badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, "", "", 0, logger)
_, err = badSM.GetSession(req)
if err == nil {
t.Log("Note: Expected error when using mismatched encryption keys")
@@ -172,7 +172,7 @@ func TestSessionErrorHandling(t *testing.T) {
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -226,7 +226,7 @@ func TestSessionClearAlwaysReturnsToPool(t *testing.T) {
Timeout: 30 * time.Second,
Operation: func() error {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
return fmt.Errorf("failed to create session manager: %w", err)
}
@@ -264,7 +264,7 @@ func TestSessionClearAlwaysReturnsToPool(t *testing.T) {
// Additional verification test
t.Run("Verify pool still works after errors", func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -324,7 +324,7 @@ func TestSessionObjectTracking(t *testing.T) {
}
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -574,7 +574,7 @@ func TestTokenChunkingIntegrity(t *testing.T) {
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -681,7 +681,7 @@ func TestTokenChunkingCorruptionResistance(t *testing.T) {
for _, test := range corruptionTests {
t.Run(test.Name, func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -772,7 +772,7 @@ func TestTokenSizeLimits(t *testing.T) {
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -837,7 +837,7 @@ func TestConcurrentTokenOperations(t *testing.T) {
Timeout: 60 * time.Second,
Operation: func() error {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
return fmt.Errorf("failed to create session manager: %w", err)
}
@@ -925,7 +925,7 @@ func TestSessionValidationAndCleanup(t *testing.T) {
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -1010,7 +1010,7 @@ func TestLargeIDTokenChunking(t *testing.T) {
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -1115,7 +1115,7 @@ func BenchmarkSessionOperations(b *testing.B) {
perfHelper := NewPerformanceTestHelper()
logger := NewLogger("error") // Reduce logging for benchmarks
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
b.Fatalf("Failed to create session manager: %v", err)
}
@@ -1256,7 +1256,7 @@ func TestSessionStatePreservationWithExpiredTokens(t *testing.T) {
t.Log("Testing session state preservation with expired tokens - this test demonstrates BROKEN BEHAVIOR")
logger := NewLogger("debug")
sm, err := NewSessionManager("test-session-key-32-bytes-long-12345", false, "", logger)
sm, err := NewSessionManager("test-session-key-32-bytes-long-12345", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -1452,7 +1452,7 @@ func TestSessionExpiryVsTokenExpiry(t *testing.T) {
t.Log("Testing session expiry vs token expiry distinction - validating proper session and token lifetime management")
logger := NewLogger("debug")
sm, err := NewSessionManager("session-vs-token-test-key-32-bytes", false, "", logger)
sm, err := NewSessionManager("session-vs-token-test-key-32-bytes", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -1591,7 +1591,7 @@ func TestSessionCleanupOnTokenExpiry(t *testing.T) {
t.Log("Testing session cleanup on token expiry - validating proper session data management")
logger := NewLogger("debug")
sm, err := NewSessionManager("cleanup-test-key-32-bytes-long-123", false, "", logger)
sm, err := NewSessionManager("cleanup-test-key-32-bytes-long-123", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
+88
View File
@@ -30,6 +30,8 @@ type Config struct {
HTTPClient *http.Client `json:"-"`
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
CookieDomain string `json:"cookieDomain"`
CookiePrefix string `json:"cookiePrefix"` // Prefix for session cookie names (default: "_oidc_raczylo_")
SessionMaxAge int `json:"sessionMaxAge"` // Maximum session age in seconds (default: 86400 = 24 hours)
CallbackURL string `json:"callbackURL"`
LogoutURL string `json:"logoutURL"`
ClientID string `json:"clientID"`
@@ -89,6 +91,7 @@ type Config struct {
// Recommended: true for multi-replica deployments
DisableReplayDetection bool `json:"disableReplayDetection,omitempty"`
SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"`
// Redis configures the Redis cache backend for distributed caching.
// When enabled, provides cache sharing across multiple Traefik replicas.
// Default: nil (disabled - uses in-memory caching)
@@ -123,6 +126,11 @@ type Config struct {
//
// Default: "groups"
GroupClaimName string `json:"groupClaimName,omitempty"`
// DynamicClientRegistration enables OIDC Dynamic Client Registration (RFC 7591)
// When enabled, the middleware will automatically register as a client with
// the OIDC provider if ClientID/ClientSecret are not provided.
DynamicClientRegistration *DynamicClientRegistrationConfig `json:"dynamicClientRegistration,omitempty"`
}
// RedisConfig configures Redis cache backend settings for distributed caching.
@@ -188,6 +196,86 @@ type RedisConfig struct {
HealthCheckInterval int `json:"healthCheckInterval" yaml:"healthCheckInterval"`
}
// DynamicClientRegistrationConfig configures OIDC Dynamic Client Registration (RFC 7591)
type DynamicClientRegistrationConfig struct {
// Enabled enables automatic client registration with the OIDC provider
Enabled bool `json:"enabled"`
// InitialAccessToken is an optional bearer token for protected registration endpoints
// Some providers require this token to authorize new client registrations
InitialAccessToken string `json:"initialAccessToken,omitempty"`
// RegistrationEndpoint overrides the endpoint discovered from provider metadata
// If empty, uses the registration_endpoint from .well-known/openid-configuration
RegistrationEndpoint string `json:"registrationEndpoint,omitempty"`
// ClientMetadata contains the client metadata to register
ClientMetadata *ClientRegistrationMetadata `json:"clientMetadata,omitempty"`
// PersistCredentials determines whether to save registered credentials to a file
// This allows reusing the same client_id/client_secret across restarts
PersistCredentials bool `json:"persistCredentials"`
// CredentialsFile is the path to store/load registered client credentials
// Defaults to "/tmp/oidc-client-credentials.json" if not specified
CredentialsFile string `json:"credentialsFile,omitempty"`
}
// ClientRegistrationMetadata contains client metadata for dynamic registration (RFC 7591)
type ClientRegistrationMetadata struct {
// RedirectURIs is REQUIRED - array of redirect URIs for authorization
RedirectURIs []string `json:"redirect_uris"`
// ResponseTypes specifies OAuth 2.0 response types (default: ["code"])
ResponseTypes []string `json:"response_types,omitempty"`
// GrantTypes specifies OAuth 2.0 grant types (default: ["authorization_code"])
GrantTypes []string `json:"grant_types,omitempty"`
// ApplicationType is either "web" (default) or "native"
ApplicationType string `json:"application_type,omitempty"`
// Contacts is an array of email addresses for responsible parties
Contacts []string `json:"contacts,omitempty"`
// ClientName is a human-readable name for the client
ClientName string `json:"client_name,omitempty"`
// LogoURI is a URL pointing to a logo for the client
LogoURI string `json:"logo_uri,omitempty"`
// ClientURI is a URL of the home page of the client
ClientURI string `json:"client_uri,omitempty"`
// PolicyURI is a URL pointing to the client's privacy policy
PolicyURI string `json:"policy_uri,omitempty"`
// TOSURI is a URL pointing to the client's terms of service
TOSURI string `json:"tos_uri,omitempty"`
// JWKSURI is a URL for the client's JSON Web Key Set
JWKSURI string `json:"jwks_uri,omitempty"`
// SubjectType is "pairwise" or "public" (provider-specific)
SubjectType string `json:"subject_type,omitempty"`
// TokenEndpointAuthMethod specifies how the client authenticates at token endpoint
// Values: "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
// DefaultMaxAge is the default maximum authentication age in seconds
DefaultMaxAge int `json:"default_max_age,omitempty"`
// RequireAuthTime specifies whether auth_time claim is required in ID token
RequireAuthTime bool `json:"require_auth_time,omitempty"`
// DefaultACRValues specifies default ACR values
DefaultACRValues []string `json:"default_acr_values,omitempty"`
// Scope is a space-separated list of scopes (alternative to config.Scopes)
Scope string `json:"scope,omitempty"`
}
// SecurityHeadersConfig configures security headers for the plugin
type SecurityHeadersConfig struct {
// Enable security headers (default: true)
+207
View File
@@ -0,0 +1,207 @@
package traefikoidc
import (
"hash/fnv"
"sync"
"time"
)
// ShardedCache provides a thread-safe cache with sharded locks to reduce contention.
// Instead of a single global mutex, it distributes entries across multiple shards,
// each with its own mutex. This dramatically reduces lock contention under high load.
type ShardedCache struct {
shards []*cacheShard
numShards uint32
maxPerShard int
}
// cacheShard represents a single shard with its own mutex and data map.
type cacheShard struct {
mu sync.RWMutex
items map[string]*shardedCacheItem
}
// shardedCacheItem represents an item in the sharded cache with expiration.
type shardedCacheItem struct {
value interface{}
expiresAt time.Time
}
// NewShardedCache creates a new sharded cache with the specified number of shards.
// More shards = less contention but more memory overhead.
// Recommended: 32-256 shards depending on expected concurrency.
func NewShardedCache(numShards int, maxSize int) *ShardedCache {
if numShards <= 0 {
numShards = 64 // Default to 64 shards
}
if maxSize <= 0 {
maxSize = 10000 // Default max size
}
shards := make([]*cacheShard, numShards)
maxPerShard := maxSize / numShards
if maxPerShard < 100 {
maxPerShard = 100 // Minimum 100 per shard
}
for i := 0; i < numShards; i++ {
shards[i] = &cacheShard{
items: make(map[string]*shardedCacheItem),
}
}
return &ShardedCache{
shards: shards,
numShards: uint32(numShards),
maxPerShard: maxPerShard,
}
}
// getShard returns the shard for a given key using FNV-1a hash.
// FNV-1a is fast and provides good distribution.
func (c *ShardedCache) getShard(key string) *cacheShard {
h := fnv.New32a()
h.Write([]byte(key))
return c.shards[h.Sum32()%c.numShards]
}
// Get retrieves an item from the cache.
// Returns the value and true if found and not expired, nil and false otherwise.
func (c *ShardedCache) Get(key string) (interface{}, bool) {
shard := c.getShard(key)
shard.mu.RLock()
item, exists := shard.items[key]
shard.mu.RUnlock()
if !exists {
return nil, false
}
// Check expiration
if !item.expiresAt.IsZero() && time.Now().After(item.expiresAt) {
// Item expired - remove it lazily
c.Delete(key)
return nil, false
}
return item.value, true
}
// Set adds or updates an item in the cache with a TTL.
// If ttl is 0 or negative, the item never expires.
func (c *ShardedCache) Set(key string, value interface{}, ttl time.Duration) {
shard := c.getShard(key)
var expiresAt time.Time
if ttl > 0 {
expiresAt = time.Now().Add(ttl)
}
shard.mu.Lock()
// Check if we need to evict items
if len(shard.items) >= c.maxPerShard {
// Simple eviction: remove expired items first, then oldest
c.evictFromShardLocked(shard)
}
shard.items[key] = &shardedCacheItem{
value: value,
expiresAt: expiresAt,
}
shard.mu.Unlock()
}
// Delete removes an item from the cache.
func (c *ShardedCache) Delete(key string) {
shard := c.getShard(key)
shard.mu.Lock()
delete(shard.items, key)
shard.mu.Unlock()
}
// Exists checks if a key exists in the cache and is not expired.
func (c *ShardedCache) Exists(key string) bool {
_, exists := c.Get(key)
return exists
}
// evictFromShardLocked removes expired items from a shard.
// Must be called with shard.mu held.
func (c *ShardedCache) evictFromShardLocked(shard *cacheShard) {
now := time.Now()
evicted := 0
maxEvict := len(shard.items) / 4 // Evict up to 25% of items
if maxEvict < 10 {
maxEvict = 10
}
// First pass: remove expired items
for key, item := range shard.items {
if !item.expiresAt.IsZero() && now.After(item.expiresAt) {
delete(shard.items, key)
evicted++
if evicted >= maxEvict {
return
}
}
}
// If still over capacity, remove some items (FIFO approximation via map iteration)
// This is an approximation since Go maps don't maintain insertion order
remaining := len(shard.items) - c.maxPerShard + 10 // Leave some headroom
if remaining > 0 {
for key := range shard.items {
delete(shard.items, key)
remaining--
if remaining <= 0 {
break
}
}
}
}
// Cleanup removes all expired items from all shards.
// Call this periodically to prevent memory growth.
func (c *ShardedCache) Cleanup() {
now := time.Now()
for _, shard := range c.shards {
shard.mu.Lock()
for key, item := range shard.items {
if !item.expiresAt.IsZero() && now.After(item.expiresAt) {
delete(shard.items, key)
}
}
shard.mu.Unlock()
}
}
// Size returns the total number of items across all shards.
func (c *ShardedCache) Size() int {
total := 0
for _, shard := range c.shards {
shard.mu.RLock()
total += len(shard.items)
shard.mu.RUnlock()
}
return total
}
// Clear removes all items from all shards.
func (c *ShardedCache) Clear() {
for _, shard := range c.shards {
shard.mu.Lock()
shard.items = make(map[string]*shardedCacheItem)
shard.mu.Unlock()
}
}
// ShardStats returns statistics about each shard for debugging/monitoring.
func (c *ShardedCache) ShardStats() []int {
stats := make([]int, len(c.shards))
for i, shard := range c.shards {
shard.mu.RLock()
stats[i] = len(shard.items)
shard.mu.RUnlock()
}
return stats
}
+413
View File
@@ -0,0 +1,413 @@
package traefikoidc
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestShardedCacheBasicOperations(t *testing.T) {
t.Run("SetAndGet", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("key1", "value1", 5*time.Minute)
cache.Set("key2", 42, 5*time.Minute)
cache.Set("key3", true, 5*time.Minute)
val1, ok := cache.Get("key1")
if !ok || val1 != "value1" {
t.Errorf("Expected 'value1', got %v, ok=%v", val1, ok)
}
val2, ok := cache.Get("key2")
if !ok || val2 != 42 {
t.Errorf("Expected 42, got %v, ok=%v", val2, ok)
}
val3, ok := cache.Get("key3")
if !ok || val3 != true {
t.Errorf("Expected true, got %v, ok=%v", val3, ok)
}
})
t.Run("GetNonExistent", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
val, ok := cache.Get("nonexistent")
if ok || val != nil {
t.Errorf("Expected nil/false for nonexistent key, got %v/%v", val, ok)
}
})
t.Run("Delete", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("key1", "value1", 5*time.Minute)
cache.Delete("key1")
val, ok := cache.Get("key1")
if ok || val != nil {
t.Errorf("Expected nil/false after delete, got %v/%v", val, ok)
}
})
t.Run("Exists", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("key1", "value1", 5*time.Minute)
if !cache.Exists("key1") {
t.Error("Expected Exists to return true for existing key")
}
if cache.Exists("nonexistent") {
t.Error("Expected Exists to return false for nonexistent key")
}
})
t.Run("Size", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
if cache.Size() != 0 {
t.Errorf("Expected size 0, got %d", cache.Size())
}
for i := 0; i < 100; i++ {
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
}
if cache.Size() != 100 {
t.Errorf("Expected size 100, got %d", cache.Size())
}
})
t.Run("Clear", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
for i := 0; i < 100; i++ {
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
}
cache.Clear()
if cache.Size() != 0 {
t.Errorf("Expected size 0 after clear, got %d", cache.Size())
}
})
}
func TestShardedCacheExpiration(t *testing.T) {
t.Run("ItemExpires", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("key1", "value1", 50*time.Millisecond)
// Should exist immediately
if !cache.Exists("key1") {
t.Error("Item should exist immediately after set")
}
// Wait for expiration
time.Sleep(100 * time.Millisecond)
// Should be expired now
if cache.Exists("key1") {
t.Error("Item should have expired")
}
})
t.Run("CleanupRemovesExpired", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
// Add items with short TTL
for i := 0; i < 50; i++ {
cache.Set(fmt.Sprintf("expired%d", i), i, 10*time.Millisecond)
}
// Add items with long TTL
for i := 0; i < 50; i++ {
cache.Set(fmt.Sprintf("valid%d", i), i, 5*time.Minute)
}
// Wait for short-TTL items to expire
time.Sleep(50 * time.Millisecond)
// Run cleanup
cache.Cleanup()
// Should have only valid items
// Note: Size still includes expired items until Get/Cleanup removes them
// So we check by accessing items
for i := 0; i < 50; i++ {
if cache.Exists(fmt.Sprintf("expired%d", i)) {
t.Errorf("Expired item %d should not exist after cleanup", i)
}
}
for i := 0; i < 50; i++ {
if !cache.Exists(fmt.Sprintf("valid%d", i)) {
t.Errorf("Valid item %d should still exist after cleanup", i)
}
}
})
t.Run("ZeroTTLNeverExpires", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("permanent", "value", 0)
time.Sleep(10 * time.Millisecond)
if !cache.Exists("permanent") {
t.Error("Item with 0 TTL should never expire")
}
})
}
func TestShardedCacheConcurrency(t *testing.T) {
t.Run("ConcurrentSetGet", func(t *testing.T) {
cache := NewShardedCache(64, 10000)
const numGoroutines = 100
const numOperations = 1000
var wg sync.WaitGroup
var errors int32
// Concurrent writers
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
cache.Set(key, j, 5*time.Minute)
}
}(i)
}
// Concurrent readers
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
cache.Get(key)
}
}(i)
}
wg.Wait()
if atomic.LoadInt32(&errors) > 0 {
t.Errorf("Encountered %d errors during concurrent access", errors)
}
})
t.Run("ConcurrentMixedOperations", func(t *testing.T) {
cache := NewShardedCache(64, 10000)
const numGoroutines = 50
const numOperations = 500
var wg sync.WaitGroup
// Mix of operations
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d", j%100) // Overlapping keys
switch j % 4 {
case 0:
cache.Set(key, j, 5*time.Minute)
case 1:
cache.Get(key)
case 2:
cache.Exists(key)
case 3:
cache.Delete(key)
}
}
}(i)
}
wg.Wait()
})
t.Run("NoConcurrentPanics", func(t *testing.T) {
cache := NewShardedCache(32, 5000)
const numGoroutines = 100
var wg sync.WaitGroup
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
t.Errorf("Panic in goroutine %d: %v", id, r)
}
}()
for j := 0; j < 100; j++ {
cache.Set(fmt.Sprintf("k%d", j), j, time.Millisecond)
cache.Get(fmt.Sprintf("k%d", j))
cache.Cleanup()
}
}(i)
}
wg.Wait()
})
}
func TestShardedCacheEviction(t *testing.T) {
t.Run("EvictsWhenFull", func(t *testing.T) {
// Small cache to trigger eviction - 4 shards with max 100 per shard minimum
// With our implementation, maxPerShard defaults to at least 100
cache := NewShardedCache(4, 100)
// Fill well beyond capacity to trigger eviction
for i := 0; i < 600; i++ {
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
}
// Should have evicted some items - eviction happens when shard reaches maxPerShard
size := cache.Size()
// With 4 shards and 100 per shard minimum, max should be ~400
// We added 600, so some should be evicted
if size >= 600 {
t.Errorf("Expected eviction to reduce size below 600, got %d", size)
}
t.Logf("Cache size after adding 600 items: %d", size)
})
t.Run("EvictsExpiredFirst", func(t *testing.T) {
cache := NewShardedCache(4, 100)
// Add expired items first
for i := 0; i < 50; i++ {
cache.Set(fmt.Sprintf("expired%d", i), i, 1*time.Millisecond)
}
time.Sleep(10 * time.Millisecond) // Let them expire
// Add valid items
for i := 0; i < 100; i++ {
cache.Set(fmt.Sprintf("valid%d", i), i, 5*time.Minute)
}
// Valid items should mostly still exist
validCount := 0
for i := 0; i < 100; i++ {
if cache.Exists(fmt.Sprintf("valid%d", i)) {
validCount++
}
}
// Should have most valid items (at least 80%)
if validCount < 80 {
t.Errorf("Expected at least 80 valid items, got %d", validCount)
}
})
}
func TestShardedCacheShardDistribution(t *testing.T) {
t.Run("EvenDistribution", func(t *testing.T) {
cache := NewShardedCache(16, 16000)
// Add many items
for i := 0; i < 10000; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
}
stats := cache.ShardStats()
// Check for reasonable distribution (no shard should have > 2x average)
average := 10000 / 16
for i, count := range stats {
if count > average*3 || count < average/3 {
t.Errorf("Shard %d has uneven distribution: %d items (expected ~%d)", i, count, average)
}
}
})
}
// BenchmarkShardedCache benchmarks the sharded cache operations
func BenchmarkShardedCache(b *testing.B) {
b.Run("Set", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
}
})
b.Run("Get", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
for i := 0; i < 10000; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get(fmt.Sprintf("key-%d", i%10000))
}
})
b.Run("ParallelSetGet", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("key-%d", i)
cache.Set(key, i, 5*time.Minute)
cache.Get(key)
i++
}
})
})
}
// BenchmarkShardedVsGlobalMutex compares sharded cache with global mutex approach
func BenchmarkShardedVsGlobalMutex(b *testing.B) {
b.Run("ShardedCache64", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("jti-%d", i%10000)
if !cache.Exists(key) {
cache.Set(key, true, 5*time.Minute)
}
i++
}
})
})
b.Run("GlobalMutexCache", func(b *testing.B) {
var mu sync.RWMutex
data := make(map[string]bool)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("jti-%d", i%10000)
mu.RLock()
_, exists := data[key]
mu.RUnlock()
if !exists {
mu.Lock()
data[key] = true
mu.Unlock()
}
i++
}
})
})
}
+55 -4
View File
@@ -345,6 +345,10 @@ type GoroutinePool struct {
shutdownChan chan struct{}
logger *Logger
started int32
// Condition variable for efficient Wait() without busy-polling
taskCond *sync.Cond
pendingTasks int64 // atomic counter for pending tasks
}
// NewGoroutinePool creates a new goroutine pool with the specified max workers
@@ -354,6 +358,8 @@ func NewGoroutinePool(maxWorkers int, logger *Logger) *GoroutinePool {
taskQueue: make(chan func(), maxWorkers*2), // Buffer for queuing
shutdownChan: make(chan struct{}),
logger: logger,
taskCond: sync.NewCond(&sync.Mutex{}),
pendingTasks: 0,
}
// Start workers
@@ -390,6 +396,14 @@ func (p *GoroutinePool) worker(id int) {
}()
task()
}()
// Signal that task is complete - decrement pending count and notify waiters
newCount := atomic.AddInt64(&p.pendingTasks, -1)
if newCount == 0 {
p.taskCond.L.Lock()
p.taskCond.Broadcast() // Wake up all waiters when queue is empty
p.taskCond.L.Unlock()
}
}
case <-p.shutdownChan:
if p.logger != nil {
@@ -406,10 +420,15 @@ func (p *GoroutinePool) Submit(task func()) error {
return fmt.Errorf("pool is shutdown")
}
// Increment pending task count BEFORE queuing to avoid race with Wait()
atomic.AddInt64(&p.pendingTasks, 1)
select {
case p.taskQueue <- task:
return nil
case <-p.shutdownChan:
// Decrement since task won't be processed
atomic.AddInt64(&p.pendingTasks, -1)
return fmt.Errorf("pool is shutting down")
default:
// Queue is full, try with a small timeout
@@ -417,21 +436,53 @@ func (p *GoroutinePool) Submit(task func()) error {
case p.taskQueue <- task:
return nil
case <-time.After(100 * time.Millisecond):
// Decrement since task won't be processed
atomic.AddInt64(&p.pendingTasks, -1)
return fmt.Errorf("task queue is full")
case <-p.shutdownChan:
// Decrement since task won't be processed
atomic.AddInt64(&p.pendingTasks, -1)
return fmt.Errorf("pool is shutting down")
}
}
}
// Wait waits for all submitted tasks to complete
// Wait waits for all submitted tasks to complete using condition variable
// This is efficient and does not busy-poll, avoiding CPU spikes
func (p *GoroutinePool) Wait() {
// Drain the task queue
for len(p.taskQueue) > 0 {
time.Sleep(10 * time.Millisecond)
p.taskCond.L.Lock()
defer p.taskCond.L.Unlock()
// Wait until all pending tasks are complete
// Uses condition variable to sleep efficiently instead of busy-polling
for atomic.LoadInt64(&p.pendingTasks) > 0 {
p.taskCond.Wait() // Efficiently blocks until signaled
}
}
// WaitWithTimeout waits for all submitted tasks to complete with a timeout
// Returns true if all tasks completed, false if timeout occurred
func (p *GoroutinePool) WaitWithTimeout(timeout time.Duration) bool {
done := make(chan struct{})
go func() {
p.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}
// PendingTasks returns the number of tasks currently pending (queued or in-progress)
func (p *GoroutinePool) PendingTasks() int64 {
return atomic.LoadInt64(&p.pendingTasks)
}
// Shutdown gracefully shuts down the pool
func (p *GoroutinePool) Shutdown(ctx context.Context) error {
var err error
+279
View File
@@ -505,6 +505,285 @@ func TestBackwardCompatibility(t *testing.T) {
})
}
// TestGoroutinePoolConditionVariable tests the condition variable-based Wait implementation
func TestGoroutinePoolConditionVariable(t *testing.T) {
t.Run("WaitDoesNotBusyPoll", func(t *testing.T) {
// This test verifies that Wait() uses condition variable instead of busy-polling
pool := NewGoroutinePool(2, nil)
defer pool.Shutdown(context.Background())
// Submit a slow task
var taskStarted, taskFinished int32
pool.Submit(func() {
atomic.StoreInt32(&taskStarted, 1)
time.Sleep(100 * time.Millisecond)
atomic.StoreInt32(&taskFinished, 1)
})
// Give task time to start
time.Sleep(10 * time.Millisecond)
// Measure CPU-time before Wait
startCPU := time.Now()
// Wait should block efficiently without consuming CPU
pool.Wait()
elapsed := time.Since(startCPU)
// Verify task completed
if atomic.LoadInt32(&taskFinished) != 1 {
t.Error("Task should have finished")
}
// Wait should have taken ~90ms (task was already running for ~10ms)
// If it was busy-polling, we would see much higher CPU usage
// This is a sanity check - the real proof is in profiling
if elapsed < 50*time.Millisecond {
t.Errorf("Wait returned too quickly: %v", elapsed)
}
})
t.Run("WaitReturnsImmediatelyWhenEmpty", func(t *testing.T) {
pool := NewGoroutinePool(2, nil)
defer pool.Shutdown(context.Background())
// Wait on empty pool should return immediately
start := time.Now()
pool.Wait()
elapsed := time.Since(start)
// Should return almost immediately
if elapsed > 10*time.Millisecond {
t.Errorf("Wait on empty pool took too long: %v", elapsed)
}
})
t.Run("ConcurrentSubmitAndWait", func(t *testing.T) {
pool := NewGoroutinePool(4, nil)
defer pool.Shutdown(context.Background())
var completed int32
const numTasks = 100
// Submit tasks concurrently
var wg sync.WaitGroup
for i := 0; i < numTasks; i++ {
wg.Add(1)
go func() {
defer wg.Done()
pool.Submit(func() {
time.Sleep(1 * time.Millisecond)
atomic.AddInt32(&completed, 1)
})
}()
}
wg.Wait() // Wait for all submissions
// Wait for all tasks to complete
pool.Wait()
if atomic.LoadInt32(&completed) != numTasks {
t.Errorf("Expected %d tasks completed, got %d", numTasks, completed)
}
})
t.Run("WaitWithTimeoutSuccess", func(t *testing.T) {
pool := NewGoroutinePool(2, nil)
defer pool.Shutdown(context.Background())
pool.Submit(func() {
time.Sleep(50 * time.Millisecond)
})
// Should complete within timeout
success := pool.WaitWithTimeout(1 * time.Second)
if !success {
t.Error("WaitWithTimeout should have succeeded")
}
})
t.Run("WaitWithTimeoutExpired", func(t *testing.T) {
pool := NewGoroutinePool(1, nil)
defer pool.Shutdown(context.Background())
pool.Submit(func() {
time.Sleep(500 * time.Millisecond)
})
// Should timeout
success := pool.WaitWithTimeout(50 * time.Millisecond)
if success {
t.Error("WaitWithTimeout should have timed out")
}
// Wait for actual completion to avoid goroutine leak in test
pool.Wait()
})
t.Run("PendingTasksCounter", func(t *testing.T) {
// Use pool with larger buffer (maxWorkers=2, buffer=4)
pool := NewGoroutinePool(2, nil)
defer pool.Shutdown(context.Background())
// Initially no pending tasks
if pool.PendingTasks() != 0 {
t.Errorf("Expected 0 pending tasks, got %d", pool.PendingTasks())
}
// Block both workers with signals that tasks have started
blocker1 := make(chan struct{})
blocker2 := make(chan struct{})
started1 := make(chan struct{})
started2 := make(chan struct{})
pool.Submit(func() {
close(started1)
<-blocker1
})
pool.Submit(func() {
close(started2)
<-blocker2
})
// Wait for both blocking tasks to actually start
<-started1
<-started2
// Submit 2 more tasks that will queue up (buffer can hold 4)
for i := 0; i < 2; i++ {
pool.Submit(func() {
time.Sleep(1 * time.Millisecond)
})
}
// Should have pending tasks (2 running + 2 queued = 4)
pending := pool.PendingTasks()
if pending != 4 {
t.Errorf("Expected 4 pending tasks, got %d", pending)
}
// Release blockers
close(blocker1)
close(blocker2)
// Wait for completion
pool.Wait()
// Should have no pending tasks
if pool.PendingTasks() != 0 {
t.Errorf("Expected 0 pending tasks after Wait, got %d", pool.PendingTasks())
}
})
t.Run("MultipleWaiters", func(t *testing.T) {
pool := NewGoroutinePool(2, nil)
defer pool.Shutdown(context.Background())
// Submit a slow task
pool.Submit(func() {
time.Sleep(100 * time.Millisecond)
})
// Multiple goroutines waiting
var waiters sync.WaitGroup
var waitCount int32
for i := 0; i < 5; i++ {
waiters.Add(1)
go func() {
defer waiters.Done()
pool.Wait()
atomic.AddInt32(&waitCount, 1)
}()
}
// All waiters should complete
waiters.Wait()
if atomic.LoadInt32(&waitCount) != 5 {
t.Errorf("Expected all 5 waiters to complete, got %d", waitCount)
}
})
t.Run("SubmitFailureDoesNotIncrementPending", func(t *testing.T) {
pool := NewGoroutinePool(1, nil)
// Shutdown the pool
pool.Shutdown(context.Background())
// Submit should fail
err := pool.Submit(func() {})
if err == nil {
t.Error("Submit should fail on shutdown pool")
}
// Pending tasks should still be 0
if pool.PendingTasks() != 0 {
t.Errorf("Pending tasks should be 0 after failed submit, got %d", pool.PendingTasks())
}
})
t.Run("PanicRecoveryDecrementsPending", func(t *testing.T) {
pool := NewGoroutinePool(2, nil)
defer pool.Shutdown(context.Background())
// Submit a task that panics
pool.Submit(func() {
panic("test panic")
})
// Submit a normal task
var normalCompleted int32
pool.Submit(func() {
atomic.StoreInt32(&normalCompleted, 1)
})
// Wait should still work (panic is recovered)
pool.Wait()
// Normal task should have completed
if atomic.LoadInt32(&normalCompleted) != 1 {
t.Error("Normal task should have completed despite panic in other task")
}
// Pending should be 0
if pool.PendingTasks() != 0 {
t.Errorf("Pending tasks should be 0 after Wait, got %d", pool.PendingTasks())
}
})
}
// BenchmarkGoroutinePoolWait benchmarks the Wait implementation
func BenchmarkGoroutinePoolWait(b *testing.B) {
pool := NewGoroutinePool(4, nil)
defer pool.Shutdown(context.Background())
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Submit a quick task
pool.Submit(func() {})
pool.Wait()
}
}
// BenchmarkGoroutinePoolHighThroughput benchmarks high throughput scenario
func BenchmarkGoroutinePoolHighThroughput(b *testing.B) {
pool := NewGoroutinePool(8, nil)
defer pool.Shutdown(context.Background())
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := 0; j < 100; j++ {
pool.Submit(func() {
// Minimal work
_ = 1 + 1
})
}
pool.Wait()
}
}
// Helper function to reset singleton for testing
func resetResourceManagerForTesting() {
resourceManagerMutex.Lock()
+4
View File
@@ -279,6 +279,8 @@ func (tf *TestFramework) CreateAuthenticatedRequest(method, path string) (*http.
tf.fixtures.EncryptionKey,
false,
"",
"",
0,
tf.oidc.logger,
)
if err != nil {
@@ -323,6 +325,8 @@ func (tf *TestFramework) CreateCallbackRequest() *http.Request {
tf.fixtures.EncryptionKey,
false,
"",
"",
0,
tf.oidc.logger,
)
+2 -2
View File
@@ -204,7 +204,7 @@ func setupTestOIDCMiddleware(t *testing.T, config *Config) (*TraefikOidc, *httpt
logInfo: log.New(&testWriter{t}, "INFO: ", 0),
logDebug: log.New(&testWriter{t}, "DEBUG: ", 0),
}
sessionManager, _ := NewSessionManager(config.SessionEncryptionKey, false, "", logger)
sessionManager, _ := NewSessionManager(config.SessionEncryptionKey, false, "", "", logger)
// Create next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -350,7 +350,7 @@ func createMockJWT(t *testing.T, sub, email string) string {
func createTestSession() *SessionData {
// Create a minimal session manager for testing
logger := newNoOpLogger()
sessionManager, _ := NewSessionManager("test-encryption-key-32-characters", false, "", logger)
sessionManager, _ := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, logger)
// Create a test request
req := httptest.NewRequest("GET", "/", nil)
+3 -3
View File
@@ -164,7 +164,7 @@ func TestTokenTypes(t *testing.T) {
func TestTokenCorruption(t *testing.T) {
t.Run("TokenCorruptionScenario", func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -291,7 +291,7 @@ func TestTokenCorruption(t *testing.T) {
func TestTokenResilience(t *testing.T) {
t.Run("ConcurrentTokenAccess", func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
@@ -337,7 +337,7 @@ func TestTokenResilience(t *testing.T) {
t.Run("TokenSizeHandling", func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
+13 -6
View File
@@ -122,15 +122,22 @@ func (t *TraefikOidc) VerifyToken(token string) error {
t.safeLogErrorf("Token blacklist not available, skipping JTI %s blacklist", jti)
}
replayCacheMu.Lock()
if replayCache == nil {
initReplayCache()
}
// Use sharded cache for replay detection - no global mutex needed
// This reduces lock contention by ~64x under high load
initReplayCache()
duration := time.Until(expiry)
if duration > 0 {
replayCache.Set(jti, true, duration)
if shardedReplayCache != nil {
shardedReplayCache.Set(jti, true, duration)
} else {
// Fall back to legacy cache (should rarely happen)
replayCacheMu.Lock()
if replayCache != nil {
replayCache.Set(jti, true, duration)
}
replayCacheMu.Unlock()
}
}
replayCacheMu.Unlock()
}
return nil
+6
View File
@@ -57,6 +57,7 @@ type ProviderMetadata struct {
EndSessionURL string `json:"end_session_endpoint"`
IntrospectionURL string `json:"introspection_endpoint,omitempty"` // OAuth 2.0 Token Introspection (RFC 7662)
ScopesSupported []string `json:"scopes_supported,omitempty"` // Supported scopes from discovery
RegistrationURL string `json:"registration_endpoint,omitempty"` // OIDC Dynamic Client Registration (RFC 7591)
}
// TraefikOidc is the main middleware struct that implements OIDC authentication for Traefik.
@@ -130,4 +131,9 @@ type TraefikOidc struct {
securityHeadersApplier func(http.ResponseWriter, *http.Request)
scopeFilter *ScopeFilter // NEW - for discovery-based scope filtering
scopesSupported []string // NEW - from provider metadata
// Dynamic Client Registration (RFC 7591)
dynamicClientRegistrar *DynamicClientRegistrar
dcrConfig *DynamicClientRegistrationConfig
registrationURL string // OIDC Dynamic Client Registration endpoint
}
+11 -2
View File
@@ -37,6 +37,11 @@ type UniversalCacheConfig struct {
Logger *Logger
Strategy CacheStrategy // For backward compatibility
// SkipAutoCleanup skips starting the per-cache cleanup goroutine.
// Use this when cleanup is managed externally (e.g., by UniversalCacheManager)
// to reduce goroutine count and consolidate cleanup operations.
SkipAutoCleanup bool
// Type-specific configurations
TokenConfig *TokenCacheConfig
MetadataConfig *MetadataCacheConfig
@@ -158,8 +163,12 @@ func createUniversalCache(config UniversalCacheConfig) *UniversalCache {
cancel: cancel,
}
// Start cleanup routine
cache.startCleanup()
// Start cleanup routine only if not skipped
// When cleanup is managed externally (e.g., by UniversalCacheManager),
// skip per-cache cleanup to reduce goroutine count
if !config.SkipAutoCleanup {
cache.startCleanup()
}
return cache
}
+161 -56
View File
@@ -1,6 +1,7 @@
package traefikoidc
import (
"context"
"sync"
"time"
@@ -9,6 +10,8 @@ import (
)
// UniversalCacheManager manages all cache instances using the universal cache
// It runs a single consolidated cleanup goroutine for all caches, reducing
// goroutine count and CPU overhead compared to per-cache cleanup routines.
type UniversalCacheManager struct {
tokenCache *UniversalCache
blacklistCache *UniversalCache
@@ -20,6 +23,12 @@ type UniversalCacheManager struct {
sharedBackend backends.CacheBackend // Shared backend (Redis) that should be closed by manager, not individual caches
mu sync.RWMutex
logger *Logger
// Consolidated cleanup management
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
cleanupStarted bool
}
var (
@@ -34,12 +43,20 @@ func GetUniversalCacheManager(logger *Logger) *UniversalCacheManager {
logger = GetSingletonNoOpLogger()
}
ctx, cancel := context.WithCancel(context.Background())
universalCacheManager = &UniversalCacheManager{
logger: logger,
ctx: ctx,
cancel: cancel,
}
// Initialize with default in-memory backends
initializeDefaultCaches(universalCacheManager, logger)
// Start single consolidated cleanup goroutine for all caches
// This replaces 7 individual cleanup goroutines with 1
universalCacheManager.startConsolidatedCleanup()
})
return universalCacheManager
@@ -52,8 +69,12 @@ func GetUniversalCacheManagerWithConfig(logger *Logger, redisConfig *RedisConfig
logger = GetSingletonNoOpLogger()
}
ctx, cancel := context.WithCancel(context.Background())
universalCacheManager = &UniversalCacheManager{
logger: logger,
ctx: ctx,
cancel: cancel,
}
if redisConfig != nil && redisConfig.Enabled {
@@ -63,6 +84,10 @@ func GetUniversalCacheManagerWithConfig(logger *Logger, redisConfig *RedisConfig
logger.Info("Initializing cache manager with memory-only backend")
initializeDefaultCaches(universalCacheManager, logger)
}
// Start single consolidated cleanup goroutine for all caches
// This replaces 7 individual cleanup goroutines with 1
universalCacheManager.startConsolidatedCleanup()
})
return universalCacheManager
@@ -72,19 +97,21 @@ func GetUniversalCacheManagerWithConfig(logger *Logger, redisConfig *RedisConfig
func initializeDefaultCaches(manager *UniversalCacheManager, logger *Logger) {
// Initialize token cache - CRITICAL FIX: Reduced from 5000 to 1000
manager.tokenCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000, // CRITICAL FIX: Reduced from 5000 to 1000 items
MaxMemoryBytes: 5 * 1024 * 1024, // CRITICAL FIX: Added 5MB memory limit
DefaultTTL: 1 * time.Hour,
Logger: logger,
Type: CacheTypeToken,
MaxSize: 1000, // CRITICAL FIX: Reduced from 5000 to 1000 items
MaxMemoryBytes: 5 * 1024 * 1024, // CRITICAL FIX: Added 5MB memory limit
DefaultTTL: 1 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize blacklist cache
manager.blacklistCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize metadata cache with grace periods
@@ -104,40 +131,45 @@ func initializeDefaultCaches(manager *UniversalCacheManager, logger *Logger) {
"issuer",
},
},
Logger: logger,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize JWK cache
manager.jwkCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeJWK,
MaxSize: 200,
DefaultTTL: 1 * time.Hour,
Logger: logger,
Type: CacheTypeJWK,
MaxSize: 200,
DefaultTTL: 1 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize session cache - CRITICAL FIX: Reduced from 10000 to 2000
manager.sessionCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeSession,
MaxSize: 2000, // CRITICAL FIX: Reduced from 10000 to 2000 items
MaxMemoryBytes: 5 * 1024 * 1024, // CRITICAL FIX: Added 5MB memory limit
DefaultTTL: 30 * time.Minute,
Logger: logger,
Type: CacheTypeSession,
MaxSize: 2000, // CRITICAL FIX: Reduced from 10000 to 2000 items
MaxMemoryBytes: 5 * 1024 * 1024, // CRITICAL FIX: Added 5MB memory limit
DefaultTTL: 30 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize introspection cache for OAuth 2.0 Token Introspection (RFC 7662)
manager.introspectionCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken, // Use token cache type for introspection results
MaxSize: 1000, // Cache up to 1000 introspection results
DefaultTTL: 5 * time.Minute, // Short TTL for security (introspect frequently)
Logger: logger,
Type: CacheTypeToken, // Use token cache type for introspection results
MaxSize: 1000, // Cache up to 1000 introspection results
DefaultTTL: 5 * time.Minute, // Short TTL for security (introspect frequently)
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize token type cache for performance optimization
manager.tokenTypeCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken, // Use token cache type for token type detection
MaxSize: 2000, // Cache up to 2000 token type detections
DefaultTTL: 5 * time.Minute, // 5 minute TTL for token type detection
Logger: logger,
Type: CacheTypeToken, // Use token cache type for token type detection
MaxSize: 2000, // Cache up to 2000 token type detections
DefaultTTL: 5 * time.Minute, // 5 minute TTL for token type detection
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
}
@@ -230,11 +262,12 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r
// Initialize token cache with backend
manager.tokenCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
MaxMemoryBytes: 5 * 1024 * 1024,
DefaultTTL: 1 * time.Hour,
Logger: logger,
Type: CacheTypeToken,
MaxSize: 1000,
MaxMemoryBytes: 5 * 1024 * 1024,
DefaultTTL: 1 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeToken),
)
@@ -242,10 +275,11 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r
// Initialize blacklist cache (CRITICAL - must be consistent across replicas)
manager.blacklistCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend("blacklist"),
)
@@ -268,7 +302,8 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r
"issuer",
},
},
Logger: logger,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeMetadata),
)
@@ -276,45 +311,106 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r
// Initialize JWK cache
manager.jwkCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeJWK,
MaxSize: 200,
DefaultTTL: 1 * time.Hour,
Logger: logger,
Type: CacheTypeJWK,
MaxSize: 200,
DefaultTTL: 1 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeJWK),
)
// Session cache stays memory-only (high volume, local state)
manager.sessionCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeSession,
MaxSize: 2000,
MaxMemoryBytes: 5 * 1024 * 1024,
DefaultTTL: 30 * time.Minute,
Logger: logger,
Type: CacheTypeSession,
MaxSize: 2000,
MaxMemoryBytes: 5 * 1024 * 1024,
DefaultTTL: 30 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Introspection cache uses backend for sharing results
manager.introspectionCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 5 * time.Minute,
Logger: logger,
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 5 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeToken),
)
// Token type cache stays memory-only (local optimization)
manager.tokenTypeCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 2000,
DefaultTTL: 5 * time.Minute,
Logger: logger,
Type: CacheTypeToken,
MaxSize: 2000,
DefaultTTL: 5 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
logger.Infof("Cache manager initialized with %s backend configuration", redisConfig.CacheMode)
}
// startConsolidatedCleanup starts a single cleanup goroutine for all caches
// This reduces goroutine count from 7 to 1 and consolidates cleanup operations
func (m *UniversalCacheManager) startConsolidatedCleanup() {
m.mu.Lock()
if m.cleanupStarted {
m.mu.Unlock()
return
}
m.cleanupStarted = true
m.mu.Unlock()
m.wg.Add(1)
go func() {
defer m.wg.Done()
// Use 5-minute interval for consolidated cleanup
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C:
m.performConsolidatedCleanup()
}
}
}()
m.logger.Info("UniversalCacheManager: Started consolidated cleanup routine for all caches")
}
// performConsolidatedCleanup runs cleanup on all caches in sequence
// This is more efficient than parallel cleanup as it reduces lock contention
func (m *UniversalCacheManager) performConsolidatedCleanup() {
m.mu.RLock()
caches := []*UniversalCache{
m.tokenCache,
m.blacklistCache,
m.metadataCache,
m.jwkCache,
m.sessionCache,
m.introspectionCache,
m.tokenTypeCache,
}
m.mu.RUnlock()
for _, cache := range caches {
if cache != nil {
// Each cache.Cleanup() is self-contained and handles its own locking
cache.Cleanup()
}
}
m.logger.Debugf("UniversalCacheManager: Consolidated cleanup completed for all caches")
}
// GetTokenCache returns the token cache
func (m *UniversalCacheManager) GetTokenCache() *UniversalCache {
m.mu.RLock()
@@ -364,8 +460,16 @@ func (m *UniversalCacheManager) GetTokenTypeCache() *UniversalCache {
return m.tokenTypeCache
}
// Close shuts down all caches
// Close shuts down all caches and the consolidated cleanup routine
func (m *UniversalCacheManager) Close() error {
// Stop the consolidated cleanup routine first
if m.cancel != nil {
m.cancel()
}
// Wait for cleanup routine to finish
m.wg.Wait()
m.mu.Lock()
defer m.mu.Unlock()
@@ -387,7 +491,8 @@ func (m *UniversalCacheManager) Close() error {
}
}
m.logger.Info("UniversalCacheManager: Closed all caches")
m.cleanupStarted = false
m.logger.Info("UniversalCacheManager: Closed all caches and cleanup routine")
return nil
}