mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
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.
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
@@ -552,6 +553,123 @@ spec:
|
||||
|
||||
**Recommendation**: For single-instance deployments, leave this setting at `false` (default) to maintain replay attack protection. For multi-replica deployments, set to `true` and consider implementing a shared cache backend (Redis/Memcached) if replay detection is required.
|
||||
|
||||
## 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:
|
||||
- "admin@your-app.com"
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+831
@@ -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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</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">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</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>• JWT signature verification with JWK rotation</li>
|
||||
<li>• Replay attack detection via JTI claims</li>
|
||||
<li>• Strict audience and issuer validation</li>
|
||||
<li>• Automatic token refresh before expiry</li>
|
||||
<li>• 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>• AES-256-GCM encrypted session cookies</li>
|
||||
<li>• CSRF protection with state parameter</li>
|
||||
<li>• Secure, HttpOnly, SameSite cookies</li>
|
||||
<li>• Configurable session timeouts</li>
|
||||
<li>• 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>• Content Security Policy (CSP)</li>
|
||||
<li>• HTTP Strict Transport Security (HSTS)</li>
|
||||
<li>• X-Frame-Options, X-Content-Type-Options</li>
|
||||
<li>• CORS configuration</li>
|
||||
<li>• 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>• Configurable request rate limits</li>
|
||||
<li>• Protection against brute force attacks</li>
|
||||
<li>• Per-client rate limiting</li>
|
||||
<li>• Graceful handling of limit exceeded</li>
|
||||
<li>• 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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-red-500">✗</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">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</span></td>
|
||||
<td class="px-4 py-3 text-center"><span class="text-green-500">✓</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">✓</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>
|
||||
@@ -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, ®Error); 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, ®Resp); 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 = ®Resp
|
||||
r.mu.Unlock()
|
||||
|
||||
// Persist credentials if enabled
|
||||
if r.config.PersistCredentials {
|
||||
if err := r.saveCredentials(®Resp); err != nil {
|
||||
r.logger.Errorf("Failed to persist client credentials: %v", err)
|
||||
// Don't fail registration if persistence fails
|
||||
}
|
||||
}
|
||||
|
||||
return ®Resp, 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, ®Error); 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, ®Resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse update response: %w", err)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
r.mu.Lock()
|
||||
r.registrationResponse = ®Resp
|
||||
r.mu.Unlock()
|
||||
|
||||
// Persist updated credentials if enabled
|
||||
if r.config.PersistCredentials {
|
||||
if err := r.saveCredentials(®Resp); 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 ®Resp, 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, ®Error); 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, ®Resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse read response: %w", err)
|
||||
}
|
||||
|
||||
return ®Resp, 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, ®Error); 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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
shardedReplayCache.Set(jtiValue, true, duration)
|
||||
}
|
||||
} else {
|
||||
// Fall back to legacy cache with mutex (should rarely happen)
|
||||
replayCacheMu.RLock()
|
||||
_, exists := replayCache.Get(jtiValue)
|
||||
replayCacheMu.RUnlock()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,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
|
||||
@@ -361,12 +362,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
|
||||
@@ -376,6 +378,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 != "" {
|
||||
@@ -386,6 +391,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.
|
||||
|
||||
+8
-14
@@ -3214,10 +3214,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")
|
||||
}
|
||||
@@ -3398,14 +3396,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
|
||||
@@ -3458,9 +3454,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
|
||||
@@ -3567,9 +3562,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")
|
||||
}
|
||||
|
||||
+90
-10
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+78
-68
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1161,6 +1157,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)
|
||||
@@ -1185,37 +1182,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)
|
||||
@@ -1230,7 +1210,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
|
||||
@@ -1292,29 +1321,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)
|
||||
}
|
||||
@@ -1327,11 +1339,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)
|
||||
|
||||
+85
@@ -89,6 +89,91 @@ type Config struct {
|
||||
// Recommended: true for multi-replica deployments
|
||||
DisableReplayDetection bool `json:"disableReplayDetection,omitempty"`
|
||||
SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+13
-6
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -128,4 +129,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
@@ -34,6 +34,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
|
||||
@@ -143,8 +148,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
|
||||
}
|
||||
|
||||
+125
-29
@@ -1,11 +1,14 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -16,6 +19,12 @@ type UniversalCacheManager struct {
|
||||
tokenTypeCache *UniversalCache // Cache for token type detection results
|
||||
mu sync.RWMutex
|
||||
logger *Logger
|
||||
|
||||
// Consolidated cleanup management
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
cleanupStarted bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -30,25 +39,34 @@ func GetUniversalCacheManager(logger *Logger) *UniversalCacheManager {
|
||||
logger = GetSingletonNoOpLogger()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
universalCacheManager = &UniversalCacheManager{
|
||||
logger: logger,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Initialize all caches with SkipAutoCleanup=true to prevent 7 separate cleanup goroutines
|
||||
// Instead, we use a single consolidated cleanup routine managed by this manager
|
||||
|
||||
// Initialize token cache - CRITICAL FIX: Reduced from 5000 to 1000
|
||||
universalCacheManager.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
|
||||
universalCacheManager.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
|
||||
@@ -68,46 +86,115 @@ func GetUniversalCacheManager(logger *Logger) *UniversalCacheManager {
|
||||
"issuer",
|
||||
},
|
||||
},
|
||||
Logger: logger,
|
||||
Logger: logger,
|
||||
SkipAutoCleanup: true, // Managed cleanup
|
||||
})
|
||||
|
||||
// Initialize JWK cache
|
||||
universalCacheManager.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
|
||||
universalCacheManager.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)
|
||||
universalCacheManager.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
|
||||
universalCacheManager.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
|
||||
})
|
||||
|
||||
// Start single consolidated cleanup goroutine for all caches
|
||||
// This replaces 7 individual cleanup goroutines with 1
|
||||
universalCacheManager.startConsolidatedCleanup()
|
||||
})
|
||||
|
||||
return universalCacheManager
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
totalCleaned := 0
|
||||
for _, cache := range caches {
|
||||
if cache != nil {
|
||||
// Each cache.Cleanup() is self-contained and handles its own locking
|
||||
cache.Cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
if totalCleaned > 0 {
|
||||
m.logger.Debugf("UniversalCacheManager: Consolidated cleanup completed for all caches")
|
||||
}
|
||||
}
|
||||
|
||||
// GetTokenCache returns the token cache
|
||||
func (m *UniversalCacheManager) GetTokenCache() *UniversalCache {
|
||||
m.mu.RLock()
|
||||
@@ -157,8 +244,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()
|
||||
|
||||
@@ -170,7 +265,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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user