mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user