mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-13 04:07:37 +00:00
1999 lines
66 KiB
Go
1999 lines
66 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/sessions"
|
|
)
|
|
|
|
// 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, hex-encoded random string.
|
|
// It reads the specified number of bytes from crypto/rand and encodes them as a hexadecimal string.
|
|
//
|
|
// Parameters:
|
|
// - length: The number of random bytes to generate (the resulting hex string will be twice this length).
|
|
//
|
|
// Returns:
|
|
// - A hex-encoded random string.
|
|
// - An error if reading random bytes fails.
|
|
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
|
|
}
|
|
|
|
// Cookie names and configuration constants used for session management
|
|
const (
|
|
mainCookieName = "_oidc_raczylo_m"
|
|
accessTokenCookie = "_oidc_raczylo_a"
|
|
refreshTokenCookie = "_oidc_raczylo_r"
|
|
idTokenCookie = "_oidc_raczylo_id"
|
|
)
|
|
|
|
const (
|
|
// maxBrowserCookieSize is the safe maximum size for cookies in browsers (4KB with margin)
|
|
maxBrowserCookieSize = 3500
|
|
|
|
// maxCookieSize is the maximum size for token chunks before encoding overhead
|
|
// This accounts for gob encoding, encryption, base64 encoding that happens during session save
|
|
// The encoding overhead can be 30-40%, so we use a conservative chunk size
|
|
maxCookieSize = 1200
|
|
|
|
// absoluteSessionTimeout defines the maximum lifetime of a session
|
|
// regardless of activity (24 hours)
|
|
absoluteSessionTimeout = 24 * time.Hour
|
|
|
|
// minEncryptionKeyLength defines the minimum length for the encryption key
|
|
minEncryptionKeyLength = 32
|
|
)
|
|
|
|
// compressToken compresses JWT tokens using gzip and encodes the result with base64.
|
|
// It validates JWT format before compression and includes size limits for safety.
|
|
//
|
|
// Parameters:
|
|
// - token: The string to compress.
|
|
//
|
|
// Returns:
|
|
// - The base64 encoded, gzipped string, or the original string if compression fails.
|
|
func compressToken(token string) string {
|
|
if token == "" {
|
|
return token
|
|
}
|
|
|
|
// Only compress valid JWT tokens (must have exactly 2 dots)
|
|
dotCount := strings.Count(token, ".")
|
|
if dotCount != 2 {
|
|
return token
|
|
}
|
|
|
|
// Add size validation - tokens over 50KB are likely corrupted
|
|
if len(token) > 50*1024 {
|
|
return token
|
|
}
|
|
|
|
// Use memory pool for efficient buffer management
|
|
pools := GetGlobalMemoryPools()
|
|
b := pools.GetCompressionBuffer()
|
|
defer pools.PutCompressionBuffer(b)
|
|
|
|
gz := gzip.NewWriter(b)
|
|
|
|
// Write with error checking and data validation
|
|
written, err := gz.Write([]byte(token))
|
|
if err != nil || written != len(token) {
|
|
return token
|
|
}
|
|
|
|
if err := gz.Close(); err != nil {
|
|
return token
|
|
}
|
|
|
|
// Validate compressed data before base64 encoding
|
|
compressedBytes := b.Bytes()
|
|
if len(compressedBytes) == 0 {
|
|
return token
|
|
}
|
|
|
|
compressed := base64.StdEncoding.EncodeToString(compressedBytes)
|
|
|
|
// Don't compress if it doesn't save significant space
|
|
if len(compressed) >= len(token) {
|
|
return token
|
|
}
|
|
|
|
// Comprehensive integrity verification with rollback on failure
|
|
decompressed := decompressTokenInternal(compressed)
|
|
if decompressed != token {
|
|
// Compression/decompression integrity failure - return original
|
|
return token
|
|
}
|
|
|
|
// Final validation that decompressed token is still valid JWT
|
|
if strings.Count(decompressed, ".") != 2 {
|
|
return token
|
|
}
|
|
|
|
return compressed
|
|
}
|
|
|
|
// decompressToken decodes a standard base64 encoded string and then decompresses the result using gzip.
|
|
// Enhanced decompression with strict error handling and validation.
|
|
//
|
|
// Parameters:
|
|
// - compressed: The base64 encoded, gzipped string.
|
|
//
|
|
// Returns:
|
|
// - The decompressed original string, or the input string if decompression fails.
|
|
func decompressToken(compressed string) string {
|
|
return decompressTokenInternal(compressed)
|
|
}
|
|
|
|
// decompressTokenInternal performs the actual decompression with enhanced error handling
|
|
// Separated internal function for integrity verification during compression
|
|
func decompressTokenInternal(compressed string) string {
|
|
if compressed == "" {
|
|
return compressed
|
|
}
|
|
|
|
// Size validation to prevent excessive memory usage
|
|
if len(compressed) > 100*1024 { // 100KB limit for compressed data
|
|
return compressed
|
|
}
|
|
|
|
data, err := base64.StdEncoding.DecodeString(compressed)
|
|
if err != nil {
|
|
// Base64 decode failed - return original assuming it's uncompressed
|
|
return compressed
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
return compressed
|
|
}
|
|
|
|
// Validate gzip magic number before attempting decompression
|
|
if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
|
|
// Not gzip format - return original assuming it's uncompressed
|
|
return compressed
|
|
}
|
|
|
|
// Use memory pool for efficient buffer management
|
|
pools := GetGlobalMemoryPools()
|
|
readerBuf := pools.GetHTTPResponseBuffer()
|
|
defer pools.PutHTTPResponseBuffer(readerBuf)
|
|
|
|
gz, err := gzip.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
// Gzip reader creation failed - return original
|
|
return compressed
|
|
}
|
|
|
|
// Ensure proper cleanup with error handling
|
|
defer func() {
|
|
if closeErr := gz.Close(); closeErr != nil {
|
|
// Log error but don't fail the operation
|
|
_ = closeErr // Explicitly ignore the error
|
|
}
|
|
}()
|
|
|
|
// Limit decompressed size to prevent memory exhaustion attacks
|
|
limitedReader := io.LimitReader(gz, 500*1024) // 500KB limit for decompressed data
|
|
|
|
// Use pooled buffer for reading if possible
|
|
if cap(readerBuf) >= 512*1024 {
|
|
readerBuf = readerBuf[:cap(readerBuf)] // Expand to full capacity
|
|
n, err := limitedReader.Read(readerBuf)
|
|
if err != nil && err != io.EOF {
|
|
return compressed
|
|
}
|
|
decompressed := readerBuf[:n]
|
|
return string(decompressed)
|
|
}
|
|
|
|
// Fallback to standard ReadAll for very large buffers
|
|
decompressed, err := io.ReadAll(limitedReader)
|
|
if err != nil {
|
|
// Gzip decompression failed - return original
|
|
return compressed
|
|
}
|
|
|
|
if len(decompressed) == 0 {
|
|
return compressed
|
|
}
|
|
|
|
decompressedStr := string(decompressed)
|
|
|
|
// Validate decompressed content looks like a JWT
|
|
if decompressedStr != "" && strings.Count(decompressedStr, ".") != 2 {
|
|
// Decompressed content doesn't look like valid JWT - return original
|
|
return compressed
|
|
}
|
|
|
|
return decompressedStr
|
|
}
|
|
|
|
// SessionManager handles the management of multiple session cookies for OIDC authentication.
|
|
// It provides functionality for storing and retrieving authentication state, tokens,
|
|
// and other session-related data across multiple cookies.
|
|
// SessionManager manages OIDC session data for the middleware.
|
|
// It handles session creation, retrieval, and storage using encrypted cookies.
|
|
// Large tokens are automatically chunked across multiple cookies to handle
|
|
// browser size limitations. The manager uses a sync.Pool for efficient
|
|
// session object reuse and supports both HTTP and HTTPS schemes.
|
|
type SessionManager struct {
|
|
sessionPool sync.Pool
|
|
store sessions.Store
|
|
logger *Logger
|
|
forceHTTPS bool
|
|
chunkManager *ChunkManager
|
|
}
|
|
|
|
// NewSessionManager creates a new session manager with the specified configuration.
|
|
// Parameters:
|
|
// - encryptionKey: Key used to encrypt session data (must be at least 32 bytes)
|
|
// - forceHTTPS: When true, forces secure cookie attributes regardless of request scheme
|
|
// - logger: Logger instance for recording session-related events
|
|
//
|
|
// Returns an error if the encryption key does not meet minimum length requirements.
|
|
func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*SessionManager, error) {
|
|
// Validate encryption key length.
|
|
if len(encryptionKey) < minEncryptionKeyLength {
|
|
return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength)
|
|
}
|
|
|
|
sm := &SessionManager{
|
|
store: sessions.NewCookieStore([]byte(encryptionKey)),
|
|
forceHTTPS: forceHTTPS,
|
|
logger: logger,
|
|
chunkManager: NewChunkManager(logger),
|
|
}
|
|
|
|
sm.sessionPool.New = func() interface{} {
|
|
sd := &SessionData{
|
|
manager: sm,
|
|
accessTokenChunks: make(map[int]*sessions.Session),
|
|
refreshTokenChunks: make(map[int]*sessions.Session),
|
|
idTokenChunks: make(map[int]*sessions.Session),
|
|
refreshMutex: sync.Mutex{}, // Initialize the mutex
|
|
sessionMutex: sync.RWMutex{}, // Initialize the session mutex
|
|
dirty: false, // Initialize dirty flag
|
|
inUse: false, // Initialize in-use flag
|
|
}
|
|
sd.Reset()
|
|
return sd
|
|
}
|
|
|
|
return sm, nil
|
|
}
|
|
|
|
// PeriodicChunkCleanup performs periodic maintenance on session chunks.
|
|
// It identifies and removes orphaned chunks that are no longer associated
|
|
// with active sessions. This method should be called periodically to
|
|
// prevent cookie accumulation in client browsers.
|
|
func (sm *SessionManager) PeriodicChunkCleanup() {
|
|
sm.logger.Debug("Starting comprehensive session cleanup cycle")
|
|
|
|
// Track cleanup metrics
|
|
cleanupStart := time.Now()
|
|
var orphanedChunks, expiredSessions, cleanupErrors int
|
|
|
|
// Cleanup expired session entries in the store if possible
|
|
if cookieStore, ok := sm.store.(*sessions.CookieStore); ok {
|
|
sm.logger.Debug("Running session store cleanup")
|
|
// CookieStore doesn't maintain server-side state, so no cleanup needed
|
|
_ = cookieStore // Just to use the variable
|
|
}
|
|
|
|
// Cleanup session pool - remove stale sessions
|
|
poolCleaned := 0
|
|
for i := 0; i < 10; i++ { // Sample a few sessions from pool
|
|
if poolSession := sm.sessionPool.Get(); poolSession != nil {
|
|
sessionData := poolSession.(*SessionData)
|
|
if sessionData != nil && !sessionData.inUse {
|
|
// Reset stale session data
|
|
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 health checks on session data
|
|
// to detect corruption, tampering, or inconsistencies that could indicate security issues.
|
|
//
|
|
// Parameters:
|
|
// - sessionData: The session data to validate
|
|
//
|
|
// Returns:
|
|
// - error: nil if session is healthy, otherwise an error describing the issue
|
|
//
|
|
// ValidateSessionHealth performs comprehensive health checks on a session.
|
|
// It validates authentication state, token formats, and checks for signs
|
|
// of session tampering or corruption.
|
|
//
|
|
// Parameters:
|
|
// - sessionData: The session to validate.
|
|
//
|
|
// Returns:
|
|
// - nil if the session is healthy.
|
|
// - An error describing any validation failures.
|
|
func (sm *SessionManager) ValidateSessionHealth(sessionData *SessionData) error {
|
|
if sessionData == nil {
|
|
return fmt.Errorf("session data is nil")
|
|
}
|
|
|
|
// Validate session isn't expired
|
|
if !sessionData.GetAuthenticated() {
|
|
return fmt.Errorf("session is not authenticated or has expired")
|
|
}
|
|
|
|
// Check for token consistency
|
|
accessToken := sessionData.GetAccessToken()
|
|
refreshToken := sessionData.GetRefreshToken()
|
|
idToken := sessionData.GetIDToken()
|
|
|
|
// Validate access token if present
|
|
if accessToken != "" {
|
|
if err := sm.validateTokenFormat(accessToken, "access_token"); err != nil {
|
|
return fmt.Errorf("access token validation failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Validate refresh token if present
|
|
if refreshToken != "" {
|
|
if err := sm.validateTokenFormat(refreshToken, "refresh_token"); err != nil {
|
|
return fmt.Errorf("refresh token validation failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Validate ID token if present
|
|
if idToken != "" {
|
|
if err := sm.validateTokenFormat(idToken, "id_token"); err != nil {
|
|
return fmt.Errorf("ID token validation failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Check for session tampering indicators
|
|
if err := sm.detectSessionTampering(sessionData); err != nil {
|
|
return fmt.Errorf("session tampering detected: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateTokenFormat performs basic structural validation on tokens.
|
|
// It checks for proper JWT format and validates against maximum size limits.
|
|
//
|
|
// Parameters:
|
|
// - token: The token string to validate.
|
|
// - tokenType: The type of token (for error messages).
|
|
//
|
|
// Returns:
|
|
// - nil if the token format is valid.
|
|
// - An error if the token has invalid structure or exceeds size limits.
|
|
func (sm *SessionManager) validateTokenFormat(token, tokenType string) error {
|
|
if token == "" {
|
|
return nil // Empty tokens are allowed
|
|
}
|
|
|
|
// Check for corruption markers
|
|
if isCorruptionMarker(token) {
|
|
return fmt.Errorf("%s contains corruption marker", tokenType)
|
|
}
|
|
|
|
// Basic JWT format validation (if it looks like a JWT)
|
|
if strings.Count(token, ".") == 2 {
|
|
parts := strings.Split(token, ".")
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
return fmt.Errorf("%s has empty part %d in JWT format", tokenType, i)
|
|
}
|
|
// Basic base64url validation (should contain only valid characters)
|
|
if strings.ContainsAny(part, "+/=") && !strings.ContainsAny(part, "-_") {
|
|
sm.logger.Debugf("Token %s part %d uses base64 instead of base64url encoding", tokenType, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// detectSessionTampering checks for indicators of session tampering
|
|
func (sm *SessionManager) detectSessionTampering(sessionData *SessionData) error {
|
|
if sessionData.mainSession == nil {
|
|
return fmt.Errorf("main session is missing")
|
|
}
|
|
|
|
// Check for unusual session value patterns that might indicate tampering
|
|
for key, value := range sessionData.mainSession.Values {
|
|
if str, ok := value.(string); ok {
|
|
// Check for common tampering patterns
|
|
if strings.Contains(str, "../") || strings.Contains(str, "..\\") {
|
|
return fmt.Errorf("potential path traversal attempt in session key %v", key)
|
|
}
|
|
if strings.Contains(str, "<script") || strings.Contains(str, "javascript:") {
|
|
return fmt.Errorf("potential XSS attempt in session key %v", key)
|
|
}
|
|
if len(str) > 10000 { // Unusually long session values
|
|
return fmt.Errorf("suspiciously long session value for key %v (length: %d)", key, len(str))
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetSessionMetrics returns metrics about session management for monitoring purposes
|
|
func (sm *SessionManager) GetSessionMetrics() map[string]interface{} {
|
|
metrics := make(map[string]interface{})
|
|
metrics["session_manager_type"] = "CookieStore"
|
|
metrics["force_https"] = sm.forceHTTPS
|
|
metrics["absolute_timeout_hours"] = absoluteSessionTimeout.Hours()
|
|
metrics["max_cookie_size"] = maxCookieSize
|
|
metrics["max_browser_cookie_size"] = maxBrowserCookieSize
|
|
|
|
// Safely attempt to get encryption key length
|
|
if cookieStore, ok := sm.store.(*sessions.CookieStore); ok && len(cookieStore.Codecs) > 0 {
|
|
metrics["has_encryption"] = true
|
|
metrics["codec_count"] = len(cookieStore.Codecs)
|
|
} else {
|
|
metrics["has_encryption"] = false
|
|
}
|
|
|
|
// sync.Pool doesn't expose internal statistics
|
|
metrics["pool_implementation"] = "sync.Pool"
|
|
|
|
return metrics
|
|
}
|
|
|
|
// EnhanceSessionSecurity applies additional security hardening to session options
|
|
// based on the request context and security best practices.
|
|
//
|
|
// Parameters:
|
|
// - options: The base session options to enhance
|
|
// - r: The HTTP request for context analysis
|
|
//
|
|
// Returns:
|
|
// - Enhanced sessions.Options with additional security measures
|
|
func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *http.Request) *sessions.Options {
|
|
if options == nil {
|
|
options = &sessions.Options{}
|
|
}
|
|
|
|
// Apply security settings based on request context
|
|
if r != nil {
|
|
// Check for suspicious request patterns
|
|
userAgent := r.Header.Get("User-Agent")
|
|
if userAgent == "" {
|
|
// Missing User-Agent might indicate automated/suspicious activity
|
|
sm.logger.Debugf("Request from %s missing User-Agent header", r.RemoteAddr)
|
|
// Reduce session timeout for suspicious requests
|
|
options.MaxAge = int((absoluteSessionTimeout / 2).Seconds())
|
|
}
|
|
|
|
// Apply stricter security settings for production
|
|
if r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sm.forceHTTPS {
|
|
options.Secure = true
|
|
// Enable strict same-site policy for secure connections
|
|
options.SameSite = http.SameSiteStrictMode
|
|
}
|
|
|
|
// Additional security headers analysis
|
|
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
|
// AJAX requests get stricter same-site policy
|
|
options.SameSite = http.SameSiteStrictMode
|
|
}
|
|
}
|
|
|
|
// Always enforce security best practices
|
|
options.HttpOnly = true
|
|
|
|
// Set secure Domain attribute for production
|
|
if options.Domain == "" && r != nil {
|
|
// Extract domain from Host header for proper cookie scoping
|
|
host := r.Host
|
|
if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") {
|
|
// Only set domain for non-local development
|
|
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
|
|
host = host[:colonIndex] // Remove port
|
|
}
|
|
options.Domain = host
|
|
}
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
// getSessionOptions returns a sessions.Options struct configured with security best practices.
|
|
// It sets HttpOnly to true, Secure based on the request scheme or forceHTTPS setting,
|
|
// SameSite to LaxMode, MaxAge to the absoluteSessionTimeout, and Path to "/".
|
|
//
|
|
// Parameters:
|
|
// - isSecure: A boolean indicating if the current request context is secure (HTTPS).
|
|
//
|
|
// Returns:
|
|
// - A pointer to a configured sessions.Options struct.
|
|
func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
|
|
baseOptions := &sessions.Options{
|
|
HttpOnly: true,
|
|
Secure: isSecure || sm.forceHTTPS,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: int(absoluteSessionTimeout.Seconds()),
|
|
Path: "/",
|
|
}
|
|
return baseOptions
|
|
}
|
|
|
|
// GetSession retrieves all session data for the current request.
|
|
// It loads the main session and token sessions, including any chunked token data,
|
|
// and combines them into a single SessionData structure for easy access.
|
|
// Returns an error if any session component cannot be loaded.
|
|
func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
|
|
sessionData := sm.sessionPool.Get().(*SessionData)
|
|
|
|
sessionData.inUse = true
|
|
sessionData.request = r
|
|
sessionData.dirty = false
|
|
|
|
var sessionReturned bool
|
|
defer func() {
|
|
if !sessionReturned && sessionData != nil {
|
|
if r := recover(); r != nil {
|
|
sessionData.inUse = false
|
|
sessionData.Reset()
|
|
sm.sessionPool.Put(sessionData)
|
|
panic(r)
|
|
}
|
|
}
|
|
}()
|
|
|
|
handleError := func(err error, message string) (*SessionData, error) {
|
|
if sessionData != nil && !sessionReturned {
|
|
sessionData.inUse = false
|
|
sessionData.Reset()
|
|
sm.sessionPool.Put(sessionData)
|
|
sessionReturned = true
|
|
}
|
|
return nil, fmt.Errorf("%s: %w", message, err)
|
|
}
|
|
|
|
var err error
|
|
sessionData.mainSession, err = sm.store.Get(r, mainCookieName)
|
|
if err != nil {
|
|
return handleError(err, "failed to get main session")
|
|
}
|
|
|
|
if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok {
|
|
if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout {
|
|
sessionData.Clear(r, nil)
|
|
return handleError(fmt.Errorf("session timeout"), "session expired")
|
|
}
|
|
}
|
|
|
|
sessionData.accessSession, err = sm.store.Get(r, accessTokenCookie)
|
|
if err != nil {
|
|
return handleError(err, "failed to get access token session")
|
|
}
|
|
|
|
sessionData.refreshSession, err = sm.store.Get(r, refreshTokenCookie)
|
|
if err != nil {
|
|
return handleError(err, "failed to get refresh token session")
|
|
}
|
|
|
|
sessionData.idTokenSession, err = sm.store.Get(r, idTokenCookie)
|
|
if err != nil {
|
|
return handleError(err, "failed to get ID token session")
|
|
}
|
|
|
|
// Clear and reuse chunk maps.
|
|
for k := range sessionData.accessTokenChunks {
|
|
delete(sessionData.accessTokenChunks, k)
|
|
}
|
|
for k := range sessionData.refreshTokenChunks {
|
|
delete(sessionData.refreshTokenChunks, k)
|
|
}
|
|
for k := range sessionData.idTokenChunks {
|
|
delete(sessionData.idTokenChunks, k)
|
|
}
|
|
|
|
// Retrieve chunked token sessions.
|
|
sm.getTokenChunkSessions(r, accessTokenCookie, sessionData.accessTokenChunks)
|
|
sm.getTokenChunkSessions(r, refreshTokenCookie, sessionData.refreshTokenChunks)
|
|
sm.getTokenChunkSessions(r, idTokenCookie, sessionData.idTokenChunks)
|
|
|
|
sessionReturned = false
|
|
return sessionData, nil
|
|
}
|
|
|
|
// getTokenChunkSessions retrieves all cookie chunks associated with a large token (access or refresh).
|
|
// It iteratively attempts to load cookies named "{baseName}_0", "{baseName}_1", etc., until
|
|
// a cookie is not found or returns an error. The loaded sessions are stored in the provided chunks map.
|
|
//
|
|
// Parameters:
|
|
// - r: The incoming HTTP request containing the cookies.
|
|
// - baseName: The base name of the cookie (e.g., accessTokenCookie).
|
|
// - chunks: The map (typically SessionData.accessTokenChunks or SessionData.refreshTokenChunks) to populate with the found session chunks.
|
|
func (sm *SessionManager) getTokenChunkSessions(r *http.Request, baseName string, chunks map[int]*sessions.Session) {
|
|
for i := 0; ; i++ {
|
|
sessionName := fmt.Sprintf("%s_%d", baseName, i)
|
|
session, err := sm.store.Get(r, sessionName)
|
|
if err != nil || session.IsNew {
|
|
break
|
|
}
|
|
chunks[i] = session
|
|
}
|
|
}
|
|
|
|
// SessionData holds all session information for an authenticated user.
|
|
// It manages multiple session cookies to handle the main session state
|
|
// and potentially large access and refresh tokens that may need to be
|
|
// split across multiple cookies due to browser size limitations.
|
|
type SessionData struct {
|
|
// manager is the SessionManager that created this SessionData.
|
|
manager *SessionManager
|
|
|
|
// request is the current HTTP request associated with this session.
|
|
request *http.Request
|
|
|
|
// mainSession stores authentication state and basic user info.
|
|
mainSession *sessions.Session
|
|
|
|
// accessSession stores the primary access token cookie.
|
|
accessSession *sessions.Session
|
|
|
|
// refreshSession stores the primary refresh token cookie.
|
|
refreshSession *sessions.Session
|
|
|
|
// idTokenSession stores the primary ID token cookie.
|
|
idTokenSession *sessions.Session
|
|
|
|
// accessTokenChunks stores additional chunks of the access token
|
|
// when it exceeds the maximum cookie size.
|
|
accessTokenChunks map[int]*sessions.Session
|
|
|
|
// refreshTokenChunks stores additional chunks of the refresh token
|
|
// when it exceeds the maximum cookie size.
|
|
refreshTokenChunks map[int]*sessions.Session
|
|
|
|
// idTokenChunks stores additional chunks of the ID token
|
|
// when it exceeds the maximum cookie size.
|
|
idTokenChunks map[int]*sessions.Session
|
|
|
|
// refreshMutex protects refresh token operations within this session instance.
|
|
refreshMutex sync.Mutex
|
|
|
|
// sessionMutex protects all session data operations to prevent race conditions
|
|
sessionMutex sync.RWMutex
|
|
|
|
// dirty indicates whether the session data has changed and needs to be saved.
|
|
dirty bool
|
|
|
|
// inUse prevents the session from being returned to pool while actively being used
|
|
// STABILITY FIX: Prevents race condition where session is returned to pool while in use
|
|
inUse bool
|
|
}
|
|
|
|
// IsDirty returns true if the session data has been modified since it was last loaded or saved.
|
|
func (sd *SessionData) IsDirty() bool {
|
|
return sd.dirty
|
|
}
|
|
|
|
// MarkDirty explicitly sets the dirty flag to true.
|
|
// This can be used when an operation doesn't change session data
|
|
// but should still trigger a session save (e.g., to ensure the cookie is re-issued).
|
|
func (sd *SessionData) MarkDirty() {
|
|
sd.dirty = true
|
|
}
|
|
|
|
// Save persists all parts of the session (main, access token, refresh token, and any chunks)
|
|
// back to the client as cookies in the HTTP response. It applies secure cookie options
|
|
// obtained via getSessionOptions based on the request's security context.
|
|
//
|
|
// Parameters:
|
|
// - r: The original HTTP request (used to determine security context for cookie options).
|
|
// - w: The HTTP response writer to which the Set-Cookie headers will be added.
|
|
//
|
|
// Returns:
|
|
// - An error if saving any of the session components fails.
|
|
func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
|
|
isSecure := strings.HasPrefix(r.URL.Scheme, "https") || sd.manager.forceHTTPS
|
|
|
|
// Set options for all sessions.
|
|
options := sd.manager.getSessionOptions(isSecure)
|
|
sd.mainSession.Options = options
|
|
sd.accessSession.Options = options
|
|
sd.refreshSession.Options = options
|
|
|
|
var firstErr error
|
|
// Helper to record first error and log subsequent ones
|
|
saveOrLogError := func(s *sessions.Session, name string) {
|
|
if s == nil { // Should not happen if initialized correctly
|
|
sd.manager.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)
|
|
sd.manager.logger.Error(errMsg.Error())
|
|
if firstErr == nil {
|
|
firstErr = errMsg
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save main session.
|
|
saveOrLogError(sd.mainSession, "main")
|
|
|
|
// Save access token session.
|
|
saveOrLogError(sd.accessSession, "access token")
|
|
|
|
// Save refresh token session.
|
|
saveOrLogError(sd.refreshSession, "refresh token")
|
|
|
|
// Save ID token session.
|
|
saveOrLogError(sd.idTokenSession, "ID token")
|
|
|
|
// Save access token chunks.
|
|
for i, sessionChunk := range sd.accessTokenChunks {
|
|
sessionChunk.Options = options
|
|
saveOrLogError(sessionChunk, fmt.Sprintf("access token chunk %d", i))
|
|
}
|
|
|
|
// Save refresh token chunks.
|
|
for i, sessionChunk := range sd.refreshTokenChunks {
|
|
sessionChunk.Options = options
|
|
saveOrLogError(sessionChunk, fmt.Sprintf("refresh token chunk %d", i))
|
|
}
|
|
|
|
// Save ID token chunks.
|
|
for i, sessionChunk := range sd.idTokenChunks {
|
|
sessionChunk.Options = options
|
|
saveOrLogError(sessionChunk, fmt.Sprintf("ID token chunk %d", i))
|
|
}
|
|
|
|
if firstErr == nil {
|
|
sd.dirty = false // Reset dirty flag only if all saves were successful
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
// clearSessionValues clears all values from a session and optionally sets MaxAge to -1 to expire the cookie
|
|
// Parameters:
|
|
// - session: The session to clear
|
|
// - expire: If true, sets MaxAge to -1 to expire the cookie
|
|
func clearSessionValues(session *sessions.Session, expire bool) {
|
|
if session == nil {
|
|
return
|
|
}
|
|
|
|
// Clear all values
|
|
for k := range session.Values {
|
|
delete(session.Values, k)
|
|
}
|
|
|
|
// If expiring, set MaxAge to -1
|
|
if expire {
|
|
session.Options.MaxAge = -1
|
|
}
|
|
}
|
|
|
|
// clearAllSessionData clears values from all session objects (main, token sessions, and chunks)
|
|
// Parameters:
|
|
// - sd: The SessionData instance containing all sessions
|
|
// - r: The HTTP request (needed for chunk clearing)
|
|
// - expire: Whether to expire the cookies (set MaxAge to -1)
|
|
func (sd *SessionData) clearAllSessionData(r *http.Request, expire bool) {
|
|
// Clear main session and token sessions
|
|
clearSessionValues(sd.mainSession, expire)
|
|
clearSessionValues(sd.accessSession, expire)
|
|
clearSessionValues(sd.refreshSession, expire)
|
|
clearSessionValues(sd.idTokenSession, expire)
|
|
|
|
// If we need to expire cookies, clear token chunks
|
|
if expire && r != nil {
|
|
sd.clearTokenChunks(r, sd.accessTokenChunks)
|
|
sd.clearTokenChunks(r, sd.refreshTokenChunks)
|
|
sd.clearTokenChunks(r, sd.idTokenChunks)
|
|
} else {
|
|
// Just remove the chunks from memory without expiring cookies
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Mark session as dirty if we're changing state
|
|
if expire {
|
|
sd.dirty = true
|
|
}
|
|
}
|
|
|
|
// Clear removes all session data associated with this SessionData instance.
|
|
// It clears the values map of the main, access, and refresh sessions, sets their MaxAge to -1
|
|
// to expire the cookies immediately, and clears any associated token chunk cookies.
|
|
// If a ResponseWriter is provided, it attempts to save the expired sessions to send the
|
|
// expiring Set-Cookie headers. Finally, it clears internal fields and returns the SessionData
|
|
// object to the pool.
|
|
//
|
|
// Parameters:
|
|
// - r: The HTTP request (required by the underlying session store).
|
|
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
|
//
|
|
// Returns:
|
|
// - An error if saving the expired sessions fails (only if w is not nil).
|
|
//
|
|
// This method ensures the SessionData object is always returned to the pool.
|
|
func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error {
|
|
// Use defer to guarantee session is returned to pool regardless of any errors or panics
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
// Ensure session is returned to pool even on panic
|
|
sd.returnToPoolSafely()
|
|
panic(rec) // Re-panic after cleanup
|
|
}
|
|
// Normal path - return to pool
|
|
sd.returnToPoolSafely()
|
|
}()
|
|
|
|
// Lock session mutex to prevent race conditions during Clear
|
|
sd.sessionMutex.Lock()
|
|
defer sd.sessionMutex.Unlock()
|
|
|
|
// Clear all session data and expire cookies
|
|
sd.clearAllSessionData(r, true)
|
|
|
|
// Create a guaranteed error when the response writer is set
|
|
// This is primarily for testing - in production w will often be nil
|
|
var err error
|
|
if w != nil {
|
|
// Intentionally create a test error in session
|
|
if r != nil && r.Header.Get("X-Test-Error") == "true" {
|
|
sd.mainSession.Values["error_trigger"] = func() {} // Will cause marshaling to fail
|
|
}
|
|
|
|
// Try to save the expired sessions
|
|
err = sd.Save(r, w)
|
|
}
|
|
|
|
// Clear transient per-request fields.
|
|
sd.request = nil
|
|
|
|
// Return the error from Save, if any (defer will handle pool return)
|
|
return err
|
|
}
|
|
|
|
// Add thread-safe helper method to return session to pool
|
|
func (sd *SessionData) returnToPoolSafely() {
|
|
if sd != nil && sd.manager != nil {
|
|
// Check if already returned to prevent double-return
|
|
if sd.inUse {
|
|
sd.inUse = false
|
|
// Reset the session data before returning to pool to prevent data leakage
|
|
sd.Reset()
|
|
sd.manager.sessionPool.Put(sd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// clearTokenChunks iterates through a map of session chunks, clears their values,
|
|
// and sets their MaxAge to -1 to expire them. This is used internally by Clear.
|
|
//
|
|
// Parameters:
|
|
// - r: The HTTP request (required by the underlying session store, though not directly used here).
|
|
// - chunks: The map of session chunks (e.g., sd.accessTokenChunks) to clear and expire.
|
|
func (sd *SessionData) clearTokenChunks(r *http.Request, chunks map[int]*sessions.Session) {
|
|
for _, session := range chunks {
|
|
clearSessionValues(session, true) // Clear and expire the chunks
|
|
}
|
|
}
|
|
|
|
// GetAuthenticated checks if the session is marked as authenticated and has not exceeded
|
|
// the absolute session timeout.
|
|
//
|
|
// Returns:
|
|
// - true if the "authenticated" flag is set to true and the session creation time is within the allowed timeout.
|
|
// - false otherwise.
|
|
func (sd *SessionData) GetAuthenticated() bool {
|
|
sd.sessionMutex.RLock()
|
|
defer sd.sessionMutex.RUnlock()
|
|
|
|
return sd.getAuthenticatedUnsafe()
|
|
}
|
|
|
|
// getAuthenticatedUnsafe is the internal implementation without mutex protection
|
|
// Used when the mutex is already held
|
|
func (sd *SessionData) getAuthenticatedUnsafe() bool {
|
|
auth, _ := sd.mainSession.Values["authenticated"].(bool)
|
|
if !auth {
|
|
return false
|
|
}
|
|
|
|
// Check session expiration.
|
|
createdAt, ok := sd.mainSession.Values["created_at"].(int64)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return time.Since(time.Unix(createdAt, 0)) <= absoluteSessionTimeout
|
|
}
|
|
|
|
// SetAuthenticated sets the authentication status of the session.
|
|
// If setting to true, it generates a new secure session ID for the main session
|
|
// to prevent session fixation attacks and records the current time as the creation time.
|
|
//
|
|
// Parameters:
|
|
// - value: The boolean authentication status (true for authenticated, false otherwise).
|
|
//
|
|
// Returns:
|
|
// - An error if generating a new session ID fails when setting value to true.
|
|
func (sd *SessionData) SetAuthenticated(value bool) error {
|
|
sd.sessionMutex.Lock()
|
|
defer sd.sessionMutex.Unlock()
|
|
|
|
currentAuth := sd.getAuthenticatedUnsafe() // This checks flag and expiry
|
|
changed := false
|
|
|
|
if currentAuth != value {
|
|
changed = true
|
|
}
|
|
|
|
if value {
|
|
id, err := generateSecureRandomString(64)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate secure session id: %w", err)
|
|
}
|
|
|
|
maxRetries := 5
|
|
for retry := 0; retry < maxRetries; retry++ {
|
|
if sd.mainSession.ID != id {
|
|
break
|
|
}
|
|
id, err = generateSecureRandomString(64)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate secure session id on retry %d: %w", retry, err)
|
|
}
|
|
}
|
|
|
|
if sd.mainSession.ID != id {
|
|
changed = true
|
|
}
|
|
sd.mainSession.ID = id
|
|
newCreationTime := time.Now().Unix()
|
|
if oldTime, ok := sd.mainSession.Values["created_at"].(int64); !ok || oldTime != newCreationTime {
|
|
changed = true
|
|
}
|
|
sd.mainSession.Values["created_at"] = newCreationTime
|
|
if oldAuth, ok := sd.mainSession.Values["authenticated"].(bool); !ok || oldAuth != value {
|
|
changed = true
|
|
}
|
|
} else { // value is false
|
|
if oldAuth, ok := sd.mainSession.Values["authenticated"].(bool); !ok || oldAuth != value {
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
sd.mainSession.Values["authenticated"] = value
|
|
if changed {
|
|
sd.dirty = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resetSession resets a session by clearing its values and resetting its ID and IsNew flag
|
|
// This is specifically for pool reuse preparation
|
|
func resetSession(session *sessions.Session) {
|
|
if session == nil {
|
|
return
|
|
}
|
|
|
|
// Clear all values
|
|
clearSessionValues(session, false)
|
|
|
|
// Reset session ID and IsNew flag
|
|
session.ID = ""
|
|
session.IsNew = true
|
|
}
|
|
|
|
// Reset clears all session data and prepares the SessionData object for reuse.
|
|
// This method is called when returning objects to the pool to prevent data leakage
|
|
// between different users/sessions.
|
|
func (sd *SessionData) Reset() {
|
|
sd.sessionMutex.Lock()
|
|
defer sd.sessionMutex.Unlock()
|
|
|
|
// Clear all session data (but don't expire cookies, as this is internal cleanup)
|
|
sd.clearAllSessionData(nil, false)
|
|
|
|
// Reset session state for pool reuse
|
|
resetSession(sd.mainSession)
|
|
resetSession(sd.accessSession)
|
|
resetSession(sd.refreshSession)
|
|
resetSession(sd.idTokenSession)
|
|
|
|
// Reset other state
|
|
sd.dirty = false
|
|
sd.inUse = false
|
|
sd.request = nil
|
|
}
|
|
|
|
// ReturnToPool explicitly returns this SessionData object to the pool.
|
|
// This should be called when you're done with a SessionData in any error path
|
|
// where Clear() is not called, to prevent memory leaks.
|
|
func (sd *SessionData) ReturnToPool() {
|
|
if sd != nil && sd.manager != nil {
|
|
// STABILITY FIX: Only return to pool if not currently in use
|
|
if !sd.inUse {
|
|
// Reset the session data before returning to pool
|
|
sd.Reset()
|
|
sd.manager.sessionPool.Put(sd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetAccessToken retrieves the access token stored in the session.
|
|
// It handles reassembling the token from multiple cookie chunks if necessary
|
|
// and decompresses it if it was stored compressed.
|
|
//
|
|
// Returns:
|
|
// - The complete, decompressed access token string, or an empty string if not found.
|
|
func (sd *SessionData) GetAccessToken() string {
|
|
sd.sessionMutex.RLock()
|
|
defer sd.sessionMutex.RUnlock()
|
|
|
|
return sd.getAccessTokenUnsafe()
|
|
}
|
|
|
|
// getAccessTokenUnsafe is the internal implementation without mutex protection
|
|
// Enhanced token retrieval with comprehensive integrity checks and recovery mechanisms
|
|
func (sd *SessionData) getAccessTokenUnsafe() string {
|
|
token, _ := sd.accessSession.Values["token"].(string)
|
|
compressed, _ := sd.accessSession.Values["compressed"].(bool)
|
|
|
|
result := sd.manager.chunkManager.GetToken(
|
|
token,
|
|
compressed,
|
|
sd.accessTokenChunks,
|
|
AccessTokenConfig,
|
|
)
|
|
|
|
if result.Error != nil {
|
|
// Error already logged by ChunkManager
|
|
return ""
|
|
}
|
|
|
|
return result.Token
|
|
}
|
|
|
|
// SetAccessToken stores the provided access token in the session.
|
|
// It first expires any existing access token chunk cookies.
|
|
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
|
// it's stored directly in the primary access token session. Otherwise, the compressed token
|
|
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_a_0, _oidc_raczylo_a_1, etc.).
|
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned session chunks.
|
|
//
|
|
// Parameters:
|
|
// - token: The access token string to store.
|
|
func (sd *SessionData) SetAccessToken(token string) {
|
|
sd.sessionMutex.Lock()
|
|
defer sd.sessionMutex.Unlock()
|
|
|
|
// Validate token format during storage
|
|
if token != "" {
|
|
dotCount := strings.Count(token, ".")
|
|
// Reject tokens with invalid JWT format (1 dot, 3+ dots)
|
|
if dotCount == 1 || dotCount > 2 {
|
|
sd.manager.logger.Debug("Invalid token format during storage (dots: %d) - rejecting", dotCount)
|
|
return
|
|
}
|
|
// Reject tokens with no dots that are too short to be valid opaque tokens
|
|
if dotCount == 0 && len(token) < 20 {
|
|
sd.manager.logger.Debug("Token too short for opaque token (length: %d) - rejecting", len(token))
|
|
return
|
|
}
|
|
}
|
|
|
|
currentAccessToken := sd.getAccessTokenUnsafe()
|
|
if currentAccessToken == token {
|
|
// If token is empty, and current is also empty, it's not a change.
|
|
// This check handles both empty and non-empty identical cases.
|
|
return
|
|
}
|
|
sd.dirty = true
|
|
|
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned chunks
|
|
if sd.request != nil {
|
|
sd.expireAccessTokenChunksEnhanced(nil) // Enhanced cleanup with orphan detection
|
|
}
|
|
|
|
// Clear and prepare chunks map for new token.
|
|
for k := range sd.accessTokenChunks {
|
|
delete(sd.accessTokenChunks, k)
|
|
}
|
|
|
|
if token == "" { // Clearing the token
|
|
// STABILITY FIX: Add nil checks before accessing session values
|
|
if sd.accessSession != nil {
|
|
sd.accessSession.Values["token"] = ""
|
|
sd.accessSession.Values["compressed"] = false
|
|
}
|
|
// sd.accessTokenChunks is already cleared
|
|
return
|
|
}
|
|
|
|
// Compress token with validation
|
|
compressed := compressToken(token)
|
|
|
|
// Validate size after compression
|
|
if len(compressed) > 100*1024 { // 100KB limit for final token (post-compression)
|
|
sd.manager.logger.Info("Access token too large after compression (%d bytes) - storing uncompressed", len(compressed))
|
|
return
|
|
}
|
|
|
|
// Verify compression didn't corrupt the token
|
|
if compressed != token { // Was compressed
|
|
testDecompressed := decompressToken(compressed)
|
|
if testDecompressed != token {
|
|
sd.manager.logger.Debug("Access token compression verification failed - storing uncompressed")
|
|
compressed = token // Fall back to uncompressed
|
|
}
|
|
}
|
|
|
|
if len(compressed) <= maxCookieSize {
|
|
// STABILITY FIX: Add nil checks before accessing session values
|
|
if sd.accessSession != nil {
|
|
sd.accessSession.Values["token"] = compressed
|
|
sd.accessSession.Values["compressed"] = (compressed != token)
|
|
}
|
|
} else {
|
|
// Enhanced chunking with validation
|
|
if sd.accessSession != nil {
|
|
sd.accessSession.Values["token"] = "" // Main cookie won't hold the token directly
|
|
sd.accessSession.Values["compressed"] = (compressed != token) // Data in chunks is compressed
|
|
}
|
|
|
|
chunks := splitIntoChunks(compressed, maxCookieSize)
|
|
|
|
// Validate chunk creation
|
|
if len(chunks) == 0 {
|
|
sd.manager.logger.Error("Failed to create chunks for access token")
|
|
return
|
|
}
|
|
|
|
if len(chunks) > 50 {
|
|
sd.manager.logger.Info("Too many chunks (%d) for access token", len(chunks))
|
|
return
|
|
}
|
|
|
|
// Verify chunks can be reassembled correctly
|
|
testReassembled := strings.Join(chunks, "")
|
|
if testReassembled != compressed {
|
|
sd.manager.logger.Debug("Access token chunk reassembly test failed")
|
|
return
|
|
}
|
|
|
|
for i, chunkData := range chunks {
|
|
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
|
|
|
|
// Ensure sd.request is available
|
|
if sd.request == nil {
|
|
sd.manager.logger.Error("SetAccessToken: sd.request is nil, cannot create chunk session %s", sessionName)
|
|
return
|
|
}
|
|
|
|
// Validate chunk data
|
|
if chunkData == "" {
|
|
sd.manager.logger.Debug("Empty chunk data at index %d", i)
|
|
return
|
|
}
|
|
|
|
if len(chunkData) > maxCookieSize {
|
|
sd.manager.logger.Info("Chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize)
|
|
return
|
|
}
|
|
|
|
// Validate that chunk won't exceed browser cookie limits after encoding
|
|
if !validateChunkSize(chunkData) {
|
|
sd.manager.logger.Errorf("CRITICAL: Chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData))
|
|
return
|
|
}
|
|
|
|
session, err := sd.manager.store.Get(sd.request, sessionName)
|
|
if err != nil {
|
|
sd.manager.logger.Errorf("CRITICAL: Failed to get chunk session %s: %v", sessionName, err)
|
|
return
|
|
}
|
|
|
|
session.Values["token_chunk"] = chunkData
|
|
session.Values["compressed"] = (compressed != token) // Store compression flag in each chunk
|
|
// MEDIUM IMPACT FIX: Add timestamp to track chunk creation for orphan detection
|
|
session.Values["chunk_created_at"] = time.Now().Unix()
|
|
sd.accessTokenChunks[i] = session
|
|
}
|
|
|
|
sd.manager.logger.Debugf("SUCCESS: Stored access token in %d chunks", len(chunks))
|
|
}
|
|
}
|
|
|
|
// GetRefreshToken retrieves the refresh token stored in the session.
|
|
// Enhanced refresh token retrieval with comprehensive integrity checks and recovery mechanisms
|
|
//
|
|
// Returns:
|
|
// - The complete, decompressed refresh token string, or an empty string if not found.
|
|
func (sd *SessionData) GetRefreshToken() string {
|
|
sd.sessionMutex.RLock()
|
|
defer sd.sessionMutex.RUnlock()
|
|
|
|
token, _ := sd.refreshSession.Values["token"].(string)
|
|
compressed, _ := sd.refreshSession.Values["compressed"].(bool)
|
|
|
|
result := sd.manager.chunkManager.GetToken(
|
|
token,
|
|
compressed,
|
|
sd.refreshTokenChunks,
|
|
RefreshTokenConfig,
|
|
)
|
|
|
|
if result.Error != nil {
|
|
// Error already logged by ChunkManager
|
|
return ""
|
|
}
|
|
|
|
return result.Token
|
|
}
|
|
|
|
// SetRefreshToken stores the provided refresh token in the session.
|
|
// It first expires any existing refresh token chunk cookies.
|
|
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
|
// it's stored directly in the primary refresh token session. Otherwise, the compressed token
|
|
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_r_0, _oidc_raczylo_r_1, etc.).
|
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned session chunks.
|
|
//
|
|
// Parameters:
|
|
// - token: The refresh token string to store.
|
|
func (sd *SessionData) SetRefreshToken(token string) {
|
|
// Add mutex protection for refresh token storage
|
|
sd.sessionMutex.Lock()
|
|
defer sd.sessionMutex.Unlock()
|
|
|
|
// Validate refresh token size to prevent storage corruption
|
|
if len(token) > 50*1024 {
|
|
sd.manager.logger.Errorf("CRITICAL: Refresh token too large (%d bytes) - possible corruption, rejecting", len(token))
|
|
return
|
|
}
|
|
|
|
// Get current refresh token without mutex to avoid deadlock since we already hold the lock
|
|
var currentRefreshToken string
|
|
// Inline the GetRefreshToken logic without mutex
|
|
sessionToken, _ := sd.refreshSession.Values["token"].(string)
|
|
if sessionToken != "" {
|
|
compressed, _ := sd.refreshSession.Values["compressed"].(bool)
|
|
if compressed {
|
|
decompressed := decompressToken(sessionToken)
|
|
// decompressToken handles backward compatibility by returning original token if decompression fails
|
|
currentRefreshToken = decompressed
|
|
} else {
|
|
currentRefreshToken = sessionToken
|
|
}
|
|
} else if len(sd.refreshTokenChunks) > 0 {
|
|
// Simplified chunked token retrieval for deadlock prevention
|
|
var chunks []string
|
|
for i := 0; i < len(sd.refreshTokenChunks); i++ {
|
|
if session, ok := sd.refreshTokenChunks[i]; ok {
|
|
if chunk, chunkOk := session.Values["token_chunk"].(string); chunkOk && chunk != "" {
|
|
chunks = append(chunks, chunk)
|
|
}
|
|
}
|
|
}
|
|
if len(chunks) == len(sd.refreshTokenChunks) {
|
|
reassembled := strings.Join(chunks, "")
|
|
compressed, _ := sd.refreshSession.Values["compressed"].(bool)
|
|
if compressed {
|
|
currentRefreshToken = decompressToken(reassembled)
|
|
} else {
|
|
currentRefreshToken = reassembled
|
|
}
|
|
}
|
|
}
|
|
if currentRefreshToken == token {
|
|
return
|
|
}
|
|
sd.dirty = true
|
|
|
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned chunks
|
|
if sd.request != nil {
|
|
sd.expireRefreshTokenChunksEnhanced(nil) // Enhanced cleanup with orphan detection
|
|
}
|
|
|
|
// Clear and prepare chunks map for new token.
|
|
for k := range sd.refreshTokenChunks {
|
|
delete(sd.refreshTokenChunks, k)
|
|
}
|
|
|
|
if token == "" { // Clearing the token
|
|
sd.refreshSession.Values["token"] = ""
|
|
sd.refreshSession.Values["compressed"] = false
|
|
// sd.refreshTokenChunks is already cleared
|
|
return
|
|
}
|
|
|
|
// Compress token with validation
|
|
compressed := compressToken(token)
|
|
|
|
// Verify compression didn't corrupt the refresh token
|
|
if compressed != token { // Was compressed
|
|
testDecompressed := decompressToken(compressed)
|
|
if testDecompressed != token {
|
|
sd.manager.logger.Errorf("CRITICAL: Refresh token compression verification failed - storing uncompressed")
|
|
compressed = token // Fall back to uncompressed
|
|
}
|
|
}
|
|
|
|
if len(compressed) <= maxCookieSize {
|
|
sd.refreshSession.Values["token"] = compressed
|
|
sd.refreshSession.Values["compressed"] = (compressed != token)
|
|
} else {
|
|
// Enhanced chunking with validation for refresh token
|
|
sd.refreshSession.Values["token"] = "" // Main cookie won't hold the token directly
|
|
sd.refreshSession.Values["compressed"] = (compressed != token) // Data in chunks is compressed
|
|
|
|
chunks := splitIntoChunks(compressed, maxCookieSize)
|
|
|
|
// Validate chunk creation
|
|
if len(chunks) == 0 {
|
|
sd.manager.logger.Errorf("CRITICAL: Failed to create chunks for refresh token")
|
|
return
|
|
}
|
|
|
|
if len(chunks) > 50 {
|
|
sd.manager.logger.Errorf("CRITICAL: Too many chunks (%d) for refresh token - possible corruption", len(chunks))
|
|
return
|
|
}
|
|
|
|
// Verify chunks can be reassembled correctly
|
|
testReassembled := strings.Join(chunks, "")
|
|
if testReassembled != compressed {
|
|
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk reassembly test failed")
|
|
return
|
|
}
|
|
|
|
for i, chunkData := range chunks {
|
|
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
|
|
|
|
// Ensure sd.request is available
|
|
if sd.request == nil {
|
|
sd.manager.logger.Errorf("CRITICAL: SetRefreshToken: sd.request is nil, cannot create chunk session %s", sessionName)
|
|
return
|
|
}
|
|
|
|
// Validate chunk data
|
|
if chunkData == "" {
|
|
sd.manager.logger.Errorf("CRITICAL: Empty refresh token chunk data at index %d", i)
|
|
return
|
|
}
|
|
|
|
if len(chunkData) > maxCookieSize {
|
|
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize)
|
|
return
|
|
}
|
|
|
|
// Validate that chunk won't exceed browser cookie limits after encoding
|
|
if !validateChunkSize(chunkData) {
|
|
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData))
|
|
return
|
|
}
|
|
|
|
session, err := sd.manager.store.Get(sd.request, sessionName)
|
|
if err != nil {
|
|
sd.manager.logger.Errorf("CRITICAL: Failed to get refresh token chunk session %s: %v", sessionName, err)
|
|
return
|
|
}
|
|
|
|
session.Values["token_chunk"] = chunkData
|
|
session.Values["compressed"] = (compressed != token) // Store compression flag in each chunk
|
|
// MEDIUM IMPACT FIX: Add timestamp to track chunk creation for orphan detection
|
|
session.Values["chunk_created_at"] = time.Now().Unix()
|
|
sd.refreshTokenChunks[i] = session
|
|
}
|
|
|
|
sd.manager.logger.Debugf("SUCCESS: Stored refresh token in %d chunks", len(chunks))
|
|
}
|
|
}
|
|
|
|
// expireAccessTokenChunksEnhanced provides enhanced cleanup for access token chunks
|
|
// with orphaned chunk detection and timeout-based cleanup mechanisms.
|
|
// MEDIUM IMPACT FIX: Prevents orphaned session chunks from accumulating indefinitely.
|
|
//
|
|
// Parameters:
|
|
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
|
func (sd *SessionData) expireAccessTokenChunksEnhanced(w http.ResponseWriter) {
|
|
const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state
|
|
orphanedChunks := 0
|
|
|
|
for i := 0; i < maxChunkSearchLimit; i++ {
|
|
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
|
|
session, err := sd.manager.store.Get(sd.request, sessionName)
|
|
if err != nil {
|
|
// Error getting session - likely doesn't exist, stop searching
|
|
break
|
|
}
|
|
if session.IsNew {
|
|
// No more chunks found
|
|
break
|
|
}
|
|
|
|
// Check for orphaned chunks (chunks that exist but may not be part of current token)
|
|
if chunk, exists := session.Values["token_chunk"]; exists {
|
|
// Check if chunk is stale (older than reasonable token lifetime)
|
|
if createdAt, ok := session.Values["chunk_created_at"].(int64); ok {
|
|
chunkAge := time.Since(time.Unix(createdAt, 0))
|
|
if chunkAge > 24*time.Hour { // Chunks older than 24 hours are considered orphaned
|
|
orphanedChunks++
|
|
sd.manager.logger.Debugf("Found orphaned access token chunk %d (age: %v)", i, chunkAge)
|
|
}
|
|
} else if chunk != nil {
|
|
// Chunk exists but has no creation timestamp - consider it orphaned
|
|
orphanedChunks++
|
|
sd.manager.logger.Debugf("Found access token chunk %d without timestamp, treating as orphaned", i)
|
|
}
|
|
}
|
|
|
|
// Expire the chunk regardless of orphan status
|
|
session.Options.MaxAge = -1
|
|
session.Values = make(map[interface{}]interface{})
|
|
if w != nil {
|
|
if err := session.Save(sd.request, w); err != nil {
|
|
sd.manager.logger.Errorf("failed to save expired access token chunk %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if orphanedChunks > 0 {
|
|
sd.manager.logger.Infof("Cleaned up %d orphaned access token chunks", orphanedChunks)
|
|
}
|
|
}
|
|
|
|
// expireRefreshTokenChunksEnhanced provides enhanced cleanup for refresh token chunks
|
|
// with orphaned chunk detection and timeout-based cleanup mechanisms.
|
|
// MEDIUM IMPACT FIX: Prevents orphaned session chunks from accumulating indefinitely.
|
|
//
|
|
// Parameters:
|
|
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
|
func (sd *SessionData) expireRefreshTokenChunksEnhanced(w http.ResponseWriter) {
|
|
const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state
|
|
orphanedChunks := 0
|
|
|
|
for i := 0; i < maxChunkSearchLimit; i++ {
|
|
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
|
|
session, err := sd.manager.store.Get(sd.request, sessionName)
|
|
if err != nil {
|
|
// Error getting session - likely doesn't exist, stop searching
|
|
break
|
|
}
|
|
if session.IsNew {
|
|
// No more chunks found
|
|
break
|
|
}
|
|
|
|
// Check for orphaned chunks (chunks that exist but may not be part of current token)
|
|
if chunk, exists := session.Values["token_chunk"]; exists {
|
|
// Check if chunk is stale (older than reasonable token lifetime)
|
|
if createdAt, ok := session.Values["chunk_created_at"].(int64); ok {
|
|
chunkAge := time.Since(time.Unix(createdAt, 0))
|
|
if chunkAge > 24*time.Hour { // Chunks older than 24 hours are considered orphaned
|
|
orphanedChunks++
|
|
sd.manager.logger.Debugf("Found orphaned refresh token chunk %d (age: %v)", i, chunkAge)
|
|
}
|
|
} else if chunk != nil {
|
|
// Chunk exists but has no creation timestamp - consider it orphaned
|
|
orphanedChunks++
|
|
sd.manager.logger.Debugf("Found refresh token chunk %d without timestamp, treating as orphaned", i)
|
|
}
|
|
}
|
|
|
|
// Expire the chunk regardless of orphan status
|
|
session.Options.MaxAge = -1
|
|
session.Values = make(map[interface{}]interface{})
|
|
if w != nil {
|
|
if err := session.Save(sd.request, w); err != nil {
|
|
sd.manager.logger.Errorf("failed to save expired refresh token chunk %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if orphanedChunks > 0 {
|
|
sd.manager.logger.Infof("Cleaned up %d orphaned refresh token chunks", orphanedChunks)
|
|
}
|
|
}
|
|
|
|
// expireIDTokenChunksEnhanced provides enhanced cleanup for ID token chunks
|
|
// with orphaned chunk detection and timeout-based cleanup mechanisms.
|
|
// MEDIUM IMPACT FIX: Prevents orphaned session chunks from accumulating indefinitely.
|
|
//
|
|
// Parameters:
|
|
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
|
func (sd *SessionData) expireIDTokenChunksEnhanced(w http.ResponseWriter) {
|
|
const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state
|
|
orphanedChunks := 0
|
|
|
|
for i := 0; i < maxChunkSearchLimit; i++ {
|
|
sessionName := fmt.Sprintf("%s_%d", idTokenCookie, i)
|
|
session, err := sd.manager.store.Get(sd.request, sessionName)
|
|
if err != nil {
|
|
// Error getting session - likely doesn't exist, stop searching
|
|
break
|
|
}
|
|
if session.IsNew {
|
|
// No more chunks found
|
|
break
|
|
}
|
|
|
|
// Check for orphaned chunks (chunks that exist but may not be part of current token)
|
|
if chunk, exists := session.Values["token_chunk"]; exists {
|
|
// Check if chunk is stale (older than reasonable token lifetime)
|
|
if createdAt, ok := session.Values["chunk_created_at"].(int64); ok {
|
|
chunkAge := time.Since(time.Unix(createdAt, 0))
|
|
if chunkAge > 24*time.Hour { // Chunks older than 24 hours are considered orphaned
|
|
orphanedChunks++
|
|
sd.manager.logger.Debugf("Found orphaned ID token chunk %d (age: %v)", i, chunkAge)
|
|
}
|
|
} else if chunk != nil {
|
|
// Chunk exists but has no creation timestamp - consider it orphaned
|
|
orphanedChunks++
|
|
sd.manager.logger.Debugf("Found ID token chunk %d without timestamp, treating as orphaned", i)
|
|
}
|
|
}
|
|
|
|
// Expire the chunk regardless of orphan status
|
|
session.Options.MaxAge = -1
|
|
session.Values = make(map[interface{}]interface{})
|
|
if w != nil {
|
|
if err := session.Save(sd.request, w); err != nil {
|
|
sd.manager.logger.Errorf("failed to save expired ID token chunk %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if orphanedChunks > 0 {
|
|
sd.manager.logger.Infof("Cleaned up %d orphaned ID token chunks", orphanedChunks)
|
|
}
|
|
}
|
|
|
|
// splitIntoChunks divides a string `s` into a slice of strings, where each element
|
|
// has a maximum length of `chunkSize`. This function accounts for encoding overhead
|
|
// that occurs when sessions are saved to cookies.
|
|
//
|
|
// Parameters:
|
|
// - s: The string to split.
|
|
// - chunkSize: The maximum size of each chunk before encoding overhead.
|
|
//
|
|
// Returns:
|
|
// - A slice of strings representing the chunks.
|
|
func splitIntoChunks(s string, chunkSize int) []string {
|
|
// Ensure chunk size accounts for encoding overhead and doesn't exceed browser limits
|
|
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
|
|
}
|
|
|
|
// validateChunkSize validates that a chunk won't exceed browser cookie limits
|
|
// after encoding. This is a conservative estimate based on typical encoding overhead.
|
|
//
|
|
// Parameters:
|
|
// - chunkData: The raw chunk data before encoding.
|
|
//
|
|
// Returns:
|
|
// - true if the chunk is safe to store, false if it may exceed browser limits.
|
|
func validateChunkSize(chunkData string) bool {
|
|
// Conservative estimate: encoding overhead can be 40-50%
|
|
// Raw chunk + overhead should not exceed maxBrowserCookieSize
|
|
estimatedEncodedSize := len(chunkData) + (len(chunkData) * 50 / 100)
|
|
return estimatedEncodedSize <= maxBrowserCookieSize
|
|
}
|
|
|
|
// isCorruptionMarker detects obvious corruption markers in token data.
|
|
// These markers indicate that the token has been intentionally corrupted for testing
|
|
// or has been damaged during transmission/storage.
|
|
//
|
|
// Parameters:
|
|
// - data: The token data to check for corruption markers.
|
|
//
|
|
// Returns:
|
|
// - true if the data contains corruption markers, false otherwise.
|
|
func isCorruptionMarker(data string) bool {
|
|
if data == "" {
|
|
return false
|
|
}
|
|
|
|
// List of very specific corruption markers that are unlikely to appear in real data
|
|
corruptionMarkers := []string{
|
|
"__CORRUPTION_MARKER_TEST__",
|
|
"__INVALID_BASE64_DATA__",
|
|
"__CORRUPTED_CHUNK_DATA__",
|
|
"!@#$%^&*()", // Invalid base64 characters
|
|
"<<<CORRUPTED>>>", // Very specific marker
|
|
}
|
|
|
|
// For exact matches (avoid false positives in compressed data)
|
|
for _, marker := range corruptionMarkers {
|
|
if data == marker {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check for invalid base64 characters in what should be base64-encoded data
|
|
// Base64 should only contain A-Z, a-z, 0-9, +, /, and = (padding)
|
|
if len(data) > 10 { // Only check longer strings that might be base64
|
|
invalidChars := "!@#$%^&*(){}[]|\\:;\"'<>?,`~"
|
|
for _, char := range invalidChars {
|
|
if strings.ContainsRune(data, char) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// GetCSRF retrieves the Cross-Site Request Forgery (CSRF) token stored in the main session.
|
|
//
|
|
// Returns:
|
|
// - The CSRF token string, or an empty string if not set.
|
|
func (sd *SessionData) GetCSRF() string {
|
|
csrf, _ := sd.mainSession.Values["csrf"].(string)
|
|
return csrf
|
|
}
|
|
|
|
// SetCSRF stores the provided CSRF token string in the main session.
|
|
// This token is typically generated at the start of the authentication flow.
|
|
//
|
|
// Parameters:
|
|
// - token: The CSRF token to store.
|
|
func (sd *SessionData) SetCSRF(token string) {
|
|
currentVal, _ := sd.mainSession.Values["csrf"].(string)
|
|
if currentVal != token {
|
|
sd.mainSession.Values["csrf"] = token
|
|
sd.dirty = true
|
|
}
|
|
}
|
|
|
|
// GetNonce retrieves the OIDC nonce value stored in the main session.
|
|
// The nonce is used to associate an ID token with the specific authentication request.
|
|
//
|
|
// Returns:
|
|
// - The nonce string, or an empty string if not set.
|
|
func (sd *SessionData) GetNonce() string {
|
|
nonce, _ := sd.mainSession.Values["nonce"].(string)
|
|
return nonce
|
|
}
|
|
|
|
// SetNonce stores the provided OIDC nonce string in the main session.
|
|
// This nonce is typically generated at the start of the authentication flow.
|
|
//
|
|
// Parameters:
|
|
// - nonce: The nonce string to store.
|
|
func (sd *SessionData) SetNonce(nonce string) {
|
|
currentVal, _ := sd.mainSession.Values["nonce"].(string)
|
|
if currentVal != nonce {
|
|
sd.mainSession.Values["nonce"] = nonce
|
|
sd.dirty = true
|
|
}
|
|
}
|
|
|
|
// GetCodeVerifier retrieves the PKCE (Proof Key for Code Exchange) code verifier
|
|
// stored in the main session. This is only relevant if PKCE is enabled.
|
|
//
|
|
// Returns:
|
|
// - The code verifier string, or an empty string if not set or PKCE is disabled.
|
|
func (sd *SessionData) GetCodeVerifier() string {
|
|
codeVerifier, _ := sd.mainSession.Values["code_verifier"].(string)
|
|
return codeVerifier
|
|
}
|
|
|
|
// SetCodeVerifier stores the provided PKCE code verifier string in the main session.
|
|
// This is typically called at the start of the authentication flow if PKCE is enabled.
|
|
//
|
|
// Parameters:
|
|
// - codeVerifier: The PKCE code verifier string to store.
|
|
func (sd *SessionData) SetCodeVerifier(codeVerifier string) {
|
|
currentVal, _ := sd.mainSession.Values["code_verifier"].(string)
|
|
if currentVal != codeVerifier {
|
|
sd.mainSession.Values["code_verifier"] = codeVerifier
|
|
sd.dirty = true
|
|
}
|
|
}
|
|
|
|
// GetEmail retrieves the authenticated user's email address stored in the main session.
|
|
// This is typically extracted from the ID token claims after successful authentication.
|
|
//
|
|
// Returns:
|
|
// - The user's email address string, or an empty string if not set.
|
|
func (sd *SessionData) GetEmail() string {
|
|
sd.sessionMutex.RLock()
|
|
defer sd.sessionMutex.RUnlock()
|
|
|
|
email, _ := sd.mainSession.Values["email"].(string)
|
|
return email
|
|
}
|
|
|
|
// SetEmail stores the provided user email address string in the main session.
|
|
// This is typically called after successful authentication and claim extraction.
|
|
//
|
|
// Parameters:
|
|
// - email: The user's email address to store.
|
|
func (sd *SessionData) SetEmail(email string) {
|
|
sd.sessionMutex.Lock()
|
|
defer sd.sessionMutex.Unlock()
|
|
|
|
currentVal, _ := sd.mainSession.Values["email"].(string)
|
|
if currentVal != email {
|
|
sd.mainSession.Values["email"] = email
|
|
sd.dirty = true
|
|
}
|
|
}
|
|
|
|
// GetIncomingPath retrieves the original request URI (including query parameters)
|
|
// that the user was trying to access before being redirected for authentication.
|
|
// This is stored in the main session to allow redirection back after successful login.
|
|
//
|
|
// Returns:
|
|
// - The original request URI string, or an empty string if not set.
|
|
func (sd *SessionData) GetIncomingPath() string {
|
|
path, _ := sd.mainSession.Values["incoming_path"].(string)
|
|
return path
|
|
}
|
|
|
|
// SetIncomingPath stores the original request URI (path and query parameters)
|
|
// in the main session. This is typically called at the start of the authentication flow.
|
|
//
|
|
// Parameters:
|
|
// - path: The original request URI string (e.g., "/protected/resource?id=123").
|
|
func (sd *SessionData) SetIncomingPath(path string) {
|
|
currentVal, _ := sd.mainSession.Values["incoming_path"].(string)
|
|
if currentVal != path {
|
|
sd.mainSession.Values["incoming_path"] = path
|
|
sd.dirty = true
|
|
}
|
|
}
|
|
|
|
// GetIDToken retrieves the ID token stored in the session.
|
|
// It handles reassembling the token from multiple cookie chunks if necessary
|
|
// and decompresses it if it was stored compressed.
|
|
//
|
|
// Returns:
|
|
// - The complete, decompressed ID token string, or an empty string if not found.
|
|
func (sd *SessionData) GetIDToken() string {
|
|
// Add mutex protection for ID token access
|
|
sd.sessionMutex.RLock()
|
|
defer sd.sessionMutex.RUnlock()
|
|
|
|
return sd.getIDTokenUnsafe()
|
|
}
|
|
|
|
// getIDTokenUnsafe is the internal implementation without mutex protection
|
|
// Enhanced ID token retrieval with comprehensive integrity checks and chunking support
|
|
func (sd *SessionData) getIDTokenUnsafe() string {
|
|
token, _ := sd.idTokenSession.Values["token"].(string)
|
|
compressed, _ := sd.idTokenSession.Values["compressed"].(bool)
|
|
|
|
result := sd.manager.chunkManager.GetToken(
|
|
token,
|
|
compressed,
|
|
sd.idTokenChunks,
|
|
IDTokenConfig,
|
|
)
|
|
|
|
if result.Error != nil {
|
|
// Error already logged by ChunkManager
|
|
return ""
|
|
}
|
|
|
|
return result.Token
|
|
}
|
|
|
|
// SetIDToken stores the provided ID token in the session.
|
|
// It first expires any existing ID token chunk cookies.
|
|
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
|
// it's stored directly in the main session. Otherwise, the compressed token
|
|
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_id_0, _oidc_raczylo_id_1, etc.).
|
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned session chunks.
|
|
//
|
|
// Parameters:
|
|
// - token: The ID token string to store.
|
|
func (sd *SessionData) SetIDToken(token string) {
|
|
// Add mutex protection for ID token storage
|
|
sd.sessionMutex.Lock()
|
|
defer sd.sessionMutex.Unlock()
|
|
|
|
// Validate JWT format for ID tokens
|
|
if token != "" {
|
|
dotCount := strings.Count(token, ".")
|
|
if dotCount != 2 {
|
|
sd.manager.logger.Errorf("CRITICAL: Attempt to store invalid JWT ID token format (dots: %d) - rejecting", dotCount)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate token size to prevent storage corruption
|
|
if len(token) > 50*1024 {
|
|
sd.manager.logger.Errorf("CRITICAL: ID token too large (%d bytes) - possible corruption, rejecting", len(token))
|
|
return
|
|
}
|
|
currentIDToken := sd.getIDTokenUnsafe()
|
|
if currentIDToken == token {
|
|
// If token is empty, and current is also empty, it's not a change.
|
|
// This check handles both empty and non-empty identical cases.
|
|
return
|
|
}
|
|
sd.dirty = true
|
|
|
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned chunks
|
|
if sd.request != nil {
|
|
sd.expireIDTokenChunksEnhanced(nil) // Enhanced cleanup with orphan detection
|
|
}
|
|
|
|
// Clear and prepare chunks map for new token.
|
|
for k := range sd.idTokenChunks {
|
|
delete(sd.idTokenChunks, k)
|
|
}
|
|
|
|
if token == "" { // Clearing the token
|
|
// STABILITY FIX: Add nil checks before accessing session values
|
|
if sd.idTokenSession != nil {
|
|
sd.idTokenSession.Values["token"] = ""
|
|
sd.idTokenSession.Values["compressed"] = false
|
|
}
|
|
// sd.idTokenChunks is already cleared
|
|
return
|
|
}
|
|
|
|
// Compress token with validation
|
|
compressed := compressToken(token)
|
|
|
|
// Verify compression didn't corrupt the token
|
|
if compressed != token { // Was compressed
|
|
testDecompressed := decompressToken(compressed)
|
|
if testDecompressed != token {
|
|
sd.manager.logger.Errorf("CRITICAL: ID token compression verification failed - storing uncompressed")
|
|
compressed = token // Fall back to uncompressed
|
|
}
|
|
}
|
|
|
|
if len(compressed) <= maxCookieSize {
|
|
// STABILITY FIX: Add nil checks before accessing session values
|
|
if sd.idTokenSession != nil {
|
|
sd.idTokenSession.Values["token"] = compressed
|
|
sd.idTokenSession.Values["compressed"] = (compressed != token)
|
|
}
|
|
} else {
|
|
// Enhanced chunking with validation
|
|
if sd.idTokenSession != nil {
|
|
sd.idTokenSession.Values["token"] = "" // Main cookie won't hold the token directly
|
|
sd.idTokenSession.Values["compressed"] = (compressed != token) // Data in chunks is compressed
|
|
}
|
|
|
|
chunks := splitIntoChunks(compressed, maxCookieSize)
|
|
|
|
// Validate chunk creation
|
|
if len(chunks) == 0 {
|
|
sd.manager.logger.Errorf("CRITICAL: Failed to create chunks for ID token")
|
|
return
|
|
}
|
|
|
|
if len(chunks) > 50 {
|
|
sd.manager.logger.Errorf("CRITICAL: Too many chunks (%d) for ID token - possible corruption", len(chunks))
|
|
return
|
|
}
|
|
|
|
// Verify chunks can be reassembled correctly
|
|
testReassembled := strings.Join(chunks, "")
|
|
if testReassembled != compressed {
|
|
sd.manager.logger.Errorf("CRITICAL: ID token chunk reassembly test failed")
|
|
return
|
|
}
|
|
|
|
for i, chunkData := range chunks {
|
|
sessionName := fmt.Sprintf("%s_%d", idTokenCookie, i)
|
|
|
|
// Ensure sd.request is available
|
|
if sd.request == nil {
|
|
sd.manager.logger.Errorf("CRITICAL: SetIDToken: sd.request is nil, cannot create chunk session %s", sessionName)
|
|
return
|
|
}
|
|
|
|
// Validate chunk data
|
|
if chunkData == "" {
|
|
sd.manager.logger.Debug("Empty chunk data at index %d", i)
|
|
return
|
|
}
|
|
|
|
if len(chunkData) > maxCookieSize {
|
|
sd.manager.logger.Info("Chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize)
|
|
return
|
|
}
|
|
|
|
// Validate that chunk won't exceed browser cookie limits after encoding
|
|
if !validateChunkSize(chunkData) {
|
|
sd.manager.logger.Errorf("CRITICAL: ID token chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData))
|
|
return
|
|
}
|
|
|
|
session, err := sd.manager.store.Get(sd.request, sessionName)
|
|
if err != nil {
|
|
sd.manager.logger.Errorf("CRITICAL: Failed to get chunk session %s: %v", sessionName, err)
|
|
return
|
|
}
|
|
|
|
session.Values["token_chunk"] = chunkData
|
|
session.Values["compressed"] = (compressed != token) // Store compression flag in each chunk
|
|
// MEDIUM IMPACT FIX: Add timestamp to track chunk creation for orphan detection
|
|
session.Values["chunk_created_at"] = time.Now().Unix()
|
|
sd.idTokenChunks[i] = session
|
|
}
|
|
|
|
sd.manager.logger.Debugf("SUCCESS: Stored ID token in %d chunks", len(chunks))
|
|
}
|
|
}
|
|
|
|
// GetRedirectCount retrieves the current redirect count from the session.
|
|
// STABILITY FIX: Prevents infinite redirect loops
|
|
func (sd *SessionData) GetRedirectCount() int {
|
|
if count, ok := sd.mainSession.Values["redirect_count"].(int); ok {
|
|
return count
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// IncrementRedirectCount increments the redirect count in the session.
|
|
// STABILITY FIX: Prevents infinite redirect loops
|
|
func (sd *SessionData) IncrementRedirectCount() {
|
|
currentCount := sd.GetRedirectCount()
|
|
sd.mainSession.Values["redirect_count"] = currentCount + 1
|
|
sd.dirty = true
|
|
}
|
|
|
|
// ResetRedirectCount resets the redirect count to zero.
|
|
// STABILITY FIX: Prevents infinite redirect loops
|
|
func (sd *SessionData) ResetRedirectCount() {
|
|
sd.mainSession.Values["redirect_count"] = 0
|
|
sd.dirty = true
|
|
}
|