release 0.7.9 (#78)

* Speed improvements.

After introduction of introspection the plugin became significantly slower.
This commit introduces several optimizations to bring the speed back up.

* Add relevant documentation and tests.
This commit is contained in:
Lukasz Raczylo
2025-10-13 10:43:35 +01:00
committed by GitHub
parent 93888e56d1
commit 79e9b164f9
11 changed files with 611 additions and 105 deletions
+62
View File
@@ -121,6 +121,12 @@ testData:
- "https://*.example.com" - "https://*.example.com"
corsAllowCredentials: true corsAllowCredentials: true
# Cross-origin policies
permissionsPolicy: "geolocation=(), camera=(), microphone=()"
crossOriginEmbedderPolicy: "require-corp"
crossOriginOpenerPolicy: "same-origin"
crossOriginResourcePolicy: "same-origin"
# Custom headers # Custom headers
customHeaders: customHeaders:
X-Custom-Header: "production" X-Custom-Header: "production"
@@ -1031,3 +1037,59 @@ configuration:
Remove the X-Powered-By header to hide technology stack information. Remove the X-Powered-By header to hide technology stack information.
Default: true Default: true
required: false required: false
permissionsPolicy:
type: string
description: |
Permissions-Policy header to control browser feature permissions.
This header allows you to control which features and APIs can be used.
Examples:
- "geolocation=(), camera=(), microphone=()" (deny all)
- "geolocation=(self), camera=()" (allow geolocation for same origin only)
Common directives: accelerometer, camera, geolocation, gyroscope,
magnetometer, microphone, payment, usb
required: false
crossOriginEmbedderPolicy:
type: string
description: |
Cross-Origin-Embedder-Policy (COEP) header to prevent untrusted
resources from being loaded.
Options:
- "require-corp": Resources must explicitly grant permission
- "credentialless": Load without credentials for cross-origin resources
- "unsafe-none": No restrictions (default)
Required for certain browser features like SharedArrayBuffer.
required: false
crossOriginOpenerPolicy:
type: string
description: |
Cross-Origin-Opener-Policy (COOP) header to isolate browsing context
from cross-origin windows.
Options:
- "same-origin": Isolate from cross-origin documents
- "same-origin-allow-popups": Allow popups that don't set COOP
- "unsafe-none": No isolation (default)
Helps prevent cross-origin attacks and Spectre-like vulnerabilities.
required: false
crossOriginResourcePolicy:
type: string
description: |
Cross-Origin-Resource-Policy (CORP) header to control which origins
can load this resource.
Options:
- "same-origin": Only same-origin requests can load the resource
- "same-site": Only same-site requests can load the resource
- "cross-origin": Any origin can load the resource (default)
Prevents your resources from being embedded on other sites.
required: false
+8 -4
View File
@@ -76,7 +76,7 @@ experimental:
plugins: plugins:
traefikoidc: traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.2.1 # Use the latest version version: v0.7.8 # Use the latest version
``` ```
2. Configure the middleware in your dynamic configuration (see examples below). 2. Configure the middleware in your dynamic configuration (see examples below).
@@ -301,7 +301,7 @@ spec:
strictAudienceValidation: true strictAudienceValidation: true
``` ```
For detailed Auth0 configuration including all three scenarios, troubleshooting, and security best practices, see **[AUTH0_AUDIENCE_GUIDE.md](AUTH0_AUDIENCE_GUIDE.md)**. For detailed Auth0 configuration including all three scenarios, troubleshooting, and security best practices, see **[AUTH0_AUDIENCE_GUIDE.md](docs/AUTH0_AUDIENCE_GUIDE.md)**.
## Security Headers Configuration ## Security Headers Configuration
@@ -421,6 +421,10 @@ securityHeaders:
| `customHeaders` | Additional custom headers | `{}` | `{"X-Custom": "value"}` | | `customHeaders` | Additional custom headers | `{}` | `{"X-Custom": "value"}` |
| `disableServerHeader` | Remove Server header | `true` | `true`, `false` | | `disableServerHeader` | Remove Server header | `true` | `true`, `false` |
| `disablePoweredByHeader` | Remove X-Powered-By header | `true` | `true`, `false` | | `disablePoweredByHeader` | Remove X-Powered-By header | `true` | `true`, `false` |
| `permissionsPolicy` | Permissions-Policy header | `` | `"geolocation=(), camera=(), microphone=()"` |
| `crossOriginEmbedderPolicy` | Cross-Origin-Embedder-Policy header | `` | `"require-corp"`, `"credentialless"`, `"unsafe-none"` |
| `crossOriginOpenerPolicy` | Cross-Origin-Opener-Policy header | `` | `"same-origin"`, `"same-origin-allow-popups"`, `"unsafe-none"` |
| `crossOriginResourcePolicy` | Cross-Origin-Resource-Policy header | `` | `"same-origin"`, `"same-site"`, `"cross-origin"` |
### CORS Wildcard Support ### CORS Wildcard Support
@@ -855,7 +859,7 @@ spec:
postLogoutRedirectURI: /logged-out-page # Must be in Auth0 Allowed Logout URLs postLogoutRedirectURI: /logged-out-page # Must be in Auth0 Allowed Logout URLs
``` ```
**Note**: For detailed Auth0 audience configuration including opaque tokens and all security scenarios, see [AUTH0_AUDIENCE_GUIDE.md](AUTH0_AUDIENCE_GUIDE.md). **Note**: For detailed Auth0 audience configuration including opaque tokens and all security scenarios, see [AUTH0_AUDIENCE_GUIDE.md](docs/AUTH0_AUDIENCE_GUIDE.md).
### Okta Configuration ### Okta Configuration
@@ -1029,7 +1033,7 @@ services:
image: traefik:v3.2.1 image: traefik:v3.2.1
command: command:
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc" - "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
- "--experimental.plugins.traefikoidc.version=v0.2.1" - "--experimental.plugins.traefikoidc.version=v0.7.8"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./traefik-config/traefik.yml:/etc/traefik/traefik.yml - ./traefik-config/traefik.yml:/etc/traefik/traefik.yml
+8
View File
@@ -69,6 +69,14 @@ func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache()} return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache()}
} }
// GetSharedTokenTypeCache returns the shared token type cache
// for caching token type detection results to improve performance
func (cm *CacheManager) GetSharedTokenTypeCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache()}
}
// Close gracefully shuts down all cache components // Close gracefully shuts down all cache components
func (cm *CacheManager) Close() error { func (cm *CacheManager) Close() error {
cm.mu.Lock() cm.mu.Lock()
+1
View File
@@ -153,6 +153,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
return config.PostLogoutRedirectURI return config.PostLogoutRedirectURI
}(), }(),
tokenBlacklist: cacheManager.GetSharedTokenBlacklist(), tokenBlacklist: cacheManager.GetSharedTokenBlacklist(),
tokenTypeCache: cacheManager.GetSharedTokenTypeCache(), // Cache for token type detection
jwkCache: cacheManager.GetSharedJWKCache(), jwkCache: cacheManager.GetSharedJWKCache(),
metadataCache: cacheManager.GetSharedMetadataCache(), metadataCache: cacheManager.GetSharedMetadataCache(),
introspectionCache: cacheManager.GetSharedIntrospectionCache(), // Cache for introspection results introspectionCache: cacheManager.GetSharedIntrospectionCache(), // Cache for introspection results
+130 -100
View File
@@ -158,6 +158,134 @@ func (t *TraefikOidc) cacheVerifiedToken(token string, claims map[string]interfa
t.tokenCache.Set(token, claims, duration) t.tokenCache.Set(token, claims, duration)
} }
// detectTokenType efficiently detects whether a token is an ID token or access token.
// It uses caching to avoid re-detection and optimizes the detection order for performance.
// Parameters:
// - jwt: The parsed JWT structure containing header and claims.
// - token: The raw token string for cache key generation.
//
// Returns:
// - true if the token is an ID token, false if it's an access token.
func (t *TraefikOidc) detectTokenType(jwt *JWT, token string) bool {
// Use first 32 chars of token as cache key (sufficient for uniqueness)
cacheKey := token
if len(token) > 32 {
cacheKey = token[:32]
}
// Check cache first
if t.tokenTypeCache != nil {
if cachedType, found := t.tokenTypeCache.Get(cacheKey); found {
if isIDToken, ok := cachedType.(bool); ok {
return isIDToken
}
}
}
// Perform optimized detection
isIDToken := false
// 1. Check 'nonce' claim first (most definitive for ID tokens - short circuit)
if nonce, ok := jwt.Claims["nonce"]; ok {
if _, ok := nonce.(string); ok {
isIDToken = true
if !t.suppressDiagnosticLogs {
t.safeLogDebugf("ID token detected via nonce claim")
}
// Cache and return immediately
if t.tokenTypeCache != nil {
t.tokenTypeCache.Set(cacheKey, true, 5*time.Minute)
}
return true
}
}
// 2. Check 'typ' header for "at+jwt" (definitive for access tokens - short circuit)
if typ, ok := jwt.Header["typ"].(string); ok && typ == "at+jwt" {
// RFC 9068 compliant access token
if !t.suppressDiagnosticLogs {
t.safeLogDebugf("RFC 9068 access token detected (typ=at+jwt)")
}
// Cache and return immediately
if t.tokenTypeCache != nil {
t.tokenTypeCache.Set(cacheKey, false, 5*time.Minute)
}
return false
}
// 3. Check 'token_use' claim (definitive if present - short circuit)
if tokenUse, ok := jwt.Claims["token_use"].(string); ok {
if tokenUse == "id" {
isIDToken = true
if !t.suppressDiagnosticLogs {
t.safeLogDebugf("ID token detected via token_use claim")
}
// Cache and return
if t.tokenTypeCache != nil {
t.tokenTypeCache.Set(cacheKey, true, 5*time.Minute)
}
return true
} else if tokenUse == "access" {
if !t.suppressDiagnosticLogs {
t.safeLogDebugf("Access token detected via token_use claim")
}
// Cache and return
if t.tokenTypeCache != nil {
t.tokenTypeCache.Set(cacheKey, false, 5*time.Minute)
}
return false
}
}
// 4. Check 'scope' claim (strong indicator for access tokens)
if scope, ok := jwt.Claims["scope"]; ok {
if _, ok := scope.(string); ok {
if !t.suppressDiagnosticLogs {
t.safeLogDebugf("Access token detected via scope claim")
}
// Cache and return
if t.tokenTypeCache != nil {
t.tokenTypeCache.Set(cacheKey, false, 5*time.Minute)
}
return false
}
}
// 5. Check if aud == clientID only (ID token pattern)
if aud, ok := jwt.Claims["aud"]; ok {
// Check string audience
if audStr, ok := aud.(string); ok && audStr == t.clientID {
isIDToken = true
} else if audArr, ok := aud.([]interface{}); ok {
// Check array audience - only treat as ID token if client_id is sole audience
if len(audArr) == 1 {
for _, v := range audArr {
if str, ok := v.(string); ok && str == t.clientID {
isIDToken = true
break
}
}
}
}
}
// Cache the result
if t.tokenTypeCache != nil {
t.tokenTypeCache.Set(cacheKey, isIDToken, 5*time.Minute)
}
// Log detection result in debug mode
if !t.suppressDiagnosticLogs {
if isIDToken {
t.safeLogDebugf("ID token detected via audience matching")
} else {
t.safeLogDebugf("Defaulting to access token")
}
}
return isIDToken
}
// VerifyJWTSignatureAndClaims verifies JWT signature using provider's public keys and validates standard claims. // VerifyJWTSignatureAndClaims verifies JWT signature using provider's public keys and validates standard claims.
// It retrieves the appropriate public key from the JWKS cache, verifies the token signature, // It retrieves the appropriate public key from the JWKS cache, verifies the token signature,
// and validates standard OIDC claims like issuer, audience, and expiration. // and validates standard OIDC claims like issuer, audience, and expiration.
@@ -240,105 +368,8 @@ func (t *TraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error
t.safeLogDebugf("DIAGNOSTIC: Signature verification successful for kid=%s", kid) t.safeLogDebugf("DIAGNOSTIC: Signature verification successful for kid=%s", kid)
} }
// Determine expected audience based on token type // Detect token type (cached for performance)
// Per OIDC spec: ID tokens MUST have aud=client_id isIDToken := t.detectTokenType(jwt, token)
// Access tokens can have custom audience values (e.g., Auth0 API identifiers)
// Token type detection strategy (RFC 9068 + OIDC Core 1.0):
// 1. Check 'typ' header claim (RFC 9068) → "at+jwt" = ACCESS_TOKEN, "JWT" = could be either
// 2. Check explicit token type claims (token_use, token_type) if present
// 3. Check 'scope' claim → ACCESS_TOKEN (use configured audience)
// 4. Check 'nonce' claim → ID_TOKEN (use client_id, per OIDC spec)
// 5. Check if aud == client_id only → ID_TOKEN (use client_id)
// 6. Else → ACCESS_TOKEN with custom audience (use configured audience)
isIDToken := false
isAccessToken := false
// Step 1: Check typ header for explicit type (RFC 9068)
if typ, ok := jwt.Header["typ"].(string); ok {
if typ == "at+jwt" {
// RFC 9068 compliant access token
isAccessToken = true
if !t.suppressDiagnosticLogs {
t.safeLogDebugf("RFC 9068 access token detected (typ=at+jwt)")
}
} else if typ == "JWT" {
// Generic JWT, need further checks
if !t.suppressDiagnosticLogs {
t.safeLogDebugf("Generic JWT detected (typ=JWT), checking claims")
}
}
}
// Step 2: Check explicit token type claims (if not already determined)
if !isAccessToken && !isIDToken {
// Check for token_use claim (used by some providers like AWS Cognito)
if tokenUse, ok := jwt.Claims["token_use"].(string); ok {
if tokenUse == "access" {
isAccessToken = true
} else if tokenUse == "id" {
isIDToken = true
}
}
// Check for token_type claim
if !isAccessToken && !isIDToken {
if tokenType, ok := jwt.Claims["token_type"].(string); ok {
if tokenType == "access_token" || tokenType == "Bearer" {
isAccessToken = true
} else if tokenType == "id_token" {
isIDToken = true
}
}
}
}
// Step 3: Check scope claim (access tokens have this)
if !isAccessToken && !isIDToken {
if scope, ok := jwt.Claims["scope"]; ok {
if _, ok := scope.(string); ok {
isAccessToken = true
}
}
}
// Step 4: Check nonce claim (ID tokens have this per OIDC spec for replay protection)
if !isAccessToken && !isIDToken {
if nonce, ok := jwt.Claims["nonce"]; ok {
if _, ok := nonce.(string); ok {
isIDToken = true // Nonce indicates ID token
}
}
}
// Step 5: If no scope and no nonce, check if aud matches client_id (indicates ID token)
if !isAccessToken && !isIDToken {
if aud, ok := jwt.Claims["aud"]; ok {
// Check string audience
if audStr, ok := aud.(string); ok && audStr == t.clientID {
isIDToken = true
}
// Check array audience
if audArr, ok := aud.([]interface{}); ok {
for _, v := range audArr {
if str, ok := v.(string); ok && str == t.clientID {
// Only treat as ID token if it's the sole audience
// Access tokens can also contain client_id in array
if len(audArr) == 1 {
isIDToken = true
}
break
}
}
}
}
}
// Step 6: Default to access token if still undetermined
if !isIDToken {
isAccessToken = true
}
// Determine expected audience // Determine expected audience
expectedAudience := t.audience // Default to configured audience expectedAudience := t.audience // Default to configured audience
@@ -348,7 +379,6 @@ func (t *TraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error
t.safeLogDebugf("ID token detected, validating with client_id: %s", expectedAudience) t.safeLogDebugf("ID token detected, validating with client_id: %s", expectedAudience)
} }
} else { } else {
// Access token or ambiguous - use configured audience
if !t.suppressDiagnosticLogs { if !t.suppressDiagnosticLogs {
t.safeLogDebugf("Access token detected, validating with audience: %s", expectedAudience) t.safeLogDebugf("Access token detected, validating with audience: %s", expectedAudience)
} }
+173
View File
@@ -0,0 +1,173 @@
package traefikoidc
import (
"testing"
"time"
)
func BenchmarkDetectTokenType(b *testing.B) {
tr := &TraefikOidc{
clientID: "test-client-id",
suppressDiagnosticLogs: true,
tokenTypeCache: NewTestCache(),
}
// Create various JWT test cases
jwtWithNonce := &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"nonce": "test-nonce",
"aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(),
},
}
jwtWithScope := &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"scope": "openid profile email",
"aud": "some-api",
"exp": time.Now().Add(1 * time.Hour).Unix(),
},
}
jwtComplexDetection := &JWT{
Header: map[string]interface{}{"alg": "RS256", "typ": "JWT"},
Claims: map[string]interface{}{
"aud": []interface{}{"test-client-id", "another-aud"},
"exp": time.Now().Add(1 * time.Hour).Unix(),
"sub": "user123",
"token_type": "Bearer",
"custom_claim": "value",
},
}
testCases := []struct {
name string
jwt *JWT
token string
}{
{"WithNonce", jwtWithNonce, "token-with-nonce-for-benchmark-testing-12345678901234567890"},
{"WithScope", jwtWithScope, "token-with-scope-for-benchmark-testing-12345678901234567890"},
{"ComplexDetection", jwtComplexDetection, "token-complex-for-benchmark-testing-12345678901234567890"},
}
for _, tc := range testCases {
b.Run(tc.name+"_FirstCall", func(b *testing.B) {
// Benchmark first call (uncached)
for i := 0; i < b.N; i++ {
// Clear cache before each iteration
tr.tokenTypeCache.Clear()
_ = tr.detectTokenType(tc.jwt, tc.token)
}
})
b.Run(tc.name+"_Cached", func(b *testing.B) {
// Prime the cache
_ = tr.detectTokenType(tc.jwt, tc.token)
// Benchmark cached calls
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = tr.detectTokenType(tc.jwt, tc.token)
}
})
}
}
// Benchmark comparison with the old implementation logic
func BenchmarkOldDetectionLogic(b *testing.B) {
clientID := "test-client-id"
jwt := &JWT{
Header: map[string]interface{}{"alg": "RS256", "typ": "JWT"},
Claims: map[string]interface{}{
"aud": []interface{}{"test-client-id", "another-aud"},
"exp": time.Now().Add(1 * time.Hour).Unix(),
"sub": "user123",
"token_type": "Bearer",
"custom_claim": "value",
},
}
b.Run("OldLogic", func(b *testing.B) {
for i := 0; i < b.N; i++ {
// Simulate the old detection logic (all 6 sequential checks)
isIDToken := false
isAccessToken := false
// Step 1: Check typ header
if typ, ok := jwt.Header["typ"].(string); ok {
if typ == "at+jwt" {
isAccessToken = true
}
}
// Step 2: Check token_use claim
if !isAccessToken && !isIDToken {
if tokenUse, ok := jwt.Claims["token_use"].(string); ok {
if tokenUse == "access" {
isAccessToken = true
} else if tokenUse == "id" {
isIDToken = true
}
}
}
// Step 3: Check token_type claim
if !isAccessToken && !isIDToken {
if tokenType, ok := jwt.Claims["token_type"].(string); ok {
if tokenType == "access_token" || tokenType == "Bearer" {
isAccessToken = true
} else if tokenType == "id_token" {
isIDToken = true
}
}
}
// Step 4: Check scope claim
if !isAccessToken && !isIDToken {
if scope, ok := jwt.Claims["scope"]; ok {
if _, ok := scope.(string); ok {
isAccessToken = true
}
}
}
// Step 5: Check nonce claim
if !isAccessToken && !isIDToken {
if nonce, ok := jwt.Claims["nonce"]; ok {
if _, ok := nonce.(string); ok {
isIDToken = true
}
}
}
// Step 6: Check audience
if !isAccessToken && !isIDToken {
if aud, ok := jwt.Claims["aud"]; ok {
if audStr, ok := aud.(string); ok && audStr == clientID {
isIDToken = true
}
if audArr, ok := aud.([]interface{}); ok {
for _, v := range audArr {
if str, ok := v.(string); ok && str == clientID {
if len(audArr) == 1 {
isIDToken = true
}
break
}
}
}
}
}
// Step 7: Default to access token
if !isIDToken {
isAccessToken = true
}
_ = isAccessToken
}
})
}
+211
View File
@@ -0,0 +1,211 @@
package traefikoidc
import (
"testing"
"time"
)
func TestDetectTokenType(t *testing.T) {
// Create a test instance with mock cache
tr := &TraefikOidc{
clientID: "test-client-id",
suppressDiagnosticLogs: true,
tokenTypeCache: NewTestCache(),
}
testCases := []struct {
name string
jwt *JWT
token string
expectedID bool
description string
}{
{
name: "ID token with nonce",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"nonce": "test-nonce",
"aud": "test-client-id",
},
},
token: "test-token-with-nonce",
expectedID: true,
description: "Should detect ID token via nonce claim",
},
{
name: "RFC 9068 access token",
jwt: &JWT{
Header: map[string]interface{}{
"alg": "RS256",
"typ": "at+jwt",
},
Claims: map[string]interface{}{
"scope": "openid profile",
},
},
token: "test-access-token-rfc9068",
expectedID: false,
description: "Should detect access token via typ=at+jwt header",
},
{
name: "Token with token_use=id",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"token_use": "id",
"aud": "test-client-id",
},
},
token: "test-token-use-id",
expectedID: true,
description: "Should detect ID token via token_use claim",
},
{
name: "Token with token_use=access",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"token_use": "access",
"scope": "read write",
},
},
token: "test-token-use-access",
expectedID: false,
description: "Should detect access token via token_use claim",
},
{
name: "Access token with scope",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"scope": "openid profile email",
"aud": "some-api-audience",
},
},
token: "test-access-token-with-scope",
expectedID: false,
description: "Should detect access token via scope claim",
},
{
name: "ID token with client_id audience",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"aud": "test-client-id",
"sub": "user123",
},
},
token: "test-id-token-client-aud",
expectedID: true,
description: "Should detect ID token via audience matching client_id",
},
{
name: "Default to access token",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"aud": "different-audience",
"sub": "user123",
},
},
token: "test-default-access-token",
expectedID: false,
description: "Should default to access token when no clear indicators",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// First call - should not be cached
result := tr.detectTokenType(tc.jwt, tc.token)
if result != tc.expectedID {
t.Errorf("%s: expected isIDToken=%v, got %v", tc.description, tc.expectedID, result)
}
// Second call - should be cached
result2 := tr.detectTokenType(tc.jwt, tc.token)
if result2 != tc.expectedID {
t.Errorf("%s (cached): expected isIDToken=%v, got %v", tc.description, tc.expectedID, result2)
}
})
}
}
func TestDetectTokenTypeCaching(t *testing.T) {
cache := NewTestCache()
tr := &TraefikOidc{
clientID: "test-client-id",
suppressDiagnosticLogs: true,
tokenTypeCache: cache,
}
jwt := &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"nonce": "test-nonce",
},
}
token := "test-token-for-caching-with-enough-characters-for-key"
cacheKey := token
if len(token) > 32 {
cacheKey = token[:32] // First 32 chars
}
// First call - should cache
result := tr.detectTokenType(jwt, token)
if !result {
t.Error("Expected ID token detection via nonce")
}
// Check cache was populated
if cached, found := cache.Get(cacheKey); !found {
t.Error("Expected token type to be cached")
} else if cachedBool, ok := cached.(bool); !ok || !cachedBool {
t.Error("Expected cached value to be true (ID token)")
}
// Modify JWT to have different detection (but use same token for cache key)
jwt.Claims = map[string]interface{}{
"scope": "openid profile", // This would normally make it an access token
}
// Second call with modified JWT - should still return cached value
result2 := tr.detectTokenType(jwt, token)
if !result2 {
t.Error("Expected cached ID token result, ignoring modified JWT")
}
}
// TestCache is a simple in-memory cache for testing
type TestCache struct {
data map[string]interface{}
}
func NewTestCache() *TestCache {
return &TestCache{
data: make(map[string]interface{}),
}
}
func (c *TestCache) Set(key string, value interface{}, ttl time.Duration) {
c.data[key] = value
}
func (c *TestCache) Get(key string) (interface{}, bool) {
val, ok := c.data[key]
return val, ok
}
func (c *TestCache) Delete(key string) {
delete(c.data, key)
}
func (c *TestCache) SetMaxSize(size int) {}
func (c *TestCache) Size() int { return len(c.data) }
func (c *TestCache) Clear() { c.data = make(map[string]interface{}) }
func (c *TestCache) Cleanup() {}
func (c *TestCache) Close() {}
func (c *TestCache) GetStats() map[string]interface{} {
return map[string]interface{}{"size": len(c.data)}
}
+1
View File
@@ -73,6 +73,7 @@ type TraefikOidc struct {
initComplete chan struct{} initComplete chan struct{}
limiter *rate.Limiter limiter *rate.Limiter
tokenBlacklist CacheInterface tokenBlacklist CacheInterface
tokenTypeCache CacheInterface // Cache for token type detection results
headerTemplates map[string]*template.Template headerTemplates map[string]*template.Template
sessionManager *SessionManager sessionManager *SessionManager
tokenCleanupStopChan chan struct{} tokenCleanupStopChan chan struct{}
+17 -1
View File
@@ -13,6 +13,7 @@ type UniversalCacheManager struct {
jwkCache *UniversalCache jwkCache *UniversalCache
sessionCache *UniversalCache sessionCache *UniversalCache
introspectionCache *UniversalCache // OAuth 2.0 Token Introspection cache (RFC 7662) introspectionCache *UniversalCache // OAuth 2.0 Token Introspection cache (RFC 7662)
tokenTypeCache *UniversalCache // Cache for token type detection results
mu sync.RWMutex mu sync.RWMutex
logger *Logger logger *Logger
} }
@@ -94,6 +95,14 @@ func GetUniversalCacheManager(logger *Logger) *UniversalCacheManager {
DefaultTTL: 5 * time.Minute, // Short TTL for security (introspect frequently) DefaultTTL: 5 * time.Minute, // Short TTL for security (introspect frequently)
Logger: logger, Logger: logger,
}) })
// 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,
})
}) })
return universalCacheManager return universalCacheManager
@@ -141,13 +150,20 @@ func (m *UniversalCacheManager) GetIntrospectionCache() *UniversalCache {
return m.introspectionCache return m.introspectionCache
} }
// GetTokenTypeCache returns the token type detection cache
func (m *UniversalCacheManager) GetTokenTypeCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.tokenTypeCache
}
// Close shuts down all caches // Close shuts down all caches
func (m *UniversalCacheManager) Close() error { func (m *UniversalCacheManager) Close() error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
for _, cache := range []*UniversalCache{ for _, cache := range []*UniversalCache{
m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache, m.introspectionCache, m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache, m.introspectionCache, m.tokenTypeCache,
} { } {
if cache != nil { if cache != nil {
cache.Close() cache.Close()