mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
bde1db1c3b
* Automatic discovery of the scopes. Issue #61 raised very valid concerns about users configuring scopes that are not supported by the provider. This change introduces automatic discovery of supported scopes by fetching the provider's discovery document and filtering out unsupported scopes. Before: User configures: scopes: ["openid", "profile", "email", "offline_access"] Self-hosted GitLab: "The requested scope is invalid, unknown, or malformed" Authentication: ❌ FAILS After: User configures: scopes: ["openid", "profile", "email", "offline_access"] Middleware checks discovery doc → offline_access not supported Automatically filters to: ["openid", "profile", "email"] Authentication: ✅ SUCCEEDS * Resolves issue #74 by enabling user to specify expected audience in the configuration. * Fix flaky tests.
381 lines
11 KiB
Go
381 lines
11 KiB
Go
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,
|
|
audience: 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)
|
|
}
|
|
}
|
|
*/
|