mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
Cleanup [dec2025] (#101)
* Cleanup excessive comments. * Remove leftovers hanging around from previous refactor * Improve test coverage
This commit is contained in:
@@ -1,130 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCookiePrefix tests that custom cookie prefixes work correctly
|
||||
func TestCookiePrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cookiePrefix string
|
||||
wantMain string
|
||||
wantAccess string
|
||||
wantRefresh string
|
||||
wantID string
|
||||
}{
|
||||
{
|
||||
name: "Default prefix",
|
||||
cookiePrefix: "",
|
||||
wantMain: "_oidc_raczylo_m",
|
||||
wantAccess: "_oidc_raczylo_a",
|
||||
wantRefresh: "_oidc_raczylo_r",
|
||||
wantID: "_oidc_raczylo_id",
|
||||
},
|
||||
{
|
||||
name: "Custom prefix",
|
||||
cookiePrefix: "_oidc_myapp_",
|
||||
wantMain: "_oidc_myapp_m",
|
||||
wantAccess: "_oidc_myapp_a",
|
||||
wantRefresh: "_oidc_myapp_r",
|
||||
wantID: "_oidc_myapp_id",
|
||||
},
|
||||
{
|
||||
name: "Custom prefix without underscore suffix",
|
||||
cookiePrefix: "myapp",
|
||||
wantMain: "myappm",
|
||||
wantAccess: "myappa",
|
||||
wantRefresh: "myappr",
|
||||
wantID: "myappid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
chunkManager := &MockChunkManager{}
|
||||
|
||||
sm, err := NewSessionManager(
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
false,
|
||||
"",
|
||||
tt.cookiePrefix,
|
||||
0,
|
||||
logger,
|
||||
chunkManager,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
// Test cookie names
|
||||
if got := sm.MainCookieName(); got != tt.wantMain {
|
||||
t.Errorf("MainCookieName() = %q, want %q", got, tt.wantMain)
|
||||
}
|
||||
if got := sm.AccessTokenCookie(); got != tt.wantAccess {
|
||||
t.Errorf("AccessTokenCookie() = %q, want %q", got, tt.wantAccess)
|
||||
}
|
||||
if got := sm.RefreshTokenCookie(); got != tt.wantRefresh {
|
||||
t.Errorf("RefreshTokenCookie() = %q, want %q", got, tt.wantRefresh)
|
||||
}
|
||||
if got := sm.IDTokenCookie(); got != tt.wantID {
|
||||
t.Errorf("IDTokenCookie() = %q, want %q", got, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleInstancesWithDifferentPrefixes tests that multiple session managers
|
||||
// with different prefixes can coexist (addresses issue #87)
|
||||
func TestMultipleInstancesWithDifferentPrefixes(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
chunkManager1 := &MockChunkManager{}
|
||||
chunkManager2 := &MockChunkManager{}
|
||||
|
||||
// Create two session managers with different prefixes
|
||||
sm1, err := NewSessionManager(
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
false,
|
||||
"example.com",
|
||||
"_oidc_app1_",
|
||||
0,
|
||||
logger,
|
||||
chunkManager1,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager 1: %v", err)
|
||||
}
|
||||
|
||||
sm2, err := NewSessionManager(
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210", // Different encryption key
|
||||
false,
|
||||
"example.com",
|
||||
"_oidc_app2_",
|
||||
0,
|
||||
logger,
|
||||
chunkManager2,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager 2: %v", err)
|
||||
}
|
||||
|
||||
// Verify they have different cookie names
|
||||
if sm1.MainCookieName() == sm2.MainCookieName() {
|
||||
t.Error("Expected different main cookie names for different instances")
|
||||
}
|
||||
|
||||
// Verify cookie name patterns
|
||||
expectedPrefix1 := "_oidc_app1_"
|
||||
expectedPrefix2 := "_oidc_app2_"
|
||||
|
||||
if sm1.MainCookieName() != expectedPrefix1+"m" {
|
||||
t.Errorf("Expected main cookie name %s, got %s", expectedPrefix1+"m", sm1.MainCookieName())
|
||||
}
|
||||
|
||||
if sm2.MainCookieName() != expectedPrefix2+"m" {
|
||||
t.Errorf("Expected main cookie name %s, got %s", expectedPrefix2+"m", sm2.MainCookieName())
|
||||
}
|
||||
|
||||
t.Log("✓ Session isolation verified: Different cookie prefixes prevent session sharing")
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
// Package core provides core session management functionality for the OIDC middleware
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
const (
|
||||
minEncryptionKeyLength = 32
|
||||
absoluteSessionTimeout = 24 * time.Hour
|
||||
)
|
||||
|
||||
// SessionManager handles session creation, management and cleanup
|
||||
type SessionManager struct {
|
||||
sessionPool sync.Pool
|
||||
store sessions.Store
|
||||
logger Logger
|
||||
chunkManager ChunkManager
|
||||
cookieDomain string
|
||||
cookiePrefix string // Prefix for cookie names (default: "_oidc_raczylo_")
|
||||
sessionMaxAge time.Duration // Maximum session age (default: 24 hours)
|
||||
cleanupMutex sync.RWMutex
|
||||
forceHTTPS bool
|
||||
cleanupDone bool
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Debug(msg string)
|
||||
Debugf(format string, args ...interface{})
|
||||
Error(msg string)
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// ChunkManager interface for chunk operations
|
||||
type ChunkManager interface {
|
||||
CleanupExpiredSessions()
|
||||
}
|
||||
|
||||
// SessionData interface for session data operations
|
||||
type SessionData interface {
|
||||
Reset()
|
||||
SetManager(manager *SessionManager)
|
||||
SetAuthenticated(bool) error
|
||||
GetAuthenticated() bool
|
||||
GetAccessToken() string
|
||||
GetRefreshToken() string
|
||||
GetIDToken() string
|
||||
GetEmail() string
|
||||
GetCSRF() string
|
||||
GetNonce() string
|
||||
GetCodeVerifier() string
|
||||
GetIncomingPath() string
|
||||
GetRedirectCount() int
|
||||
IncrementRedirectCount()
|
||||
ResetRedirectCount()
|
||||
MarkDirty()
|
||||
IsDirty() bool
|
||||
Save(r *http.Request, w http.ResponseWriter) error
|
||||
Clear(r *http.Request, w http.ResponseWriter) error
|
||||
GetRefreshTokenIssuedAt() time.Time
|
||||
returnToPoolSafely()
|
||||
}
|
||||
|
||||
// NewSessionManager creates a new SessionManager instance with secure defaults.
|
||||
// It initializes the cookie store with encryption, sets up session pooling,
|
||||
// and configures chunk management for large tokens.
|
||||
func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, cookiePrefix string, sessionMaxAge time.Duration, logger Logger, chunkManager ChunkManager) (*SessionManager, error) {
|
||||
if len(encryptionKey) < minEncryptionKeyLength {
|
||||
return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength)
|
||||
}
|
||||
|
||||
// Set default cookie prefix if not provided
|
||||
if cookiePrefix == "" {
|
||||
cookiePrefix = "_oidc_raczylo_"
|
||||
}
|
||||
|
||||
// Set default session max age if not provided (24 hours for backward compatibility)
|
||||
if sessionMaxAge == 0 {
|
||||
sessionMaxAge = absoluteSessionTimeout
|
||||
}
|
||||
|
||||
sm := &SessionManager{
|
||||
store: sessions.NewCookieStore([]byte(encryptionKey)),
|
||||
forceHTTPS: forceHTTPS,
|
||||
cookieDomain: cookieDomain,
|
||||
cookiePrefix: cookiePrefix,
|
||||
sessionMaxAge: sessionMaxAge,
|
||||
logger: logger,
|
||||
chunkManager: chunkManager,
|
||||
}
|
||||
|
||||
sm.sessionPool.New = func() interface{} {
|
||||
return NewSessionData(sm, logger)
|
||||
}
|
||||
|
||||
return sm, nil
|
||||
}
|
||||
|
||||
// GetSession retrieves or creates a session for the request
|
||||
func (sm *SessionManager) GetSession(r *http.Request) (SessionData, error) {
|
||||
sessionDataInterface := sm.sessionPool.Get()
|
||||
sessionData, ok := sessionDataInterface.(SessionData)
|
||||
if !ok || sessionData == nil {
|
||||
sessionData = NewSessionData(sm, sm.logger)
|
||||
}
|
||||
|
||||
// Initialize the session data
|
||||
err := sm.initializeSession(sessionData, r)
|
||||
if err != nil {
|
||||
sm.sessionPool.Put(sessionData)
|
||||
return nil, fmt.Errorf("failed to initialize session: %w", err)
|
||||
}
|
||||
|
||||
return sessionData, nil
|
||||
}
|
||||
|
||||
// initializeSession initializes session data from HTTP request
|
||||
func (sm *SessionManager) initializeSession(sessionData SessionData, r *http.Request) error {
|
||||
// Reset session data to clean state
|
||||
sessionData.Reset()
|
||||
sessionData.SetManager(sm)
|
||||
|
||||
// Load session data from cookies
|
||||
session, err := sm.store.Get(r, sm.MainCookieName())
|
||||
if err != nil {
|
||||
sm.logger.Debugf("Error getting main session: %v", err)
|
||||
return nil // Not a fatal error, will create new session
|
||||
}
|
||||
|
||||
// Extract and set session values
|
||||
if auth, ok := session.Values["authenticated"].(bool); ok {
|
||||
_ = sessionData.SetAuthenticated(auth) // Safe to ignore: session initialization error
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupOldCookies removes old/expired cookies from the response
|
||||
func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Request) {
|
||||
sm.cleanupMutex.Lock()
|
||||
defer sm.cleanupMutex.Unlock()
|
||||
|
||||
if sm.cleanupDone {
|
||||
return
|
||||
}
|
||||
|
||||
sm.logger.Debug("Starting cleanup of old session cookies")
|
||||
|
||||
oldCookieNames := []string{
|
||||
"_oidc_session_old_v1",
|
||||
"_oidc_session_legacy",
|
||||
"_oidc_auth_state_old",
|
||||
"_legacy_oidc_token",
|
||||
"_old_session_chunks",
|
||||
}
|
||||
|
||||
for _, cookieName := range oldCookieNames {
|
||||
if cookie, err := r.Cookie(cookieName); err == nil && cookie.Value != "" {
|
||||
sm.logger.Debugf("Expiring old cookie: %s", cookieName)
|
||||
expiredCookie := &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: sm.cookieDomain,
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
Secure: sm.shouldUseSecureCookies(r),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
http.SetCookie(w, expiredCookie)
|
||||
}
|
||||
}
|
||||
|
||||
sm.cleanupDone = true
|
||||
}
|
||||
|
||||
// PeriodicChunkCleanup performs comprehensive session maintenance and cleanup
|
||||
func (sm *SessionManager) PeriodicChunkCleanup() {
|
||||
if sm == nil || sm.logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sm.logger.Debug("Starting comprehensive session cleanup cycle")
|
||||
|
||||
cleanupStart := time.Now()
|
||||
var orphanedChunks, expiredSessions, cleanupErrors int
|
||||
|
||||
if sm.store != nil {
|
||||
if cookieStore, ok := sm.store.(*sessions.CookieStore); ok {
|
||||
sm.logger.Debug("Running session store cleanup")
|
||||
_ = cookieStore
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup expired sessions in chunk manager to prevent memory leaks
|
||||
if sm.chunkManager != nil {
|
||||
sm.chunkManager.CleanupExpiredSessions()
|
||||
}
|
||||
|
||||
poolCleaned := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
if poolSession := sm.sessionPool.Get(); poolSession != nil {
|
||||
if sessionData, ok := poolSession.(SessionData); ok && sessionData != nil {
|
||||
sessionData.Reset()
|
||||
poolCleaned++
|
||||
}
|
||||
sm.sessionPool.Put(poolSession)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupDuration := time.Since(cleanupStart)
|
||||
sm.logger.Debugf("Session cleanup completed in %v: pool_cleaned=%d, orphaned_chunks=%d, expired_sessions=%d, errors=%d",
|
||||
cleanupDuration, poolCleaned, orphanedChunks, expiredSessions, cleanupErrors)
|
||||
}
|
||||
|
||||
// ValidateSessionHealth performs comprehensive validation of session integrity
|
||||
func (sm *SessionManager) ValidateSessionHealth(sessionData SessionData) error {
|
||||
if sessionData == nil {
|
||||
return fmt.Errorf("session data is nil")
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
if !sessionData.GetAuthenticated() {
|
||||
return nil // Not authenticated is not an error
|
||||
}
|
||||
|
||||
// Validate token formats
|
||||
if accessToken := sessionData.GetAccessToken(); accessToken != "" {
|
||||
if err := sm.validateTokenFormat(accessToken, "access"); err != nil {
|
||||
return fmt.Errorf("invalid access token format: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if idToken := sessionData.GetIDToken(); idToken != "" {
|
||||
if err := sm.validateTokenFormat(idToken, "id"); err != nil {
|
||||
return fmt.Errorf("invalid ID token format: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for session tampering
|
||||
if err := sm.detectSessionTampering(sessionData); err != nil {
|
||||
return fmt.Errorf("session tampering detected: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTokenFormat validates the format of JWT tokens
|
||||
func (sm *SessionManager) validateTokenFormat(token, tokenType string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// JWT tokens should have exactly 3 parts separated by dots
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("%s token is not a valid JWT format", tokenType)
|
||||
}
|
||||
|
||||
// Each part should be non-empty
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return fmt.Errorf("%s token part %d is empty", tokenType, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectSessionTampering detects potential tampering in session data
|
||||
func (sm *SessionManager) detectSessionTampering(sessionData SessionData) error {
|
||||
email := sessionData.GetEmail()
|
||||
authenticated := sessionData.GetAuthenticated()
|
||||
|
||||
// If authenticated but no email, that's suspicious
|
||||
if authenticated && email == "" {
|
||||
return fmt.Errorf("authenticated session without email")
|
||||
}
|
||||
|
||||
// If email exists but not authenticated, that's also suspicious
|
||||
if !authenticated && email != "" {
|
||||
sm.logger.Debugf("Warning: Email exists (%s) but session not authenticated", email)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSessionMetrics returns metrics about session usage
|
||||
func (sm *SessionManager) GetSessionMetrics() map[string]interface{} {
|
||||
metrics := make(map[string]interface{})
|
||||
|
||||
metrics["store_type"] = fmt.Sprintf("%T", sm.store)
|
||||
metrics["cookie_domain"] = sm.cookieDomain
|
||||
metrics["force_https"] = sm.forceHTTPS
|
||||
metrics["cleanup_done"] = sm.cleanupDone
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// shouldUseSecureCookies determines if cookies should be secure based on request
|
||||
func (sm *SessionManager) shouldUseSecureCookies(r *http.Request) bool {
|
||||
if sm.forceHTTPS {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if the request came over HTTPS
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check X-Forwarded-Proto header
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getSessionOptions returns session options for the given security context
|
||||
func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
|
||||
return &sessions.Options{
|
||||
Path: "/",
|
||||
Domain: sm.cookieDomain,
|
||||
MaxAge: int(sm.sessionMaxAge.Seconds()),
|
||||
Secure: isSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie name methods - these now use the configurable prefix
|
||||
func (sm *SessionManager) MainCookieName() string { return sm.cookiePrefix + "m" }
|
||||
func (sm *SessionManager) AccessTokenCookie() string { return sm.cookiePrefix + "a" }
|
||||
func (sm *SessionManager) RefreshTokenCookie() string { return sm.cookiePrefix + "r" }
|
||||
func (sm *SessionManager) IDTokenCookie() string { return sm.cookiePrefix + "id" }
|
||||
|
||||
// Package-level functions for backward compatibility (use default prefix)
|
||||
// These are deprecated and will be removed in a future version
|
||||
func MainCookieName() string { return "_oidc_raczylo_m" }
|
||||
func AccessTokenCookie() string { return "_oidc_raczylo_a" }
|
||||
func RefreshTokenCookie() string { return "_oidc_raczylo_r" }
|
||||
func IDTokenCookie() string { return "_oidc_raczylo_id" }
|
||||
|
||||
// NewSessionData creates a new session data instance
|
||||
func NewSessionData(manager *SessionManager, logger Logger) SessionData {
|
||||
// This function should be implemented to return a concrete SessionData implementation
|
||||
// The actual implementation depends on the SessionData struct definition
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,264 +0,0 @@
|
||||
// Package crypto provides cryptographic operations for session management
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MemoryPools interface for memory management
|
||||
type MemoryPools interface {
|
||||
GetCompressionBuffer() *bytes.Buffer
|
||||
PutCompressionBuffer(*bytes.Buffer)
|
||||
GetHTTPResponseBuffer() []byte
|
||||
PutHTTPResponseBuffer([]byte)
|
||||
}
|
||||
|
||||
// SessionCrypto provides cryptographic operations for session data
|
||||
type SessionCrypto struct {
|
||||
memoryPools MemoryPools
|
||||
}
|
||||
|
||||
// NewSessionCrypto creates a new session crypto instance
|
||||
func NewSessionCrypto(memoryPools MemoryPools) *SessionCrypto {
|
||||
return &SessionCrypto{
|
||||
memoryPools: memoryPools,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSecureRandomString creates a cryptographically secure random string.
|
||||
// It generates random bytes using crypto/rand and encodes them as hexadecimal.
|
||||
// This is used for session IDs and other security-sensitive random values.
|
||||
func (sc *SessionCrypto) GenerateSecureRandomString(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// CompressToken compresses a JWT token using gzip compression if beneficial.
|
||||
// It validates the token format, attempts compression, and verifies the compressed
|
||||
// data can be decompressed correctly. Only compresses if it reduces size.
|
||||
func (sc *SessionCrypto) CompressToken(token string) string {
|
||||
if token == "" {
|
||||
return token
|
||||
}
|
||||
|
||||
// Validate JWT format (should have exactly 2 dots)
|
||||
dotCount := strings.Count(token, ".")
|
||||
if dotCount != 2 {
|
||||
return token
|
||||
}
|
||||
|
||||
// Don't try to compress extremely large tokens
|
||||
if len(token) > 50*1024 {
|
||||
return token
|
||||
}
|
||||
|
||||
b := sc.memoryPools.GetCompressionBuffer()
|
||||
defer sc.memoryPools.PutCompressionBuffer(b)
|
||||
|
||||
gz := gzip.NewWriter(b)
|
||||
|
||||
written, err := gz.Write([]byte(token))
|
||||
if err != nil || written != len(token) {
|
||||
return token
|
||||
}
|
||||
|
||||
if err := gz.Close(); err != nil {
|
||||
return token
|
||||
}
|
||||
|
||||
compressedBytes := b.Bytes()
|
||||
if len(compressedBytes) == 0 {
|
||||
return token
|
||||
}
|
||||
|
||||
compressed := base64.StdEncoding.EncodeToString(compressedBytes)
|
||||
|
||||
// Only use compression if it actually reduces size
|
||||
if len(compressed) >= len(token) {
|
||||
return token
|
||||
}
|
||||
|
||||
// Verify compression integrity by attempting decompression
|
||||
decompressed := sc.decompressTokenInternal(compressed)
|
||||
if decompressed != token {
|
||||
return token
|
||||
}
|
||||
|
||||
// Final validation of decompressed token
|
||||
if strings.Count(decompressed, ".") != 2 {
|
||||
return token
|
||||
}
|
||||
|
||||
return compressed
|
||||
}
|
||||
|
||||
// DecompressToken decompresses a previously compressed token string.
|
||||
// It decodes the base64 data, validates gzip headers, and decompresses safely
|
||||
// with size limits to prevent compression bombs.
|
||||
func (sc *SessionCrypto) DecompressToken(compressed string) string {
|
||||
return sc.decompressTokenInternal(compressed)
|
||||
}
|
||||
|
||||
// decompressTokenInternal is the internal decompression function.
|
||||
// Separated internal function for integrity verification during compression.
|
||||
// It performs the actual decompression logic with proper resource management.
|
||||
func (sc *SessionCrypto) decompressTokenInternal(compressed string) string {
|
||||
if compressed == "" {
|
||||
return compressed
|
||||
}
|
||||
|
||||
// Prevent decompression of extremely large inputs
|
||||
if len(compressed) > 100*1024 {
|
||||
return compressed
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(compressed)
|
||||
if err != nil {
|
||||
return compressed
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return compressed
|
||||
}
|
||||
|
||||
// Validate gzip header
|
||||
if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
|
||||
return compressed
|
||||
}
|
||||
|
||||
readerBuf := sc.memoryPools.GetHTTPResponseBuffer()
|
||||
defer sc.memoryPools.PutHTTPResponseBuffer(readerBuf)
|
||||
|
||||
gz, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return compressed
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if closeErr := gz.Close(); closeErr != nil {
|
||||
_ = closeErr
|
||||
}
|
||||
}()
|
||||
|
||||
// Limit decompressed size to prevent compression bombs
|
||||
limitedReader := io.LimitReader(gz, 500*1024)
|
||||
|
||||
// Optimize for large buffer reuse
|
||||
if cap(readerBuf) >= 512*1024 {
|
||||
readerBuf = readerBuf[:cap(readerBuf)]
|
||||
n, err := limitedReader.Read(readerBuf)
|
||||
if err != nil && err != io.EOF {
|
||||
return compressed
|
||||
}
|
||||
decompressed := readerBuf[:n]
|
||||
return string(decompressed)
|
||||
}
|
||||
|
||||
decompressed, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return compressed
|
||||
}
|
||||
|
||||
if len(decompressed) == 0 {
|
||||
return compressed
|
||||
}
|
||||
|
||||
decompressedStr := string(decompressed)
|
||||
|
||||
// Validate the decompressed token is a valid JWT
|
||||
if decompressedStr != "" && strings.Count(decompressedStr, ".") != 2 {
|
||||
return compressed
|
||||
}
|
||||
|
||||
return decompressedStr
|
||||
}
|
||||
|
||||
// ValidateTokenFormat validates that a token has the correct JWT format
|
||||
func (sc *SessionCrypto) ValidateTokenFormat(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// JWT tokens should have exactly 3 parts separated by dots
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Each part should be non-empty
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// IsTokenCompressed checks if a token appears to be compressed
|
||||
func (sc *SessionCrypto) IsTokenCompressed(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// JWT tokens have exactly 2 dots, compressed tokens don't
|
||||
if strings.Count(token, ".") == 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to decode as base64
|
||||
data, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for gzip header
|
||||
if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SecureWipeBytes securely wipes sensitive data from memory
|
||||
func (sc *SessionCrypto) SecureWipeBytes(data []byte) {
|
||||
for i := range data {
|
||||
data[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// SecureWipeString securely wipes sensitive string data
|
||||
func (sc *SessionCrypto) SecureWipeString(s *string) {
|
||||
if s != nil {
|
||||
*s = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions that don't require instance state
|
||||
|
||||
// Min returns the minimum of two integers
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// GenerateSecureRandomString creates a cryptographically secure random string without dependencies
|
||||
func GenerateSecureRandomString(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
@@ -1,900 +0,0 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Mock memory pools for testing
|
||||
type MockMemoryPools struct{}
|
||||
|
||||
func (mp *MockMemoryPools) GetCompressionBuffer() *bytes.Buffer {
|
||||
return &bytes.Buffer{}
|
||||
}
|
||||
|
||||
func (mp *MockMemoryPools) PutCompressionBuffer(*bytes.Buffer) {
|
||||
// Mock implementation - nothing to do
|
||||
}
|
||||
|
||||
func (mp *MockMemoryPools) GetHTTPResponseBuffer() []byte {
|
||||
return make([]byte, 32768) // 32KB buffer
|
||||
}
|
||||
|
||||
func (mp *MockMemoryPools) PutHTTPResponseBuffer([]byte) {
|
||||
// Mock implementation - nothing to do
|
||||
}
|
||||
|
||||
// TestGenerateSecureRandomString tests secure random string generation
|
||||
func TestGenerateSecureRandomString(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
length int
|
||||
expectError bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Valid length",
|
||||
length: 16,
|
||||
expectError: false,
|
||||
description: "Should generate random string of correct length",
|
||||
},
|
||||
{
|
||||
name: "Minimum length",
|
||||
length: 1,
|
||||
expectError: false,
|
||||
description: "Should handle minimum length",
|
||||
},
|
||||
{
|
||||
name: "Zero length",
|
||||
length: 0,
|
||||
expectError: false,
|
||||
description: "Should handle zero length",
|
||||
},
|
||||
{
|
||||
name: "Large length",
|
||||
length: 1024,
|
||||
expectError: false,
|
||||
description: "Should handle large length",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := sc.GenerateSecureRandomString(tt.length)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for %s, got nil", tt.description)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for %s: %v", tt.description, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check length (hex encoding doubles the length)
|
||||
expectedLen := tt.length * 2
|
||||
if len(result) != expectedLen {
|
||||
t.Errorf("Expected length %d, got %d for %s", expectedLen, len(result), tt.description)
|
||||
}
|
||||
|
||||
// Check that result is hex
|
||||
for _, char := range result {
|
||||
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
|
||||
t.Errorf("Result contains non-hex character: %c", char)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSecureRandomStringUniqueness tests that generated strings are unique
|
||||
func TestGenerateSecureRandomStringUniqueness(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
// Generate multiple strings and check uniqueness
|
||||
generated := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
result, err := sc.GenerateSecureRandomString(16)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate random string: %v", err)
|
||||
}
|
||||
|
||||
if generated[result] {
|
||||
t.Errorf("Generated duplicate string: %s", result)
|
||||
}
|
||||
generated[result] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenCompressionIntegrity tests token compression and decompression
|
||||
func TestTokenCompressionIntegrity(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
expectValid bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Valid JWT small",
|
||||
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
expectValid: true,
|
||||
description: "Should compress and decompress small JWT correctly",
|
||||
},
|
||||
{
|
||||
name: "Valid JWT large",
|
||||
token: createLargeJWT(2000),
|
||||
expectValid: true,
|
||||
description: "Should compress and decompress large JWT correctly",
|
||||
},
|
||||
{
|
||||
name: "Invalid token - no dots",
|
||||
token: "invalidtoken",
|
||||
expectValid: false,
|
||||
description: "Should not compress token without dots",
|
||||
},
|
||||
{
|
||||
name: "Invalid token - wrong number of dots",
|
||||
token: "header.payload",
|
||||
expectValid: false,
|
||||
description: "Should not compress token with wrong number of dots",
|
||||
},
|
||||
{
|
||||
name: "Empty token",
|
||||
token: "",
|
||||
expectValid: false,
|
||||
description: "Should handle empty token",
|
||||
},
|
||||
{
|
||||
name: "Oversized token",
|
||||
token: createOversizedToken(),
|
||||
expectValid: false,
|
||||
description: "Should reject oversized tokens",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compressed := sc.CompressToken(tt.token)
|
||||
|
||||
if !tt.expectValid {
|
||||
// For invalid tokens, compression should return original
|
||||
if compressed != tt.token {
|
||||
t.Errorf("Expected compression to return original for invalid token, got different result")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For valid tokens, test round-trip integrity
|
||||
decompressed := sc.DecompressToken(compressed)
|
||||
if decompressed != tt.token {
|
||||
t.Errorf("Token integrity lost: original length=%d, compressed length=%d, decompressed length=%d",
|
||||
len(tt.token), len(compressed), len(decompressed))
|
||||
}
|
||||
|
||||
// Test that decompression is idempotent
|
||||
decompressed2 := sc.DecompressToken(decompressed)
|
||||
if decompressed2 != tt.token {
|
||||
t.Errorf("Decompression not idempotent: %d != %d", len(decompressed2), len(tt.token))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenCompressionCorruptionDetection tests corruption detection
|
||||
func TestTokenCompressionCorruptionDetection(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
corruptionTests := []struct {
|
||||
name string
|
||||
corruptedInput string
|
||||
expectOriginal bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Corrupted base64",
|
||||
corruptedInput: "invalid-base64!",
|
||||
expectOriginal: true,
|
||||
description: "Should return original for corrupted base64",
|
||||
},
|
||||
{
|
||||
name: "Truncated compressed data",
|
||||
corruptedInput: "H4sI", // Truncated gzip header
|
||||
expectOriginal: true,
|
||||
description: "Should return original for truncated data",
|
||||
},
|
||||
{
|
||||
name: "Invalid gzip data",
|
||||
corruptedInput: base64.StdEncoding.EncodeToString([]byte("not gzip data")),
|
||||
expectOriginal: true,
|
||||
description: "Should return original for invalid gzip data",
|
||||
},
|
||||
{
|
||||
name: "Empty compressed data",
|
||||
corruptedInput: "",
|
||||
expectOriginal: true,
|
||||
description: "Should handle empty compressed data",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range corruptionTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := sc.DecompressToken(tt.corruptedInput)
|
||||
if tt.expectOriginal && result != tt.corruptedInput {
|
||||
t.Errorf("Expected decompression to return original corrupted input, got: %q", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test that valid compression still works
|
||||
t.Run("Valid compression verification", func(t *testing.T) {
|
||||
validJWT := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
compressed := sc.CompressToken(validJWT)
|
||||
decompressed := sc.DecompressToken(compressed)
|
||||
if decompressed != validJWT {
|
||||
t.Errorf("Valid compression/decompression failed: %q != %q", decompressed, validJWT)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCompressionEfficiency tests that compression only occurs when beneficial
|
||||
func TestCompressionEfficiency(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
shouldCompress bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Small JWT",
|
||||
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
shouldCompress: false, // Small tokens might not benefit from compression
|
||||
description: "Small tokens should not be compressed if no benefit",
|
||||
},
|
||||
{
|
||||
name: "Large repetitive JWT",
|
||||
token: createLargeRepetitiveJWT(2000),
|
||||
shouldCompress: true, // Repetitive data should compress well
|
||||
description: "Large repetitive tokens should be compressed",
|
||||
},
|
||||
{
|
||||
name: "Incompressible token",
|
||||
token: createIncompressibleJWT(1000),
|
||||
shouldCompress: false, // Random data won't compress well
|
||||
description: "Incompressible tokens should not be compressed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compressed := sc.CompressToken(tt.token)
|
||||
wasCompressed := compressed != tt.token
|
||||
|
||||
if tt.shouldCompress && !wasCompressed {
|
||||
t.Errorf("Expected token to be compressed but it wasn't")
|
||||
} else if !tt.shouldCompress && wasCompressed {
|
||||
// This is okay - compression might still occur if beneficial
|
||||
t.Logf("Token was compressed even though not expected (this is acceptable)")
|
||||
}
|
||||
|
||||
// Verify decompression still works regardless
|
||||
decompressed := sc.DecompressToken(compressed)
|
||||
if decompressed != tt.token {
|
||||
t.Errorf("Decompression failed for %s", tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompressionSizeLimits tests compression size limits
|
||||
func TestCompressionSizeLimits(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
t.Run("Oversized token rejection", func(t *testing.T) {
|
||||
oversizedToken := createOversizedToken()
|
||||
compressed := sc.CompressToken(oversizedToken)
|
||||
|
||||
// Oversized tokens should not be compressed
|
||||
if compressed != oversizedToken {
|
||||
t.Error("Oversized token should not be compressed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Oversized compressed data rejection", func(t *testing.T) {
|
||||
oversizedCompressed := strings.Repeat("a", 150*1024) // >100KB
|
||||
decompressed := sc.DecompressToken(oversizedCompressed)
|
||||
|
||||
// Should return original when input is too large
|
||||
if decompressed != oversizedCompressed {
|
||||
t.Error("Oversized compressed data should be returned as-is")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions for creating test tokens
|
||||
|
||||
func createLargeJWT(size int) string {
|
||||
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
// Create payload that will result in desired total size
|
||||
payloadSize := size - len(header) - len(signature) - 2 // -2 for dots
|
||||
if payloadSize < 10 {
|
||||
payloadSize = 10
|
||||
}
|
||||
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", payloadSize*3/4)))
|
||||
|
||||
return header + "." + payload + "." + signature
|
||||
}
|
||||
|
||||
func createLargeRepetitiveJWT(size int) string {
|
||||
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
// Create repetitive payload that compresses well
|
||||
payloadSize := size - len(header) - len(signature) - 2
|
||||
if payloadSize < 10 {
|
||||
payloadSize = 10
|
||||
}
|
||||
|
||||
repetitiveData := strings.Repeat("repetitive_data_", payloadSize/16)
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(repetitiveData))
|
||||
|
||||
return header + "." + payload + "." + signature
|
||||
}
|
||||
|
||||
func createIncompressibleJWT(size int) string {
|
||||
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
// Create random payload that won't compress well
|
||||
payloadSize := size - len(header) - len(signature) - 2
|
||||
if payloadSize < 10 {
|
||||
payloadSize = 10
|
||||
}
|
||||
|
||||
randomBytes := make([]byte, payloadSize*3/4)
|
||||
rand.Read(randomBytes)
|
||||
payload := base64.StdEncoding.EncodeToString(randomBytes)
|
||||
|
||||
return header + "." + payload + "." + signature
|
||||
}
|
||||
|
||||
func createOversizedToken() string {
|
||||
// Create a token larger than 50KB (the limit in CompressToken)
|
||||
size := 55 * 1024 // 55KB
|
||||
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
payloadSize := size - len(header) - len(signature) - 2
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", payloadSize*3/4)))
|
||||
|
||||
return header + "." + payload + "." + signature
|
||||
}
|
||||
|
||||
// BenchmarkCompression benchmarks compression operations
|
||||
func BenchmarkCompression(b *testing.B) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
b.Run("CompressLargeJWT", func(b *testing.B) {
|
||||
largeToken := createLargeJWT(5000)
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = sc.CompressToken(largeToken)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("DecompressLargeJWT", func(b *testing.B) {
|
||||
largeToken := createLargeJWT(5000)
|
||||
compressed := sc.CompressToken(largeToken)
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = sc.DecompressToken(compressed)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("RoundTripCompression", func(b *testing.B) {
|
||||
largeToken := createLargeJWT(5000)
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
compressed := sc.CompressToken(largeToken)
|
||||
_ = sc.DecompressToken(compressed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateTokenFormat tests JWT token format validation
|
||||
func TestValidateTokenFormat(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid JWT token",
|
||||
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid JWT with different content",
|
||||
token: "header.payload.signature",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Empty token",
|
||||
token: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Token with no dots",
|
||||
token: "nodots",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Token with one dot",
|
||||
token: "header.payload",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Token with four dots",
|
||||
token: "header.payload.signature.extra",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Token with empty header",
|
||||
token: ".payload.signature",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Token with empty payload",
|
||||
token: "header..signature",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Token with empty signature",
|
||||
token: "header.payload.",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Token with all empty parts",
|
||||
token: "..",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Opaque token",
|
||||
token: "opaque_token_without_dots",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := sc.ValidateTokenFormat(tt.token)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ValidateTokenFormat(%q) = %v, expected %v", tt.token, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTokenCompressed tests token compression detection
|
||||
func TestIsTokenCompressed(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Empty token",
|
||||
token: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Valid JWT token (uncompressed)",
|
||||
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid base64",
|
||||
token: "invalid!base64",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Valid base64 but not gzip",
|
||||
token: base64.StdEncoding.EncodeToString([]byte("not gzip data")),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Valid gzip header",
|
||||
token: base64.StdEncoding.EncodeToString([]byte{0x1f, 0x8b, 0x08, 0x00}), // gzip magic bytes
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Partial gzip header",
|
||||
token: base64.StdEncoding.EncodeToString([]byte{0x1f}), // only first byte
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := sc.IsTokenCompressed(tt.token)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsTokenCompressed(%q) = %v, expected %v", tt.token, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test with actual compressed token
|
||||
t.Run("Real compressed token", func(t *testing.T) {
|
||||
originalToken := createLargeJWT(2000)
|
||||
compressedToken := sc.CompressToken(originalToken)
|
||||
|
||||
// If compression occurred (token changed), it should be detected as compressed
|
||||
if compressedToken != originalToken {
|
||||
if !sc.IsTokenCompressed(compressedToken) {
|
||||
t.Error("Failed to detect actual compressed token")
|
||||
}
|
||||
}
|
||||
|
||||
// Original token should not be detected as compressed
|
||||
if sc.IsTokenCompressed(originalToken) {
|
||||
t.Error("Original JWT detected as compressed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSecureWipeBytes tests secure byte wiping
|
||||
func TestSecureWipeBytes(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{
|
||||
name: "Normal byte slice",
|
||||
data: []byte("sensitive data"),
|
||||
},
|
||||
{
|
||||
name: "Empty slice",
|
||||
data: []byte{},
|
||||
},
|
||||
{
|
||||
name: "Single byte",
|
||||
data: []byte{0xFF},
|
||||
},
|
||||
{
|
||||
name: "Large data",
|
||||
data: bytes.Repeat([]byte("secret"), 1000),
|
||||
},
|
||||
{
|
||||
name: "Nil slice",
|
||||
data: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a copy to verify original content
|
||||
original := make([]byte, len(tt.data))
|
||||
copy(original, tt.data)
|
||||
|
||||
// Wipe the data
|
||||
sc.SecureWipeBytes(tt.data)
|
||||
|
||||
// Verify all bytes are zero (except for nil slice)
|
||||
if tt.data != nil {
|
||||
for i, b := range tt.data {
|
||||
if b != 0 {
|
||||
t.Errorf("Byte at index %d not wiped: got %d, expected 0", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we had actual data to wipe (except for empty/nil cases)
|
||||
if len(original) > 0 {
|
||||
hasNonZero := false
|
||||
for _, b := range original {
|
||||
if b != 0 {
|
||||
hasNonZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNonZero {
|
||||
t.Log("Test data was already all zeros")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecureWipeString tests secure string wiping
|
||||
func TestSecureWipeString(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input *string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "Normal string",
|
||||
input: func() *string { s := "sensitive data"; return &s }(),
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
input: func() *string { s := ""; return &s }(),
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
name: "Long string",
|
||||
input: func() *string { s := strings.Repeat("secret", 1000); return &s }(),
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
name: "Nil string pointer",
|
||||
input: nil,
|
||||
expect: "", // This test verifies no panic occurs
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Store original value for verification
|
||||
var original string
|
||||
if tt.input != nil {
|
||||
original = *tt.input
|
||||
}
|
||||
|
||||
// Wipe the string
|
||||
sc.SecureWipeString(tt.input)
|
||||
|
||||
// Verify result
|
||||
if tt.input != nil {
|
||||
if *tt.input != tt.expect {
|
||||
t.Errorf("String not wiped properly: got %q, expected %q", *tt.input, tt.expect)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we had actual data to wipe (except for nil case)
|
||||
if tt.input != nil && original != "" {
|
||||
t.Logf("Successfully wiped string of length %d", len(original))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMin tests the minimum utility function
|
||||
func TestMin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b int
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "a smaller than b",
|
||||
a: 5,
|
||||
b: 10,
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
name: "b smaller than a",
|
||||
a: 15,
|
||||
b: 7,
|
||||
expected: 7,
|
||||
},
|
||||
{
|
||||
name: "equal values",
|
||||
a: 42,
|
||||
b: 42,
|
||||
expected: 42,
|
||||
},
|
||||
{
|
||||
name: "negative values",
|
||||
a: -10,
|
||||
b: -5,
|
||||
expected: -10,
|
||||
},
|
||||
{
|
||||
name: "zero values",
|
||||
a: 0,
|
||||
b: 0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "mixed positive and negative",
|
||||
a: -3,
|
||||
b: 2,
|
||||
expected: -3,
|
||||
},
|
||||
{
|
||||
name: "large numbers",
|
||||
a: 1000000,
|
||||
b: 999999,
|
||||
expected: 999999,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := Min(tt.a, tt.b)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Min(%d, %d) = %d, expected %d", tt.a, tt.b, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSecureRandomStringStandalone tests the standalone random string function
|
||||
func TestGenerateSecureRandomStringStandalone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
length int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid length",
|
||||
length: 16,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Zero length",
|
||||
length: 0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Large length",
|
||||
length: 1024,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := GenerateSecureRandomString(tt.length)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check length (hex encoding doubles the length)
|
||||
expectedLen := tt.length * 2
|
||||
if len(result) != expectedLen {
|
||||
t.Errorf("Expected length %d, got %d", expectedLen, len(result))
|
||||
}
|
||||
|
||||
// Check that result is hex
|
||||
for _, char := range result {
|
||||
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
|
||||
t.Errorf("Result contains non-hex character: %c", char)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test uniqueness
|
||||
t.Run("Uniqueness test", func(t *testing.T) {
|
||||
generated := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
result, err := GenerateSecureRandomString(16)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate random string: %v", err)
|
||||
}
|
||||
|
||||
if generated[result] {
|
||||
t.Errorf("Generated duplicate string: %s", result)
|
||||
}
|
||||
generated[result] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCompressionEdgeCases tests edge cases for compression
|
||||
func TestCompressionEdgeCases(t *testing.T) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
t.Run("Token with exact size limit", func(t *testing.T) {
|
||||
// Create token at exactly 50KB
|
||||
token := createTokenWithExactSize(50 * 1024)
|
||||
compressed := sc.CompressToken(token)
|
||||
|
||||
// Should still attempt compression at the limit
|
||||
decompressed := sc.DecompressToken(compressed)
|
||||
if decompressed != token {
|
||||
t.Error("Failed to handle token at size limit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Compressed token with exact decompression limit", func(t *testing.T) {
|
||||
// Create data that decompresses to exactly 100KB
|
||||
largeData := strings.Repeat("a", 100*1024)
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(largeData))
|
||||
|
||||
result := sc.DecompressToken(encoded)
|
||||
// Should return original since it's not valid gzip
|
||||
if result != encoded {
|
||||
t.Error("Failed to handle large non-gzip data")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create token with exact size
|
||||
func createTokenWithExactSize(targetSize int) string {
|
||||
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
// Calculate needed payload size
|
||||
dotsSize := 2 // two dots
|
||||
otherSize := len(header) + len(signature) + dotsSize
|
||||
payloadSize := targetSize - otherSize
|
||||
|
||||
if payloadSize <= 0 {
|
||||
payloadSize = 10 // minimum payload
|
||||
}
|
||||
|
||||
// Create payload of exact size
|
||||
payload := strings.Repeat("x", payloadSize)
|
||||
|
||||
return header + "." + payload + "." + signature
|
||||
}
|
||||
|
||||
// BenchmarkRandomGeneration benchmarks random string generation
|
||||
func BenchmarkRandomGeneration(b *testing.B) {
|
||||
memoryPools := &MockMemoryPools{}
|
||||
sc := NewSessionCrypto(memoryPools)
|
||||
|
||||
b.Run("Generate16Bytes", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = sc.GenerateSecureRandomString(16)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Generate32Bytes", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = sc.GenerateSecureRandomString(32)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
// Package storage provides session storage operations for the OIDC middleware
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
// SessionData represents a user's authentication session with comprehensive token management.
|
||||
// It handles main session data and supports large tokens that need to be
|
||||
// split across multiple cookies due to browser size limitations.
|
||||
type SessionData struct {
|
||||
manager SessionManager
|
||||
request *http.Request
|
||||
mainSession *sessions.Session
|
||||
accessSession *sessions.Session
|
||||
refreshSession *sessions.Session
|
||||
idTokenSession *sessions.Session
|
||||
accessTokenChunks map[int]*sessions.Session
|
||||
refreshTokenChunks map[int]*sessions.Session
|
||||
idTokenChunks map[int]*sessions.Session
|
||||
refreshMutex sync.Mutex
|
||||
sessionMutex sync.RWMutex
|
||||
dirty bool
|
||||
inUse bool
|
||||
}
|
||||
|
||||
// ChunkCleaner interface for chunk cleanup operations
|
||||
type ChunkCleaner interface {
|
||||
CleanupChunks(chunks map[int]*sessions.Session, w http.ResponseWriter)
|
||||
}
|
||||
|
||||
// SessionManager interface for session management operations
|
||||
type SessionManager interface {
|
||||
GetSessionOptions(isSecure bool) *sessions.Options
|
||||
EnhanceSessionSecurity(options *sessions.Options, r *http.Request) *sessions.Options
|
||||
GetLogger() Logger
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Error(msg string)
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// NewSessionData creates a new session data instance
|
||||
func NewSessionData(manager SessionManager) *SessionData {
|
||||
return &SessionData{
|
||||
manager: manager,
|
||||
accessTokenChunks: make(map[int]*sessions.Session),
|
||||
refreshTokenChunks: make(map[int]*sessions.Session),
|
||||
idTokenChunks: make(map[int]*sessions.Session),
|
||||
refreshMutex: sync.Mutex{},
|
||||
sessionMutex: sync.RWMutex{},
|
||||
dirty: false,
|
||||
inUse: false,
|
||||
}
|
||||
}
|
||||
|
||||
// IsDirty returns true if the session data has been modified since it was last loaded or saved.
|
||||
// This is used to optimize session saves by only writing when necessary.
|
||||
func (sd *SessionData) IsDirty() bool {
|
||||
sd.sessionMutex.RLock()
|
||||
defer sd.sessionMutex.RUnlock()
|
||||
return sd.dirty
|
||||
}
|
||||
|
||||
// MarkDirty marks the session as having pending changes that need to be saved.
|
||||
// This is used when session data hasn't changed in content but should still
|
||||
// trigger a session save (e.g., to ensure the cookie is re-issued).
|
||||
func (sd *SessionData) MarkDirty() {
|
||||
sd.sessionMutex.Lock()
|
||||
defer sd.sessionMutex.Unlock()
|
||||
sd.dirty = true
|
||||
}
|
||||
|
||||
// Save persists all session data including main session and token chunks.
|
||||
// It applies security options, saves all session components, and handles
|
||||
// errors gracefully by continuing to save other components even if one fails.
|
||||
func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
|
||||
isSecure := r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil
|
||||
if forceHTTPS := sd.manager.GetLogger(); forceHTTPS != nil {
|
||||
// Add force HTTPS check if needed
|
||||
}
|
||||
|
||||
options := sd.manager.GetSessionOptions(isSecure)
|
||||
options = sd.manager.EnhanceSessionSecurity(options, r)
|
||||
|
||||
if sd.mainSession != nil {
|
||||
sd.mainSession.Options = options
|
||||
}
|
||||
if sd.accessSession != nil {
|
||||
sd.accessSession.Options = options
|
||||
}
|
||||
if sd.refreshSession != nil {
|
||||
sd.refreshSession.Options = options
|
||||
}
|
||||
if sd.idTokenSession != nil {
|
||||
sd.idTokenSession.Options = options
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
saveOrLogError := func(s *sessions.Session, name string) {
|
||||
if s == nil {
|
||||
logger := sd.manager.GetLogger()
|
||||
if logger != nil {
|
||||
logger.Errorf("Attempted to save nil session: %s", name)
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("attempted to save nil session: %s", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.Save(r, w); err != nil {
|
||||
errMsg := fmt.Errorf("failed to save %s session: %w", name, err)
|
||||
logger := sd.manager.GetLogger()
|
||||
if logger != nil {
|
||||
logger.Error(errMsg.Error())
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = errMsg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveOrLogError(sd.mainSession, "main")
|
||||
saveOrLogError(sd.accessSession, "access token")
|
||||
saveOrLogError(sd.refreshSession, "refresh token")
|
||||
saveOrLogError(sd.idTokenSession, "ID token")
|
||||
|
||||
for i, sessionChunk := range sd.accessTokenChunks {
|
||||
if sessionChunk != nil {
|
||||
sessionChunk.Options = options
|
||||
saveOrLogError(sessionChunk, fmt.Sprintf("access token chunk %d", i))
|
||||
}
|
||||
}
|
||||
|
||||
for i, sessionChunk := range sd.refreshTokenChunks {
|
||||
if sessionChunk != nil {
|
||||
sessionChunk.Options = options
|
||||
saveOrLogError(sessionChunk, fmt.Sprintf("refresh token chunk %d", i))
|
||||
}
|
||||
}
|
||||
|
||||
for i, sessionChunk := range sd.idTokenChunks {
|
||||
if sessionChunk != nil {
|
||||
sessionChunk.Options = options
|
||||
saveOrLogError(sessionChunk, fmt.Sprintf("ID token chunk %d", i))
|
||||
}
|
||||
}
|
||||
|
||||
if firstErr == nil {
|
||||
sd.dirty = false
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Clear completely clears all session data and safely returns the session to the pool.
|
||||
// It removes all authentication data, expires cookies, and handles panic recovery.
|
||||
func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error {
|
||||
defer func() {
|
||||
sd.returnToPoolSafely()
|
||||
}()
|
||||
|
||||
sd.sessionMutex.Lock()
|
||||
defer sd.sessionMutex.Unlock()
|
||||
|
||||
sd.clearAllSessionData(r, true)
|
||||
|
||||
// This is primarily for testing - in production w will often be nil
|
||||
var err error
|
||||
if w != nil {
|
||||
if r != nil && r.Header.Get("X-Test-Error") == "true" {
|
||||
if sd.mainSession != nil {
|
||||
sd.mainSession.Values["error_trigger"] = func() {}
|
||||
}
|
||||
}
|
||||
|
||||
err = sd.Save(r, w)
|
||||
}
|
||||
|
||||
sd.request = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// clearAllSessionData clears all session data including main session and token chunks.
|
||||
// It removes all session values and optionally expires all associated cookies.
|
||||
func (sd *SessionData) clearAllSessionData(r *http.Request, expire bool) {
|
||||
clearSessionValues(sd.mainSession, expire)
|
||||
clearSessionValues(sd.accessSession, expire)
|
||||
clearSessionValues(sd.refreshSession, expire)
|
||||
clearSessionValues(sd.idTokenSession, expire)
|
||||
|
||||
if expire && r != nil {
|
||||
sd.clearTokenChunks(r, sd.accessTokenChunks)
|
||||
sd.clearTokenChunks(r, sd.refreshTokenChunks)
|
||||
sd.clearTokenChunks(r, sd.idTokenChunks)
|
||||
} else {
|
||||
for k := range sd.accessTokenChunks {
|
||||
delete(sd.accessTokenChunks, k)
|
||||
}
|
||||
for k := range sd.refreshTokenChunks {
|
||||
delete(sd.refreshTokenChunks, k)
|
||||
}
|
||||
for k := range sd.idTokenChunks {
|
||||
delete(sd.idTokenChunks, k)
|
||||
}
|
||||
}
|
||||
|
||||
if expire {
|
||||
sd.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// clearSessionValues removes all values from a session and optionally expires it.
|
||||
// This is used during session cleanup and logout operations.
|
||||
func clearSessionValues(session *sessions.Session, expire bool) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for k := range session.Values {
|
||||
delete(session.Values, k)
|
||||
}
|
||||
|
||||
if expire {
|
||||
session.Options.MaxAge = -1
|
||||
}
|
||||
}
|
||||
|
||||
// clearTokenChunks clears token chunks from the session
|
||||
func (sd *SessionData) clearTokenChunks(r *http.Request, chunks map[int]*sessions.Session) {
|
||||
for i, chunk := range chunks {
|
||||
if chunk != nil {
|
||||
clearSessionValues(chunk, true)
|
||||
}
|
||||
delete(chunks, i)
|
||||
}
|
||||
}
|
||||
|
||||
// returnToPoolSafely safely returns the session to the object pool
|
||||
func (sd *SessionData) returnToPoolSafely() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger := sd.manager.GetLogger()
|
||||
if logger != nil {
|
||||
logger.Errorf("Panic during session pool return: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
sd.sessionMutex.Lock()
|
||||
defer sd.sessionMutex.Unlock()
|
||||
|
||||
if sd.inUse {
|
||||
sd.inUse = false
|
||||
sd.Reset()
|
||||
// Pool return should be handled by calling code
|
||||
}
|
||||
}
|
||||
|
||||
// Reset resets the session data to a clean state
|
||||
func (sd *SessionData) Reset() {
|
||||
sd.mainSession = nil
|
||||
sd.accessSession = nil
|
||||
sd.refreshSession = nil
|
||||
sd.idTokenSession = nil
|
||||
|
||||
// Clear maps without recreating them
|
||||
for k := range sd.accessTokenChunks {
|
||||
delete(sd.accessTokenChunks, k)
|
||||
}
|
||||
for k := range sd.refreshTokenChunks {
|
||||
delete(sd.refreshTokenChunks, k)
|
||||
}
|
||||
for k := range sd.idTokenChunks {
|
||||
delete(sd.idTokenChunks, k)
|
||||
}
|
||||
|
||||
sd.dirty = false
|
||||
sd.inUse = false
|
||||
sd.request = nil
|
||||
}
|
||||
|
||||
// SetSessions sets the session objects
|
||||
func (sd *SessionData) SetSessions(main, access, refresh, idToken *sessions.Session) {
|
||||
sd.mainSession = main
|
||||
sd.accessSession = access
|
||||
sd.refreshSession = refresh
|
||||
sd.idTokenSession = idToken
|
||||
}
|
||||
|
||||
// GetMainSession returns the main session
|
||||
func (sd *SessionData) GetMainSession() *sessions.Session {
|
||||
return sd.mainSession
|
||||
}
|
||||
|
||||
// GetAccessSession returns the access token session
|
||||
func (sd *SessionData) GetAccessSession() *sessions.Session {
|
||||
return sd.accessSession
|
||||
}
|
||||
|
||||
// GetRefreshSession returns the refresh token session
|
||||
func (sd *SessionData) GetRefreshSession() *sessions.Session {
|
||||
return sd.refreshSession
|
||||
}
|
||||
|
||||
// GetIDTokenSession returns the ID token session
|
||||
func (sd *SessionData) GetIDTokenSession() *sessions.Session {
|
||||
return sd.idTokenSession
|
||||
}
|
||||
|
||||
// GetTokenChunks returns the token chunk maps
|
||||
func (sd *SessionData) GetTokenChunks() (map[int]*sessions.Session, map[int]*sessions.Session, map[int]*sessions.Session) {
|
||||
return sd.accessTokenChunks, sd.refreshTokenChunks, sd.idTokenChunks
|
||||
}
|
||||
|
||||
// SetInUse marks the session as in use
|
||||
func (sd *SessionData) SetInUse(inUse bool) {
|
||||
sd.inUse = inUse
|
||||
}
|
||||
|
||||
// IsInUse returns whether the session is in use
|
||||
func (sd *SessionData) IsInUse() bool {
|
||||
return sd.inUse
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,300 +0,0 @@
|
||||
// Package validators provides validation functionality for session data
|
||||
package validators
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxBrowserCookieSize = 3500
|
||||
maxCookieSize = 1200
|
||||
)
|
||||
|
||||
// SessionValidator provides validation operations for session data
|
||||
type SessionValidator struct{}
|
||||
|
||||
// NewSessionValidator creates a new session validator
|
||||
func NewSessionValidator() *SessionValidator {
|
||||
return &SessionValidator{}
|
||||
}
|
||||
|
||||
// ValidateChunkSize checks if a chunk will fit within browser cookie limits.
|
||||
// It estimates the encoded size including cookie overhead and headers
|
||||
// to ensure the chunk won't exceed browser-imposed cookie size limits.
|
||||
func (sv *SessionValidator) ValidateChunkSize(chunkData string) bool {
|
||||
estimatedEncodedSize := len(chunkData) + (len(chunkData)*50)/100
|
||||
return estimatedEncodedSize <= maxBrowserCookieSize
|
||||
}
|
||||
|
||||
// IsCorruptionMarker detects if data contains known corruption indicators.
|
||||
// It checks for specific corruption markers and invalid characters
|
||||
// that indicate the data has been tampered with or corrupted.
|
||||
func (sv *SessionValidator) IsCorruptionMarker(data string) bool {
|
||||
if data == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
corruptionMarkers := []string{
|
||||
"__CORRUPTION_MARKER_TEST__",
|
||||
"__INVALID_BASE64_DATA__",
|
||||
"__CORRUPTED_CHUNK_DATA__",
|
||||
"!@#$%^&*()",
|
||||
"<<<CORRUPTED>>>",
|
||||
}
|
||||
|
||||
for _, marker := range corruptionMarkers {
|
||||
if data == marker {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if len(data) > 10 {
|
||||
invalidChars := "!@#$%^&*(){}[]|\\:;\"'<>?,`~"
|
||||
for _, char := range invalidChars {
|
||||
if strings.ContainsRune(data, char) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateTokenFormat validates that a token has the correct JWT format
|
||||
func (sv *SessionValidator) ValidateTokenFormat(token, tokenType string) error {
|
||||
if token == "" {
|
||||
return nil // Empty token is not an error
|
||||
}
|
||||
|
||||
// JWT tokens should have exactly 3 parts separated by dots
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return &ValidationError{
|
||||
Type: tokenType,
|
||||
Reason: "invalid JWT format",
|
||||
Details: "token must have exactly 3 parts separated by dots",
|
||||
}
|
||||
}
|
||||
|
||||
// Each part should be non-empty
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return &ValidationError{
|
||||
Type: tokenType,
|
||||
Reason: "empty token part",
|
||||
Details: strings.Join([]string{"token part", string(rune(i + 1)), "is empty"}, " "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSessionIntegrity performs comprehensive validation of session data integrity
|
||||
func (sv *SessionValidator) ValidateSessionIntegrity(sessionData SessionData) error {
|
||||
if sessionData == nil {
|
||||
return &ValidationError{
|
||||
Type: "session",
|
||||
Reason: "nil session data",
|
||||
Details: "session data cannot be nil",
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication state consistency
|
||||
authenticated := sessionData.GetAuthenticated()
|
||||
email := sessionData.GetEmail()
|
||||
|
||||
if authenticated && email == "" {
|
||||
return &ValidationError{
|
||||
Type: "session",
|
||||
Reason: "authentication inconsistency",
|
||||
Details: "session is authenticated but has no email",
|
||||
}
|
||||
}
|
||||
|
||||
// Validate token formats if present
|
||||
if accessToken := sessionData.GetAccessToken(); accessToken != "" {
|
||||
if err := sv.ValidateTokenFormat(accessToken, "access"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if idToken := sessionData.GetIDToken(); idToken != "" {
|
||||
if err := sv.ValidateTokenFormat(idToken, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if refreshToken := sessionData.GetRefreshToken(); refreshToken != "" {
|
||||
// Refresh tokens don't have to be JWTs, so we do basic validation
|
||||
if len(refreshToken) == 0 {
|
||||
return &ValidationError{
|
||||
Type: "refresh",
|
||||
Reason: "empty refresh token",
|
||||
Details: "refresh token cannot be empty if set",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSessionTiming validates session timing and expiration
|
||||
func (sv *SessionValidator) ValidateSessionTiming(sessionData SessionData, maxAge time.Duration) error {
|
||||
if sessionData == nil {
|
||||
return &ValidationError{
|
||||
Type: "session",
|
||||
Reason: "nil session data",
|
||||
Details: "session data cannot be nil",
|
||||
}
|
||||
}
|
||||
|
||||
// Check refresh token timing
|
||||
refreshTokenIssuedAt := sessionData.GetRefreshTokenIssuedAt()
|
||||
if !refreshTokenIssuedAt.IsZero() {
|
||||
age := time.Since(refreshTokenIssuedAt)
|
||||
if age > maxAge {
|
||||
return &ValidationError{
|
||||
Type: "timing",
|
||||
Reason: "refresh token expired",
|
||||
Details: strings.Join([]string{"refresh token age", age.String(), "exceeds max age", maxAge.String()}, " "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEmailDomain validates that an email belongs to an allowed domain
|
||||
func (sv *SessionValidator) ValidateEmailDomain(email string, allowedDomains map[string]struct{}) error {
|
||||
if email == "" {
|
||||
return &ValidationError{
|
||||
Type: "email",
|
||||
Reason: "empty email",
|
||||
Details: "email cannot be empty",
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedDomains) == 0 {
|
||||
return nil // No domain restrictions
|
||||
}
|
||||
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return &ValidationError{
|
||||
Type: "email",
|
||||
Reason: "invalid email format",
|
||||
Details: "email must contain exactly one @ symbol",
|
||||
}
|
||||
}
|
||||
|
||||
domain := parts[1]
|
||||
if _, allowed := allowedDomains[domain]; !allowed {
|
||||
return &ValidationError{
|
||||
Type: "email",
|
||||
Reason: "domain not allowed",
|
||||
Details: strings.Join([]string{"domain", domain, "is not in allowed domains list"}, " "),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SplitIntoChunks splits a string into chunks that fit within cookie size limits
|
||||
func (sv *SessionValidator) SplitIntoChunks(s string, chunkSize int) []string {
|
||||
effectiveChunkSize := min(chunkSize, maxCookieSize)
|
||||
|
||||
var chunks []string
|
||||
for len(s) > 0 {
|
||||
if len(s) > effectiveChunkSize {
|
||||
chunks = append(chunks, s[:effectiveChunkSize])
|
||||
s = s[effectiveChunkSize:]
|
||||
} else {
|
||||
chunks = append(chunks, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// ValidateChunks validates all chunks in a chunk set
|
||||
func (sv *SessionValidator) ValidateChunks(chunks []string) error {
|
||||
for i, chunk := range chunks {
|
||||
if chunk == "" {
|
||||
return &ValidationError{
|
||||
Type: "chunk",
|
||||
Reason: "empty chunk",
|
||||
Details: strings.Join([]string{"chunk", string(rune(i)), "is empty"}, " "),
|
||||
}
|
||||
}
|
||||
|
||||
if !sv.ValidateChunkSize(chunk) {
|
||||
return &ValidationError{
|
||||
Type: "chunk",
|
||||
Reason: "chunk too large",
|
||||
Details: strings.Join([]string{"chunk", string(rune(i)), "exceeds size limit"}, " "),
|
||||
}
|
||||
}
|
||||
|
||||
if sv.IsCorruptionMarker(chunk) {
|
||||
return &ValidationError{
|
||||
Type: "chunk",
|
||||
Reason: "corrupted chunk",
|
||||
Details: strings.Join([]string{"chunk", string(rune(i)), "contains corruption markers"}, " "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidationError represents a validation error with context
|
||||
type ValidationError struct {
|
||||
Type string
|
||||
Reason string
|
||||
Details string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (ve *ValidationError) Error() string {
|
||||
return strings.Join([]string{ve.Type, "validation error:", ve.Reason, "-", ve.Details}, " ")
|
||||
}
|
||||
|
||||
// SessionData interface for validation operations
|
||||
type SessionData interface {
|
||||
GetAuthenticated() bool
|
||||
GetEmail() string
|
||||
GetAccessToken() string
|
||||
GetIDToken() string
|
||||
GetRefreshToken() string
|
||||
GetRefreshTokenIssuedAt() time.Time
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
// min returns the minimum of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ValidateChunkSize is a package-level function for backward compatibility
|
||||
func ValidateChunkSize(chunkData string) bool {
|
||||
sv := &SessionValidator{}
|
||||
return sv.ValidateChunkSize(chunkData)
|
||||
}
|
||||
|
||||
// IsCorruptionMarker is a package-level function for backward compatibility
|
||||
func IsCorruptionMarker(data string) bool {
|
||||
sv := &SessionValidator{}
|
||||
return sv.IsCorruptionMarker(data)
|
||||
}
|
||||
|
||||
// SplitIntoChunks is a package-level function for backward compatibility
|
||||
func SplitIntoChunks(s string, chunkSize int) []string {
|
||||
sv := &SessionValidator{}
|
||||
return sv.SplitIntoChunks(s, chunkSize)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user