Fix bug affecting Azure OIDC authentication ( and most likely others )

This commit is contained in:
2025-06-17 20:21:36 +01:00
parent efa0cd708b
commit cd307f88a1
3 changed files with 631 additions and 138 deletions
+387
View File
@@ -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": "[email protected]",
}
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": "[email protected]",
}
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": "[email protected]",
}
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": "[email protected]",
}
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": "[email protected]",
}
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("[email protected]")
// 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": "[email protected]",
}
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": "[email protected]",
}
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": "[email protected]",
}
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("[email protected]")
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": "[email protected]",
}
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": "[email protected]",
}
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
}
+239 -138
View File
@@ -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")
+5
View File
@@ -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