mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
6efb78b7a8
* Smarter approach to the cookies - Single maxCookieSize = 1400 constant with clear documentation - Combined cookie storage for ~40-45% size reduction - Backward compatible migration from legacy cookies * Tuneup the code.
1519 lines
48 KiB
Go
1519 lines
48 KiB
Go
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)
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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.SetEmail("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())
|
|
}
|
|
})
|
|
}
|