mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
1b49e133da
* Fix bug affecting Azure OIDC authentication ( and most likely others ) * Fixes issue #51 * Ensure that appended roles are unique. Update the documentation. * Improvements targetting possible memory usage spikes. * Additional fixes and cleanup * Refactoring code to fix the issues identified by the users. * Modernize run * Fieldalignment * Multiple changes to improve performance and reduce complexity. - Optimise the errors and recovery. - Deduplicate code in metadata cache. - Remove unused performance monitoring code. - Simplify session management and settings handling. * Fix claims issue. * Add ability to overwrite the default scopes in the settings file * Well.. that escalated quickly. Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ). * Bugfix #51: Ensures that user provided scopes overrides work. * fixup! Bugfix #51: Ensures that user provided scopes overrides work. * fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. * Abstract the provider logic into a separate package. * Additional micro fixes and cleanups. * Simplify all the things. * fixup! Simplify all the things. * fixup! fixup! Simplify all the things. * fixup! fixup! fixup! Simplify all the things. * fixup! fixup! fixup! fixup! Simplify all the things. * ... * Cleanup tests. * fixup! Cleanup tests. * fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! fixup! Cleanup tests. * Issue #53: Fix CSRF token handling in reverse proxy 1. ✅ HTTPS Detection Fixed (session.go:723) - Now uses X-Forwarded-Proto header instead of r.URL.Scheme - Properly detects HTTPS in reverse proxy environments 2. ✅ SameSite Cookie Attribute Fixed - Removed automatic SameSiteStrictMode for HTTPS (would break OAuth) - Keeps SameSiteLaxMode to allow OAuth callbacks from external domains - Only uses Strict for AJAX requests which don't involve OAuth redirects 3. ✅ Cookie Domain Handling Fixed - Now respects X-Forwarded-Host header for cookie domain - Ensures cookies are set for the public domain, not internal proxy domain 4. ✅ EnhanceSessionSecurity Properly Integrated - Function is now actually called during session save - Applies security enhancements without breaking OAuth flow Why Issue #53 Failed Before: 1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back) 2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail 3. Cookie domain might have been wrong (internal vs public domain) Why It Works Now: 1. Cookies are properly marked Secure for HTTPS 2. Uses SameSite=Lax to allow OAuth provider callbacks 3. Cookie domain uses public domain from X-Forwarded-Host 4. CSRF token persists through the entire OAuth flow * Next set of enhancements together with memory usage improvements. * Memory leak fixes and optimisations. * CSRF and Cookie Domain fixes * fixup! CSRF and Cookie Domain fixes * Metadata cache leak fix + profiling * fixup! Metadata cache leak fix + profiling * Memory leaks hunting, part 1337. * Further pursue of perfection. * fixup! Further pursue of perfection. * fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * Clear race conditions * fixup! Clear race conditions * Weekend fun with memory leaks * Splitting code into multiple files with reasonable testing coverage. ``` ok github.com/lukaszraczylo/traefikoidc 117.017s coverage: 72.6% of statements ok github.com/lukaszraczylo/traefikoidc/auth 0.505s coverage: 87.1% of statements ok github.com/lukaszraczylo/traefikoidc/circuit_breaker 0.283s coverage: 99.0% of statements github.com/lukaszraczylo/traefikoidc/config coverage: 0.0% of statements ok github.com/lukaszraczylo/traefikoidc/handlers 0.349s coverage: 98.2% of statements ok github.com/lukaszraczylo/traefikoidc/internal/providers (cached) coverage: 94.3% of statements ok github.com/lukaszraczylo/traefikoidc/middleware 0.808s coverage: 78.0% of statements ok github.com/lukaszraczylo/traefikoidc/recovery 0.653s coverage: 100.0% of statements ok github.com/lukaszraczylo/traefikoidc/session/chunking (cached) coverage: 87.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/core (cached) coverage: 85.6% of statements ok github.com/lukaszraczylo/traefikoidc/session/crypto (cached) coverage: 81.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/storage (cached) coverage: 93.5% of statements ok github.com/lukaszraczylo/traefikoidc/session/validators (cached) coverage: 98.8% of statements ```` * fixup! Splitting code into multiple files with reasonable testing coverage. * fixup! fixup! Splitting code into multiple files with reasonable testing coverage. * Weekend fun with further optimisations. * fixup! Weekend fun with further optimisations. * fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * Pre-release cleanup. * Enhance test coverage. * fixup! Enhance test coverage. * fixup! fixup! Enhance test coverage. * fixup! fixup! fixup! Enhance test coverage.
256 lines
6.1 KiB
Go
256 lines
6.1 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// TokenValidator provides unified token validation functionality
|
|
type TokenValidator struct {
|
|
logger *Logger
|
|
}
|
|
|
|
// NewTokenValidator creates a new token validator
|
|
func NewTokenValidator(logger *Logger) *TokenValidator {
|
|
if logger == nil {
|
|
logger = GetSingletonNoOpLogger()
|
|
}
|
|
return &TokenValidator{
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// TokenValidationResult contains the result of token validation
|
|
type TokenValidationResult struct {
|
|
Valid bool
|
|
TokenType string
|
|
Claims map[string]interface{}
|
|
Expiry *time.Time
|
|
IssuedAt *time.Time
|
|
Error error
|
|
}
|
|
|
|
// ValidateToken performs comprehensive token validation
|
|
func (v *TokenValidator) ValidateToken(token string, requireJWT bool) TokenValidationResult {
|
|
result := TokenValidationResult{}
|
|
|
|
// Basic validation
|
|
if token == "" {
|
|
result.Error = fmt.Errorf("token is empty")
|
|
return result
|
|
}
|
|
|
|
// Check if it's a JWT or opaque token
|
|
dotCount := strings.Count(token, ".")
|
|
isJWT := dotCount == 2
|
|
|
|
if requireJWT && !isJWT {
|
|
result.Error = fmt.Errorf("token is not a valid JWT (found %d dots, expected 2)", dotCount)
|
|
return result
|
|
}
|
|
|
|
if isJWT {
|
|
return v.validateJWT(token)
|
|
} else {
|
|
return v.validateOpaqueToken(token)
|
|
}
|
|
}
|
|
|
|
// validateJWT validates a JWT token
|
|
func (v *TokenValidator) validateJWT(token string) TokenValidationResult {
|
|
result := TokenValidationResult{
|
|
TokenType: "JWT",
|
|
}
|
|
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
result.Error = fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
|
|
return result
|
|
}
|
|
|
|
// Validate each part
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
result.Error = fmt.Errorf("JWT part %d is empty", i)
|
|
return result
|
|
}
|
|
|
|
// Check for valid base64url characters
|
|
if !v.isValidBase64URL(part) {
|
|
result.Error = fmt.Errorf("JWT part %d contains invalid base64url characters", i)
|
|
return result
|
|
}
|
|
}
|
|
|
|
// Decode and parse claims
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("failed to decode JWT payload: %w", err)
|
|
return result
|
|
}
|
|
|
|
var claims map[string]interface{}
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
result.Error = fmt.Errorf("failed to parse JWT claims: %w", err)
|
|
return result
|
|
}
|
|
|
|
result.Claims = claims
|
|
|
|
// Extract standard claims
|
|
if exp, ok := claims["exp"]; ok {
|
|
expTime := v.extractTime(exp)
|
|
if expTime != nil {
|
|
result.Expiry = expTime
|
|
// Check if expired
|
|
if time.Now().After(*expTime) {
|
|
result.Error = fmt.Errorf("token is expired (expired at %v)", expTime.Format(time.RFC3339))
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
if iat, ok := claims["iat"]; ok {
|
|
iatTime := v.extractTime(iat)
|
|
if iatTime != nil {
|
|
result.IssuedAt = iatTime
|
|
// Check if issued in future
|
|
if iatTime.After(time.Now().Add(5 * time.Minute)) {
|
|
result.Error = fmt.Errorf("token issued in future (iat: %v)", iatTime.Format(time.RFC3339))
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check nbf (not before)
|
|
if nbf, ok := claims["nbf"]; ok {
|
|
nbfTime := v.extractTime(nbf)
|
|
if nbfTime != nil && time.Now().Before(*nbfTime) {
|
|
result.Error = fmt.Errorf("token not yet valid (nbf: %v)", nbfTime.Format(time.RFC3339))
|
|
return result
|
|
}
|
|
}
|
|
|
|
result.Valid = true
|
|
return result
|
|
}
|
|
|
|
// validateOpaqueToken validates an opaque token
|
|
func (v *TokenValidator) validateOpaqueToken(token string) TokenValidationResult {
|
|
result := TokenValidationResult{
|
|
TokenType: "Opaque",
|
|
}
|
|
|
|
// Check minimum length
|
|
if len(token) < 20 {
|
|
result.Error = fmt.Errorf("opaque token too short (length: %d)", len(token))
|
|
return result
|
|
}
|
|
|
|
// Check for spaces
|
|
if strings.Contains(token, " ") {
|
|
result.Error = fmt.Errorf("opaque token contains spaces")
|
|
return result
|
|
}
|
|
|
|
// Check for control characters
|
|
for i, char := range token {
|
|
if char < 32 || char == 127 {
|
|
result.Error = fmt.Errorf("opaque token contains control character at position %d", i)
|
|
return result
|
|
}
|
|
}
|
|
|
|
// Check entropy
|
|
if len(token) >= 20 {
|
|
uniqueChars := make(map[rune]bool)
|
|
for _, char := range token {
|
|
uniqueChars[char] = true
|
|
}
|
|
if len(uniqueChars) < 8 {
|
|
result.Error = fmt.Errorf("opaque token has insufficient entropy (unique chars: %d)", len(uniqueChars))
|
|
return result
|
|
}
|
|
}
|
|
|
|
result.Valid = true
|
|
return result
|
|
}
|
|
|
|
// isValidBase64URL checks if a string contains only valid base64url characters
|
|
func (v *TokenValidator) isValidBase64URL(s string) bool {
|
|
for _, char := range s {
|
|
if !((char >= 'A' && char <= 'Z') ||
|
|
(char >= 'a' && char <= 'z') ||
|
|
(char >= '0' && char <= '9') ||
|
|
char == '-' || char == '_' || char == '=') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// extractTime extracts a time.Time from various claim formats
|
|
func (v *TokenValidator) extractTime(claim interface{}) *time.Time {
|
|
var timestamp int64
|
|
|
|
switch val := claim.(type) {
|
|
case float64:
|
|
timestamp = int64(val)
|
|
case int64:
|
|
timestamp = val
|
|
case int:
|
|
timestamp = int64(val)
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
t := time.Unix(timestamp, 0)
|
|
return &t
|
|
}
|
|
|
|
// ValidateTokenSize checks if token size is within acceptable limits
|
|
func (v *TokenValidator) ValidateTokenSize(token string, maxSize int) error {
|
|
if len(token) > maxSize {
|
|
return fmt.Errorf("token exceeds maximum size (size: %d, max: %d)", len(token), maxSize)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExtractClaims extracts claims from a JWT without full validation
|
|
func (v *TokenValidator) ExtractClaims(token string) (map[string]interface{}, error) {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("invalid JWT format")
|
|
}
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode payload: %w", err)
|
|
}
|
|
|
|
var claims map[string]interface{}
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return nil, fmt.Errorf("failed to parse claims: %w", err)
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// CompareTokens safely compares two tokens for equality
|
|
func (v *TokenValidator) CompareTokens(token1, token2 string) bool {
|
|
if len(token1) != len(token2) {
|
|
return false
|
|
}
|
|
|
|
// Use constant-time comparison to prevent timing attacks
|
|
var result byte
|
|
for i := 0; i < len(token1); i++ {
|
|
result |= token1[i] ^ token2[i]
|
|
}
|
|
return result == 0
|
|
}
|