package traefikoidc import ( "context" "crypto/rand" "crypto/rsa" "encoding/base64" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "golang.org/x/time/rate" ) // ============================================================================= // AUDIENCE CONFIGURATION TESTS // ============================================================================= // TestAudienceConfiguration tests the custom audience configuration feature func TestAudienceConfiguration(t *testing.T) { tests := []struct { name string configAudience string clientID string expectedAudience string }{ { name: "no custom audience - uses clientID", configAudience: "", clientID: "test-client-id", expectedAudience: "test-client-id", }, { name: "custom audience specified", configAudience: "api://custom-audience", clientID: "test-client-id", expectedAudience: "api://custom-audience", }, { name: "auth0 style custom audience", configAudience: "https://api.example.com", clientID: "test-client-id", expectedAudience: "https://api.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create config with custom audience config := CreateConfig() config.ProviderURL = "https://provider.example.com" config.ClientID = tt.clientID config.ClientSecret = "test-secret" config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" config.CallbackURL = "/callback" config.Audience = tt.configAudience // Create middleware instance next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) traefikOidc, err := NewWithContext(context.Background(), config, next, "test") if err != nil { t.Fatalf("Failed to create middleware: %v", err) } // Verify audience is set correctly if traefikOidc.audience != tt.expectedAudience { t.Errorf("Expected audience %s, got %s", tt.expectedAudience, traefikOidc.audience) } // Cleanup _ = traefikOidc.Close() }) } } // TestAudienceValidation tests the audience validation in Config.Validate() func TestAudienceValidation(t *testing.T) { tests := []struct { name string audience string errorContains string expectError bool }{ { name: "valid custom audience URL", audience: "https://api.example.com", expectError: false, }, { name: "valid azure style audience", audience: "api://12345678-1234-1234-1234-123456789012", expectError: false, }, { name: "empty audience is valid (uses clientID)", audience: "", expectError: false, }, { name: "http URL not allowed", audience: "http://api.example.com", expectError: true, errorContains: "audience URL must use HTTPS", }, { name: "wildcard not allowed", audience: "https://*.example.com", expectError: true, errorContains: "audience must not contain wildcards", }, { name: "too long audience", audience: "https://" + string(make([]byte, 250)) + ".com", expectError: true, errorContains: "audience must not exceed 256 characters", }, { name: "invalid characters", audience: "api://test\ninjection", expectError: true, errorContains: "audience contains invalid characters", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := CreateConfig() config.ProviderURL = "https://provider.example.com" config.ClientID = "test-client" config.ClientSecret = "test-secret" config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" config.CallbackURL = "/callback" config.Audience = tt.audience err := config.Validate() if tt.expectError { if err == nil { t.Errorf("Expected error but got none") } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { t.Errorf("Expected error containing '%s', got: %v", tt.errorContains, err) } } else { if err != nil { t.Errorf("Unexpected error: %v", err) } } }) } } // ============================================================================= // CONFIG AUDIENCE VALIDATION TESTS // ============================================================================= // TestConfigAudienceValidation tests the Config.Validate() method for the audience field func TestConfigAudienceValidation(t *testing.T) { tests := []struct { name string audience string errContains string wantErr bool }{ { name: "Empty audience is valid for backward compatibility", audience: "", wantErr: false, }, { name: "Valid HTTPS URL audience Auth0 format", audience: "https://api.example.com", wantErr: false, }, { name: "Valid identifier audience", audience: "my-api", wantErr: false, }, { name: "Valid Azure AD Application ID URI format", audience: "api://12345-guid-67890", wantErr: false, }, { name: "Valid Auth0 API identifier", audience: "https://my-company.auth0.com/api/v2/", wantErr: false, }, { name: "HTTP URL audience should fail", audience: "http://api.example.com", wantErr: true, errContains: "must use HTTPS", }, { name: "Audience with wildcard should fail", audience: "https://api.*.example.com", wantErr: true, errContains: "must not contain wildcards", }, { name: "Audience with single asterisk should fail", audience: "*", wantErr: true, errContains: "must not contain wildcards", }, { name: "Audience over 256 characters should fail", audience: strings.Repeat("a", 257), wantErr: true, errContains: "must not exceed 256 characters", }, { name: "Audience with newline should fail", audience: "my-api\ninjection", wantErr: true, errContains: "contains invalid characters", }, { name: "Audience with carriage return should fail", audience: "my-api\rinjection", wantErr: true, errContains: "contains invalid characters", }, { name: "Audience with tab should fail", audience: "my-api\tinjection", wantErr: true, errContains: "contains invalid characters", }, { name: "Valid audience exactly 256 characters", audience: strings.Repeat("a", 256), wantErr: false, }, { name: "Valid simple identifier", audience: "my-service-api", wantErr: false, }, { name: "Valid URN format", audience: "urn:myservice:api:v1", wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := CreateConfig() config.ProviderURL = "https://provider.example.com" config.ClientID = "test-client-id" config.ClientSecret = "test-client-secret" config.CallbackURL = "/callback" config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength) config.Audience = tt.audience err := config.Validate() if (err != nil) != tt.wantErr { t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { t.Errorf("Error message should contain %q, got: %v", tt.errContains, err) } }) } } // ============================================================================= // AUTH0 SCENARIO TESTS // ============================================================================= // TestAuth0Scenario1WithCustomAudience tests Auth0 scenario 1: // - Custom audience configured in plugin // - Authorize endpoint called WITH audience parameter // - ID token: aud = client_id // - Access token: aud = [userinfo, custom_audience] // Expected: Both tokens validate correctly func TestAuth0Scenario1WithCustomAudience(t *testing.T) { ts := NewTestSuite(t) ts.Setup() customAudience := "https://my-api.example.com" ts.tOidc.audience = customAudience // Create ID token with aud = client_id (OIDC standard) idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": "test-client-id", // ID token always has client_id "nonce": "test-nonce-scenario1", // ID tokens have nonce per OIDC spec "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "email": "test@example.com", "jti": "id-token-jti", }) if err != nil { t.Fatalf("Failed to create ID token: %v", err) } // Create access token with aud = [userinfo, custom_audience] accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": []interface{}{ "https://test-issuer.com/userinfo", customAudience, // Custom API audience }, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "scope": "openid profile email read:data", // Access tokens have scope "jti": "access-token-jti", }) if err != nil { t.Fatalf("Failed to create access token: %v", err) } // Verify ID token validates against client_id cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(idToken) if err != nil { t.Errorf("ID token validation failed (should validate against client_id): %v", err) } // Verify access token validates against custom audience cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(accessToken) if err != nil { t.Errorf("Access token validation failed (should validate against custom audience): %v", err) } // Verify buildAuthURL includes audience parameter (URL-encoded) authURL := ts.tOidc.buildAuthURL("https://example.com/callback", "state", "nonce", "") if !strings.Contains(authURL, "audience=") { t.Errorf("Auth URL should contain audience parameter when custom audience is configured, got: %s", authURL) } // Verify the audience is properly URL-encoded (contains %3A for :, %2F for /) if !strings.Contains(authURL, "audience=https%3A%2F%2Fmy-api.example.com") { t.Errorf("Auth URL should contain URL-encoded custom audience, got: %s", authURL) } } // TestAuth0Scenario2DefaultAudience tests Auth0 scenario 2: // - No custom audience configured (defaults to client_id) // - Authorize endpoint called WITHOUT audience parameter // - ID token: aud = client_id // - Access token: aud = [userinfo, default_audience] (no client_id) // Expected: ID token validates, access token falls back to ID token validation func TestAuth0Scenario2DefaultAudience(t *testing.T) { ts := NewTestSuite(t) ts.Setup() // No custom audience - defaults to client_id ts.tOidc.audience = ts.tOidc.clientID // Create ID token with aud = client_id idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": "test-client-id", "nonce": "test-nonce-scenario2", // ID tokens have nonce per OIDC spec "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "email": "test@example.com", "jti": "id-token-jti-2", }) if err != nil { t.Fatalf("Failed to create ID token: %v", err) } // Create access token with aud = [userinfo, some_default_audience] // This represents Auth0's default audience behavior accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": []interface{}{ "https://test-issuer.com/userinfo", "https://test-issuer.com/api/v2/", // Default Auth0 Management API }, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "scope": "openid profile email", "jti": "access-token-jti-2", }) if err != nil { t.Fatalf("Failed to create access token: %v", err) } // Verify ID token validates cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(idToken) if err != nil { t.Errorf("ID token validation failed: %v", err) } // Access token won't have client_id in aud, so it will fail validation // This is expected for scenario 2 - the session validation relies on ID token cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(accessToken) if err == nil { t.Logf("Access token validation passed (unexpected but OK if client_id is in aud array)") } else { // Expected failure - access token doesn't have client_id in aud t.Logf("Access token validation failed as expected (aud doesn't contain client_id): %v", err) } // Verify buildAuthURL does NOT include audience parameter (since audience == client_id) authURL := ts.tOidc.buildAuthURL("https://example.com/callback", "state", "nonce", "") if strings.Contains(authURL, "audience=") { t.Errorf("Auth URL should NOT contain audience parameter when audience equals client_id, got: %s", authURL) } } // TestAuth0Scenario3OpaqueAccessToken tests Auth0 scenario 3: // - No custom audience configured // - No default audience in Auth0 // - ID token: aud = client_id // - Access token: opaque (not JWT) // Expected: ID token validates, opaque access token is accepted func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) { ts := NewTestSuite(t) ts.Setup() // Enable opaque tokens for this scenario (Option C requirement) ts.tOidc.allowOpaqueTokens = true // No custom audience ts.tOidc.audience = ts.tOidc.clientID // Create ID token idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": "test-client-id", "nonce": "test-nonce-scenario3", // ID tokens have nonce per OIDC spec "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "email": "test@example.com", "jti": "id-token-jti-3", }) if err != nil { t.Fatalf("Failed to create ID token: %v", err) } // Opaque access token (not a JWT - just a random string) opaqueAccessToken := "opaque_access_token_random_string_12345" // Verify ID token validates cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(idToken) if err != nil { t.Errorf("ID token validation failed: %v", err) } // Opaque access token should fail JWT validation (expected) err = ts.tOidc.VerifyToken(opaqueAccessToken) if err == nil { t.Error("Opaque access token should fail JWT validation") } else { t.Logf("Opaque access token correctly rejected by JWT validator: %v", err) } // Test that validateStandardTokens handles opaque tokens correctly // by falling back to ID token validation req := httptest.NewRequest("GET", "https://example.com/test", nil) session, err := ts.tOidc.sessionManager.GetSession(req) if err != nil { t.Fatalf("Failed to get session: %v", err) } session.SetAuthenticated(true) session.SetAccessToken(opaqueAccessToken) session.SetIDToken(idToken) rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs) if !authenticated || needsRefresh || expired { t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v", authenticated, needsRefresh, expired) } } // TestAuth0AudienceArrayValidation tests that audience validation // correctly handles array audiences (common in Auth0) func TestAuth0AudienceArrayValidation(t *testing.T) { ts := NewTestSuite(t) ts.Setup() customAudience := "https://my-api.example.com" ts.tOidc.audience = customAudience // Access token with audience as array containing our custom audience accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": []interface{}{ "https://test-issuer.com/userinfo", customAudience, "https://another-api.example.com", }, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "scope": "openid profile email read:data write:data", "jti": "array-aud-token-jti", }) if err != nil { t.Fatalf("Failed to create access token: %v", err) } // Should validate successfully - custom audience is in the array cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(accessToken) if err != nil { t.Errorf("Access token with audience array should validate when custom audience is present: %v", err) } } // TestAuth0MismatchedAudience tests that tokens with wrong audience fail validation func TestAuth0MismatchedAudience(t *testing.T) { ts := NewTestSuite(t) ts.Setup() customAudience := "https://my-api.example.com" ts.tOidc.audience = customAudience // Access token with WRONG audience accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": []interface{}{ "https://test-issuer.com/userinfo", "https://different-api.example.com", // Wrong audience }, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "scope": "openid profile email", "jti": "wrong-aud-token-jti", }) if err != nil { t.Fatalf("Failed to create access token: %v", err) } // Should fail validation - audience doesn't match cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(accessToken) if err == nil { t.Error("Access token with wrong audience should fail validation") } else if !strings.Contains(err.Error(), "invalid audience") { t.Errorf("Expected 'invalid audience' error, got: %v", err) } } // TestAuth0Scenario2StrictMode tests strict audience validation mode: // - Scenario 2 (access token with wrong audience) should be REJECTED // - strictAudienceValidation=true prevents fallback to ID token // - This addresses Allan's security concerns about audience bypass func TestAuth0Scenario2StrictMode(t *testing.T) { ts := NewTestSuite(t) ts.Setup() // Enable strict mode to prevent Scenario 2 bypass (Option C) ts.tOidc.strictAudienceValidation = true // Configure custom audience customAudience := "https://my-api.example.com" ts.tOidc.audience = customAudience // Create ID token with aud = client_id (valid) idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": "test-client-id", "nonce": "test-nonce-strict", "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "email": "test@example.com", "jti": "id-token-strict-jti", }) if err != nil { t.Fatalf("Failed to create ID token: %v", err) } // Create access token with WRONG audience (doesn't include custom audience) accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": []interface{}{ "https://test-issuer.com/userinfo", "https://wrong-api.example.com", // Wrong audience - not our custom audience }, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "scope": "openid profile email", "jti": "access-token-strict-jti", }) if err != nil { t.Fatalf("Failed to create access token: %v", err) } // Test session validation with wrong access token and valid ID token req := httptest.NewRequest("GET", "https://example.com/test", nil) session, err := ts.tOidc.sessionManager.GetSession(req) if err != nil { t.Fatalf("Failed to get session: %v", err) } session.SetAuthenticated(true) session.SetAccessToken(accessToken) session.SetIDToken(idToken) session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh // In strict mode, this should FAIL (no fallback to ID token) rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs) if authenticated { t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true") } if !needsRefresh { t.Errorf("Strict mode: Should signal refresh needed after rejection, got needsRefresh=%v", needsRefresh) } if expired { t.Errorf("Strict mode: Should not mark as expired (should try refresh first), got expired=%v", expired) } t.Logf("Strict mode correctly rejected Scenario 2 (access token audience mismatch)") } // TestIDTokenAlwaysValidatesAgainstClientID verifies that ID tokens // are ALWAYS validated against client_id, regardless of configured audience func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) { ts := NewTestSuite(t) ts.Setup() // Configure a custom audience different from client_id customAudience := "https://my-api.example.com" ts.tOidc.audience = customAudience // Create ID token with aud = client_id (per OIDC spec) idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": "test-client-id", // ID token MUST have client_id "nonce": "test-nonce-123", // ID tokens have nonce for replay protection "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "email": "test@example.com", "jti": "id-token-client-id-jti", }) if err != nil { t.Fatalf("Failed to create ID token: %v", err) } // Should validate successfully - ID tokens are checked against client_id cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(idToken) if err != nil { t.Errorf("ID token should validate against client_id even when custom audience is configured: %v", err) } // Create ID token with WRONG audience (should fail) wrongIDToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": customAudience, // WRONG - should be client_id "nonce": "test-nonce-wrong-456", // ID token has nonce, so it will be detected as ID token "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "test-user", "email": "test@example.com", "jti": "wrong-id-token-jti", }) if err != nil { t.Fatalf("Failed to create wrong ID token: %v", err) } // Should fail - ID tokens must have client_id as audience cleanupReplayCache() initReplayCache() err = ts.tOidc.VerifyToken(wrongIDToken) if err == nil { t.Error("ID token with custom audience (not client_id) should fail validation") } } // ============================================================================= // JWT AUDIENCE VERIFICATION TESTS // ============================================================================= // TestJWTAudienceVerification tests JWT verification with custom audience values func TestJWTAudienceVerification(t *testing.T) { // Create cleanup helper tc := newTestCleanup(t) // Generate RSA key for signing JWTs rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate RSA key: %v", err) } rsaPublicKey := &rsaPrivateKey.PublicKey // Create JWK jwk := JWK{ Kty: "RSA", Kid: "test-key-id", Alg: "RS256", N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), } jwks := &JWKSet{ Keys: []JWK{jwk}, } mockJWKCache := &MockJWKCache{ JWKS: jwks, Err: nil, } logger := NewLogger("debug") tokenBlacklist := tc.addCache(NewCache()) tokenCache := tc.addTokenCache(NewTokenCache()) tests := []struct { tokenAudience interface{} name string configAudience string errContains string wantErr bool skipReplayCheck bool }{ { name: "JWT with string aud matching configured audience", configAudience: "https://api.example.com", tokenAudience: "https://api.example.com", wantErr: false, skipReplayCheck: true, }, { name: "JWT with array aud containing configured audience", configAudience: "https://api.example.com", tokenAudience: []interface{}{"https://other.com", "https://api.example.com", "https://another.com"}, wantErr: false, skipReplayCheck: true, }, { name: "JWT with string aud NOT matching configured audience", configAudience: "https://api.example.com", tokenAudience: "https://wrong-api.example.com", wantErr: true, errContains: "invalid audience", skipReplayCheck: true, }, { name: "JWT with array aud NOT containing configured audience", configAudience: "https://api.example.com", tokenAudience: []interface{}{"https://other.com", "https://another.com"}, wantErr: true, errContains: "invalid audience", skipReplayCheck: true, }, { name: "JWT with clientID as aud when no custom audience configured", configAudience: "", tokenAudience: "test-client-id", wantErr: false, skipReplayCheck: true, }, { name: "JWT with empty string aud", configAudience: "https://api.example.com", tokenAudience: "", wantErr: true, errContains: "invalid audience", skipReplayCheck: true, }, { name: "Azure AD Application ID URI format", configAudience: "api://12345-app-id", tokenAudience: "api://12345-app-id", wantErr: false, skipReplayCheck: true, }, { name: "Auth0 custom API audience", configAudience: "https://mycompany.com/api", tokenAudience: "https://mycompany.com/api", wantErr: false, skipReplayCheck: true, }, { name: "Token confusion attack - audience for different service", configAudience: "https://service-a.example.com", tokenAudience: "https://service-b.example.com", wantErr: true, errContains: "invalid audience", skipReplayCheck: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create TraefikOidc instance tOidc := &TraefikOidc{ issuerURL: "https://test-issuer.com", clientID: "test-client-id", clientSecret: "test-client-secret", jwkCache: mockJWKCache, jwksURL: "https://test-jwks-url.com", tokenBlacklist: tokenBlacklist, tokenCache: tokenCache, limiter: rate.NewLimiter(rate.Every(time.Second), 10), logger: logger, httpClient: &http.Client{}, } // Set up the token verifier and JWT verifier tOidc.jwtVerifier = tOidc tOidc.tokenVerifier = tOidc // Determine the expected audience for validation expectedAudience := tt.configAudience if expectedAudience == "" { expectedAudience = tOidc.clientID } // Set the audience field on the tOidc instance tOidc.audience = expectedAudience // Create JWT with specified audience jti := generateRandomString(16) if tt.skipReplayCheck { // Use a unique JTI for each test to avoid replay detection jti = fmt.Sprintf("test-%s-%s", tt.name, jti) } jwt, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": tt.tokenAudience, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "test-subject", "email": "user@example.com", "jti": jti, }) if err != nil { t.Fatalf("Failed to create test JWT: %v", err) } // Verify the token err = tOidc.VerifyToken(jwt) if (err != nil) != tt.wantErr { t.Errorf("VerifyToken() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { t.Errorf("Error message should contain %q, got: %v", tt.errContains, err) } }) } } // TestJWTAudienceBackwardCompatibility tests that existing behavior is preserved // when the Audience field is not set func TestJWTAudienceBackwardCompatibility(t *testing.T) { ts := NewTestSuite(t) ts.Setup() // Test with no custom audience configured - should use clientID jwt, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": "test-client-id", // Should match clientID "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "test-subject", "email": "user@example.com", "jti": generateRandomString(16), }) if err != nil { t.Fatalf("Failed to create test JWT: %v", err) } err = ts.tOidc.VerifyToken(jwt) if err != nil { t.Errorf("Backward compatibility broken: VerifyToken() error = %v, expected nil", err) } } // ============================================================================= // INTEGRATION TESTS - AUTH0 // ============================================================================= // TestAudienceIntegrationAuth0Scenario tests Auth0-specific use case func TestAudienceIntegrationAuth0Scenario(t *testing.T) { // Create cleanup helper tc := newTestCleanup(t) // Simulate Auth0 scenario: custom audience for API access config := CreateConfig() config.ProviderURL = "https://mycompany.auth0.com" config.ClientID = "auth0-client-id" config.ClientSecret = "auth0-client-secret" config.CallbackURL = "/callback" config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength) config.Audience = "https://api.mycompany.com" // Custom API audience // Validate config if err := config.Validate(); err != nil { t.Fatalf("Auth0 config validation failed: %v", err) } // Generate test keys rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate RSA key: %v", err) } rsaPublicKey := &rsaPrivateKey.PublicKey jwk := JWK{ Kty: "RSA", Kid: "auth0-key-id", Alg: "RS256", N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), } jwks := &JWKSet{ Keys: []JWK{jwk}, } mockJWKCache := &MockJWKCache{ JWKS: jwks, Err: nil, } logger := NewLogger("debug") tokenBlacklist := tc.addCache(NewCache()) tokenCache := tc.addTokenCache(NewTokenCache()) tOidc := &TraefikOidc{ issuerURL: config.ProviderURL, clientID: config.ClientID, clientSecret: config.ClientSecret, audience: config.Audience, // Set audience from config jwkCache: mockJWKCache, jwksURL: "https://mycompany.auth0.com/.well-known/jwks.json", tokenBlacklist: tokenBlacklist, tokenCache: tokenCache, limiter: rate.NewLimiter(rate.Every(time.Second), 10), logger: logger, httpClient: &http.Client{}, } // Default audience to clientID if not specified if tOidc.audience == "" { tOidc.audience = tOidc.clientID } tOidc.jwtVerifier = tOidc tOidc.tokenVerifier = tOidc t.Run("Valid Auth0 API access token with custom audience", func(t *testing.T) { jwt, err := createTestJWT(rsaPrivateKey, "RS256", "auth0-key-id", map[string]interface{}{ "iss": config.ProviderURL, "aud": config.Audience, // Matches configured audience "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "auth0|123456", "email": "user@example.com", "jti": generateRandomString(16), }) if err != nil { t.Fatalf("Failed to create Auth0 JWT: %v", err) } err = tOidc.VerifyToken(jwt) if err != nil { t.Errorf("Auth0 token verification failed: %v", err) } }) t.Run("Auth0 ACCESS token with clientID instead of API audience should fail", func(t *testing.T) { jwt, err := createTestJWT(rsaPrivateKey, "RS256", "auth0-key-id", map[string]interface{}{ "iss": config.ProviderURL, "aud": config.ClientID, // Using clientID instead of API audience "scope": "openid profile email", // Mark as access token "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "auth0|123456", "email": "user@example.com", "jti": generateRandomString(16), }) if err != nil { t.Fatalf("Failed to create Auth0 JWT: %v", err) } err = tOidc.VerifyToken(jwt) if err == nil { t.Error("Auth0 access token with wrong audience should have been rejected") } else if !strings.Contains(err.Error(), "invalid audience") { t.Errorf("Expected 'invalid audience' error, got: %v", err) } }) } // ============================================================================= // INTEGRATION TESTS - AZURE AD // ============================================================================= // TestAudienceIntegrationAzureADScenario tests Azure AD-specific use case func TestAudienceIntegrationAzureADScenario(t *testing.T) { // Create cleanup helper tc := newTestCleanup(t) // Simulate Azure AD scenario: Application ID URI format config := CreateConfig() config.ProviderURL = "https://login.microsoftonline.com/tenant-id/v2.0" config.ClientID = "azure-client-id" config.ClientSecret = "azure-client-secret" config.CallbackURL = "/callback" config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength) config.Audience = "api://12345-abcd-6789-efgh" // Azure AD Application ID URI // Validate config if err := config.Validate(); err != nil { t.Fatalf("Azure AD config validation failed: %v", err) } // Generate test keys rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate RSA key: %v", err) } rsaPublicKey := &rsaPrivateKey.PublicKey jwk := JWK{ Kty: "RSA", Kid: "azure-key-id", Alg: "RS256", N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), } jwks := &JWKSet{ Keys: []JWK{jwk}, } mockJWKCache := &MockJWKCache{ JWKS: jwks, Err: nil, } logger := NewLogger("debug") tokenBlacklist := tc.addCache(NewCache()) tokenCache := tc.addTokenCache(NewTokenCache()) tOidc := &TraefikOidc{ issuerURL: config.ProviderURL, clientID: config.ClientID, clientSecret: config.ClientSecret, audience: config.Audience, // Set audience from config jwkCache: mockJWKCache, jwksURL: config.ProviderURL + "/.well-known/jwks.json", tokenBlacklist: tokenBlacklist, tokenCache: tokenCache, limiter: rate.NewLimiter(rate.Every(time.Second), 10), logger: logger, httpClient: &http.Client{}, } // Default audience to clientID if not specified if tOidc.audience == "" { tOidc.audience = tOidc.clientID } tOidc.jwtVerifier = tOidc tOidc.tokenVerifier = tOidc t.Run("Valid Azure AD token with Application ID URI audience", func(t *testing.T) { jwt, err := createTestJWT(rsaPrivateKey, "RS256", "azure-key-id", map[string]interface{}{ "iss": config.ProviderURL, "aud": config.Audience, // Matches Application ID URI "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "azure-user-id", "email": "user@example.com", "oid": "object-id-12345", "tid": "tenant-id", "jti": generateRandomString(16), }) if err != nil { t.Fatalf("Failed to create Azure AD JWT: %v", err) } err = tOidc.VerifyToken(jwt) if err != nil { t.Errorf("Azure AD token verification failed: %v", err) } }) t.Run("Azure AD token with multiple audiences including correct one", func(t *testing.T) { jwt, err := createTestJWT(rsaPrivateKey, "RS256", "azure-key-id", map[string]interface{}{ "iss": config.ProviderURL, "aud": []interface{}{config.ClientID, config.Audience, "https://graph.microsoft.com"}, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "azure-user-id", "email": "user@example.com", "oid": "object-id-12345", "tid": "tenant-id", "jti": generateRandomString(16), }) if err != nil { t.Fatalf("Failed to create Azure AD JWT: %v", err) } err = tOidc.VerifyToken(jwt) if err != nil { t.Errorf("Azure AD token with multiple audiences verification failed: %v", err) } }) } // ============================================================================= // SECURITY TESTS // ============================================================================= // TestAudienceSecurityTokenConfusionAttack tests security against token confusion attacks func TestAudienceSecurityTokenConfusionAttack(t *testing.T) { // Create cleanup helper tc := newTestCleanup(t) // Generate test keys rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate RSA key: %v", err) } rsaPublicKey := &rsaPrivateKey.PublicKey jwk := JWK{ Kty: "RSA", Kid: "test-key-id", Alg: "RS256", N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), } jwks := &JWKSet{ Keys: []JWK{jwk}, } mockJWKCache := &MockJWKCache{ JWKS: jwks, Err: nil, } logger := NewLogger("debug") tokenBlacklist := tc.addCache(NewCache()) tokenCache := tc.addTokenCache(NewTokenCache()) // Service A configuration serviceA := &TraefikOidc{ issuerURL: "https://auth.example.com", clientID: "service-a-client-id", clientSecret: "service-a-secret", audience: "service-a-client-id", // Service A uses its clientID as audience jwkCache: mockJWKCache, jwksURL: "https://auth.example.com/.well-known/jwks.json", tokenBlacklist: tokenBlacklist, tokenCache: tokenCache, limiter: rate.NewLimiter(rate.Every(time.Second), 10), logger: logger, httpClient: &http.Client{}, } serviceA.jwtVerifier = serviceA serviceA.tokenVerifier = serviceA t.Run("Token confusion - Try to use service B token on service A", func(t *testing.T) { // Create a token intended for service B serviceBToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://auth.example.com", "aud": "https://service-b.example.com", // For service B "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "attacker@example.com", "email": "attacker@example.com", "jti": generateRandomString(16), }) if err != nil { t.Fatalf("Failed to create service B token: %v", err) } // Try to verify the service B token on service A err = serviceA.VerifyToken(serviceBToken) switch { case err == nil: t.Error("SECURITY VULNERABILITY: Token confusion attack succeeded - service B token was accepted by service A") case !strings.Contains(err.Error(), "invalid audience"): t.Errorf("Expected 'invalid audience' error for token confusion, got: %v", err) default: t.Logf("Token confusion attack correctly prevented: %v", err) } }) } // TestAudienceSecurityWildcardInjection tests that wildcards are rejected func TestAudienceSecurityWildcardInjection(t *testing.T) { tests := []struct { name string audience string }{ { name: "Single asterisk", audience: "*", }, { name: "Wildcard in URL", audience: "https://*.example.com", }, { name: "Wildcard in path", audience: "https://api.example.com/*", }, { name: "Multiple wildcards", audience: "https://*.*.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := CreateConfig() config.ProviderURL = "https://provider.example.com" config.ClientID = "test-client-id" config.ClientSecret = "test-client-secret" config.CallbackURL = "/callback" config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength) config.Audience = tt.audience err := config.Validate() if err == nil { t.Errorf("SECURITY VULNERABILITY: Wildcard audience %q was not rejected", tt.audience) } else if !strings.Contains(err.Error(), "must not contain wildcards") { t.Errorf("Expected 'must not contain wildcards' error, got: %v", err) } }) } } // TestAudienceSecurityInjectionAttempts tests various injection attempts func TestAudienceSecurityInjectionAttempts(t *testing.T) { tests := []struct { name string audience string errContains string }{ { name: "Newline injection", audience: "api.example.com\nmalicious.com", errContains: "contains invalid characters", }, { name: "Carriage return injection", audience: "api.example.com\rmalicious.com", errContains: "contains invalid characters", }, { name: "Tab injection", audience: "api.example.com\tmalicious.com", errContains: "contains invalid characters", }, { name: "Null byte injection", audience: "api.example.com\x00malicious.com", errContains: "contains invalid characters", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := CreateConfig() config.ProviderURL = "https://provider.example.com" config.ClientID = "test-client-id" config.ClientSecret = "test-client-secret" config.CallbackURL = "/callback" config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength) config.Audience = tt.audience err := config.Validate() if err == nil { t.Errorf("SECURITY VULNERABILITY: Injection attempt with %q was not rejected", tt.name) } else if !strings.Contains(err.Error(), tt.errContains) { t.Errorf("Expected error containing %q, got: %v", tt.errContains, err) } }) } } // TestAudienceWithReplayProtection tests that replay protection works correctly with custom audiences func TestAudienceWithReplayProtection(t *testing.T) { // Create cleanup helper tc := newTestCleanup(t) // Generate test keys rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate RSA key: %v", err) } rsaPublicKey := &rsaPrivateKey.PublicKey jwk := JWK{ Kty: "RSA", Kid: "test-key-id", Alg: "RS256", N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), } jwks := &JWKSet{ Keys: []JWK{jwk}, } mockJWKCache := &MockJWKCache{ JWKS: jwks, Err: nil, } logger := NewLogger("debug") tokenBlacklist := tc.addCache(NewCache()) tokenCache := tc.addTokenCache(NewTokenCache()) tOidc := &TraefikOidc{ issuerURL: "https://auth.example.com", clientID: "test-client-id", clientSecret: "test-client-secret", jwkCache: mockJWKCache, jwksURL: "https://auth.example.com/.well-known/jwks.json", tokenBlacklist: tokenBlacklist, tokenCache: tokenCache, limiter: rate.NewLimiter(rate.Every(time.Second), 10), logger: logger, httpClient: &http.Client{}, } tOidc.jwtVerifier = tOidc tOidc.tokenVerifier = tOidc // Create a token with custom audience and fixed JTI fixedJTI := "replay-test-jti-" + generateRandomString(8) customAudience := "https://api.example.com" // Set the audience field to match what we expect tOidc.audience = customAudience jwt, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://auth.example.com", "aud": customAudience, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "test-user", "email": "user@example.com", "jti": fixedJTI, }) if err != nil { t.Fatalf("Failed to create JWT: %v", err) } // First verification should succeed err = tOidc.VerifyToken(jwt) if err != nil { t.Fatalf("First verification failed: %v", err) } // Verify that the JTI was blacklisted if blacklisted, exists := tOidc.tokenBlacklist.Get(fixedJTI); !exists || blacklisted == nil { t.Logf("Note: JTI was not added to blacklist (may be due to test token prefix)") } else { t.Logf("Replay protection verified: JTI %s is correctly blacklisted", fixedJTI) } } // ============================================================================= // END-TO-END TESTS // ============================================================================= // TestAudienceEndToEndScenario tests a complete end-to-end scenario with middleware func TestAudienceEndToEndScenario(t *testing.T) { // Create cleanup helper tc := newTestCleanup(t) // Create a test next handler nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("Authenticated with custom audience")) }) // Generate test keys rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate RSA key: %v", err) } rsaPublicKey := &rsaPrivateKey.PublicKey jwk := JWK{ Kty: "RSA", Kid: "test-key-id", Alg: "RS256", N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), } jwks := &JWKSet{ Keys: []JWK{jwk}, } mockJWKCache := &MockJWKCache{ JWKS: jwks, Err: nil, } logger := NewLogger("debug") sm, err := NewSessionManager(strings.Repeat("a", MinSessionEncryptionKeyLength), false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } tokenBlacklist := tc.addCache(NewCache()) tokenCache := tc.addTokenCache(NewTokenCache()) customAudience := "https://api.company.com" tOidc := &TraefikOidc{ next: nextHandler, name: "test", redirURLPath: "/callback", logoutURLPath: "/callback/logout", issuerURL: "https://auth.company.com", clientID: "test-client-id", clientSecret: "test-client-secret", audience: customAudience, // Set custom audience jwkCache: mockJWKCache, jwksURL: "https://auth.company.com/.well-known/jwks.json", tokenBlacklist: tokenBlacklist, tokenCache: tokenCache, limiter: rate.NewLimiter(rate.Every(time.Second), 10), logger: logger, allowedUserDomains: map[string]struct{}{"company.com": {}}, userIdentifierClaim: "email", // Required for user identification excludedURLs: map[string]struct{}{}, httpClient: &http.Client{}, initComplete: make(chan struct{}), sessionManager: sm, extractClaimsFunc: extractClaims, } tOidc.jwtVerifier = tOidc tOidc.tokenVerifier = tOidc close(tOidc.initComplete) t.Run("End-to-end with correct custom audience", func(t *testing.T) { // Create a valid token with the custom audience validJWT, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://auth.company.com", "aud": customAudience, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), "sub": "user-123", "email": "user@company.com", "jti": generateRandomString(16), }) if err != nil { t.Fatalf("Failed to create valid JWT: %v", err) } // Create a request with authenticated session req := httptest.NewRequest("GET", "/protected", nil) req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Host", "company.com") // Create session with token resp := httptest.NewRecorder() session, err := sm.GetSession(req) if err != nil { t.Fatalf("Failed to get session: %v", err) } if err := session.SetAuthenticated(true); err != nil { t.Fatalf("Failed to set authenticated: %v", err) } session.SetUserIdentifier("user@company.com") session.SetIDToken(validJWT) session.SetAccessToken(validJWT) if err := session.Save(req, resp); err != nil { t.Fatalf("Failed to save session: %v", err) } // Get cookies and add them to a new request cookies := resp.Result().Cookies() req = httptest.NewRequest("GET", "/protected", nil) req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Host", "company.com") for _, cookie := range cookies { req.AddCookie(cookie) } resp = httptest.NewRecorder() tOidc.ServeHTTP(resp, req) if resp.Code != http.StatusOK { t.Errorf("Expected status 200, got %d. Body: %s", resp.Code, resp.Body.String()) } }) }