mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
9cbca4c4fb
patch-release
The refresh path in token_manager.go hardcoded the "email" claim when
extracting the user identifier from a refreshed ID token, ignoring the
configured userIdentifierClaim. Keycloak users without an email claim
(using sub or another identifier) were kicked out on refresh even
though their initial login worked.
The callback path (auth_flow.go:226-239) already honored
userIdentifierClaim with "sub" fallback; PR #100 (commit a316a98)
added that support but missed the refresh path.
Mirror the callback logic in refreshToken so both paths behave the same.
Cleanup: rename Get/SetEmail to Get/SetUserIdentifier on SessionData
to match the actual semantics. The slot already stored the configured
identifier (email, sub, oid, upn, preferred_username), only the API
name was misleading. Storage key "email" → "user_identifier" and
combinedSessionPayload field E (json:"e") → Ui (json:"ui").
Compat note: existing user sessions invalidate on upgrade — every active
user re-authenticates once after deploying this change.
403 lines
13 KiB
Go
403 lines
13 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestMiddlewareContextCancellation tests request context cancellation
|
|
func TestMiddlewareContextCancellation(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
initComplete: make(chan struct{}), // Never close to simulate waiting
|
|
sessionManager: createTestSessionManager(t),
|
|
firstRequestReceived: true,
|
|
metadataRefreshStarted: true,
|
|
}
|
|
|
|
// Create request with canceled context
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil).WithContext(ctx)
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
// Should return timeout/cancel error
|
|
if rw.Code != http.StatusRequestTimeout && rw.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("Expected timeout status for canceled context, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareSessionErrorRecovery tests session error recovery
|
|
func TestMiddlewareSessionErrorRecovery(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
logger: NewLogger("debug"),
|
|
initComplete: make(chan struct{}),
|
|
sessionManager: createTestSessionManager(t),
|
|
firstRequestReceived: true,
|
|
metadataRefreshStarted: true,
|
|
issuerURL: "https://provider.example.com",
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/logout",
|
|
clientID: "test-client",
|
|
audience: "test-client",
|
|
authURL: "https://provider.example.com/auth",
|
|
}
|
|
close(oidc.initComplete)
|
|
|
|
// Create request with corrupted session cookie
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "_oidc_session",
|
|
Value: "corrupted!!!invalid!!!",
|
|
})
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
// Should handle gracefully and initiate auth
|
|
if rw.Code != http.StatusFound && rw.Code != http.StatusSeeOther {
|
|
t.Errorf("Expected redirect for corrupted session, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareAJAXRequestHandling tests AJAX-specific request handling
|
|
func TestMiddlewareAJAXRequestHandling(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
logger: NewLogger("debug"),
|
|
initComplete: make(chan struct{}),
|
|
sessionManager: createTestSessionManager(t),
|
|
firstRequestReceived: true,
|
|
metadataRefreshStarted: true,
|
|
issuerURL: "https://provider.example.com",
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/logout",
|
|
clientID: "test-client",
|
|
audience: "test-client",
|
|
}
|
|
close(oidc.initComplete)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
// AJAX request without auth should get 401, not redirect
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Errorf("Expected 401 for unauthenticated AJAX request, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestLogoutWorksWithoutOIDCInitialization tests that logout works even if OIDC provider is unavailable
|
|
// This is critical for allowing users to clear their session when the provider is down
|
|
func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
initComplete: make(chan struct{}), // Never close to simulate provider unavailable
|
|
sessionManager: createTestSessionManager(t),
|
|
firstRequestReceived: true,
|
|
metadataRefreshStarted: true,
|
|
logoutURLPath: "/logout",
|
|
postLogoutRedirectURI: "/",
|
|
forceHTTPS: false,
|
|
}
|
|
// Note: initComplete is NOT closed, simulating OIDC provider being unavailable
|
|
|
|
req := httptest.NewRequest("GET", "/logout", nil)
|
|
req.Host = "example.com"
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
// Should redirect to post-logout URI even without OIDC initialization
|
|
if rw.Code != http.StatusFound {
|
|
t.Errorf("Expected redirect (302) for logout, got %d", rw.Code)
|
|
}
|
|
|
|
location := rw.Header().Get("Location")
|
|
if location == "" {
|
|
t.Error("Expected Location header for logout redirect")
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareDomainRestrictions tests domain-based access control
|
|
// NOTE: Currently commented out due to complex session setup requirements
|
|
// These scenarios are tested indirectly through integration tests
|
|
/*
|
|
func TestMiddlewareDomainRestrictions(t *testing.T) {
|
|
sessionManager := createTestSessionManager(t)
|
|
|
|
t.Run("allowed_domain_passes", func(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
logger: NewLogger("debug"),
|
|
initComplete: make(chan struct{}),
|
|
sessionManager: sessionManager,
|
|
firstRequestReceived: true,
|
|
metadataRefreshStarted: true,
|
|
issuerURL: "https://provider.example.com",
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/logout",
|
|
clientID: "test-client",
|
|
audience: "test-client",
|
|
allowedUserDomains: map[string]struct{}{
|
|
"example.com": {},
|
|
},
|
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
|
return map[string]interface{}{"email": "user@example.com"}, nil
|
|
},
|
|
}
|
|
close(oidc.initComplete)
|
|
|
|
// Create authenticated session
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
session, _ := sessionManager.GetSession(req)
|
|
session.SetUserIdentifier("user@example.com")
|
|
session.SetAuthenticated(true)
|
|
session.SetIDToken("dummy-token")
|
|
session.Save(req, httptest.NewRecorder())
|
|
|
|
// Add session cookies to request
|
|
rw := httptest.NewRecorder()
|
|
session.Save(req, rw)
|
|
for _, cookie := range rw.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
rw = httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected 200 for allowed domain, got %d", rw.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("forbidden_domain_blocked", func(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
logger: NewLogger("debug"),
|
|
initComplete: make(chan struct{}),
|
|
sessionManager: sessionManager,
|
|
firstRequestReceived: true,
|
|
metadataRefreshStarted: true,
|
|
issuerURL: "https://provider.example.com",
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/logout",
|
|
clientID: "test-client",
|
|
audience: "test-client",
|
|
allowedUserDomains: map[string]struct{}{
|
|
"example.com": {},
|
|
},
|
|
}
|
|
close(oidc.initComplete)
|
|
|
|
// Create session with forbidden domain
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
session, _ := sessionManager.GetSession(req)
|
|
session.SetUserIdentifier("user@forbidden.com")
|
|
session.SetAuthenticated(true)
|
|
|
|
// Save and inject cookies
|
|
rw := httptest.NewRecorder()
|
|
session.Save(req, rw)
|
|
for _, cookie := range rw.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
rw = httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusForbidden {
|
|
t.Errorf("Expected 403 for forbidden domain, got %d", rw.Code)
|
|
}
|
|
})
|
|
}
|
|
*/
|
|
|
|
// TestMiddlewareOpaqueTokenHandling tests opaque (non-JWT) token handling
|
|
// NOTE: Currently commented out due to complex session setup requirements
|
|
/*
|
|
func TestMiddlewareOpaqueTokenHandling(t *testing.T) {
|
|
sessionManager := createTestSessionManager(t)
|
|
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
logger: NewLogger("debug"),
|
|
initComplete: make(chan struct{}),
|
|
sessionManager: sessionManager,
|
|
firstRequestReceived: true,
|
|
metadataRefreshStarted: true,
|
|
issuerURL: "https://provider.example.com",
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/logout",
|
|
clientID: "test-client",
|
|
audience: "test-client",
|
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
|
return map[string]interface{}{"email": "user@example.com"}, nil
|
|
},
|
|
}
|
|
close(oidc.initComplete)
|
|
|
|
// Create session with opaque token
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
session, _ := sessionManager.GetSession(req)
|
|
session.SetUserIdentifier("user@example.com")
|
|
session.SetAccessToken("sk_live_abcdefghijklmnopqrstuvwxyz") // Opaque token (no dots)
|
|
session.SetAuthenticated(true)
|
|
|
|
// Save and inject cookies
|
|
rw := httptest.NewRecorder()
|
|
session.Save(req, rw)
|
|
for _, cookie := range rw.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
rw = httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
// Should process successfully without JWT verification
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected 200 for opaque token, got %d", rw.Code)
|
|
}
|
|
}
|
|
*/
|
|
|
|
// TestMiddlewareProcessAuthorizedRequestEdgeCases tests processAuthorizedRequest edge cases
|
|
func TestMiddlewareProcessAuthorizedRequestEdgeCases(t *testing.T) {
|
|
sessionManager := createTestSessionManager(t)
|
|
|
|
t.Run("missing_email_initiates_reauth", func(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
logger: NewLogger("debug"),
|
|
sessionManager: sessionManager,
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/logout",
|
|
clientID: "test-client",
|
|
audience: "test-client",
|
|
authURL: "https://provider.example.com/auth",
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
session, _ := sessionManager.GetSession(req)
|
|
session.SetUserIdentifier("") // No email
|
|
session.SetIDToken("dummy-token")
|
|
|
|
rw := httptest.NewRecorder()
|
|
redirectURL := "https://example.com/callback"
|
|
oidc.processAuthorizedRequest(rw, req, session, redirectURL)
|
|
|
|
// Should initiate re-auth
|
|
if rw.Code != http.StatusFound && rw.Code != http.StatusSeeOther {
|
|
t.Errorf("Expected redirect when email is missing, got %d", rw.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("missing_token_with_role_checks", func(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
logger: NewLogger("debug"),
|
|
sessionManager: sessionManager,
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/logout",
|
|
clientID: "test-client",
|
|
audience: "test-client",
|
|
authURL: "https://provider.example.com/auth",
|
|
allowedRolesAndGroups: map[string]struct{}{
|
|
"admin": {},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
session, _ := sessionManager.GetSession(req)
|
|
session.SetUserIdentifier("user@example.com")
|
|
session.SetIDToken("") // No ID token
|
|
session.SetAccessToken("") // No access token
|
|
|
|
rw := httptest.NewRecorder()
|
|
redirectURL := "https://example.com/callback"
|
|
oidc.processAuthorizedRequest(rw, req, session, redirectURL)
|
|
|
|
// Should initiate re-auth when token is missing but role checks required
|
|
if rw.Code != http.StatusFound && rw.Code != http.StatusSeeOther {
|
|
t.Errorf("Expected redirect when token is missing with role checks, got %d", rw.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("security_headers_applied", func(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
logger: NewLogger("debug"),
|
|
sessionManager: sessionManager,
|
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
|
return map[string]interface{}{}, nil
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
session, _ := sessionManager.GetSession(req)
|
|
session.SetUserIdentifier("user@example.com")
|
|
session.SetIDToken("dummy-token")
|
|
|
|
rw := httptest.NewRecorder()
|
|
redirectURL := "https://example.com/callback"
|
|
oidc.processAuthorizedRequest(rw, req, session, redirectURL)
|
|
|
|
// Verify security headers are set
|
|
if rw.Header().Get("X-Frame-Options") == "" {
|
|
t.Error("Expected X-Frame-Options header to be set")
|
|
}
|
|
if rw.Header().Get("X-Content-Type-Options") == "" {
|
|
t.Error("Expected X-Content-Type-Options header to be set")
|
|
}
|
|
if rw.Header().Get("X-XSS-Protection") == "" {
|
|
t.Error("Expected X-XSS-Protection header to be set")
|
|
}
|
|
})
|
|
|
|
t.Run("authentication_headers_set", func(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
logger: NewLogger("debug"),
|
|
sessionManager: sessionManager,
|
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
|
return map[string]interface{}{}, nil
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
session, _ := sessionManager.GetSession(req)
|
|
testEmail := "user@example.com"
|
|
session.SetUserIdentifier(testEmail)
|
|
session.SetIDToken("dummy-id-token")
|
|
|
|
rw := httptest.NewRecorder()
|
|
redirectURL := "https://example.com/callback"
|
|
oidc.processAuthorizedRequest(rw, req, session, redirectURL)
|
|
|
|
// Verify authentication headers
|
|
if req.Header.Get("X-Forwarded-User") != testEmail {
|
|
t.Errorf("Expected X-Forwarded-User=%s, got %s", testEmail, req.Header.Get("X-Forwarded-User"))
|
|
}
|
|
if req.Header.Get("X-Auth-Request-User") != testEmail {
|
|
t.Errorf("Expected X-Auth-Request-User=%s, got %s", testEmail, req.Header.Get("X-Auth-Request-User"))
|
|
}
|
|
// Token header may not be set in all scenarios, just verify it's not causing errors
|
|
})
|
|
}
|