Files
traefikoidc/token_validator_test.go
T
lukaszraczylo ae59a5e88a 0.7.10 (#80)
* Add ability to disable replay protection. - This is useful for runs with multiple traefik replicas to avoid false positives and tokens re-creation.
* Enhance the CI/CD pipelines
* Increase test coverage.
* Update vendored dependencies.
* Update behaviour on forceHTTPS as per issue #82
2025-10-16 10:56:28 +01:00

740 lines
18 KiB
Go

package traefikoidc
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
)
// Test TokenValidator Creation
func TestNewTokenValidator(t *testing.T) {
validator := NewTokenValidator(nil)
if validator == nil {
t.Fatal("Expected non-nil token validator")
}
if validator.logger == nil {
t.Error("Expected logger to be initialized")
}
}
func TestNewTokenValidatorWithLogger(t *testing.T) {
logger := GetSingletonNoOpLogger()
validator := NewTokenValidator(logger)
if validator == nil {
t.Fatal("Expected non-nil token validator")
}
if validator.logger != logger {
t.Error("Expected provided logger to be used")
}
}
// Test ValidateToken - Entry Point
func TestValidateTokenEmpty(t *testing.T) {
validator := NewTokenValidator(nil)
result := validator.ValidateToken("", false)
if result.Valid {
t.Error("Expected invalid result for empty token")
}
if result.Error == nil {
t.Error("Expected error for empty token")
}
if !strings.Contains(result.Error.Error(), "empty") {
t.Errorf("Expected 'empty' in error, got: %v", result.Error)
}
}
func TestValidateTokenRequireJWT(t *testing.T) {
validator := NewTokenValidator(nil)
// Opaque token when JWT required
result := validator.ValidateToken("opaque_token_value_here", true)
if result.Valid {
t.Error("Expected invalid result for opaque token when JWT required")
}
if result.Error == nil {
t.Error("Expected error when JWT required but opaque token provided")
}
}
// Test JWT Validation
func TestValidateJWTValidFormat(t *testing.T) {
validator := NewTokenValidator(nil)
// Create a valid JWT with valid claims
claims := map[string]interface{}{
"sub": "user123",
"exp": time.Now().Add(1 * time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := createTestJWTSimple(claims)
result := validator.ValidateToken(token, false)
if !result.Valid {
t.Errorf("Expected valid result, got error: %v", result.Error)
}
if result.TokenType != "JWT" {
t.Errorf("Expected TokenType 'JWT', got %s", result.TokenType)
}
if result.Claims == nil {
t.Error("Expected claims to be parsed")
}
if result.Expiry == nil {
t.Error("Expected expiry to be extracted")
}
if result.IssuedAt == nil {
t.Error("Expected issued at to be extracted")
}
}
func TestValidateJWTExpiredToken(t *testing.T) {
validator := NewTokenValidator(nil)
claims := map[string]interface{}{
"sub": "user123",
"exp": time.Now().Add(-1 * time.Hour).Unix(), // Expired 1 hour ago
"iat": time.Now().Add(-2 * time.Hour).Unix(),
}
token := createTestJWTSimple(claims)
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for expired token")
}
if result.Error == nil {
t.Error("Expected error for expired token")
}
if !strings.Contains(result.Error.Error(), "expired") {
t.Errorf("Expected 'expired' in error, got: %v", result.Error)
}
}
func TestValidateJWTFutureIssuedAt(t *testing.T) {
validator := NewTokenValidator(nil)
claims := map[string]interface{}{
"sub": "user123",
"exp": time.Now().Add(2 * time.Hour).Unix(),
"iat": time.Now().Add(10 * time.Minute).Unix(), // Issued 10 minutes in future
}
token := createTestJWTSimple(claims)
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for future iat")
}
if result.Error == nil {
t.Error("Expected error for future iat")
}
if !strings.Contains(result.Error.Error(), "future") {
t.Errorf("Expected 'future' in error, got: %v", result.Error)
}
}
func TestValidateJWTNotBeforeClaim(t *testing.T) {
validator := NewTokenValidator(nil)
claims := map[string]interface{}{
"sub": "user123",
"exp": time.Now().Add(2 * time.Hour).Unix(),
"iat": time.Now().Unix(),
"nbf": time.Now().Add(1 * time.Hour).Unix(), // Not valid for 1 hour
}
token := createTestJWTSimple(claims)
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for nbf in future")
}
if result.Error == nil {
t.Error("Expected error for nbf in future")
}
if !strings.Contains(result.Error.Error(), "not yet valid") {
t.Errorf("Expected 'not yet valid' in error, got: %v", result.Error)
}
}
func TestValidateJWTInvalidFormat(t *testing.T) {
validator := NewTokenValidator(nil)
tests := []struct {
name string
token string
}{
{"single part", "eyJhbGciOiJIUzI1NiJ9"},
{"two parts", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0"},
{"four parts", "part1.part2.part3.part4"},
{"empty part", "eyJhbGciOiJIUzI1NiJ9..signature"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Use requireJWT=true to ensure these are treated as invalid JWTs, not opaque tokens
result := validator.ValidateToken(tt.token, true)
if result.Valid {
t.Error("Expected invalid result for malformed JWT")
}
if result.Error == nil {
t.Error("Expected error for malformed JWT")
}
})
}
}
func TestValidateJWTInvalidBase64URL(t *testing.T) {
validator := NewTokenValidator(nil)
// Token with invalid base64url characters
token := "invalid@chars.eyJzdWIiOiIxMjM0In0.signature"
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for invalid base64url characters")
}
if result.Error == nil {
t.Error("Expected error for invalid base64url characters")
}
}
func TestValidateJWTInvalidJSON(t *testing.T) {
validator := NewTokenValidator(nil)
// Valid base64 but invalid JSON
header := base64.RawURLEncoding.EncodeToString([]byte("not json"))
payload := base64.RawURLEncoding.EncodeToString([]byte("{invalid json"))
signature := base64.RawURLEncoding.EncodeToString([]byte("signature"))
token := header + "." + payload + "." + signature
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for invalid JSON in claims")
}
if result.Error == nil {
t.Error("Expected error for invalid JSON in claims")
}
}
// Test Opaque Token Validation
func TestValidateOpaqueTokenValid(t *testing.T) {
validator := NewTokenValidator(nil)
// Valid opaque token (>20 chars, good entropy)
token := "sk_live_abcdef123456GHIJKL789"
result := validator.ValidateToken(token, false)
if !result.Valid {
t.Errorf("Expected valid result, got error: %v", result.Error)
}
if result.TokenType != "Opaque" {
t.Errorf("Expected TokenType 'Opaque', got %s", result.TokenType)
}
}
func TestValidateOpaqueTokenTooShort(t *testing.T) {
validator := NewTokenValidator(nil)
token := "short"
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for short token")
}
if result.Error == nil {
t.Error("Expected error for short token")
}
if !strings.Contains(result.Error.Error(), "too short") {
t.Errorf("Expected 'too short' in error, got: %v", result.Error)
}
}
func TestValidateOpaqueTokenWithSpaces(t *testing.T) {
validator := NewTokenValidator(nil)
token := "this token has spaces in it"
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for token with spaces")
}
if result.Error == nil {
t.Error("Expected error for token with spaces")
}
if !strings.Contains(result.Error.Error(), "spaces") {
t.Errorf("Expected 'spaces' in error, got: %v", result.Error)
}
}
func TestValidateOpaqueTokenControlCharacters(t *testing.T) {
validator := NewTokenValidator(nil)
// Token with control character (null byte)
token := "token_with\x00control_char"
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for token with control characters")
}
if result.Error == nil {
t.Error("Expected error for token with control characters")
}
if !strings.Contains(result.Error.Error(), "control character") {
t.Errorf("Expected 'control character' in error, got: %v", result.Error)
}
}
func TestValidateOpaqueTokenInsufficientEntropy(t *testing.T) {
validator := NewTokenValidator(nil)
// Token with low entropy (only 3 unique characters)
token := "aaaaaabbbbbbccccccdddd"
result := validator.ValidateToken(token, false)
if result.Valid {
t.Error("Expected invalid result for low entropy token")
}
if result.Error == nil {
t.Error("Expected error for low entropy token")
}
if !strings.Contains(result.Error.Error(), "entropy") {
t.Errorf("Expected 'entropy' in error, got: %v", result.Error)
}
}
// Test Base64URL Validation
func TestIsValidBase64URL(t *testing.T) {
validator := NewTokenValidator(nil)
tests := []struct {
name string
input string
expected bool
}{
{"valid uppercase", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", true},
{"valid lowercase", "abcdefghijklmnopqrstuvwxyz", true},
{"valid numbers", "0123456789", true},
{"valid dash", "abc-def", true},
{"valid underscore", "abc_def", true},
{"valid equals", "abc=", true},
{"invalid at sign", "abc@def", false},
{"invalid space", "abc def", false},
{"invalid plus", "abc+def", false},
{"invalid slash", "abc/def", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.isValidBase64URL(tt.input)
if result != tt.expected {
t.Errorf("Expected %v for %s, got %v", tt.expected, tt.input, result)
}
})
}
}
// Test Time Extraction
func TestExtractTime(t *testing.T) {
validator := NewTokenValidator(nil)
tests := []struct {
name string
claim interface{}
expected bool
}{
{"float64", float64(1609459200), true},
{"int64", int64(1609459200), true},
{"int", int(1609459200), true},
{"string", "not a timestamp", false},
{"nil", nil, false},
{"map", map[string]interface{}{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.extractTime(tt.claim)
if tt.expected && result == nil {
t.Error("Expected non-nil time")
}
if !tt.expected && result != nil {
t.Error("Expected nil time")
}
})
}
}
func TestExtractTimeCorrectValue(t *testing.T) {
validator := NewTokenValidator(nil)
// Unix timestamp for 2021-01-01 00:00:00 UTC
timestamp := int64(1609459200)
result := validator.extractTime(timestamp)
if result == nil {
t.Fatal("Expected non-nil time")
}
expected := time.Unix(timestamp, 0)
if !result.Equal(expected) {
t.Errorf("Expected time %v, got %v", expected, *result)
}
}
// Test Token Size Validation
func TestValidateTokenSize(t *testing.T) {
validator := NewTokenValidator(nil)
tests := []struct {
name string
token string
maxSize int
expectError bool
}{
{"within limit", "short_token", 20, false},
{"at limit", "exactly_twenty_c", 16, false},
{"exceeds limit", "this_token_is_too_long", 10, true},
{"empty token", "", 10, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateTokenSize(tt.token, tt.maxSize)
if tt.expectError && err == nil {
t.Error("Expected error for oversized token")
}
if !tt.expectError && err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if err != nil && !strings.Contains(err.Error(), "exceeds") {
t.Errorf("Expected 'exceeds' in error, got: %v", err)
}
})
}
}
// Test Claims Extraction
func TestExtractClaims(t *testing.T) {
validator := NewTokenValidator(nil)
claims := map[string]interface{}{
"sub": "user123",
"email": "user@example.com",
"exp": float64(1609459200),
}
token := createTestJWTSimple(claims)
extracted, err := validator.ExtractClaims(token)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if extracted == nil {
t.Fatal("Expected non-nil claims")
}
if extracted["sub"] != "user123" {
t.Errorf("Expected sub 'user123', got %v", extracted["sub"])
}
if extracted["email"] != "user@example.com" {
t.Errorf("Expected email 'user@example.com', got %v", extracted["email"])
}
}
func TestExtractClaimsInvalidFormat(t *testing.T) {
validator := NewTokenValidator(nil)
tests := []struct {
name string
token string
}{
{"single part", "onlyonepart"},
{"two parts", "two.parts"},
{"four parts", "one.two.three.four"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := validator.ExtractClaims(tt.token)
if err == nil {
t.Error("Expected error for invalid format")
}
if !strings.Contains(err.Error(), "invalid JWT format") {
t.Errorf("Expected 'invalid JWT format' in error, got: %v", err)
}
})
}
}
func TestExtractClaimsInvalidBase64(t *testing.T) {
validator := NewTokenValidator(nil)
token := "header.invalid@base64.signature"
_, err := validator.ExtractClaims(token)
if err == nil {
t.Error("Expected error for invalid base64")
}
if !strings.Contains(err.Error(), "decode") {
t.Errorf("Expected 'decode' in error, got: %v", err)
}
}
func TestExtractClaimsInvalidJSON(t *testing.T) {
validator := NewTokenValidator(nil)
header := base64.RawURLEncoding.EncodeToString([]byte("header"))
payload := base64.RawURLEncoding.EncodeToString([]byte("{not valid json"))
signature := base64.RawURLEncoding.EncodeToString([]byte("signature"))
token := header + "." + payload + "." + signature
_, err := validator.ExtractClaims(token)
if err == nil {
t.Error("Expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "parse") {
t.Errorf("Expected 'parse' in error, got: %v", err)
}
}
// Test Token Comparison (Security - Timing Attack Resistance)
func TestCompareTokensEqual(t *testing.T) {
validator := NewTokenValidator(nil)
token1 := "secret_token_12345"
token2 := "secret_token_12345"
if !validator.CompareTokens(token1, token2) {
t.Error("Expected tokens to be equal")
}
}
func TestCompareTokensDifferent(t *testing.T) {
validator := NewTokenValidator(nil)
token1 := "secret_token_12345"
token2 := "secret_token_54321"
if validator.CompareTokens(token1, token2) {
t.Error("Expected tokens to be different")
}
}
func TestCompareTokensDifferentLength(t *testing.T) {
validator := NewTokenValidator(nil)
token1 := "short"
token2 := "much_longer_token"
if validator.CompareTokens(token1, token2) {
t.Error("Expected tokens to be different (different lengths)")
}
}
func TestCompareTokensEmpty(t *testing.T) {
validator := NewTokenValidator(nil)
token1 := ""
token2 := ""
if !validator.CompareTokens(token1, token2) {
t.Error("Expected empty tokens to be equal")
}
}
func TestCompareTokensConstantTime(t *testing.T) {
validator := NewTokenValidator(nil)
// This test verifies the comparison is constant-time
// by checking that different tokens take similar time
token1 := strings.Repeat("a", 1000)
token2First := "b" + strings.Repeat("a", 999)
token2Last := strings.Repeat("a", 999) + "b"
// Both comparisons should take similar time regardless of where difference occurs
startFirst := time.Now()
validator.CompareTokens(token1, token2First)
durationFirst := time.Since(startFirst)
startLast := time.Now()
validator.CompareTokens(token1, token2Last)
durationLast := time.Since(startLast)
// Allow 10x variance (generous, but timing can vary)
ratio := float64(durationFirst) / float64(durationLast)
if ratio < 0.1 || ratio > 10.0 {
t.Logf("Warning: timing variance detected (ratio: %.2f). First: %v, Last: %v",
ratio, durationFirst, durationLast)
// Not failing test as timing can be affected by many factors
}
}
// Security Tests
func TestValidateTokenMaliciousPayloads(t *testing.T) {
validator := NewTokenValidator(nil)
tests := []struct {
name string
token string
}{
{"sql injection attempt", "'; DROP TABLE users; --"},
{"xss attempt", "<script>alert('xss')</script>"},
{"path traversal", "../../../etc/passwd"},
{"null bytes", "token\x00with\x00nulls"},
{"unicode exploit", "token\u0000\u0001\u0002"},
{"extremely long", strings.Repeat("a", 100000)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateToken(tt.token, false)
// Should either reject or handle safely
if result.Valid {
// If considered valid, should have parsed safely
if result.Claims != nil {
t.Logf("Token considered valid: %s", tt.name)
}
} else {
// If invalid, should have error
if result.Error == nil {
t.Error("Expected error for malicious payload")
}
}
})
}
}
func TestValidateTokenBoundaryConditions(t *testing.T) {
validator := NewTokenValidator(nil)
tests := []struct {
name string
claims map[string]interface{}
wantErr bool
}{
{
name: "expiry at exact current time",
claims: map[string]interface{}{
"exp": time.Now().Unix(),
},
wantErr: true, // Should be expired (not <=, but <)
},
{
name: "iat 5 minutes in future (boundary)",
claims: map[string]interface{}{
"iat": time.Now().Add(5 * time.Minute).Unix(),
"exp": time.Now().Add(1 * time.Hour).Unix(),
},
wantErr: false, // Allowed within 5-minute tolerance
},
{
name: "iat 6 minutes in future",
claims: map[string]interface{}{
"iat": time.Now().Add(6 * time.Minute).Unix(),
"exp": time.Now().Add(1 * time.Hour).Unix(),
},
wantErr: true,
},
{
name: "nbf at exact current time",
claims: map[string]interface{}{
"nbf": time.Now().Unix(),
"exp": time.Now().Add(1 * time.Hour).Unix(),
},
wantErr: false, // Should be valid at exact time
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := createTestJWTSimple(tt.claims)
result := validator.ValidateToken(token, false)
if tt.wantErr && result.Valid {
t.Error("Expected invalid result at boundary condition")
}
if !tt.wantErr && !result.Valid {
t.Errorf("Expected valid result at boundary condition, got error: %v", result.Error)
}
})
}
}
// Helper Functions
func createTestJWTSimple(claims map[string]interface{}) string {
// Create a minimal JWT for testing (not cryptographically signed)
header := map[string]interface{}{
"alg": "HS256",
"typ": "JWT",
}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
signature := base64.RawURLEncoding.EncodeToString([]byte("fake_signature"))
return headerB64 + "." + claimsB64 + "." + signature
}