Files
traefikoidc/pkce_flow_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

364 lines
11 KiB
Go

package traefikoidc
import (
"crypto/sha256"
"encoding/base64"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGenerateNonce tests the nonce generation for OIDC flows
func TestGenerateNonce(t *testing.T) {
t.Run("basic generation", func(t *testing.T) {
nonce, err := generateNonce()
require.NoError(t, err)
assert.NotEmpty(t, nonce)
// 32 bytes base64 URL encoded should produce 44 characters (with potential padding)
// but typically 43 characters without padding
assert.GreaterOrEqual(t, len(nonce), 43, "nonce should be at least 43 characters")
})
t.Run("nonce is base64 URL encoded", func(t *testing.T) {
nonce, err := generateNonce()
require.NoError(t, err)
// Should be valid base64 URL encoding
_, err = base64.URLEncoding.DecodeString(nonce)
assert.NoError(t, err, "nonce should be valid base64 URL encoding")
})
t.Run("multiple generations produce different values", func(t *testing.T) {
nonce1, err1 := generateNonce()
nonce2, err2 := generateNonce()
require.NoError(t, err1)
require.NoError(t, err2)
assert.NotEqual(t, nonce1, nonce2, "consecutive generations should produce different nonces")
})
t.Run("nonce has sufficient entropy", func(t *testing.T) {
// Generate multiple nonces and verify they're all unique
nonces := make(map[string]bool)
iterations := 100
for i := 0; i < iterations; i++ {
nonce, err := generateNonce()
require.NoError(t, err)
// Check for duplicates
assert.False(t, nonces[nonce], "nonce should be unique across multiple generations")
nonces[nonce] = true
}
assert.Len(t, nonces, iterations, "all nonces should be unique")
})
t.Run("nonce length is consistent", func(t *testing.T) {
nonce1, err1 := generateNonce()
nonce2, err2 := generateNonce()
require.NoError(t, err1)
require.NoError(t, err2)
assert.Equal(t, len(nonce1), len(nonce2), "nonce length should be consistent")
})
}
// TestGenerateCodeVerifier tests the PKCE code verifier generation
func TestGenerateCodeVerifier(t *testing.T) {
t.Run("basic generation", func(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
assert.NotEmpty(t, verifier)
// RFC 7636 requires 43-128 characters for code verifier
// With 32 bytes base64 raw URL encoded, we get 43 characters
assert.Len(t, verifier, 43, "code verifier should be 43 characters (32 bytes base64 encoded)")
})
t.Run("verifier is base64 URL encoded", func(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// Should be valid base64 URL encoding
_, err = base64.RawURLEncoding.DecodeString(verifier)
assert.NoError(t, err, "verifier should be valid base64 URL encoding")
})
t.Run("multiple generations produce different values", func(t *testing.T) {
verifier1, err1 := generateCodeVerifier()
verifier2, err2 := generateCodeVerifier()
require.NoError(t, err1)
require.NoError(t, err2)
assert.NotEqual(t, verifier1, verifier2, "consecutive generations should produce different verifiers")
})
t.Run("verifier contains only URL-safe characters", func(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// Base64 URL encoding should only contain A-Z, a-z, 0-9, -, _
for _, char := range verifier {
validChar := (char >= 'A' && char <= 'Z') ||
(char >= 'a' && char <= 'z') ||
(char >= '0' && char <= '9') ||
char == '-' || char == '_'
assert.True(t, validChar, "verifier should only contain URL-safe characters")
}
})
t.Run("no padding characters", func(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// Raw URL encoding should not have padding
assert.False(t, strings.Contains(verifier, "="), "verifier should not contain padding")
})
}
// TestDeriveCodeChallenge tests the PKCE code challenge derivation
func TestDeriveCodeChallenge(t *testing.T) {
t.Run("basic derivation", func(t *testing.T) {
verifier := "test-verifier-value-1234567890abcdefghij"
challenge := deriveCodeChallenge(verifier)
assert.NotEmpty(t, challenge)
assert.NotEqual(t, verifier, challenge, "challenge should be different from verifier")
})
t.Run("challenge is SHA256 hash", func(t *testing.T) {
verifier := "test-code-verifier"
// Manually compute expected challenge
hasher := sha256.New()
hasher.Write([]byte(verifier))
expectedHash := hasher.Sum(nil)
expectedChallenge := base64.RawURLEncoding.EncodeToString(expectedHash)
challenge := deriveCodeChallenge(verifier)
assert.Equal(t, expectedChallenge, challenge, "challenge should match SHA256 hash")
})
t.Run("same verifier produces same challenge", func(t *testing.T) {
verifier := "consistent-verifier-12345"
challenge1 := deriveCodeChallenge(verifier)
challenge2 := deriveCodeChallenge(verifier)
assert.Equal(t, challenge1, challenge2, "same verifier should always produce same challenge")
})
t.Run("different verifiers produce different challenges", func(t *testing.T) {
verifier1 := "verifier-one"
verifier2 := "verifier-two"
challenge1 := deriveCodeChallenge(verifier1)
challenge2 := deriveCodeChallenge(verifier2)
assert.NotEqual(t, challenge1, challenge2, "different verifiers should produce different challenges")
})
t.Run("challenge is base64 URL encoded", func(t *testing.T) {
verifier := "test-verifier"
challenge := deriveCodeChallenge(verifier)
// Should be valid base64 URL encoding
_, err := base64.RawURLEncoding.DecodeString(challenge)
assert.NoError(t, err, "challenge should be valid base64 URL encoding")
})
t.Run("challenge length is correct", func(t *testing.T) {
verifier := "some-random-verifier"
challenge := deriveCodeChallenge(verifier)
// SHA256 produces 32 bytes, which when base64 encoded becomes 43 characters
assert.Len(t, challenge, 43, "SHA256 hash should produce 43-character base64 string")
})
t.Run("no padding in challenge", func(t *testing.T) {
verifier := "test-verifier-no-padding"
challenge := deriveCodeChallenge(verifier)
assert.False(t, strings.Contains(challenge, "="), "challenge should not contain padding")
})
t.Run("empty verifier produces valid challenge", func(t *testing.T) {
verifier := ""
challenge := deriveCodeChallenge(verifier)
assert.NotEmpty(t, challenge, "even empty verifier should produce a challenge")
assert.Len(t, challenge, 43, "challenge should still be 43 characters")
})
}
// TestPKCEFlowIntegration tests the complete PKCE flow
func TestPKCEFlowIntegration(t *testing.T) {
t.Run("complete PKCE flow", func(t *testing.T) {
// Step 1: Generate code verifier
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// Step 2: Derive code challenge
challenge := deriveCodeChallenge(verifier)
// Verify challenge was derived from verifier
expectedChallenge := deriveCodeChallenge(verifier)
assert.Equal(t, expectedChallenge, challenge)
// Verify verifier can be used to recreate challenge
rechallenge := deriveCodeChallenge(verifier)
assert.Equal(t, challenge, rechallenge, "verifier should consistently produce same challenge")
})
t.Run("multiple PKCE flows are independent", func(t *testing.T) {
// Flow 1
verifier1, err1 := generateCodeVerifier()
require.NoError(t, err1)
challenge1 := deriveCodeChallenge(verifier1)
// Flow 2
verifier2, err2 := generateCodeVerifier()
require.NoError(t, err2)
challenge2 := deriveCodeChallenge(verifier2)
// Flows should be independent
assert.NotEqual(t, verifier1, verifier2)
assert.NotEqual(t, challenge1, challenge2)
// Each flow should be internally consistent
assert.Equal(t, challenge1, deriveCodeChallenge(verifier1))
assert.Equal(t, challenge2, deriveCodeChallenge(verifier2))
})
t.Run("RFC 7636 compliance", func(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
challenge := deriveCodeChallenge(verifier)
// RFC 7636 Section 4.2:
// - code_verifier: high-entropy cryptographic random string
// - Minimum length: 43 characters
// - Maximum length: 128 characters
// - Character set: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
assert.GreaterOrEqual(t, len(verifier), 43, "verifier should be at least 43 characters")
assert.LessOrEqual(t, len(verifier), 128, "verifier should be at most 128 characters")
// RFC 7636 Section 4.2:
// - code_challenge = BASE64URL(SHA256(code_verifier))
assert.NotEmpty(t, challenge)
assert.Len(t, challenge, 43, "S256 challenge should be 43 characters")
})
}
// TestTokenCacheCleanupAndClose tests the no-op Cleanup and Close methods
func TestTokenCacheCleanupAndClose(t *testing.T) {
cache := NewTokenCache()
require.NotNil(t, cache)
t.Run("cleanup is safe to call", func(t *testing.T) {
// Should not panic
assert.NotPanics(t, func() {
cache.Cleanup()
})
})
t.Run("close is safe to call", func(t *testing.T) {
// Should not panic
assert.NotPanics(t, func() {
cache.Close()
})
})
t.Run("multiple cleanup calls are safe", func(t *testing.T) {
assert.NotPanics(t, func() {
cache.Cleanup()
cache.Cleanup()
cache.Cleanup()
})
})
t.Run("multiple close calls are safe", func(t *testing.T) {
assert.NotPanics(t, func() {
cache.Close()
cache.Close()
cache.Close()
})
})
t.Run("operations work after cleanup", func(t *testing.T) {
cache.Cleanup()
// Should still work
testClaims := map[string]interface{}{"sub": "user123"}
cache.Set("token1", testClaims, 1*time.Minute)
claims, found := cache.Get("token1")
assert.True(t, found)
assert.Equal(t, testClaims, claims)
})
t.Run("operations work after close", func(t *testing.T) {
cache.Close()
// Should still work (close is a no-op)
testClaims := map[string]interface{}{"sub": "user456"}
cache.Set("token2", testClaims, 1*time.Minute)
claims, found := cache.Get("token2")
assert.True(t, found)
assert.Equal(t, testClaims, claims)
})
}
// TestCreateStringMap tests the createStringMap utility function
func TestCreateStringMap(t *testing.T) {
t.Run("empty slice", func(t *testing.T) {
result := createStringMap([]string{})
assert.Empty(t, result)
})
t.Run("single item", func(t *testing.T) {
result := createStringMap([]string{"key1"})
assert.Len(t, result, 1)
_, exists := result["key1"]
assert.True(t, exists)
})
t.Run("multiple items", func(t *testing.T) {
result := createStringMap([]string{"key1", "key2", "key3"})
assert.Len(t, result, 3)
for _, key := range []string{"key1", "key2", "key3"} {
_, exists := result[key]
assert.True(t, exists, "key %s should exist", key)
}
})
t.Run("duplicate items", func(t *testing.T) {
result := createStringMap([]string{"key1", "key2", "key1", "key3", "key2"})
// Map should only contain unique keys
assert.Len(t, result, 3)
for _, key := range []string{"key1", "key2", "key3"} {
_, exists := result[key]
assert.True(t, exists, "key %s should exist", key)
}
})
}