mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
eff9bd7bd2
- Issue #74 - Issue #14
429 lines
15 KiB
Go
429 lines
15 KiB
Go
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
|
// This file contains tests for Auth0-specific audience validation scenarios.
|
|
package traefikoidc
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// 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)
|
|
|
|
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
|
|
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)
|
|
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
|
|
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")
|
|
}
|
|
}
|