diff --git a/azure_oidc_test.go b/azure_oidc_test.go new file mode 100644 index 0000000..d11468e --- /dev/null +++ b/azure_oidc_test.go @@ -0,0 +1,387 @@ +package traefikoidc + +import ( + "net/http/httptest" + "strings" + "testing" + "time" + + "golang.org/x/time/rate" +) + +// mockTraefikOidc extends TraefikOidc to override JWT verification for testing +type mockTraefikOidc struct { + *TraefikOidc +} + +// Override VerifyToken to avoid JWKS lookup in tests +func (m *mockTraefikOidc) VerifyToken(token string) error { + // Cache test claims to avoid "claims not found" errors + testClaims := map[string]interface{}{ + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "iat": float64(time.Now().Unix()), + "sub": "test-user", + "email": "test@example.com", + } + m.tokenCache.Set(token, testClaims, time.Hour) + return nil // Always succeed for testing +} + +// Override VerifyJWTSignatureAndClaims to avoid JWKS lookup in tests +func (m *mockTraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error { + // Cache test claims to avoid "claims not found" errors + testClaims := map[string]interface{}{ + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "iat": float64(time.Now().Unix()), + "sub": "test-user", + "email": "test@example.com", + } + m.tokenCache.Set(token, testClaims, time.Hour) + return nil // Always succeed for testing +} + +func TestAzureOIDCRegression(t *testing.T) { + // Create a mocked TraefikOidc instance configured for Azure AD + mockLogger := NewLogger("debug") + + // Configure for Azure AD provider + baseOidc := &TraefikOidc{ + issuerURL: "https://login.microsoftonline.com/tenant-id/v2.0", + authURL: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize", + tokenURL: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token", + jwksURL: "https://login.microsoftonline.com/tenant-id/discovery/v2.0/keys", + clientID: "test-client-id", + clientSecret: "test-client-secret", + scopes: []string{"openid", "profile", "email"}, + refreshGracePeriod: 60 * time.Second, + limiter: rate.NewLimiter(rate.Every(time.Second), 100), // Add rate limiter + logger: mockLogger, + httpClient: createDefaultHTTPClient(), // Add HTTP client + jwkCache: &JWKCache{}, // Add JWK cache + tokenCache: NewTokenCache(), + tokenBlacklist: NewCache(), + allowedUserDomains: make(map[string]struct{}), + allowedUsers: make(map[string]struct{}), + allowedRolesAndGroups: make(map[string]struct{}), + excludedURLs: make(map[string]struct{}), + extractClaimsFunc: extractClaims, + } + + // Create the mock wrapper + tOidc := &mockTraefikOidc{TraefikOidc: baseOidc} + + // Initialize session manager + sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, mockLogger) + tOidc.sessionManager = sessionManager + + // Mock the JWT verification to avoid JWKS lookup issues + tOidc.tokenVerifier = &mockTokenVerifier{ + verifyFunc: func(token string) error { + // For test tokens, always return success and cache claims + if strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") { + // Cache test claims for JWT tokens + testClaims := map[string]interface{}{ + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "iat": float64(time.Now().Unix()), + "sub": "test-user", + "email": "test@example.com", + } + tOidc.tokenCache.Set(token, testClaims, time.Hour) + return nil + } + // For opaque tokens (non-JWT format), return success + if !strings.Contains(token, ".") || strings.Count(token, ".") != 2 { + return nil + } + // For JWT tokens, cache basic claims to avoid cache lookup issues + testClaims := map[string]interface{}{ + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "iat": float64(time.Now().Unix()), + "sub": "test-user", + "email": "test@example.com", + } + tOidc.tokenCache.Set(token, testClaims, time.Hour) + return nil // Always succeed for test purposes + }, + } + + // Mock JWT verifier to avoid JWKS lookup + tOidc.jwtVerifier = &mockJWTVerifier{ + verifyFunc: func(jwt *JWT, token string) error { + // Also cache claims here to ensure they're available + testClaims := map[string]interface{}{ + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "iat": float64(time.Now().Unix()), + "sub": "test-user", + "email": "test@example.com", + } + tOidc.tokenCache.Set(token, testClaims, time.Hour) + return nil // Always succeed + }, + } + + t.Run("Azure provider detection works correctly", func(t *testing.T) { + if !tOidc.isAzureProvider() { + t.Error("Azure provider should be detected for Azure AD issuer URL") + } + + if tOidc.isGoogleProvider() { + t.Error("Google provider should not be detected for Azure AD issuer URL") + } + }) + + t.Run("Azure auth URL includes correct parameters", func(t *testing.T) { + authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "") + + // Check that response_mode=query was added for Azure + if !strings.Contains(authURL, "response_mode=query") { + t.Errorf("response_mode=query not added to Azure auth URL: %s", authURL) + } + + // Verify offline_access scope is included for Azure providers + if !strings.Contains(authURL, "offline_access") { + t.Errorf("offline_access scope not included in Azure auth URL: %s", authURL) + } + + // Verify Azure doesn't get Google-specific parameters + if strings.Contains(authURL, "access_type=offline") { + t.Errorf("access_type=offline incorrectly added to Azure auth URL: %s", authURL) + } + + if strings.Contains(authURL, "prompt=consent") { + t.Errorf("prompt=consent incorrectly added to Azure auth URL: %s", authURL) + } + }) + + t.Run("Azure access token validation takes priority", func(t *testing.T) { + // Create a request and session + req := httptest.NewRequest("GET", "/protected", nil) + session, _ := tOidc.sessionManager.GetSession(req) + + // Set up session with Azure-style tokens + session.SetAuthenticated(true) + session.SetEmail("user@example.com") + + // Create a valid JWT access token for testing + accessTokenClaims := map[string]interface{}{ + "iss": "https://login.microsoftonline.com/tenant-id/v2.0", + "aud": "test-client-id", + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + "sub": "user123", + "email": "user@example.com", + } + accessToken, _ := createMockJWT(accessTokenClaims) + session.SetAccessToken(accessToken) + + // Create an invalid/expired ID token + idTokenClaims := map[string]interface{}{ + "iss": "https://login.microsoftonline.com/tenant-id/v2.0", + "aud": "test-client-id", + "exp": time.Now().Add(-1 * time.Hour).Unix(), // Expired + "iat": time.Now().Add(-2 * time.Hour).Unix(), + "sub": "user123", + "email": "user@example.com", + } + idToken, _ := createMockJWT(idTokenClaims) + session.SetIDToken(idToken) + + // Mock the token verification to simulate Azure behavior + originalTokenVerifier := tOidc.tokenVerifier + tOidc.tokenVerifier = &mockTokenVerifier{ + verifyFunc: func(token string) error { + if token == accessToken { + // Access token validation succeeds - cache claims + testClaims := map[string]interface{}{ + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "iat": float64(time.Now().Unix()), + "sub": "test-user", + "email": "test@example.com", + } + tOidc.tokenCache.Set(token, testClaims, time.Hour) + return nil + } + if token == idToken { + // ID token validation fails (expired) - don't cache + return newMockError("token has expired") + } + return newMockError("token validation failed") + }, + } + defer func() { tOidc.tokenVerifier = originalTokenVerifier }() + + // Test Azure-specific validation + authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session) + + // Azure should prioritize access token, so even with expired ID token, + // user should still be authenticated since access token is valid + if !authenticated { + t.Error("Azure user should be authenticated when access token is valid, even if ID token is expired") + } + + if expired { + t.Error("Azure session should not be marked as expired when access token is valid") + } + + // May need refresh if we want to get a fresh ID token + if !needsRefresh { + t.Log("Azure session may not need immediate refresh if access token is still valid") + } + }) + + t.Run("Azure handles opaque access tokens gracefully", func(t *testing.T) { + // Create a request and session + req := httptest.NewRequest("GET", "/protected", nil) + session, _ := tOidc.sessionManager.GetSession(req) + + // Set up session with opaque access token (non-JWT) + session.SetAuthenticated(true) + session.SetEmail("user@example.com") + session.SetAccessToken("opaque-access-token-1234567890") // Not a JWT + + // Create a valid ID token for claims extraction + idTokenClaims := map[string]interface{}{ + "iss": "https://login.microsoftonline.com/tenant-id/v2.0", + "aud": "test-client-id", + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + "sub": "user123", + "email": "user@example.com", + } + idToken, _ := createMockJWT(idTokenClaims) + session.SetIDToken(idToken) + + // Mock the token verification + originalTokenVerifier := tOidc.tokenVerifier + tOidc.tokenVerifier = &mockTokenVerifier{ + verifyFunc: func(token string) error { + if token == idToken { + // ID token is valid - cache claims + testClaims := map[string]interface{}{ + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "iat": float64(time.Now().Unix()), + "sub": "test-user", + "email": "test@example.com", + } + tOidc.tokenCache.Set(token, testClaims, time.Hour) + return nil + } + return newMockError("token validation failed") + }, + } + defer func() { tOidc.tokenVerifier = originalTokenVerifier }() + + // Test Azure-specific validation with opaque token + authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session) + + // Azure should handle opaque access tokens gracefully + if !authenticated { + t.Error("Azure user should be authenticated with opaque access token") + } + + if expired { + t.Error("Azure session should not be expired with valid tokens") + } + + if needsRefresh { + t.Log("Azure session with opaque token may signal refresh to get JWT tokens") + } + }) + + t.Run("Azure CSRF handling during token validation failures", func(t *testing.T) { + // Create a request and session + req := httptest.NewRequest("GET", "/protected", nil) + rw := httptest.NewRecorder() + session, _ := tOidc.sessionManager.GetSession(req) + + // Set up session with CSRF token (simulating ongoing auth flow) + session.SetCSRF("test-csrf-token-123") + session.SetNonce("test-nonce-456") + session.SetAuthenticated(false) // Not yet authenticated + + // Save session to simulate real scenario + session.Save(req, rw) + + // Mock token verification to always fail (simulating Azure token issues) + originalTokenVerifier := tOidc.tokenVerifier + tOidc.tokenVerifier = &mockTokenVerifier{ + verifyFunc: func(token string) error { + return newMockError("azure token validation failed") + }, + } + defer func() { tOidc.tokenVerifier = originalTokenVerifier }() + + // Test that CSRF is preserved during Azure validation failures + authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session) + + // Should not be authenticated due to validation failure + if authenticated { + t.Error("Should not be authenticated when token validation fails") + } + + // Should be marked as expired since no tokens work + if !expired && !needsRefresh { + t.Error("Should be marked as needing refresh or expired when validation fails") + } + + // Verify CSRF token is still preserved in session + if session.GetCSRF() != "test-csrf-token-123" { + t.Error("CSRF token should be preserved during Azure token validation failures") + } + + if session.GetNonce() != "test-nonce-456" { + t.Error("Nonce should be preserved during Azure token validation failures") + } + }) +} + +// createMockJWT creates a basic JWT token for testing purposes +func createMockJWT(claims map[string]interface{}) (string, error) { + // Simple mock JWT - in real tests you'd use a proper JWT library + // For this test, we'll create a basic three-part token structure + header := "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0" // {"alg":"RS256","kid":"test-key-id","typ":"JWT"} + + // Create a simple payload with test claims + payload := "eyJpc3MiOiJ0ZXN0LWlzc3VlciIsImF1ZCI6InRlc3QtY2xpZW50LWlkIiwiZXhwIjoxNjM4MzYwMDAwLCJpYXQiOjE2MzgzNTY0MDAsInN1YiI6InVzZXIxMjMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ" // Basic claims + + signature := "test-signature" + + return header + "." + payload + "." + signature, nil +} + +// Mock error type for testing +type mockError struct { + message string +} + +func (e *mockError) Error() string { + return e.message +} + +func newMockError(message string) error { + return &mockError{message: message} +} + +// Mock token verifier for testing +type mockTokenVerifier struct { + verifyFunc func(token string) error +} + +func (m *mockTokenVerifier) VerifyToken(token string) error { + if m.verifyFunc != nil { + return m.verifyFunc(token) + } + return nil +} + +// Mock JWT verifier for testing +type mockJWTVerifier struct { + verifyFunc func(jwt *JWT, token string) error +} + +func (m *mockJWTVerifier) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error { + if m.verifyFunc != nil { + return m.verifyFunc(jwt, token) + } + return nil +} diff --git a/main.go b/main.go index 45302f1..e634466 100644 --- a/main.go +++ b/main.go @@ -1160,7 +1160,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, } // Verify ID token and claims - if err := t.VerifyToken(tokenResponse.IDToken); err != nil { + if err := t.verifyToken(tokenResponse.IDToken); err != nil { t.logger.Errorf("Failed to verify id_token during callback: %v", err) t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError) return @@ -1311,134 +1311,13 @@ func (t *TraefikOidc) determineHost(req *http.Request) string { // - needsRefresh (bool): True if the token is valid but nearing expiration (within refreshGracePeriod) OR if VerifyJWTSignatureAndClaims failed specifically due to expiration (meaning refresh might be possible). // - expired (bool): True if the session is unauthenticated, the token is missing, or the token verification failed for reasons other than nearing/actual expiration (e.g., invalid signature, invalid claims). func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) { - if !session.GetAuthenticated() { - t.logger.Debug("User is not authenticated according to session flag") - // Check if there's still a refresh token - if so, refresh might be possible - if session.GetRefreshToken() != "" { - t.logger.Debug("Session not authenticated, but refresh token exists. Signaling need for refresh.") - return false, true, false // Not authenticated, NeedsRefresh=true (to attempt recovery), Expired=false - } - return false, false, false // Not authenticated, no refresh token, definitely not expired (just unauth) + // Route to provider-specific validation logic + if t.isAzureProvider() { + return t.validateAzureTokens(session) + } else if t.isGoogleProvider() { + return t.validateGoogleTokens(session) } - - // Check for access token - may be opaque (non-JWT) - accessToken := session.GetAccessToken() - if accessToken == "" { - t.logger.Debug("Authenticated flag set, but no access token found in session") - if session.GetRefreshToken() != "" { - t.logger.Debug("Access token missing, but refresh token exists. Signaling need for refresh.") - return false, true, false // Not authenticated (no token), NeedsRefresh=true, Expired=false - } - return false, false, true // No access or refresh token, treat as expired - } - - // Check for ID token - needed for roles/groups and some claim validations - idToken := session.GetIDToken() - - // If we have an access token but no ID token, we might be using an opaque token - // In this case, consider the user authenticated if the session flag is set - if idToken == "" { - t.logger.Debug("Authenticated flag set with access token, but no ID token found in session (possibly opaque token)") - // Make sure session is marked as authenticated since we have a valid access token - session.SetAuthenticated(true) - - // Still try to refresh if possible to get a proper ID token - if session.GetRefreshToken() != "" { - t.logger.Debug("ID token missing but refresh token exists. Signaling conditional refresh to obtain ID token.") - return true, true, false // Authenticated=true (has access token), NeedsRefresh=true (to get ID token), Expired=false - } - // User is authenticated but without ID token claims - some features may be limited - return true, false, false - } - - // For ID token validation - only if we have an ID token - // Verify the token structure and signature - // ID Token parsing is now handled within VerifyToken. - // Call VerifyToken to ensure tokenCache is populated. - if err := t.VerifyToken(idToken); err != nil { - // Check if the error is specifically about expiration - if strings.Contains(err.Error(), "token has expired") { - t.logger.Debugf("ID token signature/claims valid but token expired, needs refresh") - // Token is expired but otherwise valid, signal for refresh - // Return authenticated=false because the current token is unusable - // NeedsRefresh is true only if a refresh token exists - if session.GetRefreshToken() != "" { - return false, true, false // Not authenticated (current token unusable), NeedsRefresh=true, Expired=false - } - return false, false, true // Expired ID token, no refresh token, treat as expired - } - - // Other verification error (signature, issuer, audience etc.) - t.logger.Errorf("ID token verification failed (non-expiration): %v", err) - // Check for refresh token before declaring fully expired - if session.GetRefreshToken() != "" { - t.logger.Debug("ID token verification failed, but refresh token exists. Signaling need for refresh.") - return false, true, false // Not authenticated (bad ID token), NeedsRefresh=true, Expired=false - } - return false, false, true // Token is invalid for other reasons, no refresh token, treat as expired/invalid session - } - - // If VerifyToken succeeded, claims are in the cache. - cachedClaims, found := t.tokenCache.Get(idToken) - if !found { - t.logger.Error("CRITICAL: Claims not found in cache after successful ID token verification by VerifyToken.") - // This state implies VerifyToken succeeded but didn't cache, or cache retrieval failed. - // Safest to try to refresh if possible, otherwise treat as an error. - if session.GetRefreshToken() != "" { - t.logger.Debug("Claims missing post-VerifyToken, attempting refresh to recover.") - return false, true, false // Not authenticated (missing claims), NeedsRefresh=true, Expired=false - } - return false, false, true // Cannot recover, treat as expired/invalid - } - claims := cachedClaims - - expClaim, ok := claims["exp"].(float64) - if !ok { - t.logger.Error("Failed to get expiration time ('exp' claim) from verified token") - // Check for refresh token before declaring fully expired - if session.GetRefreshToken() != "" { - t.logger.Debug("ID token missing 'exp' claim, but refresh token exists. Signaling need for refresh.") - return false, true, false // Not authenticated (bad ID token), NeedsRefresh=true, Expired=false - } - return false, false, true // Treat as invalid if 'exp' is missing and no refresh token - } - - expTime := int64(expClaim) - expTimeObj := time.Unix(expTime, 0) - nowObj := time.Now() - refreshThreshold := nowObj.Add(t.refreshGracePeriod) - - // Explicit logging for token expiration time - t.logger.Debugf("Token expires at %v, now is %v, refresh threshold is %v", - expTimeObj.Format(time.RFC3339), - nowObj.Format(time.RFC3339), - refreshThreshold.Format(time.RFC3339)) - - // Check if token is nearing expiration (needs refresh proactively) - // Only mark for refresh if within grace period - if expTimeObj.Before(refreshThreshold) { - // Recalculate remaining seconds for logging clarity if needed - remainingSeconds := int64(time.Until(expTimeObj).Seconds()) - t.logger.Debugf("ID token nearing expiration (expires in %d seconds, grace period %s), scheduling proactive refresh", - remainingSeconds, t.refreshGracePeriod) - - // Token is still valid, but we should refresh it soon - // NeedsRefresh is true only if a refresh token exists - if session.GetRefreshToken() != "" { - return true, true, false // Authenticated=true (current token usable), NeedsRefresh=true, Expired=false - } - - // If no refresh token, we can't proactively refresh, treat as normal valid token for now - t.logger.Debugf("Token nearing expiration but no refresh token available, cannot proactively refresh.") - return true, false, false - } - - // Token is valid and not nearing expiration - t.logger.Debugf("Token is valid and not nearing expiration (expires in %d seconds, outside %s grace period)", - int64(time.Until(expTimeObj).Seconds()), t.refreshGracePeriod) - - // Refresh token exists but we don't need to use it since token is still valid and outside grace period - return true, false, false // Authenticated=true, NeedsRefresh=false, Expired=false + return t.validateStandardTokens(session) } // defaultInitiateAuthentication handles the process of starting an OIDC authentication flow. @@ -1567,11 +1446,8 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri scopes := make([]string, len(t.scopes)) copy(scopes, t.scopes) - // Check if we're dealing with a Google OIDC provider - isGoogleProvider := strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com") - - // Handle offline access differently for Google vs other providers - if isGoogleProvider { + // Handle offline access differently for Google vs Azure vs other providers + if t.isGoogleProvider() { // For Google, use access_type=offline parameter instead of offline_access scope params.Set("access_type", "offline") t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens") @@ -1579,8 +1455,25 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri // Add prompt=consent for Google to ensure refresh token is issued params.Set("prompt", "consent") t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens") + } else if t.isAzureProvider() { + // For Azure AD, use offline_access scope and set response_mode + params.Set("response_mode", "query") + t.logger.Debug("Azure AD provider detected, added response_mode=query") + + hasOfflineAccess := false + for _, scope := range scopes { + if scope == "offline_access" { + hasOfflineAccess = true + break + } + } + + if !hasOfflineAccess { + scopes = append(scopes, "offline_access") + t.logger.Debug("Azure AD provider detected, added offline_access scope for refresh tokens") + } } else { - // For non-Google providers, use the offline_access scope + // For other providers, use the standard offline_access scope hasOfflineAccess := false for _, scope := range scopes { if scope == "offline_access" { @@ -1912,10 +1805,11 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se return false } - // Detect if we're using Google's OIDC provider - isGoogleProvider := strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com") - if isGoogleProvider { + // Detect provider type for token refresh operation + if t.isGoogleProvider() { t.logger.Debug("Google OIDC provider detected for token refresh operation") + } else if t.isAzureProvider() { + t.logger.Debug("Azure AD provider detected for token refresh operation") } // Log the attempt with a truncated token for security @@ -1942,7 +1836,7 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se } } else if strings.Contains(errMsg, "invalid_client") { t.logger.Errorf("Client credentials rejected: %v - check client_id and client_secret configuration", err) - } else if isGoogleProvider && strings.Contains(errMsg, "invalid_request") { + } else if t.isGoogleProvider() && strings.Contains(errMsg, "invalid_request") { t.logger.Errorf("Google OIDC provider error: %v - check scope configuration includes 'offline_access' and prompt=consent is used during authentication", err) } @@ -2276,6 +2170,213 @@ func (t *TraefikOidc) sendErrorResponse(rw http.ResponseWriter, req *http.Reques _, _ = rw.Write([]byte(htmlBody)) // Ignore write error as header is already sent } +// isGoogleProvider checks if the current OIDC provider is Google +func (t *TraefikOidc) isGoogleProvider() bool { + return strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com") +} + +// isAzureProvider checks if the current OIDC provider is Azure AD +func (t *TraefikOidc) isAzureProvider() bool { + return strings.Contains(t.issuerURL, "login.microsoftonline.com") || + strings.Contains(t.issuerURL, "sts.windows.net") || + strings.Contains(t.issuerURL, "login.windows.net") +} + +// validateAzureTokens handles Azure AD-specific token validation logic +// Azure AD tokens may have different characteristics, so we validate access token first +func (t *TraefikOidc) validateAzureTokens(session *SessionData) (bool, bool, bool) { + if !session.GetAuthenticated() { + t.logger.Debug("Azure user is not authenticated according to session flag") + // Check if there's still a refresh token - if so, refresh might be possible + if session.GetRefreshToken() != "" { + t.logger.Debug("Azure session not authenticated, but refresh token exists. Signaling need for refresh.") + return false, true, false // Not authenticated, NeedsRefresh=true, Expired=false + } + return false, true, false // Not authenticated, no refresh token + } + + // For Azure, prioritize access token validation + accessToken := session.GetAccessToken() + idToken := session.GetIDToken() + + // If we have an access token, validate it first (Azure's preferred approach) + if accessToken != "" { + // Check if the access token is JWT format (contains two dots) + if strings.Count(accessToken, ".") == 2 { + if err := t.verifyToken(accessToken); err != nil { + // Access token validation failed, check if we have ID token as fallback + if idToken != "" { + if err := t.verifyToken(idToken); err != nil { + t.logger.Debugf("Azure: Both access and ID token validation failed: %v", err) + if session.GetRefreshToken() != "" { + return false, true, false // Failed validation, but can refresh + } + return false, false, true // Failed validation, no refresh token + } + // ID token is valid, continue with ID token validation logic + return t.validateTokenExpiry(session, idToken) + } + // No ID token fallback available + if session.GetRefreshToken() != "" { + return false, true, false // Failed validation, but can refresh + } + return false, false, true // Failed validation, no refresh token + } + // Access token is valid, check expiry with grace period + return t.validateTokenExpiry(session, accessToken) + } else { + // Access token appears opaque, assume it's valid if session is authenticated + t.logger.Debug("Azure access token appears opaque, treating as valid") + // Still validate ID token if available for proper expiry checking + if idToken != "" { + return t.validateTokenExpiry(session, idToken) + } + // No ID token, but access token exists and session is authenticated + return true, false, false + } + } + + // No access token, fall back to ID token validation (same as standard flow) + if idToken != "" { + if err := t.verifyToken(idToken); err != nil { + if strings.Contains(err.Error(), "token has expired") { + if session.GetRefreshToken() != "" { + return false, true, false // Expired but can refresh + } + return false, false, true // Expired, no refresh token + } + // Other verification error + if session.GetRefreshToken() != "" { + return false, true, false // Failed validation, but can refresh + } + return false, false, true // Failed validation, no refresh token + } + return t.validateTokenExpiry(session, idToken) + } + + // No tokens available + if session.GetRefreshToken() != "" { + return false, true, false // No tokens, but can refresh + } + return false, false, true // No tokens, no refresh token +} + +// validateGoogleTokens handles Google-specific token validation logic (existing behavior) +func (t *TraefikOidc) validateGoogleTokens(session *SessionData) (bool, bool, bool) { + // Use the existing ID token-first validation logic for Google + return t.validateStandardTokens(session) +} + +// validateStandardTokens handles standard OIDC token validation (existing logic) +func (t *TraefikOidc) validateStandardTokens(session *SessionData) (bool, bool, bool) { + if !session.GetAuthenticated() { + t.logger.Debug("User is not authenticated according to session flag") + if session.GetRefreshToken() != "" { + t.logger.Debug("Session not authenticated, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, false, false + } + + // Check for access token - may be opaque (non-JWT) + accessToken := session.GetAccessToken() + if accessToken == "" { + t.logger.Debug("Authenticated flag set, but no access token found in session") + if session.GetRefreshToken() != "" { + t.logger.Debug("Access token missing, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, false, true + } + + // Check for ID token - needed for roles/groups and some claim validations + idToken := session.GetIDToken() + if idToken == "" { + t.logger.Debug("Authenticated flag set with access token, but no ID token found in session (possibly opaque token)") + session.SetAuthenticated(true) + + if session.GetRefreshToken() != "" { + t.logger.Debug("ID token missing but refresh token exists. Signaling conditional refresh to obtain ID token.") + return true, true, false + } + return true, false, false + } + + // For ID token validation + if err := t.verifyToken(idToken); err != nil { + if strings.Contains(err.Error(), "token has expired") { + t.logger.Debugf("ID token signature/claims valid but token expired, needs refresh") + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true + } + + t.logger.Errorf("ID token verification failed (non-expiration): %v", err) + if session.GetRefreshToken() != "" { + t.logger.Debug("ID token verification failed, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, false, true + } + + return t.validateTokenExpiry(session, idToken) +} + +// validateTokenExpiry checks if a token is nearing expiration and needs refresh +func (t *TraefikOidc) validateTokenExpiry(session *SessionData, token string) (bool, bool, bool) { + // Get cached claims from verified token + cachedClaims, found := t.tokenCache.Get(token) + if !found { + t.logger.Error("CRITICAL: Claims not found in cache after successful token verification.") + if session.GetRefreshToken() != "" { + t.logger.Debug("Claims missing post-verification, attempting refresh to recover.") + return false, true, false + } + return false, false, true + } + + expClaim, ok := cachedClaims["exp"].(float64) + if !ok { + t.logger.Error("Failed to get expiration time ('exp' claim) from verified token") + if session.GetRefreshToken() != "" { + t.logger.Debug("Token missing 'exp' claim, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, false, true + } + + expTime := int64(expClaim) + expTimeObj := time.Unix(expTime, 0) + nowObj := time.Now() + refreshThreshold := nowObj.Add(t.refreshGracePeriod) + + t.logger.Debugf("Token expires at %v, now is %v, refresh threshold is %v", + expTimeObj.Format(time.RFC3339), + nowObj.Format(time.RFC3339), + refreshThreshold.Format(time.RFC3339)) + + // Check if token is nearing expiration + if expTimeObj.Before(refreshThreshold) { + remainingSeconds := int64(time.Until(expTimeObj).Seconds()) + t.logger.Debugf("Token nearing expiration (expires in %d seconds, grace period %s), scheduling proactive refresh", + remainingSeconds, t.refreshGracePeriod) + + if session.GetRefreshToken() != "" { + return true, true, false // Authenticated, NeedsRefresh, not Expired + } + + t.logger.Debugf("Token nearing expiration but no refresh token available, cannot proactively refresh.") + return true, false, false + } + + // Token is valid and not nearing expiration + t.logger.Debugf("Token is valid and not nearing expiration (expires in %d seconds, outside %s grace period)", + int64(time.Until(expTimeObj).Seconds()), t.refreshGracePeriod) + + return true, false, false // Authenticated, no refresh needed, not expired +} + // Close stops all background goroutines and closes resources with proper timeout. func (t *TraefikOidc) Close() error { t.logger.Debug("Closing TraefikOidc plugin instance") diff --git a/security_edge_cases_test.go b/security_edge_cases_test.go index e10c39c..ab047d2 100644 --- a/security_edge_cases_test.go +++ b/security_edge_cases_test.go @@ -529,6 +529,11 @@ func TestSessionFixationAttack(t *testing.T) { initComplete: make(chan struct{}), sessionManager: sm, } + + // Set up the token verifier and JWT verifier + tOidc.jwtVerifier = tOidc + tOidc.tokenVerifier = tOidc + close(tOidc.initComplete) // Now create a victim's request with the attacker's cookies