mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-13 04:07:37 +00:00
Complete rebuild of the plugin
* Fix bug affecting Azure OIDC authentication ( and most likely others ) * Fixes issue #51 * Ensure that appended roles are unique. Update the documentation. * Improvements targetting possible memory usage spikes. * Additional fixes and cleanup * Refactoring code to fix the issues identified by the users. * Modernize run * Fieldalignment * Multiple changes to improve performance and reduce complexity. - Optimise the errors and recovery. - Deduplicate code in metadata cache. - Remove unused performance monitoring code. - Simplify session management and settings handling. * Fix claims issue. * Add ability to overwrite the default scopes in the settings file * Well.. that escalated quickly. Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ). * Bugfix #51: Ensures that user provided scopes overrides work. * fixup! Bugfix #51: Ensures that user provided scopes overrides work. * fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. * Abstract the provider logic into a separate package. * Additional micro fixes and cleanups. * Simplify all the things. * fixup! Simplify all the things. * fixup! fixup! Simplify all the things. * fixup! fixup! fixup! Simplify all the things. * fixup! fixup! fixup! fixup! Simplify all the things. * ... * Cleanup tests. * fixup! Cleanup tests. * fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! fixup! Cleanup tests. * Issue #53: Fix CSRF token handling in reverse proxy 1. ✅ HTTPS Detection Fixed (session.go:723) - Now uses X-Forwarded-Proto header instead of r.URL.Scheme - Properly detects HTTPS in reverse proxy environments 2. ✅ SameSite Cookie Attribute Fixed - Removed automatic SameSiteStrictMode for HTTPS (would break OAuth) - Keeps SameSiteLaxMode to allow OAuth callbacks from external domains - Only uses Strict for AJAX requests which don't involve OAuth redirects 3. ✅ Cookie Domain Handling Fixed - Now respects X-Forwarded-Host header for cookie domain - Ensures cookies are set for the public domain, not internal proxy domain 4. ✅ EnhanceSessionSecurity Properly Integrated - Function is now actually called during session save - Applies security enhancements without breaking OAuth flow Why Issue #53 Failed Before: 1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back) 2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail 3. Cookie domain might have been wrong (internal vs public domain) Why It Works Now: 1. Cookies are properly marked Secure for HTTPS 2. Uses SameSite=Lax to allow OAuth provider callbacks 3. Cookie domain uses public domain from X-Forwarded-Host 4. CSRF token persists through the entire OAuth flow * Next set of enhancements together with memory usage improvements. * Memory leak fixes and optimisations. * CSRF and Cookie Domain fixes * fixup! CSRF and Cookie Domain fixes * Metadata cache leak fix + profiling * fixup! Metadata cache leak fix + profiling * Memory leaks hunting, part 1337. * Further pursue of perfection. * fixup! Further pursue of perfection. * fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * Clear race conditions * fixup! Clear race conditions * Weekend fun with memory leaks * Splitting code into multiple files with reasonable testing coverage. ``` ok github.com/lukaszraczylo/traefikoidc 117.017s coverage: 72.6% of statements ok github.com/lukaszraczylo/traefikoidc/auth 0.505s coverage: 87.1% of statements ok github.com/lukaszraczylo/traefikoidc/circuit_breaker 0.283s coverage: 99.0% of statements github.com/lukaszraczylo/traefikoidc/config coverage: 0.0% of statements ok github.com/lukaszraczylo/traefikoidc/handlers 0.349s coverage: 98.2% of statements ok github.com/lukaszraczylo/traefikoidc/internal/providers (cached) coverage: 94.3% of statements ok github.com/lukaszraczylo/traefikoidc/middleware 0.808s coverage: 78.0% of statements ok github.com/lukaszraczylo/traefikoidc/recovery 0.653s coverage: 100.0% of statements ok github.com/lukaszraczylo/traefikoidc/session/chunking (cached) coverage: 87.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/core (cached) coverage: 85.6% of statements ok github.com/lukaszraczylo/traefikoidc/session/crypto (cached) coverage: 81.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/storage (cached) coverage: 93.5% of statements ok github.com/lukaszraczylo/traefikoidc/session/validators (cached) coverage: 98.8% of statements ```` * fixup! Splitting code into multiple files with reasonable testing coverage. * fixup! fixup! Splitting code into multiple files with reasonable testing coverage. * Weekend fun with further optimisations. * fixup! Weekend fun with further optimisations. * fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * Pre-release cleanup. * Enhance test coverage. * fixup! Enhance test coverage. * fixup! fixup! Enhance test coverage. * fixup! fixup! fixup! Enhance test coverage.
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testWriter is an io.Writer that writes to test log
|
||||
// lint:ignore U1000 Kept for potential future use
|
||||
/*
|
||||
type testWriter struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (w *testWriter) Write(p []byte) (n int, err error) {
|
||||
w.t.Log(string(p))
|
||||
return len(p), nil
|
||||
}
|
||||
*/
|
||||
|
||||
// Test helper adapters for the new test files
|
||||
|
||||
// resetGlobalState resets all global singletons to prevent test interference
|
||||
// nolint:unused // Kept for potential future use in integration tests
|
||||
/*
|
||||
func resetGlobalState() {
|
||||
// Reset global task registry first to stop all background tasks
|
||||
ResetGlobalTaskRegistry()
|
||||
|
||||
// Give tasks a moment to stop
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Reset and cleanup replay cache - this should work now that tasks are stopped
|
||||
cleanupReplayCache()
|
||||
|
||||
// Reset memory pools
|
||||
memoryPoolMutex.Lock()
|
||||
globalMemoryPools = nil
|
||||
memoryPoolOnce = sync.Once{}
|
||||
memoryPoolMutex.Unlock()
|
||||
|
||||
// The universal cache manager is a singleton that persists across tests
|
||||
// Don't reset it as it causes issues
|
||||
}
|
||||
*/
|
||||
|
||||
// testCleanup provides comprehensive cleanup for tests to prevent goroutine leaks
|
||||
type testCleanup struct {
|
||||
t *testing.T
|
||||
caches []CacheInterface
|
||||
servers []*httptest.Server
|
||||
oidcs []*TraefikOidc
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newTestCleanup creates a new test cleanup helper that automatically registers cleanup
|
||||
func newTestCleanup(t *testing.T) *testCleanup {
|
||||
tc := &testCleanup{
|
||||
t: t,
|
||||
caches: make([]CacheInterface, 0),
|
||||
servers: make([]*httptest.Server, 0),
|
||||
oidcs: make([]*TraefikOidc, 0),
|
||||
}
|
||||
|
||||
// Register cleanup to run even if test panics
|
||||
t.Cleanup(func() {
|
||||
tc.cleanupAll()
|
||||
})
|
||||
|
||||
return tc
|
||||
}
|
||||
|
||||
// addCache registers a cache for cleanup
|
||||
func (tc *testCleanup) addCache(c CacheInterface) CacheInterface {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
tc.caches = append(tc.caches, c)
|
||||
return c
|
||||
}
|
||||
|
||||
// addTokenCache registers a token cache for cleanup
|
||||
func (tc *testCleanup) addTokenCache(c *TokenCache) *TokenCache {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
// TokenCache cleanup is handled by the global manager
|
||||
// No need to manually close as it's a singleton
|
||||
return c
|
||||
}
|
||||
|
||||
// addOIDC registers a TraefikOidc instance for cleanup
|
||||
//
|
||||
//lint:ignore U1000 Kept for potential future use
|
||||
func (tc *testCleanup) addOIDC(o *TraefikOidc) *TraefikOidc {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
tc.oidcs = append(tc.oidcs, o)
|
||||
return o
|
||||
}
|
||||
|
||||
// cleanupAll cleans up all registered resources
|
||||
func (tc *testCleanup) cleanupAll() {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
// Close all caches
|
||||
for _, c := range tc.caches {
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Close all servers
|
||||
for _, s := range tc.servers {
|
||||
if s != nil {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Close all OIDC instances
|
||||
for _, o := range tc.oidcs {
|
||||
if o != nil {
|
||||
// Close caches within the OIDC instance
|
||||
if o.tokenCache != nil && o.tokenCache.cache != nil {
|
||||
o.tokenCache.cache.Close()
|
||||
}
|
||||
if o.tokenBlacklist != nil {
|
||||
o.tokenBlacklist.Close()
|
||||
}
|
||||
// Call Close if it exists
|
||||
o.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset global state - commented out as resetGlobalState is unused
|
||||
// resetGlobalState()
|
||||
}
|
||||
|
||||
// createTestConfig creates a config with all required fields populated for testing
|
||||
// nolint:unused // Kept for potential future use in integration tests
|
||||
/*
|
||||
func createTestConfig() *Config {
|
||||
config := CreateConfig()
|
||||
config.ProviderURL = "https://test-provider.com"
|
||||
config.ClientID = "test-client-id"
|
||||
config.ClientSecret = "test-client-secret"
|
||||
config.SessionEncryptionKey = "test-encryption-key-32-characters"
|
||||
config.CallbackURL = "/oauth2/callback"
|
||||
return config
|
||||
}
|
||||
*/
|
||||
|
||||
// setupTestOIDCMiddleware creates a test OIDC middleware instance with mock servers
|
||||
// nolint:unused // Kept for potential future use in integration tests
|
||||
/*
|
||||
func setupTestOIDCMiddleware(t *testing.T, config *Config) (*TraefikOidc, *httptest.Server) {
|
||||
// Reset global state to ensure test isolation
|
||||
resetGlobalState()
|
||||
|
||||
// Create mock OIDC server
|
||||
var serverURL string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"issuer": serverURL,
|
||||
"authorization_endpoint": serverURL + "/auth",
|
||||
"token_endpoint": serverURL + "/token",
|
||||
"userinfo_endpoint": serverURL + "/userinfo",
|
||||
"jwks_uri": serverURL + "/keys",
|
||||
"revocation_endpoint": serverURL + "/revoke",
|
||||
})
|
||||
case "/keys":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"keys": [{
|
||||
"kty": "RSA",
|
||||
"kid": "test-key-id",
|
||||
"use": "sig",
|
||||
"n": "test-n-value",
|
||||
"e": "AQAB"
|
||||
}]
|
||||
}`))
|
||||
case "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"access_token": "test-access-token",
|
||||
"id_token": "` + ValidIDToken + `",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600
|
||||
}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
serverURL = server.URL
|
||||
|
||||
// Create middleware bypassing validation like main tests do
|
||||
// Create a logger that outputs to test log
|
||||
logger := &Logger{
|
||||
logError: log.New(&testWriter{t}, "ERROR: ", 0),
|
||||
logInfo: log.New(&testWriter{t}, "INFO: ", 0),
|
||||
logDebug: log.New(&testWriter{t}, "DEBUG: ", 0),
|
||||
}
|
||||
sessionManager, _ := NewSessionManager(config.SessionEncryptionKey, false, "", logger)
|
||||
|
||||
// Create next handler
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Set default paths
|
||||
callbackPath := config.CallbackURL
|
||||
if callbackPath == "" {
|
||||
callbackPath = "/oauth2/callback"
|
||||
}
|
||||
logoutPath := config.LogoutURL
|
||||
if logoutPath == "" {
|
||||
logoutPath = callbackPath + "/logout"
|
||||
}
|
||||
|
||||
// Set default post logout redirect URI to match the actual implementation
|
||||
postLogoutRedirectURI := config.PostLogoutRedirectURI
|
||||
if postLogoutRedirectURI == "" {
|
||||
postLogoutRedirectURI = "/" // Default to root path like the actual implementation
|
||||
}
|
||||
|
||||
// Use test URLs that won't be blocked by validation
|
||||
testIssuerURL := "https://test-provider.example.com"
|
||||
testAuthURL := testIssuerURL + "/auth"
|
||||
testTokenURL := testIssuerURL + "/token"
|
||||
testJWKSURL := testIssuerURL + "/keys"
|
||||
|
||||
// Create WaitGroup for background goroutines
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Create context with cancel for proper cleanup
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create TraefikOidc instance directly
|
||||
oidc := &TraefikOidc{
|
||||
next: nextHandler,
|
||||
issuerURL: testIssuerURL,
|
||||
clientID: config.ClientID,
|
||||
clientSecret: config.ClientSecret,
|
||||
redirURLPath: callbackPath,
|
||||
logoutURLPath: logoutPath,
|
||||
postLogoutRedirectURI: postLogoutRedirectURI,
|
||||
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
||||
tokenBlacklist: NewCache(),
|
||||
tokenCache: NewTokenCache(),
|
||||
logger: logger,
|
||||
excludedURLs: make(map[string]struct{}),
|
||||
httpClient: &http.Client{},
|
||||
authURL: testAuthURL,
|
||||
tokenURL: testTokenURL,
|
||||
jwksURL: testJWKSURL,
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: sessionManager,
|
||||
extractClaimsFunc: extractClaims,
|
||||
enablePKCE: config.EnablePKCE,
|
||||
refreshGracePeriod: time.Duration(config.RefreshGracePeriodSeconds) * time.Second,
|
||||
revocationURL: config.RevocationURL,
|
||||
endSessionURL: config.OIDCEndSessionURL,
|
||||
scopes: config.Scopes,
|
||||
forceHTTPS: config.ForceHTTPS,
|
||||
allowedUserDomains: make(map[string]struct{}),
|
||||
jwkCache: NewJWKCache(),
|
||||
metadataCache: NewMetadataCache(nil),
|
||||
ctx: ctx,
|
||||
cancelFunc: cancel,
|
||||
goroutineWG: &wg,
|
||||
providerURL: serverURL,
|
||||
}
|
||||
|
||||
// Process excluded URLs
|
||||
for _, url := range config.ExcludedURLs {
|
||||
oidc.excludedURLs[url] = struct{}{}
|
||||
}
|
||||
|
||||
// Set default excluded URLs
|
||||
oidc.excludedURLs["/favicon"] = struct{}{}
|
||||
oidc.excludedURLs["/favicon.ico"] = struct{}{}
|
||||
|
||||
// Close init channel
|
||||
close(oidc.initComplete)
|
||||
|
||||
// Set verifiers
|
||||
oidc.tokenVerifier = oidc
|
||||
oidc.jwtVerifier = oidc
|
||||
oidc.tokenExchanger = oidc // Set tokenExchanger to self
|
||||
|
||||
// Set default refresh grace period if not set or negative
|
||||
if config.RefreshGracePeriodSeconds <= 0 {
|
||||
oidc.refreshGracePeriod = 60 * time.Second
|
||||
}
|
||||
|
||||
// Set authentication initiation function
|
||||
oidc.initiateAuthenticationFunc = func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
||||
// Generate CSRF token and nonce
|
||||
csrfToken := uuid.NewString()
|
||||
nonce := uuid.NewString()
|
||||
|
||||
// Store in session
|
||||
session.SetCSRF(csrfToken)
|
||||
session.SetNonce(nonce)
|
||||
|
||||
// Store the original path
|
||||
session.SetIncomingPath(req.URL.RequestURI())
|
||||
|
||||
// Handle PKCE if enabled
|
||||
var codeChallenge string
|
||||
if oidc.enablePKCE {
|
||||
verifier, _ := generateCodeVerifier()
|
||||
session.SetCodeVerifier(verifier)
|
||||
codeChallenge = deriveCodeChallenge(verifier)
|
||||
}
|
||||
|
||||
// Save session
|
||||
session.Save(req, rw)
|
||||
|
||||
// Build auth URL
|
||||
authURL := oidc.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
|
||||
|
||||
// Redirect
|
||||
http.Redirect(rw, req, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// Set scopes if not set
|
||||
if len(oidc.scopes) == 0 {
|
||||
oidc.scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
return oidc, server
|
||||
}
|
||||
*/
|
||||
|
||||
// createMockJWT creates a mock JWT token for testing - adapter for existing tests
|
||||
// nolint:unused // Kept for potential future use in integration tests
|
||||
/*
|
||||
func createMockJWT(t *testing.T, sub, email string) string {
|
||||
return ValidIDToken
|
||||
}
|
||||
*/
|
||||
|
||||
// createTestSession creates a properly initialized SessionData for testing
|
||||
func createTestSession() *SessionData {
|
||||
// Create a minimal session manager for testing
|
||||
logger := newNoOpLogger()
|
||||
sessionManager, _ := NewSessionManager("test-encryption-key-32-characters", false, "", logger)
|
||||
|
||||
// Create a test request
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
// Get a session from the manager
|
||||
session, _ := sessionManager.GetSession(req)
|
||||
return session
|
||||
}
|
||||
|
||||
// injectSessionIntoRequest saves the session and adds the resulting cookies to the request
|
||||
// nolint:unused // Kept for potential future use in integration tests
|
||||
/*
|
||||
func injectSessionIntoRequest(t *testing.T, req *http.Request, session *SessionData) {
|
||||
// Create a response recorder to capture cookies
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// Save the session (this sets cookies)
|
||||
if err := session.Save(req, rec); err != nil {
|
||||
t.Fatalf("Failed to save session: %v", err)
|
||||
}
|
||||
|
||||
// Add the cookies to the request
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
*/
|
||||
Reference in New Issue
Block a user