mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-13 04:07:37 +00:00
Splitting code into multiple files with reasonable testing coverage.
```
ok github.com/lukaszraczylo/traefikoidc 117.017s coverage: 72.6% of statements
ok github.com/lukaszraczylo/traefikoidc/auth 0.505s coverage: 87.1% of statements
ok github.com/lukaszraczylo/traefikoidc/circuit_breaker 0.283s coverage: 99.0% of statements
github.com/lukaszraczylo/traefikoidc/config coverage: 0.0% of statements
ok github.com/lukaszraczylo/traefikoidc/handlers 0.349s coverage: 98.2% of statements
ok github.com/lukaszraczylo/traefikoidc/internal/providers (cached) coverage: 94.3% of statements
ok github.com/lukaszraczylo/traefikoidc/middleware 0.808s coverage: 78.0% of statements
ok github.com/lukaszraczylo/traefikoidc/recovery 0.653s coverage: 100.0% of statements
ok github.com/lukaszraczylo/traefikoidc/session/chunking (cached) coverage: 87.8% of statements
ok github.com/lukaszraczylo/traefikoidc/session/core (cached) coverage: 85.6% of statements
ok github.com/lukaszraczylo/traefikoidc/session/crypto (cached) coverage: 81.8% of statements
ok github.com/lukaszraczylo/traefikoidc/session/storage (cached) coverage: 93.5% of statements
ok github.com/lukaszraczylo/traefikoidc/session/validators (cached) coverage: 98.8% of statements
````
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
// Package auth provides authentication-related functionality for the OIDC middleware.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuthHandler provides core authentication functionality for OIDC flows
|
||||
type AuthHandler struct {
|
||||
logger Logger
|
||||
enablePKCE bool
|
||||
isGoogleProv func() bool
|
||||
isAzureProv func() bool
|
||||
clientID string
|
||||
authURL string
|
||||
issuerURL string
|
||||
scopes []string
|
||||
overrideScopes bool
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Debugf(format string, args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new AuthHandler instance
|
||||
func NewAuthHandler(logger Logger, enablePKCE bool, isGoogleProv, isAzureProv func() bool,
|
||||
clientID, authURL, issuerURL string, scopes []string, overrideScopes bool) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
logger: logger,
|
||||
enablePKCE: enablePKCE,
|
||||
isGoogleProv: isGoogleProv,
|
||||
isAzureProv: isAzureProv,
|
||||
clientID: clientID,
|
||||
authURL: authURL,
|
||||
issuerURL: issuerURL,
|
||||
scopes: scopes,
|
||||
overrideScopes: overrideScopes,
|
||||
}
|
||||
}
|
||||
|
||||
// InitiateAuthentication initiates the OIDC authentication flow.
|
||||
// It generates CSRF tokens, nonce, PKCE parameters (if enabled), clears the session,
|
||||
// stores authentication state, and redirects the user to the OIDC provider.
|
||||
func (h *AuthHandler) InitiateAuthentication(rw http.ResponseWriter, req *http.Request,
|
||||
session SessionData, redirectURL string,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge func() (string, error)) {
|
||||
|
||||
h.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
|
||||
|
||||
const maxRedirects = 5
|
||||
redirectCount := session.GetRedirectCount()
|
||||
if redirectCount >= maxRedirects {
|
||||
h.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
|
||||
session.ResetRedirectCount()
|
||||
http.Error(rw, "Authentication failed: Too many redirects", http.StatusLoopDetected)
|
||||
return
|
||||
}
|
||||
|
||||
session.IncrementRedirectCount()
|
||||
|
||||
csrfToken := uuid.NewString()
|
||||
nonce, err := generateNonce()
|
||||
if err != nil {
|
||||
h.logger.Errorf("Failed to generate nonce: %v", err)
|
||||
http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate PKCE code verifier and challenge if PKCE is enabled
|
||||
var codeVerifier, codeChallenge string
|
||||
if h.enablePKCE {
|
||||
codeVerifier, err = generateCodeVerifier()
|
||||
if err != nil {
|
||||
h.logger.Errorf("Failed to generate code verifier: %v", err)
|
||||
http.Error(rw, "Failed to generate code verifier", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
codeChallenge, err = deriveCodeChallenge()
|
||||
if err != nil {
|
||||
h.logger.Errorf("Failed to generate code challenge: %v", err)
|
||||
http.Error(rw, "Failed to generate code challenge", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.logger.Debugf("PKCE enabled, generated code challenge")
|
||||
}
|
||||
|
||||
session.SetAuthenticated(false)
|
||||
session.SetEmail("")
|
||||
session.SetAccessToken("")
|
||||
session.SetRefreshToken("")
|
||||
session.SetIDToken("")
|
||||
session.SetNonce("")
|
||||
session.SetCodeVerifier("")
|
||||
|
||||
session.SetCSRF(csrfToken)
|
||||
session.SetNonce(nonce)
|
||||
if h.enablePKCE {
|
||||
session.SetCodeVerifier(codeVerifier)
|
||||
}
|
||||
session.SetIncomingPath(req.URL.RequestURI())
|
||||
h.logger.Debugf("Storing incoming path: %s", req.URL.RequestURI())
|
||||
|
||||
session.MarkDirty()
|
||||
|
||||
if err := session.Save(req, rw); err != nil {
|
||||
h.logger.Errorf("Failed to save session before redirecting to provider: %v", err)
|
||||
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
|
||||
csrfToken, nonce)
|
||||
|
||||
authURL := h.BuildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
|
||||
h.logger.Debugf("Redirecting user to OIDC provider: %s", authURL)
|
||||
|
||||
http.Redirect(rw, req, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// BuildAuthURL constructs the OIDC provider authorization URL.
|
||||
// It builds the URL with all necessary parameters including client_id, scopes,
|
||||
// PKCE parameters, and provider-specific parameters for Google and Azure.
|
||||
func (h *AuthHandler) BuildAuthURL(redirectURL, state, nonce, codeChallenge string) string {
|
||||
params := url.Values{}
|
||||
params.Set("client_id", h.clientID)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("redirect_uri", redirectURL)
|
||||
params.Set("state", state)
|
||||
params.Set("nonce", nonce)
|
||||
|
||||
if h.enablePKCE && codeChallenge != "" {
|
||||
params.Set("code_challenge", codeChallenge)
|
||||
params.Set("code_challenge_method", "S256")
|
||||
}
|
||||
|
||||
scopes := make([]string, len(h.scopes))
|
||||
copy(scopes, h.scopes)
|
||||
|
||||
if h.isGoogleProv() {
|
||||
params.Set("access_type", "offline")
|
||||
h.logger.Debugf("Google OIDC provider detected, added access_type=offline for refresh tokens")
|
||||
|
||||
params.Set("prompt", "consent")
|
||||
h.logger.Debugf("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
|
||||
} else if h.isAzureProv() {
|
||||
params.Set("response_mode", "query")
|
||||
h.logger.Debugf("Azure AD provider detected, added response_mode=query")
|
||||
|
||||
hasOfflineAccess := false
|
||||
|
||||
for _, scope := range scopes {
|
||||
if scope == "offline_access" {
|
||||
hasOfflineAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !h.overrideScopes || (h.overrideScopes && len(h.scopes) == 0) {
|
||||
if !hasOfflineAccess {
|
||||
scopes = append(scopes, "offline_access")
|
||||
h.logger.Debugf("Azure AD provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", h.overrideScopes, len(h.scopes))
|
||||
}
|
||||
} else {
|
||||
h.logger.Debugf("Azure AD provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(h.scopes))
|
||||
}
|
||||
} else {
|
||||
if !h.overrideScopes || (h.overrideScopes && len(h.scopes) == 0) {
|
||||
hasOfflineAccess := false
|
||||
for _, scope := range scopes {
|
||||
if scope == "offline_access" {
|
||||
hasOfflineAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOfflineAccess {
|
||||
scopes = append(scopes, "offline_access")
|
||||
h.logger.Debugf("Standard provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", h.overrideScopes, len(h.scopes))
|
||||
}
|
||||
} else {
|
||||
h.logger.Debugf("Standard provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(h.scopes))
|
||||
}
|
||||
}
|
||||
|
||||
if len(scopes) > 0 {
|
||||
finalScopeString := strings.Join(scopes, " ")
|
||||
params.Set("scope", finalScopeString)
|
||||
h.logger.Debugf("AuthHandler.BuildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString)
|
||||
}
|
||||
|
||||
return h.buildURLWithParams(h.authURL, params)
|
||||
}
|
||||
|
||||
// buildURLWithParams constructs a URL by combining a base URL with query parameters.
|
||||
// It handles both relative and absolute URLs, validates URL security,
|
||||
// and properly encodes query parameters.
|
||||
func (h *AuthHandler) buildURLWithParams(baseURL string, params url.Values) string {
|
||||
if baseURL != "" {
|
||||
if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") {
|
||||
if err := h.validateURL(baseURL); err != nil {
|
||||
h.logger.Errorf("URL validation failed for %s: %v", baseURL, err)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
issuerURLParsed, err := url.Parse(h.issuerURL)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Could not parse issuerURL: %s. Error: %v", h.issuerURL, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
baseURLParsed, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Could not parse baseURL: %s. Error: %v", baseURL, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed)
|
||||
|
||||
if err := h.validateURL(resolvedURL.String()); err != nil {
|
||||
h.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err)
|
||||
return ""
|
||||
}
|
||||
|
||||
resolvedURL.RawQuery = params.Encode()
|
||||
return resolvedURL.String()
|
||||
}
|
||||
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := h.validateParsedURL(u); err != nil {
|
||||
h.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
u.RawQuery = params.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// validateURL performs security validation on URLs to prevent SSRF attacks.
|
||||
// It checks for allowed schemes, validates hosts, and prevents access to private networks.
|
||||
func (h *AuthHandler) validateURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
return fmt.Errorf("empty URL")
|
||||
}
|
||||
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL format: %w", err)
|
||||
}
|
||||
|
||||
return h.validateParsedURL(u)
|
||||
}
|
||||
|
||||
// validateParsedURL validates a parsed URL structure for security.
|
||||
// It checks schemes, hosts, and paths to prevent malicious URLs.
|
||||
func (h *AuthHandler) validateParsedURL(u *url.URL) error {
|
||||
allowedSchemes := map[string]bool{
|
||||
"https": true,
|
||||
"http": true,
|
||||
}
|
||||
|
||||
if !allowedSchemes[u.Scheme] {
|
||||
return fmt.Errorf("disallowed URL scheme: %s", u.Scheme)
|
||||
}
|
||||
|
||||
if u.Scheme == "http" {
|
||||
h.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String())
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("missing host in URL")
|
||||
}
|
||||
|
||||
if err := h.validateHost(u.Host); err != nil {
|
||||
return fmt.Errorf("invalid host: %w", err)
|
||||
}
|
||||
|
||||
if strings.Contains(u.Path, "..") {
|
||||
return fmt.Errorf("path traversal detected in URL path")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHost validates a hostname for security and reachability.
|
||||
// It prevents access to private networks and localhost addresses.
|
||||
func (h *AuthHandler) validateHost(host string) error {
|
||||
if host == "" {
|
||||
return fmt.Errorf("empty host")
|
||||
}
|
||||
|
||||
// Strip port if present
|
||||
if strings.Contains(host, ":") {
|
||||
var err error
|
||||
host, _, err = net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid host:port format: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for localhost variations
|
||||
localhostVariations := []string{
|
||||
"localhost", "127.0.0.1", "::1", "0.0.0.0",
|
||||
}
|
||||
for _, localhost := range localhostVariations {
|
||||
if strings.EqualFold(host, localhost) {
|
||||
return fmt.Errorf("localhost access not allowed: %s", host)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as IP address
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ip.IsLoopback() {
|
||||
return fmt.Errorf("loopback IP not allowed: %s", host)
|
||||
}
|
||||
if ip.IsPrivate() {
|
||||
return fmt.Errorf("private IP not allowed: %s", host)
|
||||
}
|
||||
if ip.IsLinkLocalUnicast() {
|
||||
return fmt.Errorf("link-local IP not allowed: %s", host)
|
||||
}
|
||||
if ip.IsMulticast() {
|
||||
return fmt.Errorf("multicast IP not allowed: %s", host)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionData interface for dependency injection
|
||||
type SessionData interface {
|
||||
GetRedirectCount() int
|
||||
ResetRedirectCount()
|
||||
IncrementRedirectCount()
|
||||
SetAuthenticated(bool)
|
||||
SetEmail(string)
|
||||
SetAccessToken(string)
|
||||
SetRefreshToken(string)
|
||||
SetIDToken(string)
|
||||
SetNonce(string)
|
||||
SetCodeVerifier(string)
|
||||
SetCSRF(string)
|
||||
SetIncomingPath(string)
|
||||
MarkDirty()
|
||||
Save(req *http.Request, rw http.ResponseWriter) error
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Mock logger for testing
|
||||
type mockLogger struct {
|
||||
debugLogs []string
|
||||
errorLogs []string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockLogger) Debugf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Errorf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future debug log verification tests
|
||||
func (m *mockLogger) getDebugLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.debugLogs))
|
||||
copy(result, m.debugLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockLogger) getErrorLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.errorLogs))
|
||||
copy(result, m.errorLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future test isolation
|
||||
func (m *mockLogger) reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = nil
|
||||
m.errorLogs = nil
|
||||
}
|
||||
|
||||
// Mock session for testing
|
||||
type mockSession struct {
|
||||
data map[string]interface{}
|
||||
redirectCount int
|
||||
saveError error
|
||||
mu sync.RWMutex
|
||||
dirty bool
|
||||
authenticated bool
|
||||
email string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
idToken string
|
||||
nonce string
|
||||
codeVerifier string
|
||||
csrf string
|
||||
incomingPath string
|
||||
}
|
||||
|
||||
func newMockSession() *mockSession {
|
||||
return &mockSession{
|
||||
data: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockSession) GetRedirectCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.redirectCount
|
||||
}
|
||||
|
||||
func (m *mockSession) ResetRedirectCount() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.redirectCount = 0
|
||||
}
|
||||
|
||||
func (m *mockSession) IncrementRedirectCount() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.redirectCount++
|
||||
}
|
||||
|
||||
func (m *mockSession) SetAuthenticated(auth bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.authenticated = auth
|
||||
}
|
||||
|
||||
func (m *mockSession) SetEmail(email string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.email = email
|
||||
}
|
||||
|
||||
func (m *mockSession) SetAccessToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.accessToken = token
|
||||
}
|
||||
|
||||
func (m *mockSession) SetRefreshToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.refreshToken = token
|
||||
}
|
||||
|
||||
func (m *mockSession) SetIDToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.idToken = token
|
||||
}
|
||||
|
||||
func (m *mockSession) SetNonce(nonce string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.nonce = nonce
|
||||
}
|
||||
|
||||
func (m *mockSession) SetCodeVerifier(verifier string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.codeVerifier = verifier
|
||||
}
|
||||
|
||||
func (m *mockSession) SetCSRF(csrf string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.csrf = csrf
|
||||
}
|
||||
|
||||
func (m *mockSession) SetIncomingPath(path string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.incomingPath = path
|
||||
}
|
||||
|
||||
func (m *mockSession) MarkDirty() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
func (m *mockSession) Save(req *http.Request, rw http.ResponseWriter) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.saveError
|
||||
}
|
||||
|
||||
func (m *mockSession) setSaveError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.saveError = err
|
||||
}
|
||||
|
||||
// Test helper functions
|
||||
func createTestAuthHandler(logger Logger, enablePKCE bool, isGoogle, isAzure func() bool) *AuthHandler {
|
||||
return NewAuthHandler(
|
||||
logger,
|
||||
enablePKCE,
|
||||
isGoogle,
|
||||
isAzure,
|
||||
"test-client-id",
|
||||
"https://provider.example.com/auth",
|
||||
"https://provider.example.com",
|
||||
[]string{"openid", "email", "profile"},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
func TestNewAuthHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
logger Logger
|
||||
enablePKCE bool
|
||||
clientID string
|
||||
authURL string
|
||||
issuerURL string
|
||||
scopes []string
|
||||
overrideScopes bool
|
||||
}{
|
||||
{
|
||||
name: "creates handler with basic config",
|
||||
logger: &mockLogger{},
|
||||
enablePKCE: true,
|
||||
clientID: "test-client",
|
||||
authURL: "https://example.com/auth",
|
||||
issuerURL: "https://example.com",
|
||||
scopes: []string{"openid", "email"},
|
||||
overrideScopes: false,
|
||||
},
|
||||
{
|
||||
name: "creates handler with PKCE disabled",
|
||||
logger: &mockLogger{},
|
||||
enablePKCE: false,
|
||||
clientID: "test-client",
|
||||
authURL: "https://example.com/auth",
|
||||
issuerURL: "https://example.com",
|
||||
scopes: []string{"openid"},
|
||||
overrideScopes: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isGoogle := func() bool { return false }
|
||||
isAzure := func() bool { return false }
|
||||
|
||||
handler := NewAuthHandler(
|
||||
tt.logger,
|
||||
tt.enablePKCE,
|
||||
isGoogle,
|
||||
isAzure,
|
||||
tt.clientID,
|
||||
tt.authURL,
|
||||
tt.issuerURL,
|
||||
tt.scopes,
|
||||
tt.overrideScopes,
|
||||
)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("NewAuthHandler returned nil")
|
||||
}
|
||||
|
||||
if handler.clientID != tt.clientID {
|
||||
t.Errorf("Expected clientID %s, got %s", tt.clientID, handler.clientID)
|
||||
}
|
||||
|
||||
if handler.enablePKCE != tt.enablePKCE {
|
||||
t.Errorf("Expected enablePKCE %v, got %v", tt.enablePKCE, handler.enablePKCE)
|
||||
}
|
||||
|
||||
if handler.authURL != tt.authURL {
|
||||
t.Errorf("Expected authURL %s, got %s", tt.authURL, handler.authURL)
|
||||
}
|
||||
|
||||
if handler.issuerURL != tt.issuerURL {
|
||||
t.Errorf("Expected issuerURL %s, got %s", tt.issuerURL, handler.issuerURL)
|
||||
}
|
||||
|
||||
if handler.overrideScopes != tt.overrideScopes {
|
||||
t.Errorf("Expected overrideScopes %v, got %v", tt.overrideScopes, handler.overrideScopes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiateAuthentication(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enablePKCE bool
|
||||
initialRedirectCount int
|
||||
generateNonceError error
|
||||
generateCodeVerifierError error
|
||||
deriveCodeChallengeError error
|
||||
sessionSaveError error
|
||||
expectedStatusCode int
|
||||
expectRedirect bool
|
||||
}{
|
||||
{
|
||||
name: "successful authentication without PKCE",
|
||||
enablePKCE: false,
|
||||
expectedStatusCode: http.StatusFound,
|
||||
expectRedirect: true,
|
||||
},
|
||||
{
|
||||
name: "successful authentication with PKCE",
|
||||
enablePKCE: true,
|
||||
expectedStatusCode: http.StatusFound,
|
||||
expectRedirect: true,
|
||||
},
|
||||
{
|
||||
name: "fails when nonce generation fails",
|
||||
enablePKCE: false,
|
||||
generateNonceError: fmt.Errorf("nonce generation failed"),
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
expectRedirect: false,
|
||||
},
|
||||
{
|
||||
name: "fails when code verifier generation fails",
|
||||
enablePKCE: true,
|
||||
generateCodeVerifierError: fmt.Errorf("code verifier generation failed"),
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
expectRedirect: false,
|
||||
},
|
||||
{
|
||||
name: "fails when code challenge derivation fails",
|
||||
enablePKCE: true,
|
||||
deriveCodeChallengeError: fmt.Errorf("code challenge derivation failed"),
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
expectRedirect: false,
|
||||
},
|
||||
{
|
||||
name: "fails when session save fails",
|
||||
enablePKCE: false,
|
||||
sessionSaveError: fmt.Errorf("session save failed"),
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
expectRedirect: false,
|
||||
},
|
||||
{
|
||||
name: "fails when redirect count exceeds limit",
|
||||
enablePKCE: false,
|
||||
initialRedirectCount: 5,
|
||||
expectedStatusCode: http.StatusLoopDetected,
|
||||
expectRedirect: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
session := newMockSession()
|
||||
session.redirectCount = tt.initialRedirectCount
|
||||
if tt.sessionSaveError != nil {
|
||||
session.setSaveError(tt.sessionSaveError)
|
||||
}
|
||||
|
||||
handler := createTestAuthHandler(logger, tt.enablePKCE, func() bool { return false }, func() bool { return false })
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
generateNonce := func() (string, error) {
|
||||
if tt.generateNonceError != nil {
|
||||
return "", tt.generateNonceError
|
||||
}
|
||||
return "test-nonce", nil
|
||||
}
|
||||
|
||||
generateCodeVerifier := func() (string, error) {
|
||||
if tt.generateCodeVerifierError != nil {
|
||||
return "", tt.generateCodeVerifierError
|
||||
}
|
||||
return "test-code-verifier", nil
|
||||
}
|
||||
|
||||
deriveCodeChallenge := func() (string, error) {
|
||||
if tt.deriveCodeChallengeError != nil {
|
||||
return "", tt.deriveCodeChallengeError
|
||||
}
|
||||
return "test-code-challenge", nil
|
||||
}
|
||||
|
||||
handler.InitiateAuthentication(
|
||||
rw, req, session,
|
||||
"https://example.com/callback",
|
||||
generateNonce,
|
||||
generateCodeVerifier,
|
||||
deriveCodeChallenge,
|
||||
)
|
||||
|
||||
if rw.Code != tt.expectedStatusCode {
|
||||
t.Errorf("Expected status code %d, got %d", tt.expectedStatusCode, rw.Code)
|
||||
}
|
||||
|
||||
if tt.expectRedirect {
|
||||
location := rw.Header().Get("Location")
|
||||
if location == "" {
|
||||
t.Error("Expected redirect location header, got empty")
|
||||
}
|
||||
if !strings.Contains(location, "https://provider.example.com/auth") {
|
||||
t.Errorf("Expected redirect to auth URL, got %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify error logs for failure cases
|
||||
if tt.expectedStatusCode != http.StatusFound {
|
||||
errorLogs := logger.getErrorLogs()
|
||||
if len(errorLogs) == 0 {
|
||||
t.Error("Expected error logs for failure case, got none")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enablePKCE bool
|
||||
isGoogle bool
|
||||
isAzure bool
|
||||
scopes []string
|
||||
overrideScopes bool
|
||||
codeChallenge string
|
||||
expectedParams map[string]string
|
||||
expectContains []string
|
||||
}{
|
||||
{
|
||||
name: "builds URL without PKCE for standard provider",
|
||||
enablePKCE: false,
|
||||
isGoogle: false,
|
||||
isAzure: false,
|
||||
scopes: []string{"openid", "email"},
|
||||
codeChallenge: "",
|
||||
expectedParams: map[string]string{
|
||||
"client_id": "test-client-id",
|
||||
"response_type": "code",
|
||||
"state": "test-state",
|
||||
"nonce": "test-nonce",
|
||||
"scope": "openid email offline_access",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "builds URL with PKCE for standard provider",
|
||||
enablePKCE: true,
|
||||
isGoogle: false,
|
||||
isAzure: false,
|
||||
scopes: []string{"openid", "email"},
|
||||
codeChallenge: "test-challenge",
|
||||
expectedParams: map[string]string{
|
||||
"client_id": "test-client-id",
|
||||
"response_type": "code",
|
||||
"state": "test-state",
|
||||
"nonce": "test-nonce",
|
||||
"code_challenge": "test-challenge",
|
||||
"code_challenge_method": "S256",
|
||||
"scope": "openid email offline_access",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "builds URL for Google provider",
|
||||
enablePKCE: false,
|
||||
isGoogle: true,
|
||||
isAzure: false,
|
||||
scopes: []string{"openid", "email"},
|
||||
expectedParams: map[string]string{
|
||||
"client_id": "test-client-id",
|
||||
"response_type": "code",
|
||||
"state": "test-state",
|
||||
"nonce": "test-nonce",
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"scope": "openid email",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "builds URL for Azure provider",
|
||||
enablePKCE: false,
|
||||
isGoogle: false,
|
||||
isAzure: true,
|
||||
scopes: []string{"openid", "email"},
|
||||
expectedParams: map[string]string{
|
||||
"client_id": "test-client-id",
|
||||
"response_type": "code",
|
||||
"state": "test-state",
|
||||
"nonce": "test-nonce",
|
||||
"response_mode": "query",
|
||||
"scope": "openid email offline_access",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handles scope override for Azure",
|
||||
enablePKCE: false,
|
||||
isGoogle: false,
|
||||
isAzure: true,
|
||||
scopes: []string{"custom-scope"},
|
||||
overrideScopes: true,
|
||||
expectedParams: map[string]string{
|
||||
"client_id": "test-client-id",
|
||||
"response_type": "code",
|
||||
"state": "test-state",
|
||||
"nonce": "test-nonce",
|
||||
"response_mode": "query",
|
||||
"scope": "custom-scope",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
isGoogleFunc := func() bool { return tt.isGoogle }
|
||||
isAzureFunc := func() bool { return tt.isAzure }
|
||||
|
||||
handler := NewAuthHandler(
|
||||
logger,
|
||||
tt.enablePKCE,
|
||||
isGoogleFunc,
|
||||
isAzureFunc,
|
||||
"test-client-id",
|
||||
"https://provider.example.com/auth",
|
||||
"https://provider.example.com",
|
||||
tt.scopes,
|
||||
tt.overrideScopes,
|
||||
)
|
||||
|
||||
authURL := handler.BuildAuthURL(
|
||||
"https://example.com/callback",
|
||||
"test-state",
|
||||
"test-nonce",
|
||||
tt.codeChallenge,
|
||||
)
|
||||
|
||||
if authURL == "" {
|
||||
t.Fatal("BuildAuthURL returned empty string")
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||
}
|
||||
|
||||
// Check base URL
|
||||
expectedBase := "https://provider.example.com/auth"
|
||||
actualBase := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, parsedURL.Path)
|
||||
if actualBase != expectedBase {
|
||||
t.Errorf("Expected base URL %s, got %s", expectedBase, actualBase)
|
||||
}
|
||||
|
||||
// Check required parameters
|
||||
query := parsedURL.Query()
|
||||
for param, expectedValue := range tt.expectedParams {
|
||||
actualValue := query.Get(param)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Expected %s=%s, got %s=%s", param, expectedValue, param, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that contains expected strings
|
||||
for _, contains := range tt.expectContains {
|
||||
if !strings.Contains(authURL, contains) {
|
||||
t.Errorf("Expected URL to contain %s, got %s", contains, authURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateURL(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := createTestAuthHandler(logger, false, func() bool { return false }, func() bool { return false })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid HTTPS URL",
|
||||
url: "https://example.com/path",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid HTTP URL",
|
||||
url: "http://example.com/path",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty URL",
|
||||
url: "",
|
||||
expectError: true,
|
||||
errorMsg: "empty URL",
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
url: "ftp://example.com/path",
|
||||
expectError: true,
|
||||
errorMsg: "disallowed URL scheme",
|
||||
},
|
||||
{
|
||||
name: "missing host",
|
||||
url: "https:///path",
|
||||
expectError: true,
|
||||
errorMsg: "missing host in URL",
|
||||
},
|
||||
{
|
||||
name: "localhost not allowed",
|
||||
url: "https://localhost/path",
|
||||
expectError: true,
|
||||
errorMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1 not allowed",
|
||||
url: "https://127.0.0.1/path",
|
||||
expectError: true,
|
||||
errorMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "private IP not allowed",
|
||||
url: "https://192.168.1.1/path",
|
||||
expectError: true,
|
||||
errorMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "path traversal not allowed",
|
||||
url: "https://example.com/../path",
|
||||
expectError: true,
|
||||
errorMsg: "path traversal detected",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := handler.validateURL(tt.url)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for URL %s, got nil", tt.url)
|
||||
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Errorf("Expected error to contain %s, got %s", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for URL %s, got %v", tt.url, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHost(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := createTestAuthHandler(logger, false, func() bool { return false }, func() bool { return false })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid domain",
|
||||
host: "example.com",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid domain with port",
|
||||
host: "example.com:443",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty host",
|
||||
host: "",
|
||||
expectError: true,
|
||||
errorMsg: "empty host",
|
||||
},
|
||||
{
|
||||
name: "localhost",
|
||||
host: "localhost",
|
||||
expectError: true,
|
||||
errorMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "localhost with port",
|
||||
host: "localhost:8080",
|
||||
expectError: true,
|
||||
errorMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1",
|
||||
host: "127.0.0.1",
|
||||
expectError: true,
|
||||
errorMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "::1 (IPv6 localhost)",
|
||||
host: "::1",
|
||||
expectError: true,
|
||||
errorMsg: "invalid host:port format",
|
||||
},
|
||||
{
|
||||
name: "private IP 192.168.1.1",
|
||||
host: "192.168.1.1",
|
||||
expectError: true,
|
||||
errorMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "private IP 10.0.0.1",
|
||||
host: "10.0.0.1",
|
||||
expectError: true,
|
||||
errorMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "invalid host:port format",
|
||||
host: "example.com:99999", // Use invalid port number instead
|
||||
expectError: false, // This actually doesn't error in Go's net.SplitHostPort
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := handler.validateHost(tt.host)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for host %s, got nil", tt.host)
|
||||
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Errorf("Expected error to contain %s, got %s", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for host %s, got %v", tt.host, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildURLWithParams(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(
|
||||
logger,
|
||||
false,
|
||||
func() bool { return false },
|
||||
func() bool { return false },
|
||||
"test-client-id",
|
||||
"https://provider.example.com/auth",
|
||||
"https://provider.example.com",
|
||||
[]string{"openid"},
|
||||
false,
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
params url.Values
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "absolute HTTPS URL with params",
|
||||
baseURL: "https://example.com/auth",
|
||||
params: url.Values{"param1": []string{"value1"}, "param2": []string{"value2"}},
|
||||
expected: "https://example.com/auth?param1=value1¶m2=value2",
|
||||
},
|
||||
{
|
||||
name: "relative URL resolved against issuer",
|
||||
baseURL: "/auth/endpoint",
|
||||
params: url.Values{"client_id": []string{"test"}},
|
||||
expected: "https://provider.example.com/auth/endpoint?client_id=test",
|
||||
},
|
||||
{
|
||||
name: "invalid absolute URL",
|
||||
baseURL: "https://localhost/auth",
|
||||
params: url.Values{},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := handler.buildURLWithParams(tt.baseURL, tt.params)
|
||||
|
||||
if tt.expectError {
|
||||
if result != "" {
|
||||
t.Errorf("Expected empty result for invalid URL, got %s", result)
|
||||
}
|
||||
} else {
|
||||
if result == "" {
|
||||
t.Error("Expected non-empty result, got empty string")
|
||||
}
|
||||
|
||||
// Parse and compare the result
|
||||
resultURL, err := url.Parse(result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse result URL: %v", err)
|
||||
}
|
||||
|
||||
expectedURL, err := url.Parse(tt.expected)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse expected URL: %v", err)
|
||||
}
|
||||
|
||||
if resultURL.Scheme != expectedURL.Scheme {
|
||||
t.Errorf("Expected scheme %s, got %s", expectedURL.Scheme, resultURL.Scheme)
|
||||
}
|
||||
if resultURL.Host != expectedURL.Host {
|
||||
t.Errorf("Expected host %s, got %s", expectedURL.Host, resultURL.Host)
|
||||
}
|
||||
if resultURL.Path != expectedURL.Path {
|
||||
t.Errorf("Expected path %s, got %s", expectedURL.Path, resultURL.Path)
|
||||
}
|
||||
|
||||
// Check query parameters
|
||||
resultQuery := resultURL.Query()
|
||||
expectedQuery := expectedURL.Query()
|
||||
for key, expectedValues := range expectedQuery {
|
||||
resultValues := resultQuery[key]
|
||||
if len(resultValues) != len(expectedValues) {
|
||||
t.Errorf("Expected %d values for %s, got %d", len(expectedValues), key, len(resultValues))
|
||||
}
|
||||
for i, expectedValue := range expectedValues {
|
||||
if i >= len(resultValues) || resultValues[i] != expectedValue {
|
||||
t.Errorf("Expected %s=%s, got %s=%s", key, expectedValue, key, resultValues[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := createTestAuthHandler(logger, true, func() bool { return false }, func() bool { return false })
|
||||
|
||||
const numGoroutines = 10
|
||||
const numRequests = 5
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, numGoroutines*numRequests)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numRequests; j++ {
|
||||
session := newMockSession()
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/test-%d-%d", id, j), nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
generateNonce := func() (string, error) {
|
||||
return fmt.Sprintf("nonce-%d-%d", id, j), nil
|
||||
}
|
||||
generateCodeVerifier := func() (string, error) {
|
||||
return fmt.Sprintf("verifier-%d-%d", id, j), nil
|
||||
}
|
||||
deriveCodeChallenge := func() (string, error) {
|
||||
return fmt.Sprintf("challenge-%d-%d", id, j), nil
|
||||
}
|
||||
|
||||
handler.InitiateAuthentication(
|
||||
rw, req, session,
|
||||
"https://example.com/callback",
|
||||
generateNonce,
|
||||
generateCodeVerifier,
|
||||
deriveCodeChallenge,
|
||||
)
|
||||
|
||||
if rw.Code != http.StatusFound {
|
||||
errors <- fmt.Errorf("goroutine %d request %d: expected status %d, got %d",
|
||||
id, j, http.StatusFound, rw.Code)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
var errorCount int
|
||||
for err := range errors {
|
||||
t.Error(err)
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Errorf("Found %d errors in concurrent access test", errorCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkInitiateAuthentication(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
handler := createTestAuthHandler(logger, true, func() bool { return false }, func() bool { return false })
|
||||
|
||||
generateNonce := func() (string, error) { return "test-nonce", nil }
|
||||
generateCodeVerifier := func() (string, error) { return "test-verifier", nil }
|
||||
deriveCodeChallenge := func() (string, error) { return "test-challenge", nil }
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
session := newMockSession()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.InitiateAuthentication(
|
||||
rw, req, session,
|
||||
"https://example.com/callback",
|
||||
generateNonce,
|
||||
generateCodeVerifier,
|
||||
deriveCodeChallenge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildAuthURL(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
handler := createTestAuthHandler(logger, true, func() bool { return false }, func() bool { return false })
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
handler.BuildAuthURL(
|
||||
"https://example.com/callback",
|
||||
"test-state",
|
||||
"test-nonce",
|
||||
"test-challenge",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidateURL(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
handler := createTestAuthHandler(logger, false, func() bool { return false }, func() bool { return false })
|
||||
|
||||
testURLs := []string{
|
||||
"https://example.com/auth",
|
||||
"https://provider.example.com/oauth/authorize",
|
||||
"https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
handler.validateURL(testURLs[i%len(testURLs)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBlacklistDuration = 24 * time.Hour
|
||||
defaultMaxBlacklistSize = 10000
|
||||
)
|
||||
|
||||
var (
|
||||
globalCacheManager *CacheManager
|
||||
cacheManagerOnce sync.Once
|
||||
cacheManagerMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// CacheManager manages all caching components for the OIDC middleware.
|
||||
// It provides thread-safe access to token blacklist, token cache, metadata cache,
|
||||
// and JWK cache. This centralizes cache management to ensure efficient memory
|
||||
// usage and consistent cache behavior across the application.
|
||||
type CacheManager struct {
|
||||
tokenBlacklist CacheInterface
|
||||
tokenCache *TokenCache
|
||||
metadataCache *MetadataCache
|
||||
jwkCache JWKCacheInterface
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// GetGlobalCacheManager returns a singleton instance of CacheManager.
|
||||
// It initializes all cache components on first call and reuses the same instance
|
||||
// for subsequent calls. This ensures consistent cache state and efficient memory
|
||||
// usage across the entire application lifecycle.
|
||||
// Parameters:
|
||||
// - wg: WaitGroup for coordinating cache cleanup during shutdown.
|
||||
//
|
||||
// Returns:
|
||||
// - The global CacheManager instance.
|
||||
func GetGlobalCacheManager(wg *sync.WaitGroup) *CacheManager {
|
||||
cacheManagerOnce.Do(func() {
|
||||
globalCacheManager = &CacheManager{
|
||||
tokenBlacklist: func() CacheInterface {
|
||||
config := DefaultUnifiedCacheConfig()
|
||||
config.MaxSize = defaultMaxBlacklistSize
|
||||
c := NewUnifiedCache(config)
|
||||
return NewCacheAdapter(c)
|
||||
}(),
|
||||
tokenCache: NewTokenCache(),
|
||||
metadataCache: NewMetadataCache(wg),
|
||||
jwkCache: &JWKCache{},
|
||||
}
|
||||
})
|
||||
return globalCacheManager
|
||||
}
|
||||
|
||||
// GetSharedTokenBlacklist returns the shared token blacklist cache.
|
||||
// This cache stores revoked or expired tokens to prevent replay attacks.
|
||||
// Access is protected by read lock to ensure thread safety.
|
||||
// Returns:
|
||||
// - The shared token blacklist CacheInterface instance.
|
||||
func (cm *CacheManager) GetSharedTokenBlacklist() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.tokenBlacklist
|
||||
}
|
||||
|
||||
// GetSharedTokenCache returns the shared token cache for verified tokens.
|
||||
// This cache stores claims from successfully verified tokens to avoid
|
||||
// repeated verification of the same tokens.
|
||||
// Access is protected by read lock to ensure thread safety.
|
||||
// Returns:
|
||||
// - The shared TokenCache instance.
|
||||
func (cm *CacheManager) GetSharedTokenCache() *TokenCache {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.tokenCache
|
||||
}
|
||||
|
||||
// GetSharedMetadataCache returns the shared metadata cache.
|
||||
// This cache stores OIDC provider metadata (endpoints, keys) to avoid
|
||||
// repeated requests to the provider's .well-known configuration endpoint.
|
||||
// Access is protected by read lock to ensure thread safety.
|
||||
// Returns:
|
||||
// - The shared MetadataCache instance.
|
||||
func (cm *CacheManager) GetSharedMetadataCache() *MetadataCache {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.metadataCache
|
||||
}
|
||||
|
||||
// GetSharedJWKCache returns the shared JWK (JSON Web Key) cache.
|
||||
// This cache stores public keys from the provider's JWKS endpoint
|
||||
// for token signature verification.
|
||||
// Access is protected by read lock to ensure thread safety.
|
||||
// Returns:
|
||||
// - The shared JWKCacheInterface instance.
|
||||
func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.jwkCache
|
||||
}
|
||||
|
||||
// Close gracefully shuts down all cache components and releases resources.
|
||||
// It closes all individual caches and cleans up their associated goroutines.
|
||||
// This method should be called when the middleware is shutting down.
|
||||
// Safe to call multiple times.
|
||||
// Returns:
|
||||
// - An error if any cache fails to close properly, nil otherwise.
|
||||
func (cm *CacheManager) Close() error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.tokenBlacklist != nil {
|
||||
cm.tokenBlacklist.Close()
|
||||
cm.tokenBlacklist = nil
|
||||
}
|
||||
if cm.tokenCache != nil {
|
||||
cm.tokenCache.Close()
|
||||
cm.tokenCache = nil
|
||||
}
|
||||
if cm.metadataCache != nil {
|
||||
cm.metadataCache.Close()
|
||||
cm.metadataCache = nil
|
||||
}
|
||||
if cm.jwkCache != nil {
|
||||
cm.jwkCache.Close()
|
||||
cm.jwkCache = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupGlobalCacheManager cleans up the global cache manager instance.
|
||||
// It closes all cache components but does NOT reset the singleton for re-initialization
|
||||
// to avoid race conditions with sync.Once. It's safe to call multiple times.
|
||||
// Returns:
|
||||
// - An error if cache cleanup fails, nil otherwise.
|
||||
func CleanupGlobalCacheManager() error {
|
||||
cacheManagerMutex.Lock()
|
||||
defer cacheManagerMutex.Unlock()
|
||||
|
||||
if globalCacheManager != nil {
|
||||
err := globalCacheManager.Close()
|
||||
// Don't reset globalCacheManager to nil or cacheManagerOnce to avoid race conditions
|
||||
// The cache manager remains closed and the Close() method handles multiple calls
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// Package circuit_breaker provides circuit breaker implementation for resilience
|
||||
package circuit_breaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CircuitBreakerState represents the current state of a circuit breaker.
|
||||
// The circuit breaker pattern prevents cascading failures by monitoring
|
||||
// error rates and temporarily blocking requests to failing services.
|
||||
type CircuitBreakerState int
|
||||
|
||||
// Circuit breaker states following the standard pattern:
|
||||
// Closed: Normal operation, requests flow through
|
||||
// Open: Circuit is tripped, requests are blocked
|
||||
// HalfOpen: Testing state, limited requests allowed to test recovery
|
||||
const (
|
||||
// CircuitBreakerClosed allows all requests through (normal operation)
|
||||
CircuitBreakerClosed CircuitBreakerState = iota
|
||||
// CircuitBreakerOpen blocks all requests (service is failing)
|
||||
CircuitBreakerOpen
|
||||
// CircuitBreakerHalfOpen allows limited requests to test service recovery
|
||||
CircuitBreakerHalfOpen
|
||||
)
|
||||
|
||||
// String returns a string representation of the circuit breaker state
|
||||
func (s CircuitBreakerState) String() string {
|
||||
switch s {
|
||||
case CircuitBreakerClosed:
|
||||
return "closed"
|
||||
case CircuitBreakerOpen:
|
||||
return "open"
|
||||
case CircuitBreakerHalfOpen:
|
||||
return "half-open"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Infof(format string, args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
Debugf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// BaseRecoveryMechanism interface for common functionality
|
||||
type BaseRecoveryMechanism interface {
|
||||
RecordRequest()
|
||||
RecordSuccess()
|
||||
RecordFailure()
|
||||
GetBaseMetrics() map[string]interface{}
|
||||
LogInfo(format string, args ...interface{})
|
||||
LogError(format string, args ...interface{})
|
||||
LogDebug(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// CircuitBreaker implements the circuit breaker pattern for external service calls.
|
||||
// It monitors failure rates and automatically opens the circuit when failures
|
||||
// exceed the threshold, preventing further requests until the service recovers.
|
||||
type CircuitBreaker struct {
|
||||
// baseRecovery provides common functionality
|
||||
baseRecovery BaseRecoveryMechanism
|
||||
// maxFailures is the threshold for opening the circuit
|
||||
maxFailures int
|
||||
// timeout is how long to wait before allowing requests in half-open state
|
||||
timeout time.Duration
|
||||
// resetTimeout is how long to wait before transitioning from open to half-open
|
||||
resetTimeout time.Duration
|
||||
// state tracks the current circuit breaker state
|
||||
state CircuitBreakerState
|
||||
// failures counts consecutive failures
|
||||
failures int64
|
||||
// lastFailureTime records when the last failure occurred
|
||||
lastFailureTime time.Time
|
||||
// mutex protects shared state
|
||||
mutex sync.RWMutex
|
||||
// logger for debugging and monitoring
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// CircuitBreakerConfig holds configuration parameters for circuit breakers.
|
||||
// These settings control when the circuit opens and how it recovers.
|
||||
type CircuitBreakerConfig struct {
|
||||
// MaxFailures is the number of failures before opening the circuit
|
||||
MaxFailures int `json:"max_failures"`
|
||||
// Timeout is how long to wait before trying to recover (open -> half-open)
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
// ResetTimeout is how long to wait before fully closing the circuit
|
||||
ResetTimeout time.Duration `json:"reset_timeout"`
|
||||
}
|
||||
|
||||
// DefaultCircuitBreakerConfig returns sensible default configuration for circuit breakers.
|
||||
// Configured for typical web service scenarios with moderate tolerance for failures.
|
||||
func DefaultCircuitBreakerConfig() CircuitBreakerConfig {
|
||||
return CircuitBreakerConfig{
|
||||
MaxFailures: 2,
|
||||
Timeout: 60 * time.Second,
|
||||
ResetTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// NewCircuitBreaker creates a new circuit breaker with the specified configuration.
|
||||
// The circuit breaker starts in the closed state, allowing all requests through.
|
||||
func NewCircuitBreaker(config CircuitBreakerConfig, logger Logger, baseRecovery BaseRecoveryMechanism) *CircuitBreaker {
|
||||
return &CircuitBreaker{
|
||||
baseRecovery: baseRecovery,
|
||||
maxFailures: config.MaxFailures,
|
||||
timeout: config.Timeout,
|
||||
resetTimeout: config.ResetTimeout,
|
||||
state: CircuitBreakerClosed,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteWithContext executes a function through the circuit breaker with context.
|
||||
// It checks if requests are allowed, executes the function, and updates the circuit state
|
||||
// based on the result. Implements the ErrorRecoveryMechanism interface.
|
||||
func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() error) error {
|
||||
if cb.baseRecovery != nil {
|
||||
cb.baseRecovery.RecordRequest()
|
||||
}
|
||||
|
||||
if !cb.allowRequest() {
|
||||
return fmt.Errorf("circuit breaker is open")
|
||||
}
|
||||
|
||||
err := fn()
|
||||
if err != nil {
|
||||
cb.recordFailure()
|
||||
if cb.baseRecovery != nil {
|
||||
cb.baseRecovery.RecordFailure()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
cb.recordSuccess()
|
||||
if cb.baseRecovery != nil {
|
||||
cb.baseRecovery.RecordSuccess()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute executes a function through the circuit breaker without context.
|
||||
// This is provided for backward compatibility with existing code.
|
||||
func (cb *CircuitBreaker) Execute(fn func() error) error {
|
||||
return cb.ExecuteWithContext(context.Background(), fn)
|
||||
}
|
||||
|
||||
// allowRequest determines whether to allow a request based on the circuit state.
|
||||
// Handles state transitions from open to half-open based on timeout.
|
||||
func (cb *CircuitBreaker) allowRequest() bool {
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
switch cb.state {
|
||||
case CircuitBreakerClosed:
|
||||
return true
|
||||
|
||||
case CircuitBreakerOpen:
|
||||
if now.Sub(cb.lastFailureTime) > cb.timeout {
|
||||
cb.state = CircuitBreakerHalfOpen
|
||||
if cb.logger != nil {
|
||||
cb.logger.Infof("Circuit breaker transitioning to half-open state")
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
case CircuitBreakerHalfOpen:
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// recordFailure records a failure and potentially opens the circuit.
|
||||
// Updates failure count and triggers state transitions when thresholds are exceeded.
|
||||
func (cb *CircuitBreaker) recordFailure() {
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
cb.failures++
|
||||
cb.lastFailureTime = time.Now()
|
||||
|
||||
switch cb.state {
|
||||
case CircuitBreakerClosed:
|
||||
if cb.failures >= int64(cb.maxFailures) {
|
||||
cb.state = CircuitBreakerOpen
|
||||
if cb.baseRecovery != nil {
|
||||
cb.baseRecovery.LogError("Circuit breaker opened after %d failures", cb.failures)
|
||||
}
|
||||
}
|
||||
|
||||
case CircuitBreakerHalfOpen:
|
||||
cb.state = CircuitBreakerOpen
|
||||
if cb.baseRecovery != nil {
|
||||
cb.baseRecovery.LogError("Circuit breaker returned to open state after failure in half-open")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordSuccess records a successful request and potentially closes the circuit.
|
||||
// Resets failure count and transitions from half-open to closed state on success.
|
||||
func (cb *CircuitBreaker) recordSuccess() {
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case CircuitBreakerHalfOpen:
|
||||
cb.failures = 0
|
||||
cb.state = CircuitBreakerClosed
|
||||
if cb.baseRecovery != nil {
|
||||
cb.baseRecovery.LogInfo("Circuit breaker closed after successful request in half-open state")
|
||||
}
|
||||
|
||||
case CircuitBreakerClosed:
|
||||
cb.failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
// GetState returns the current state of the circuit breaker.
|
||||
// Thread-safe method for monitoring circuit breaker status.
|
||||
func (cb *CircuitBreaker) GetState() CircuitBreakerState {
|
||||
cb.mutex.RLock()
|
||||
defer cb.mutex.RUnlock()
|
||||
return cb.state
|
||||
}
|
||||
|
||||
// Reset resets the circuit breaker to its initial closed state.
|
||||
// Clears failure count and state, effectively recovering from any open state.
|
||||
func (cb *CircuitBreaker) Reset() {
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
cb.state = CircuitBreakerClosed
|
||||
atomic.StoreInt64(&cb.failures, 0)
|
||||
if cb.baseRecovery != nil {
|
||||
cb.baseRecovery.LogInfo("Circuit breaker has been reset")
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable returns whether the circuit breaker is currently allowing requests.
|
||||
// This provides a quick way to check if the service is available.
|
||||
func (cb *CircuitBreaker) IsAvailable() bool {
|
||||
return cb.allowRequest()
|
||||
}
|
||||
|
||||
// GetMetrics returns comprehensive metrics about the circuit breaker.
|
||||
// Includes state information, failure counts, configuration, and base metrics.
|
||||
func (cb *CircuitBreaker) GetMetrics() map[string]interface{} {
|
||||
cb.mutex.RLock()
|
||||
state := cb.state
|
||||
failures := cb.failures
|
||||
lastFailureTime := cb.lastFailureTime
|
||||
cb.mutex.RUnlock()
|
||||
|
||||
var metrics map[string]interface{}
|
||||
if cb.baseRecovery != nil {
|
||||
metrics = cb.baseRecovery.GetBaseMetrics()
|
||||
} else {
|
||||
metrics = make(map[string]interface{})
|
||||
}
|
||||
|
||||
metrics["state"] = state.String()
|
||||
metrics["current_failures"] = failures
|
||||
metrics["max_failures"] = cb.maxFailures
|
||||
metrics["timeout"] = cb.timeout.String()
|
||||
metrics["reset_timeout"] = cb.resetTimeout.String()
|
||||
|
||||
if !lastFailureTime.IsZero() {
|
||||
metrics["last_failure_time"] = lastFailureTime
|
||||
metrics["time_since_last_failure"] = time.Since(lastFailureTime).String()
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// GetFailureCount returns the current failure count
|
||||
func (cb *CircuitBreaker) GetFailureCount() int64 {
|
||||
cb.mutex.RLock()
|
||||
defer cb.mutex.RUnlock()
|
||||
return cb.failures
|
||||
}
|
||||
|
||||
// GetLastFailureTime returns the time of the last failure
|
||||
func (cb *CircuitBreaker) GetLastFailureTime() time.Time {
|
||||
cb.mutex.RLock()
|
||||
defer cb.mutex.RUnlock()
|
||||
return cb.lastFailureTime
|
||||
}
|
||||
|
||||
// IsOpen returns true if the circuit breaker is in open state
|
||||
func (cb *CircuitBreaker) IsOpen() bool {
|
||||
cb.mutex.RLock()
|
||||
defer cb.mutex.RUnlock()
|
||||
return cb.state == CircuitBreakerOpen
|
||||
}
|
||||
|
||||
// IsClosed returns true if the circuit breaker is in closed state
|
||||
func (cb *CircuitBreaker) IsClosed() bool {
|
||||
cb.mutex.RLock()
|
||||
defer cb.mutex.RUnlock()
|
||||
return cb.state == CircuitBreakerClosed
|
||||
}
|
||||
|
||||
// IsHalfOpen returns true if the circuit breaker is in half-open state
|
||||
func (cb *CircuitBreaker) IsHalfOpen() bool {
|
||||
cb.mutex.RLock()
|
||||
defer cb.mutex.RUnlock()
|
||||
return cb.state == CircuitBreakerHalfOpen
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
package circuit_breaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
type mockLogger struct {
|
||||
infoLogs []string
|
||||
errorLogs []string
|
||||
debugLogs []string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockLogger) Infof(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Errorf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Debugf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) getInfoLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.infoLogs))
|
||||
copy(result, m.infoLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future error log verification tests
|
||||
func (m *mockLogger) getErrorLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.errorLogs))
|
||||
copy(result, m.errorLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future test isolation
|
||||
func (m *mockLogger) reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = nil
|
||||
m.errorLogs = nil
|
||||
m.debugLogs = nil
|
||||
}
|
||||
|
||||
type mockBaseRecoveryMechanism struct {
|
||||
requestCount int64
|
||||
successCount int64
|
||||
failureCount int64
|
||||
infoLogs []string
|
||||
errorLogs []string
|
||||
debugLogs []string
|
||||
baseMetrics map[string]interface{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newMockBaseRecovery() *mockBaseRecoveryMechanism {
|
||||
return &mockBaseRecoveryMechanism{
|
||||
baseMetrics: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) RecordRequest() {
|
||||
atomic.AddInt64(&m.requestCount, 1)
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) RecordSuccess() {
|
||||
atomic.AddInt64(&m.successCount, 1)
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) RecordFailure() {
|
||||
atomic.AddInt64(&m.failureCount, 1)
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) GetBaseMetrics() map[string]interface{} {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range m.baseMetrics {
|
||||
result[k] = v
|
||||
}
|
||||
result["total_requests"] = atomic.LoadInt64(&m.requestCount)
|
||||
result["total_successes"] = atomic.LoadInt64(&m.successCount)
|
||||
result["total_failures"] = atomic.LoadInt64(&m.failureCount)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) LogInfo(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) LogError(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) LogDebug(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) getRequestCount() int64 {
|
||||
return atomic.LoadInt64(&m.requestCount)
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) getSuccessCount() int64 {
|
||||
return atomic.LoadInt64(&m.successCount)
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) getFailureCount() int64 {
|
||||
return atomic.LoadInt64(&m.failureCount)
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) getInfoLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.infoLogs))
|
||||
copy(result, m.infoLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockBaseRecoveryMechanism) getErrorLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.errorLogs))
|
||||
copy(result, m.errorLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
func TestCircuitBreakerState_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
state CircuitBreakerState
|
||||
expected string
|
||||
}{
|
||||
{CircuitBreakerClosed, "closed"},
|
||||
{CircuitBreakerOpen, "open"},
|
||||
{CircuitBreakerHalfOpen, "half-open"},
|
||||
{CircuitBreakerState(999), "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expected, func(t *testing.T) {
|
||||
result := tt.state.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCircuitBreakerConfig(t *testing.T) {
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
|
||||
if config.MaxFailures != 2 {
|
||||
t.Errorf("Expected MaxFailures to be 2, got %d", config.MaxFailures)
|
||||
}
|
||||
|
||||
if config.Timeout != 60*time.Second {
|
||||
t.Errorf("Expected Timeout to be 60s, got %v", config.Timeout)
|
||||
}
|
||||
|
||||
if config.ResetTimeout != 30*time.Second {
|
||||
t.Errorf("Expected ResetTimeout to be 30s, got %v", config.ResetTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCircuitBreaker(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 3,
|
||||
Timeout: 30 * time.Second,
|
||||
ResetTimeout: 15 * time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
if cb == nil {
|
||||
t.Fatal("NewCircuitBreaker returned nil")
|
||||
}
|
||||
|
||||
if cb.maxFailures != 3 {
|
||||
t.Errorf("Expected maxFailures to be 3, got %d", cb.maxFailures)
|
||||
}
|
||||
|
||||
if cb.timeout != 30*time.Second {
|
||||
t.Errorf("Expected timeout to be 30s, got %v", cb.timeout)
|
||||
}
|
||||
|
||||
if cb.resetTimeout != 15*time.Second {
|
||||
t.Errorf("Expected resetTimeout to be 15s, got %v", cb.resetTimeout)
|
||||
}
|
||||
|
||||
if cb.state != CircuitBreakerClosed {
|
||||
t.Errorf("Expected initial state to be Closed, got %v", cb.state)
|
||||
}
|
||||
|
||||
if cb.logger != logger {
|
||||
t.Error("Expected logger to be set")
|
||||
}
|
||||
|
||||
if cb.baseRecovery != baseRecovery {
|
||||
t.Error("Expected baseRecovery to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_ExecuteWithContext_Success(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 2,
|
||||
Timeout: time.Second,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
callCount := 0
|
||||
testFunc := func() error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := cb.ExecuteWithContext(ctx, testFunc)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected function to be called once, got %d", callCount)
|
||||
}
|
||||
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Expected state to remain Closed, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
if baseRecovery.getRequestCount() != 1 {
|
||||
t.Errorf("Expected 1 request recorded, got %d", baseRecovery.getRequestCount())
|
||||
}
|
||||
|
||||
if baseRecovery.getSuccessCount() != 1 {
|
||||
t.Errorf("Expected 1 success recorded, got %d", baseRecovery.getSuccessCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_ExecuteWithContext_Failure(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 2,
|
||||
Timeout: time.Second,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
testError := fmt.Errorf("test error")
|
||||
testFunc := func() error {
|
||||
return testError
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := cb.ExecuteWithContext(ctx, testFunc)
|
||||
|
||||
if err != testError {
|
||||
t.Errorf("Expected test error, got %v", err)
|
||||
}
|
||||
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Expected state to remain Closed after single failure, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
if baseRecovery.getRequestCount() != 1 {
|
||||
t.Errorf("Expected 1 request recorded, got %d", baseRecovery.getRequestCount())
|
||||
}
|
||||
|
||||
if baseRecovery.getFailureCount() != 1 {
|
||||
t.Errorf("Expected 1 failure recorded, got %d", baseRecovery.getFailureCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_Execute(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: time.Second,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
callCount := 0
|
||||
testFunc := func() error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
err := cb.Execute(testFunc)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected function to be called once, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_OpenAfterMaxFailures(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 2,
|
||||
Timeout: time.Second,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
testError := fmt.Errorf("test error")
|
||||
testFunc := func() error {
|
||||
return testError
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First failure
|
||||
err := cb.ExecuteWithContext(ctx, testFunc)
|
||||
if err != testError {
|
||||
t.Errorf("Expected test error on first failure, got %v", err)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Expected state to remain Closed after first failure, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
// Second failure - should open circuit
|
||||
err = cb.ExecuteWithContext(ctx, testFunc)
|
||||
if err != testError {
|
||||
t.Errorf("Expected test error on second failure, got %v", err)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Errorf("Expected state to be Open after max failures, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
// Third attempt - should be blocked
|
||||
callCount := 0
|
||||
blockedFunc := func() error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
err = cb.ExecuteWithContext(ctx, blockedFunc)
|
||||
if err == nil {
|
||||
t.Error("Expected error when circuit is open")
|
||||
}
|
||||
if callCount != 0 {
|
||||
t.Errorf("Expected function not to be called when circuit is open, got %d calls", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_HalfOpenTransition(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 10 * time.Millisecond, // Very short for testing
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Trigger circuit opening
|
||||
testError := fmt.Errorf("test error")
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
if err != testError {
|
||||
t.Errorf("Expected test error, got %v", err)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Errorf("Expected state to be Open, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
// Wait for timeout
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
|
||||
// Next request should transition to half-open
|
||||
callCount := 0
|
||||
testFunc := func() error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
err = cb.ExecuteWithContext(context.Background(), testFunc)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error in half-open state, got %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected function to be called in half-open state, got %d calls", callCount)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Expected state to be Closed after successful half-open request, got %v", cb.GetState())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_HalfOpenFailureReturnsToOpen(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 10 * time.Millisecond,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Trigger circuit opening
|
||||
testError := fmt.Errorf("test error")
|
||||
_ = cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Errorf("Expected state to be Open, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
// Wait for timeout to allow half-open transition
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
|
||||
// First call should transition to half-open, but we'll force it by checking allowRequest
|
||||
if !cb.allowRequest() {
|
||||
t.Error("Expected allowRequest to return true after timeout")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerHalfOpen {
|
||||
t.Errorf("Expected state to be HalfOpen, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
// Failure in half-open should return to open
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
if err != testError {
|
||||
t.Errorf("Expected test error, got %v", err)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Errorf("Expected state to return to Open after half-open failure, got %v", cb.GetState())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_Reset(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: time.Second,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Trigger circuit opening
|
||||
testError := fmt.Errorf("test error")
|
||||
_ = cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Errorf("Expected state to be Open, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
// Reset circuit
|
||||
cb.Reset()
|
||||
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Expected state to be Closed after reset, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
if cb.GetFailureCount() != 0 {
|
||||
t.Errorf("Expected failure count to be 0 after reset, got %d", cb.GetFailureCount())
|
||||
}
|
||||
|
||||
// Should allow requests again
|
||||
callCount := 0
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
callCount++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error after reset, got %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected function to be called after reset, got %d calls", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_IsAvailable(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 10 * time.Millisecond,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Initially available
|
||||
if !cb.IsAvailable() {
|
||||
t.Error("Expected circuit breaker to be available initially")
|
||||
}
|
||||
|
||||
// Trigger opening
|
||||
testError := fmt.Errorf("test error")
|
||||
cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
|
||||
// Should not be available when open
|
||||
if cb.IsAvailable() {
|
||||
t.Error("Expected circuit breaker to be unavailable when open")
|
||||
}
|
||||
|
||||
// Wait for timeout
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
|
||||
// Should be available again after timeout (half-open)
|
||||
if !cb.IsAvailable() {
|
||||
t.Error("Expected circuit breaker to be available after timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_StateCheckers(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 10 * time.Millisecond,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Initially closed
|
||||
if !cb.IsClosed() {
|
||||
t.Error("Expected circuit breaker to be closed initially")
|
||||
}
|
||||
if cb.IsOpen() {
|
||||
t.Error("Expected circuit breaker not to be open initially")
|
||||
}
|
||||
if cb.IsHalfOpen() {
|
||||
t.Error("Expected circuit breaker not to be half-open initially")
|
||||
}
|
||||
|
||||
// Trigger opening
|
||||
testError := fmt.Errorf("test error")
|
||||
cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
|
||||
// Should be open
|
||||
if cb.IsClosed() {
|
||||
t.Error("Expected circuit breaker not to be closed when open")
|
||||
}
|
||||
if !cb.IsOpen() {
|
||||
t.Error("Expected circuit breaker to be open")
|
||||
}
|
||||
if cb.IsHalfOpen() {
|
||||
t.Error("Expected circuit breaker not to be half-open when open")
|
||||
}
|
||||
|
||||
// Wait for timeout and trigger half-open
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
cb.allowRequest() // This will transition to half-open
|
||||
|
||||
// Should be half-open
|
||||
if cb.IsClosed() {
|
||||
t.Error("Expected circuit breaker not to be closed when half-open")
|
||||
}
|
||||
if cb.IsOpen() {
|
||||
t.Error("Expected circuit breaker not to be open when half-open")
|
||||
}
|
||||
if !cb.IsHalfOpen() {
|
||||
t.Error("Expected circuit breaker to be half-open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_GetMetrics(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 2,
|
||||
Timeout: 30 * time.Second,
|
||||
ResetTimeout: 15 * time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
baseRecovery.baseMetrics["custom_metric"] = "custom_value"
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Record some activity
|
||||
testError := fmt.Errorf("test error")
|
||||
cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
|
||||
metrics := cb.GetMetrics()
|
||||
|
||||
// Check circuit breaker specific metrics
|
||||
if metrics["state"] != "closed" {
|
||||
t.Errorf("Expected state to be 'closed', got %v", metrics["state"])
|
||||
}
|
||||
|
||||
if metrics["current_failures"] != int64(1) {
|
||||
t.Errorf("Expected current_failures to be 1, got %v", metrics["current_failures"])
|
||||
}
|
||||
|
||||
if metrics["max_failures"] != 2 {
|
||||
t.Errorf("Expected max_failures to be 2, got %v", metrics["max_failures"])
|
||||
}
|
||||
|
||||
if metrics["timeout"] != "30s" {
|
||||
t.Errorf("Expected timeout to be '30s', got %v", metrics["timeout"])
|
||||
}
|
||||
|
||||
if metrics["reset_timeout"] != "15s" {
|
||||
t.Errorf("Expected reset_timeout to be '15s', got %v", metrics["reset_timeout"])
|
||||
}
|
||||
|
||||
// Check base metrics are included
|
||||
if metrics["total_requests"] != int64(1) {
|
||||
t.Errorf("Expected total_requests to be 1, got %v", metrics["total_requests"])
|
||||
}
|
||||
|
||||
if metrics["custom_metric"] != "custom_value" {
|
||||
t.Errorf("Expected custom_metric to be 'custom_value', got %v", metrics["custom_metric"])
|
||||
}
|
||||
|
||||
// Check failure time metrics
|
||||
if _, exists := metrics["last_failure_time"]; !exists {
|
||||
t.Error("Expected last_failure_time to exist")
|
||||
}
|
||||
|
||||
if _, exists := metrics["time_since_last_failure"]; !exists {
|
||||
t.Error("Expected time_since_last_failure to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_GetMetrics_NoBaseRecovery(t *testing.T) {
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
logger := &mockLogger{}
|
||||
cb := NewCircuitBreaker(config, logger, nil)
|
||||
|
||||
metrics := cb.GetMetrics()
|
||||
|
||||
// Should still have circuit breaker metrics
|
||||
if metrics["state"] != "closed" {
|
||||
t.Errorf("Expected state to be 'closed', got %v", metrics["state"])
|
||||
}
|
||||
|
||||
if metrics["max_failures"] != 2 {
|
||||
t.Errorf("Expected max_failures to be 2, got %v", metrics["max_failures"])
|
||||
}
|
||||
|
||||
// Should not have base metrics
|
||||
if _, exists := metrics["total_requests"]; exists {
|
||||
t.Error("Expected total_requests not to exist without base recovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_GetLastFailureTime(t *testing.T) {
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Initially should be zero
|
||||
if !cb.GetLastFailureTime().IsZero() {
|
||||
t.Error("Expected last failure time to be zero initially")
|
||||
}
|
||||
|
||||
// Record a failure
|
||||
before := time.Now()
|
||||
testError := fmt.Errorf("test error")
|
||||
cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
after := time.Now()
|
||||
|
||||
lastFailure := cb.GetLastFailureTime()
|
||||
if lastFailure.IsZero() {
|
||||
t.Error("Expected last failure time to be set after failure")
|
||||
}
|
||||
|
||||
if lastFailure.Before(before) || lastFailure.After(after) {
|
||||
t.Errorf("Expected last failure time to be between %v and %v, got %v",
|
||||
before, after, lastFailure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_ExecuteWithoutBaseRecovery(t *testing.T) {
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
logger := &mockLogger{}
|
||||
cb := NewCircuitBreaker(config, logger, nil)
|
||||
|
||||
callCount := 0
|
||||
testFunc := func() error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
err := cb.ExecuteWithContext(context.Background(), testFunc)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected function to be called once, got %d", callCount)
|
||||
}
|
||||
|
||||
// Should work fine without base recovery
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Expected state to be Closed, got %v", cb.GetState())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 10, // Higher threshold for concurrent test
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ResetTimeout: 50 * time.Millisecond,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
const numGoroutines = 10
|
||||
const numOperations = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := int64(0)
|
||||
errorCount := int64(0)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numOperations; j++ {
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
// Simulate some failures
|
||||
if j%10 == 9 { // Every 10th operation fails
|
||||
return fmt.Errorf("simulated error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
}
|
||||
|
||||
// Intermittently check state and metrics
|
||||
if j%5 == 0 {
|
||||
cb.GetState()
|
||||
cb.GetMetrics()
|
||||
cb.IsAvailable()
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify we got both successes and errors
|
||||
finalSuccessCount := atomic.LoadInt64(&successCount)
|
||||
finalErrorCount := atomic.LoadInt64(&errorCount)
|
||||
|
||||
if finalSuccessCount == 0 {
|
||||
t.Error("Expected some successful operations")
|
||||
}
|
||||
|
||||
if finalErrorCount == 0 {
|
||||
t.Error("Expected some failed operations")
|
||||
}
|
||||
|
||||
totalOperations := finalSuccessCount + finalErrorCount
|
||||
expectedMax := int64(numGoroutines * numOperations)
|
||||
|
||||
if totalOperations > expectedMax {
|
||||
t.Errorf("Expected at most %d operations, got %d", expectedMax, totalOperations)
|
||||
}
|
||||
|
||||
t.Logf("Concurrent test completed: %d successes, %d errors, final state: %v",
|
||||
finalSuccessCount, finalErrorCount, cb.GetState())
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_StateTransitionLogging(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 10 * time.Millisecond,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Trigger circuit opening
|
||||
testError := fmt.Errorf("test error")
|
||||
cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
|
||||
// Check that error was logged when circuit opened
|
||||
errorLogs := baseRecovery.getErrorLogs()
|
||||
if len(errorLogs) == 0 {
|
||||
t.Error("Expected error log when circuit breaker opened")
|
||||
} else {
|
||||
if !contains(errorLogs, "Circuit breaker opened after") {
|
||||
t.Errorf("Expected circuit opening log, got %v", errorLogs)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait and trigger half-open
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
|
||||
// Successful request should close circuit and log
|
||||
cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Check that success was logged when circuit closed
|
||||
infoLogs := baseRecovery.getInfoLogs()
|
||||
if len(infoLogs) == 0 {
|
||||
t.Error("Expected info log when circuit breaker closed")
|
||||
} else {
|
||||
if !contains(infoLogs, "Circuit breaker closed after successful request") {
|
||||
t.Errorf("Expected circuit closing log, got %v", infoLogs)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset should also be logged
|
||||
cb.Reset()
|
||||
infoLogs = baseRecovery.getInfoLogs()
|
||||
if !contains(infoLogs, "Circuit breaker has been reset") {
|
||||
t.Errorf("Expected reset log, got %v", infoLogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_LoggerTransitionLogging(t *testing.T) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 10 * time.Millisecond,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Wait for timeout and check half-open transition logging
|
||||
testError := fmt.Errorf("test error")
|
||||
cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return testError
|
||||
})
|
||||
|
||||
// Wait for timeout
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
|
||||
// Next allowRequest call should log transition to half-open
|
||||
cb.allowRequest()
|
||||
|
||||
infoLogs := logger.getInfoLogs()
|
||||
if len(infoLogs) == 0 {
|
||||
t.Error("Expected info log for half-open transition")
|
||||
} else {
|
||||
if !contains(infoLogs, "Circuit breaker transitioning to half-open state") {
|
||||
t.Errorf("Expected half-open transition log, got %v", infoLogs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a slice contains a string with substring
|
||||
func contains(slice []string, substr string) bool {
|
||||
for _, s := range slice {
|
||||
if len(s) >= len(substr) && s[:len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkCircuitBreaker_ExecuteWithContext_Success(b *testing.B) {
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
testFunc := func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
cb.ExecuteWithContext(ctx, testFunc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCircuitBreaker_ExecuteWithContext_Failure(b *testing.B) {
|
||||
config := CircuitBreakerConfig{
|
||||
MaxFailures: 1000, // High threshold to avoid opening during benchmark
|
||||
Timeout: time.Second,
|
||||
ResetTimeout: time.Second,
|
||||
}
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
testError := fmt.Errorf("test error")
|
||||
testFunc := func() error {
|
||||
return testError
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
cb.ExecuteWithContext(ctx, testFunc)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCircuitBreaker_GetState(b *testing.B) {
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
cb.GetState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCircuitBreaker_GetMetrics(b *testing.B) {
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
logger := &mockLogger{}
|
||||
baseRecovery := newMockBaseRecovery()
|
||||
cb := NewCircuitBreaker(config, logger, baseRecovery)
|
||||
|
||||
// Add some activity
|
||||
for i := 0; i < 100; i++ {
|
||||
if i%2 == 0 {
|
||||
cb.ExecuteWithContext(context.Background(), func() error { return nil })
|
||||
} else {
|
||||
cb.ExecuteWithContext(context.Background(), func() error { return fmt.Errorf("error") })
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
cb.GetMetrics()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Package config provides configuration management for the OIDC middleware
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
minEncryptionKeyLength = 16
|
||||
ConstSessionTimeout = 86400
|
||||
)
|
||||
|
||||
//lint:ignore U1000 May be referenced for default exclusion patterns
|
||||
var defaultExcludedURLs = map[string]struct{}{
|
||||
"/favicon.ico": {},
|
||||
"/robots.txt": {},
|
||||
"/health": {},
|
||||
"/.well-known/": {},
|
||||
"/metrics": {},
|
||||
"/ping": {},
|
||||
"/api/": {},
|
||||
"/static/": {},
|
||||
"/assets/": {},
|
||||
"/js/": {},
|
||||
"/css/": {},
|
||||
"/images/": {},
|
||||
"/fonts/": {},
|
||||
}
|
||||
|
||||
// Settings manages configuration and initialization for the OIDC middleware
|
||||
type Settings struct {
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Debug(msg string)
|
||||
Debugf(format string, args ...interface{})
|
||||
Info(msg string)
|
||||
Infof(format string, args ...interface{})
|
||||
Error(msg string)
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// Config represents the configuration for the OIDC middleware
|
||||
type Config struct {
|
||||
ProviderURL string `json:"providerUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
CallbackURL string `json:"callbackUrl"`
|
||||
LogoutURL string `json:"logoutUrl"`
|
||||
PostLogoutRedirectURI string `json:"postLogoutRedirectUri"`
|
||||
SessionEncryptionKey string `json:"sessionEncryptionKey"`
|
||||
ForceHTTPS bool `json:"forceHttps"`
|
||||
LogLevel string `json:"logLevel"`
|
||||
Scopes []string `json:"scopes"`
|
||||
OverrideScopes bool `json:"overrideScopes"`
|
||||
AllowedUsers []string `json:"allowedUsers"`
|
||||
AllowedUserDomains []string `json:"allowedUserDomains"`
|
||||
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
|
||||
ExcludedURLs []string `json:"excludedUrls"`
|
||||
EnablePKCE bool `json:"enablePkce"`
|
||||
RateLimit int `json:"rateLimit"`
|
||||
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
||||
Headers []HeaderConfig `json:"headers"`
|
||||
HTTPClient *http.Client `json:"-"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
}
|
||||
|
||||
// HeaderConfig represents header template configuration
|
||||
type HeaderConfig struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// NewSettings creates a new Settings instance
|
||||
func NewSettings(logger Logger) *Settings {
|
||||
return &Settings{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateConfig creates a default configuration
|
||||
func CreateConfig() *Config {
|
||||
return &Config{
|
||||
LogLevel: "INFO",
|
||||
ForceHTTPS: true,
|
||||
EnablePKCE: true,
|
||||
RateLimit: 10,
|
||||
RefreshGracePeriodSeconds: 60,
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeTraefikOidc would initialize and configure a new TraefikOidc instance
|
||||
// This functionality has been moved to the main New function in main.go
|
||||
// This function is kept for compatibility but should not be used
|
||||
func (s *Settings) InitializeTraefikOidc(ctx context.Context, next http.Handler, config *Config, name string) (interface{}, error) {
|
||||
return nil, fmt.Errorf("InitializeTraefikOidc is deprecated - use New function from main package instead")
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Kept for backward compatibility
|
||||
func (s *Settings) setupHeaderTemplates(t interface{}, config *Config, logger Logger) error {
|
||||
logger.Debug("setupHeaderTemplates is deprecated")
|
||||
return nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future background service management
|
||||
func (s *Settings) startBackgroundServices(ctx context.Context, logger Logger) {
|
||||
startReplayCacheCleanup(ctx, logger)
|
||||
|
||||
// Start memory monitoring for leak detection and performance insights
|
||||
memoryMonitor := GetGlobalMemoryMonitor()
|
||||
memoryMonitor.StartMonitoring(ctx, 60*time.Second) // Monitor every minute
|
||||
logger.Debug("Started global memory monitoring")
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
//lint:ignore U1000 May be needed for future scope processing
|
||||
func deduplicateScopes(scopes []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
result := []string{}
|
||||
for _, scope := range scopes {
|
||||
if !seen[scope] {
|
||||
seen[scope] = true
|
||||
result = append(result, scope)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future scope merging operations
|
||||
func mergeScopes(defaultScopes, userScopes []string) []string {
|
||||
result := make([]string, len(defaultScopes))
|
||||
copy(result, defaultScopes)
|
||||
return append(result, userScopes...)
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future utility operations
|
||||
func createStringMap(items []string) map[string]struct{} {
|
||||
result := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
result[item] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future case-insensitive operations
|
||||
func createCaseInsensitiveStringMap(items []string) map[string]struct{} {
|
||||
result := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
result[strings.ToLower(item)] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future test environment detection
|
||||
func isTestMode() bool {
|
||||
// This function should be implemented based on environment detection logic
|
||||
return false
|
||||
}
|
||||
|
||||
// External dependencies that need to be provided
|
||||
// TraefikOidc struct is defined in types.go
|
||||
|
||||
// These functions need to be provided by external packages
|
||||
func NewLogger(level string) Logger { return nil }
|
||||
func CreateDefaultHTTPClient() *http.Client { return nil }
|
||||
func CreateTokenHTTPClient() *http.Client { return nil }
|
||||
func GetGlobalCacheManager(*sync.WaitGroup) CacheManager { return nil }
|
||||
func NewSessionManager(string, bool, string, Logger) (SessionManager, error) { return nil, nil }
|
||||
func NewErrorRecoveryManager(Logger) ErrorRecoveryManager { return nil }
|
||||
|
||||
//lint:ignore U1000 May be needed for future token claim extraction
|
||||
func extractClaims(string) (map[string]interface{}, error) { return nil, nil }
|
||||
|
||||
//lint:ignore U1000 May be needed for future replay attack prevention
|
||||
func startReplayCacheCleanup(context.Context, Logger) {}
|
||||
func GetGlobalMemoryMonitor() MemoryMonitor { return nil }
|
||||
|
||||
// Interfaces for external dependencies
|
||||
type CacheManager interface {
|
||||
GetSharedTokenBlacklist() CacheInterface
|
||||
GetSharedTokenCache() *TokenCache
|
||||
GetSharedMetadataCache() *MetadataCache
|
||||
GetSharedJWKCache() JWKCacheInterface
|
||||
Close() error
|
||||
}
|
||||
type SessionManager interface{}
|
||||
type ErrorRecoveryManager interface{}
|
||||
type MemoryMonitor interface {
|
||||
StartMonitoring(ctx context.Context, interval time.Duration)
|
||||
}
|
||||
type CacheInterface interface {
|
||||
Set(key string, value interface{}, ttl time.Duration)
|
||||
Get(key string) (interface{}, bool)
|
||||
Delete(key string)
|
||||
SetMaxSize(size int)
|
||||
Cleanup()
|
||||
Close()
|
||||
}
|
||||
type TokenCache struct{}
|
||||
type MetadataCache struct{}
|
||||
type JWKCacheInterface interface{}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Package handlers provides HTTP request handlers for the OIDC middleware.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OAuthHandler handles OAuth callback requests
|
||||
type OAuthHandler struct {
|
||||
logger Logger
|
||||
sessionManager SessionManager
|
||||
tokenExchanger TokenExchanger
|
||||
tokenVerifier TokenVerifier
|
||||
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
||||
isAllowedDomainFunc func(email string) bool
|
||||
redirURLPath string
|
||||
sendErrorResponseFunc func(rw http.ResponseWriter, req *http.Request, message string, code int)
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Debugf(format string, args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
Error(msg string)
|
||||
}
|
||||
|
||||
// SessionManager interface for session operations
|
||||
type SessionManager interface {
|
||||
GetSession(req *http.Request) (SessionData, error)
|
||||
}
|
||||
|
||||
// SessionData interface for session data operations
|
||||
type SessionData interface {
|
||||
GetCSRF() string
|
||||
GetNonce() string
|
||||
GetCodeVerifier() string
|
||||
GetIncomingPath() string
|
||||
GetAuthenticated() bool
|
||||
SetAuthenticated(bool) error
|
||||
SetEmail(string)
|
||||
SetIDToken(string)
|
||||
SetAccessToken(string)
|
||||
SetRefreshToken(string)
|
||||
SetCSRF(string)
|
||||
SetNonce(string)
|
||||
SetCodeVerifier(string)
|
||||
SetIncomingPath(string)
|
||||
ResetRedirectCount()
|
||||
Save(req *http.Request, rw http.ResponseWriter) error
|
||||
returnToPoolSafely()
|
||||
}
|
||||
|
||||
// TokenExchanger interface for token operations
|
||||
type TokenExchanger interface {
|
||||
ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error)
|
||||
}
|
||||
|
||||
// TokenVerifier interface for token verification
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(token string) error
|
||||
}
|
||||
|
||||
// TokenResponse represents the response from token exchange
|
||||
type TokenResponse struct {
|
||||
IDToken string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
// NewOAuthHandler creates a new OAuth handler
|
||||
func NewOAuthHandler(logger Logger, sessionManager SessionManager, tokenExchanger TokenExchanger,
|
||||
tokenVerifier TokenVerifier, extractClaimsFunc func(string) (map[string]interface{}, error),
|
||||
isAllowedDomainFunc func(string) bool, redirURLPath string,
|
||||
sendErrorResponseFunc func(http.ResponseWriter, *http.Request, string, int)) *OAuthHandler {
|
||||
|
||||
return &OAuthHandler{
|
||||
logger: logger,
|
||||
sessionManager: sessionManager,
|
||||
tokenExchanger: tokenExchanger,
|
||||
tokenVerifier: tokenVerifier,
|
||||
extractClaimsFunc: extractClaimsFunc,
|
||||
isAllowedDomainFunc: isAllowedDomainFunc,
|
||||
redirURLPath: redirURLPath,
|
||||
sendErrorResponseFunc: sendErrorResponseFunc,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCallback handles OAuth callback requests
|
||||
func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
|
||||
session, err := h.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Session error during callback: %v", err)
|
||||
h.sendErrorResponseFunc(rw, req, "Session error during callback", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer session.returnToPoolSafely()
|
||||
|
||||
h.logger.Debugf("Handling callback, URL: %s", req.URL.String())
|
||||
|
||||
if req.URL.Query().Get("error") != "" {
|
||||
errorDescription := req.URL.Query().Get("error_description")
|
||||
if errorDescription == "" {
|
||||
errorDescription = req.URL.Query().Get("error")
|
||||
}
|
||||
h.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription)
|
||||
h.sendErrorResponseFunc(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state := req.URL.Query().Get("state")
|
||||
if state == "" {
|
||||
h.logger.Error("No state in callback")
|
||||
h.sendErrorResponseFunc(rw, req, "State parameter missing in callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
csrfToken := session.GetCSRF()
|
||||
if csrfToken == "" {
|
||||
h.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s",
|
||||
session.GetAuthenticated(), req.URL.String())
|
||||
|
||||
cookie, err := req.Cookie("_oidc_raczylo_m")
|
||||
if err != nil {
|
||||
h.logger.Errorf("Main session cookie not found in request: %v", err)
|
||||
} else {
|
||||
h.logger.Errorf("Main session cookie exists but CSRF token is empty. Cookie value length: %d", len(cookie.Value))
|
||||
}
|
||||
|
||||
h.sendErrorResponseFunc(rw, req, "CSRF token missing in session", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if state != csrfToken {
|
||||
h.logger.Error("State parameter does not match CSRF token in session during callback")
|
||||
h.sendErrorResponseFunc(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := req.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
h.logger.Error("No code in callback")
|
||||
h.sendErrorResponseFunc(rw, req, "No authorization code received in callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
codeVerifier := session.GetCodeVerifier()
|
||||
|
||||
tokenResponse, err := h.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Failed to exchange code for token during callback: %v", err)
|
||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.tokenVerifier.VerifyToken(tokenResponse.IDToken); err != nil {
|
||||
h.logger.Errorf("Failed to verify id_token during callback: %v", err)
|
||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.extractClaimsFunc(tokenResponse.IDToken)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Failed to extract claims during callback: %v", err)
|
||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
nonceClaim, ok := claims["nonce"].(string)
|
||||
if !ok || nonceClaim == "" {
|
||||
h.logger.Error("Nonce claim missing in id_token during callback")
|
||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sessionNonce := session.GetNonce()
|
||||
if sessionNonce == "" {
|
||||
h.logger.Error("Nonce not found in session during callback")
|
||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if nonceClaim != sessionNonce {
|
||||
h.logger.Error("Nonce claim does not match session nonce during callback")
|
||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
email, _ := claims["email"].(string)
|
||||
if email == "" {
|
||||
h.logger.Errorf("Email claim missing or empty in token during callback")
|
||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Email missing in token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !h.isAllowedDomainFunc(email) {
|
||||
h.logger.Errorf("Disallowed email domain during callback: %s", email)
|
||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Email domain not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
h.logger.Errorf("Failed to set authenticated state and regenerate session ID: %v", err)
|
||||
h.sendErrorResponseFunc(rw, req, "Failed to update session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
session.SetEmail(email)
|
||||
session.SetIDToken(tokenResponse.IDToken)
|
||||
session.SetAccessToken(tokenResponse.AccessToken)
|
||||
session.SetRefreshToken(tokenResponse.RefreshToken)
|
||||
|
||||
session.SetCSRF("")
|
||||
session.SetNonce("")
|
||||
session.SetCodeVerifier("")
|
||||
|
||||
session.ResetRedirectCount()
|
||||
|
||||
redirectPath := "/"
|
||||
if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != h.redirURLPath {
|
||||
redirectPath = incomingPath
|
||||
}
|
||||
session.SetIncomingPath("")
|
||||
|
||||
if err := session.Save(req, rw); err != nil {
|
||||
h.logger.Errorf("Failed to save session after callback: %v", err)
|
||||
h.sendErrorResponseFunc(rw, req, "Failed to save session after callback", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debugf("Callback successful, redirecting to %s", redirectPath)
|
||||
http.Redirect(rw, req, redirectPath, http.StatusFound)
|
||||
}
|
||||
|
||||
// URLHelper provides utility methods for URL operations
|
||||
type URLHelper struct {
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewURLHelper creates a new URL helper
|
||||
func NewURLHelper(logger Logger) *URLHelper {
|
||||
return &URLHelper{logger: logger}
|
||||
}
|
||||
|
||||
// DetermineExcludedURL checks if a URL path should bypass OIDC authentication.
|
||||
// It compares the request path against configured excluded URL prefixes.
|
||||
func (h *URLHelper) DetermineExcludedURL(currentRequest string, excludedURLs map[string]struct{}) bool {
|
||||
for excludedURL := range excludedURLs {
|
||||
if strings.HasPrefix(currentRequest, excludedURL) {
|
||||
h.logger.Debugf("URL is excluded - got %s / excluded hit: %s", currentRequest, excludedURL)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DetermineScheme determines the URL scheme for building redirect URLs.
|
||||
// It checks X-Forwarded-Proto header first, then TLS presence.
|
||||
func (h *URLHelper) DetermineScheme(req *http.Request) string {
|
||||
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
if req.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
// DetermineHost determines the host for building redirect URLs.
|
||||
// It checks X-Forwarded-Host header first, then falls back to req.Host.
|
||||
func (h *URLHelper) DetermineHost(req *http.Request) string {
|
||||
if host := req.Header.Get("X-Forwarded-Host"); host != "" {
|
||||
return host
|
||||
}
|
||||
return req.Host
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
type mockLogger struct {
|
||||
debugLogs []string
|
||||
errorLogs []string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockLogger) Debugf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Errorf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Error(msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorLogs = append(m.errorLogs, msg)
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future debug log verification tests
|
||||
func (m *mockLogger) getDebugLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.debugLogs))
|
||||
copy(result, m.debugLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future error log verification tests
|
||||
func (m *mockLogger) getErrorLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.errorLogs))
|
||||
copy(result, m.errorLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future test isolation
|
||||
func (m *mockLogger) reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = nil
|
||||
m.errorLogs = nil
|
||||
}
|
||||
|
||||
type mockSessionManager struct {
|
||||
sessionData *mockSessionData
|
||||
sessionError error
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) GetSession(req *http.Request) (SessionData, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.sessionError != nil {
|
||||
return nil, m.sessionError
|
||||
}
|
||||
return m.sessionData, nil
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) setSessionError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sessionError = err
|
||||
}
|
||||
|
||||
type mockSessionData struct {
|
||||
csrf string
|
||||
nonce string
|
||||
codeVerifier string
|
||||
incomingPath string
|
||||
authenticated bool
|
||||
email string
|
||||
idToken string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
saveError error
|
||||
setAuthError error
|
||||
mu sync.RWMutex
|
||||
returned bool
|
||||
}
|
||||
|
||||
func newMockSessionData() *mockSessionData {
|
||||
return &mockSessionData{
|
||||
csrf: "test-csrf-token",
|
||||
nonce: "test-nonce",
|
||||
codeVerifier: "test-code-verifier",
|
||||
incomingPath: "/original/path",
|
||||
authenticated: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetCSRF() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.csrf
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetNonce() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.nonce
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetCodeVerifier() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.codeVerifier
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetIncomingPath() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.incomingPath
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetAuthenticated() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.authenticated
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetAuthenticated(auth bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.setAuthError != nil {
|
||||
return m.setAuthError
|
||||
}
|
||||
m.authenticated = auth
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetEmail(email string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.email = email
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetIDToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.idToken = token
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetAccessToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.accessToken = token
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetRefreshToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.refreshToken = token
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetCSRF(csrf string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.csrf = csrf
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetNonce(nonce string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.nonce = nonce
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetCodeVerifier(verifier string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.codeVerifier = verifier
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetIncomingPath(path string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.incomingPath = path
|
||||
}
|
||||
|
||||
func (m *mockSessionData) ResetRedirectCount() {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
func (m *mockSessionData) Save(req *http.Request, rw http.ResponseWriter) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.saveError
|
||||
}
|
||||
|
||||
func (m *mockSessionData) returnToPoolSafely() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.returned = true
|
||||
}
|
||||
|
||||
func (m *mockSessionData) setSaveError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.saveError = err
|
||||
}
|
||||
|
||||
func (m *mockSessionData) setSetAuthError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.setAuthError = err
|
||||
}
|
||||
|
||||
type mockTokenExchanger struct {
|
||||
tokenResponse *TokenResponse
|
||||
exchangeError error
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockTokenExchanger) ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.exchangeError != nil {
|
||||
return nil, m.exchangeError
|
||||
}
|
||||
return m.tokenResponse, nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future token exchange tests
|
||||
func (m *mockTokenExchanger) setTokenResponse(response *TokenResponse) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.tokenResponse = response
|
||||
}
|
||||
|
||||
func (m *mockTokenExchanger) setExchangeError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.exchangeError = err
|
||||
}
|
||||
|
||||
type mockTokenVerifier struct {
|
||||
verifyError error
|
||||
}
|
||||
|
||||
func (m *mockTokenVerifier) VerifyToken(token string) error {
|
||||
return m.verifyError
|
||||
}
|
||||
|
||||
// Test helper functions
|
||||
func createTestOAuthHandler() *OAuthHandler {
|
||||
logger := &mockLogger{}
|
||||
sessionManager := &mockSessionManager{sessionData: newMockSessionData()}
|
||||
tokenExchanger := &mockTokenExchanger{
|
||||
tokenResponse: &TokenResponse{
|
||||
IDToken: "test.id.token",
|
||||
AccessToken: "test-access-token",
|
||||
RefreshToken: "test-refresh-token",
|
||||
},
|
||||
}
|
||||
tokenVerifier := &mockTokenVerifier{}
|
||||
|
||||
extractClaimsFunc := func(tokenString string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"nonce": "test-nonce",
|
||||
"email": "[email protected]",
|
||||
"sub": "test-user-id",
|
||||
}, nil
|
||||
}
|
||||
|
||||
isAllowedDomainFunc := func(email string) bool {
|
||||
return strings.HasSuffix(email, "@example.com")
|
||||
}
|
||||
|
||||
sendErrorResponseFunc := func(rw http.ResponseWriter, req *http.Request, message string, code int) {
|
||||
http.Error(rw, message, code)
|
||||
}
|
||||
|
||||
return NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||
extractClaimsFunc, isAllowedDomainFunc, "/auth/callback", sendErrorResponseFunc)
|
||||
}
|
||||
|
||||
func TestNewOAuthHandler(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("NewOAuthHandler returned nil")
|
||||
}
|
||||
|
||||
if handler.logger == nil {
|
||||
t.Error("Expected logger to be set")
|
||||
}
|
||||
|
||||
if handler.sessionManager == nil {
|
||||
t.Error("Expected sessionManager to be set")
|
||||
}
|
||||
|
||||
if handler.tokenExchanger == nil {
|
||||
t.Error("Expected tokenExchanger to be set")
|
||||
}
|
||||
|
||||
if handler.redirURLPath != "/auth/callback" {
|
||||
t.Errorf("Expected redirURLPath to be '/auth/callback', got %s", handler.redirURLPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_Success(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusFound {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusFound, rw.Code)
|
||||
}
|
||||
|
||||
location := rw.Header().Get("Location")
|
||||
if location != "/original/path" {
|
||||
t.Errorf("Expected redirect to '/original/path', got %s", location)
|
||||
}
|
||||
|
||||
sessionData := handler.sessionManager.(*mockSessionManager).sessionData
|
||||
if sessionData.email != "[email protected]" {
|
||||
t.Errorf("Expected email to be set to '[email protected]', got %s", sessionData.email)
|
||||
}
|
||||
|
||||
if !sessionData.authenticated {
|
||||
t.Error("Expected session to be authenticated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_SessionError(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
sessionManager := handler.sessionManager.(*mockSessionManager)
|
||||
sessionManager.setSessionError(fmt.Errorf("session error"))
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Session error during callback") {
|
||||
t.Errorf("Expected session error message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_AuthError(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?error=access_denied&error_description=User+denied+access", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "User denied access") {
|
||||
t.Errorf("Expected error description in response, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_MissingState(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "State parameter missing") {
|
||||
t.Errorf("Expected state missing message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_MissingCSRFToken(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
sessionData := handler.sessionManager.(*mockSessionManager).sessionData
|
||||
sessionData.csrf = "" // Clear CSRF token
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "CSRF token missing in session") {
|
||||
t.Errorf("Expected CSRF missing message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_CSRFMismatch(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=wrong-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "CSRF mismatch") {
|
||||
t.Errorf("Expected CSRF mismatch message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_MissingCode(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "No authorization code received") {
|
||||
t.Errorf("Expected missing code message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_TokenExchangeError(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
tokenExchanger := handler.tokenExchanger.(*mockTokenExchanger)
|
||||
tokenExchanger.setExchangeError(fmt.Errorf("token exchange failed"))
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Could not exchange code for token") {
|
||||
t.Errorf("Expected token exchange error message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_TokenVerificationError(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
tokenVerifier := handler.tokenVerifier.(*mockTokenVerifier)
|
||||
tokenVerifier.verifyError = fmt.Errorf("token verification failed")
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Could not verify ID token") {
|
||||
t.Errorf("Expected token verification error message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_ClaimsExtractionError(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
handler.extractClaimsFunc = func(tokenString string) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("claims extraction failed")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Could not extract claims from token") {
|
||||
t.Errorf("Expected claims extraction error message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_MissingNonceClaim(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
handler.extractClaimsFunc = func(tokenString string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"email": "[email protected]",
|
||||
"sub": "test-user-id",
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Nonce missing in token") {
|
||||
t.Errorf("Expected nonce missing message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_MissingSessionNonce(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
sessionData := handler.sessionManager.(*mockSessionManager).sessionData
|
||||
sessionData.nonce = "" // Clear session nonce
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Nonce missing in session") {
|
||||
t.Errorf("Expected nonce missing in session message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_NonceMismatch(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
handler.extractClaimsFunc = func(tokenString string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"nonce": "wrong-nonce",
|
||||
"email": "[email protected]",
|
||||
"sub": "test-user-id",
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Nonce mismatch") {
|
||||
t.Errorf("Expected nonce mismatch message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_MissingEmail(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
handler.extractClaimsFunc = func(tokenString string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"nonce": "test-nonce",
|
||||
"sub": "test-user-id",
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Email missing in token") {
|
||||
t.Errorf("Expected email missing message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_DisallowedEmailDomain(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
handler.extractClaimsFunc = func(tokenString string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"nonce": "test-nonce",
|
||||
"email": "[email protected]",
|
||||
"sub": "test-user-id",
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusForbidden, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Email domain not allowed") {
|
||||
t.Errorf("Expected domain not allowed message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_SetAuthenticatedError(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
sessionData := handler.sessionManager.(*mockSessionManager).sessionData
|
||||
sessionData.setSetAuthError(fmt.Errorf("failed to set authenticated"))
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Failed to update session") {
|
||||
t.Errorf("Expected session update error message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_SessionSaveError(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
sessionData := handler.sessionManager.(*mockSessionManager).sessionData
|
||||
sessionData.setSaveError(fmt.Errorf("failed to save session"))
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Failed to save session after callback") {
|
||||
t.Errorf("Expected session save error message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_DefaultRedirect(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
sessionData := handler.sessionManager.(*mockSessionManager).sessionData
|
||||
sessionData.incomingPath = "" // No incoming path
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusFound {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusFound, rw.Code)
|
||||
}
|
||||
|
||||
location := rw.Header().Get("Location")
|
||||
if location != "/" {
|
||||
t.Errorf("Expected redirect to '/', got %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback_CallbackPathNotRedirected(t *testing.T) {
|
||||
handler := createTestOAuthHandler()
|
||||
sessionData := handler.sessionManager.(*mockSessionManager).sessionData
|
||||
sessionData.incomingPath = "/auth/callback" // Incoming path is callback path
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusFound {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusFound, rw.Code)
|
||||
}
|
||||
|
||||
location := rw.Header().Get("Location")
|
||||
if location != "/" {
|
||||
t.Errorf("Expected redirect to '/' when incoming path is callback path, got %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLHelper_NewURLHelper(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
helper := NewURLHelper(logger)
|
||||
|
||||
if helper == nil {
|
||||
t.Fatal("NewURLHelper returned nil")
|
||||
}
|
||||
|
||||
if helper.logger != logger {
|
||||
t.Error("Expected logger to be set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLHelper_DetermineExcludedURL(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
helper := NewURLHelper(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
currentReq string
|
||||
excludedURLs map[string]struct{}
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "matches excluded URL",
|
||||
currentReq: "/health/check",
|
||||
excludedURLs: map[string]struct{}{
|
||||
"/health": {},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "does not match excluded URL",
|
||||
currentReq: "/protected/resource",
|
||||
excludedURLs: map[string]struct{}{
|
||||
"/health": {},
|
||||
"/api": {},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty excluded URLs",
|
||||
currentReq: "/any/path",
|
||||
excludedURLs: map[string]struct{}{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "exact match",
|
||||
currentReq: "/health",
|
||||
excludedURLs: map[string]struct{}{
|
||||
"/health": {},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "prefix match",
|
||||
currentReq: "/api/v1/users",
|
||||
excludedURLs: map[string]struct{}{
|
||||
"/api": {},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := helper.DetermineExcludedURL(tt.currentReq, tt.excludedURLs)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLHelper_DetermineScheme(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
helper := NewURLHelper(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "X-Forwarded-Proto header present",
|
||||
req: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
return req
|
||||
}(),
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "TLS present",
|
||||
req: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
return req
|
||||
}(),
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "no TLS or header",
|
||||
req: httptest.NewRequest("GET", "/test", nil),
|
||||
expected: "http",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Proto takes precedence over TLS",
|
||||
req: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
return req
|
||||
}(),
|
||||
expected: "http",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := helper.DetermineScheme(tt.req)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLHelper_DetermineHost(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
helper := NewURLHelper(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "X-Forwarded-Host header present",
|
||||
req: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
req.Header.Set("X-Forwarded-Host", "proxy.example.com")
|
||||
return req
|
||||
}(),
|
||||
expected: "proxy.example.com",
|
||||
},
|
||||
{
|
||||
name: "no X-Forwarded-Host header",
|
||||
req: httptest.NewRequest("GET", "http://example.com/test", nil),
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with port",
|
||||
req: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "http://example.com:8080/test", nil)
|
||||
req.Header.Set("X-Forwarded-Host", "proxy.example.com:443")
|
||||
return req
|
||||
}(),
|
||||
expected: "proxy.example.com:443",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := helper.DetermineHost(tt.req)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCallbacks(t *testing.T) {
|
||||
const numGoroutines = 10
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
|
||||
// Create unique session data for each goroutine
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.csrf = fmt.Sprintf("csrf-token-%d", id)
|
||||
sessionData.nonce = fmt.Sprintf("nonce-%d", id)
|
||||
|
||||
sessionManager := &mockSessionManager{sessionData: sessionData}
|
||||
localHandler := createTestOAuthHandler()
|
||||
localHandler.sessionManager = sessionManager
|
||||
|
||||
// Update extract claims function to match the session nonce
|
||||
localHandler.extractClaimsFunc = func(tokenString string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"nonce": fmt.Sprintf("nonce-%d", id),
|
||||
"email": fmt.Sprintf("test%[email protected]", id),
|
||||
"sub": fmt.Sprintf("test-user-%d", id),
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET",
|
||||
fmt.Sprintf("/auth/callback?state=csrf-token-%d&code=auth-code-%d", id, id), nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
localHandler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
|
||||
if rw.Code != http.StatusFound {
|
||||
errors <- fmt.Errorf("goroutine %d: expected status %d, got %d",
|
||||
id, http.StatusFound, rw.Code)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
var errorCount int
|
||||
for err := range errors {
|
||||
t.Error(err)
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Errorf("Found %d errors in concurrent callback test", errorCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkHandleCallback_Success(b *testing.B) {
|
||||
handler := createTestOAuthHandler()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=test-csrf-token&code=test-auth-code", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
handler.HandleCallback(rw, req, "https://example.com/callback")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkURLHelper_DetermineExcludedURL(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
helper := NewURLHelper(logger)
|
||||
excludedURLs := map[string]struct{}{
|
||||
"/health": {},
|
||||
"/api": {},
|
||||
"/status": {},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
helper.DetermineExcludedURL("/protected/resource", excludedURLs)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkURLHelper_DetermineScheme(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
helper := NewURLHelper(logger)
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
helper.DetermineScheme(req)
|
||||
}
|
||||
}
|
||||
@@ -31,251 +31,9 @@ func createDefaultHTTPClient() *http.Client {
|
||||
}
|
||||
|
||||
const (
|
||||
ConstSessionTimeout = 86400
|
||||
defaultBlacklistDuration = 24 * time.Hour
|
||||
defaultMaxBlacklistSize = 10000
|
||||
ConstSessionTimeout = 86400
|
||||
)
|
||||
|
||||
var (
|
||||
globalCacheManager *CacheManager
|
||||
cacheManagerOnce sync.Once
|
||||
cacheManagerMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// CacheManager manages all caching components for the OIDC middleware.
|
||||
// It provides thread-safe access to token blacklist, token cache, metadata cache,
|
||||
// and JWK cache. This centralizes cache management to ensure efficient memory
|
||||
// usage and consistent cache behavior across the application.
|
||||
// CacheInterface defines the common cache operations
|
||||
type CacheInterface interface {
|
||||
Set(key string, value interface{}, ttl time.Duration)
|
||||
Get(key string) (interface{}, bool)
|
||||
Delete(key string)
|
||||
SetMaxSize(size int)
|
||||
Cleanup()
|
||||
Close()
|
||||
}
|
||||
|
||||
type CacheManager struct {
|
||||
tokenBlacklist CacheInterface
|
||||
tokenCache *TokenCache
|
||||
metadataCache *MetadataCache
|
||||
jwkCache JWKCacheInterface
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// GetGlobalCacheManager returns a singleton instance of CacheManager.
|
||||
// It initializes all cache components on first call and reuses the same instance
|
||||
// for subsequent calls. This ensures consistent cache state and efficient memory
|
||||
// usage across the entire application lifecycle.
|
||||
// Parameters:
|
||||
// - wg: WaitGroup for coordinating cache cleanup during shutdown.
|
||||
//
|
||||
// Returns:
|
||||
// - The global CacheManager instance.
|
||||
func GetGlobalCacheManager(wg *sync.WaitGroup) *CacheManager {
|
||||
cacheManagerOnce.Do(func() {
|
||||
globalCacheManager = &CacheManager{
|
||||
tokenBlacklist: func() CacheInterface {
|
||||
config := DefaultUnifiedCacheConfig()
|
||||
config.MaxSize = defaultMaxBlacklistSize
|
||||
c := NewUnifiedCache(config)
|
||||
return NewCacheAdapter(c)
|
||||
}(),
|
||||
tokenCache: NewTokenCache(),
|
||||
metadataCache: NewMetadataCache(wg),
|
||||
jwkCache: &JWKCache{},
|
||||
}
|
||||
})
|
||||
return globalCacheManager
|
||||
}
|
||||
|
||||
// GetSharedTokenBlacklist returns the shared token blacklist cache.
|
||||
// This cache stores revoked or expired tokens to prevent replay attacks.
|
||||
// Access is protected by read lock to ensure thread safety.
|
||||
// Returns:
|
||||
// - The shared token blacklist CacheInterface instance.
|
||||
func (cm *CacheManager) GetSharedTokenBlacklist() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.tokenBlacklist
|
||||
}
|
||||
|
||||
// GetSharedTokenCache returns the shared token cache for verified tokens.
|
||||
// This cache stores claims from successfully verified tokens to avoid
|
||||
// repeated verification of the same tokens.
|
||||
// Access is protected by read lock to ensure thread safety.
|
||||
// Returns:
|
||||
// - The shared TokenCache instance.
|
||||
func (cm *CacheManager) GetSharedTokenCache() *TokenCache {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.tokenCache
|
||||
}
|
||||
|
||||
// GetSharedMetadataCache returns the shared metadata cache.
|
||||
// This cache stores OIDC provider metadata (endpoints, keys) to avoid
|
||||
// repeated requests to the provider's .well-known configuration endpoint.
|
||||
// Access is protected by read lock to ensure thread safety.
|
||||
// Returns:
|
||||
// - The shared MetadataCache instance.
|
||||
func (cm *CacheManager) GetSharedMetadataCache() *MetadataCache {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.metadataCache
|
||||
}
|
||||
|
||||
// GetSharedJWKCache returns the shared JWK (JSON Web Key) cache.
|
||||
// This cache stores public keys from the provider's JWKS endpoint
|
||||
// for token signature verification.
|
||||
// Access is protected by read lock to ensure thread safety.
|
||||
// Returns:
|
||||
// - The shared JWKCacheInterface instance.
|
||||
func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.jwkCache
|
||||
}
|
||||
|
||||
// Close gracefully shuts down all cache components and releases resources.
|
||||
// It closes all individual caches and cleans up their associated goroutines.
|
||||
// This method should be called when the middleware is shutting down.
|
||||
// Safe to call multiple times.
|
||||
// Returns:
|
||||
// - An error if any cache fails to close properly, nil otherwise.
|
||||
func (cm *CacheManager) Close() error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.tokenBlacklist != nil {
|
||||
cm.tokenBlacklist.Close()
|
||||
cm.tokenBlacklist = nil
|
||||
}
|
||||
if cm.tokenCache != nil {
|
||||
cm.tokenCache.Close()
|
||||
cm.tokenCache = nil
|
||||
}
|
||||
if cm.metadataCache != nil {
|
||||
cm.metadataCache.Close()
|
||||
cm.metadataCache = nil
|
||||
}
|
||||
if cm.jwkCache != nil {
|
||||
cm.jwkCache.Close()
|
||||
cm.jwkCache = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupGlobalCacheManager cleans up the global cache manager instance.
|
||||
// It closes all cache components but does NOT reset the singleton for re-initialization
|
||||
// to avoid race conditions with sync.Once. It's safe to call multiple times.
|
||||
// Returns:
|
||||
// - An error if cache cleanup fails, nil otherwise.
|
||||
func CleanupGlobalCacheManager() error {
|
||||
cacheManagerMutex.Lock()
|
||||
defer cacheManagerMutex.Unlock()
|
||||
|
||||
if globalCacheManager != nil {
|
||||
err := globalCacheManager.Close()
|
||||
// Don't reset globalCacheManager to nil or cacheManagerOnce to avoid race conditions
|
||||
// The cache manager remains closed and the Close() method handles multiple calls
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TokenVerifier interface defines token verification capabilities.
|
||||
// Implementations should validate token format, signature, and claims.
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(token string) error
|
||||
}
|
||||
|
||||
// JWTVerifier interface defines JWT-specific verification capabilities.
|
||||
// Implementations should validate JWT structure, signature using JWKs, and standard claims.
|
||||
type JWTVerifier interface {
|
||||
VerifyJWTSignatureAndClaims(jwt *JWT, token string) error
|
||||
}
|
||||
|
||||
// TokenExchanger interface defines OAuth 2.0 and OpenID Connect token exchange capabilities.
|
||||
// Implementations should handle authorization code exchange, refresh tokens, and revocation
|
||||
// according to the OAuth 2.0 and OpenID Connect specifications.
|
||||
type TokenExchanger interface {
|
||||
ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error)
|
||||
GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error)
|
||||
RevokeTokenWithProvider(token, tokenType string) error
|
||||
}
|
||||
|
||||
// TraefikOidc is the main middleware struct that implements OIDC authentication for Traefik.
|
||||
// It integrates with various OIDC providers, manages sessions, caches tokens, and handles
|
||||
// the complete authentication flow. It's designed to work seamlessly with Traefik's
|
||||
// plugin system and provides flexible configuration options.
|
||||
type TraefikOidc struct {
|
||||
jwkCache JWKCacheInterface
|
||||
jwtVerifier JWTVerifier
|
||||
ctx context.Context
|
||||
tokenVerifier TokenVerifier
|
||||
next http.Handler
|
||||
tokenExchanger TokenExchanger
|
||||
initComplete chan struct{}
|
||||
limiter *rate.Limiter
|
||||
tokenBlacklist CacheInterface
|
||||
headerTemplates map[string]*template.Template
|
||||
sessionManager *SessionManager
|
||||
tokenCleanupStopChan chan struct{}
|
||||
excludedURLs map[string]struct{}
|
||||
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
||||
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
|
||||
metadataCache *MetadataCache
|
||||
allowedRolesAndGroups map[string]struct{}
|
||||
allowedUsers map[string]struct{}
|
||||
allowedUserDomains map[string]struct{}
|
||||
tokenCache *TokenCache
|
||||
httpClient *http.Client
|
||||
tokenHTTPClient *http.Client
|
||||
logger *Logger
|
||||
metadataRefreshStopChan chan struct{}
|
||||
cancelFunc context.CancelFunc
|
||||
errorRecoveryManager *ErrorRecoveryManager
|
||||
goroutineWG *sync.WaitGroup
|
||||
clientSecret string
|
||||
clientID string
|
||||
name string
|
||||
redirURLPath string
|
||||
logoutURLPath string
|
||||
tokenURL string
|
||||
authURL string
|
||||
endSessionURL string
|
||||
postLogoutRedirectURI string
|
||||
scheme string
|
||||
jwksURL string
|
||||
issuerURL string
|
||||
revocationURL string
|
||||
providerURL string
|
||||
scopes []string
|
||||
refreshGracePeriod time.Duration
|
||||
shutdownOnce sync.Once
|
||||
firstRequestMutex sync.Mutex
|
||||
forceHTTPS bool
|
||||
enablePKCE bool
|
||||
overrideScopes bool
|
||||
suppressDiagnosticLogs bool
|
||||
firstRequestReceived bool
|
||||
metadataRefreshStarted bool
|
||||
}
|
||||
|
||||
// ProviderMetadata represents OIDC provider configuration data.
|
||||
// This data is typically retrieved from the provider's .well-known/openid-configuration endpoint
|
||||
// and contains essential URLs for authentication, token exchange, and key retrieval.
|
||||
type ProviderMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthURL string `json:"authorization_endpoint"`
|
||||
TokenURL string `json:"token_endpoint"`
|
||||
JWKSURL string `json:"jwks_uri"`
|
||||
RevokeURL string `json:"revocation_endpoint"`
|
||||
EndSessionURL string `json:"end_session_endpoint"`
|
||||
}
|
||||
|
||||
// isTestMode detects if the code is running in a test environment.
|
||||
// It checks various indicators including environment variables, command-line arguments,
|
||||
// and runtime compiler information to determine test context.
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
// Package middleware provides authentication middleware for OIDC flows
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthMiddleware handles the main OIDC authentication flow
|
||||
type AuthMiddleware struct {
|
||||
logger Logger
|
||||
next http.Handler
|
||||
sessionManager SessionManager
|
||||
authHandler AuthHandler
|
||||
oauthHandler OAuthHandler
|
||||
urlHelper URLHelper
|
||||
tokenVerifier TokenVerifier
|
||||
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
||||
extractGroupsAndRolesFunc func(tokenString string) ([]string, []string, error)
|
||||
sendErrorResponseFunc func(rw http.ResponseWriter, req *http.Request, message string, code int)
|
||||
refreshTokenFunc func(rw http.ResponseWriter, req *http.Request, session SessionData) bool
|
||||
isUserAuthenticatedFunc func(session SessionData) (bool, bool, bool)
|
||||
isAllowedDomainFunc func(email string) bool
|
||||
isAjaxRequestFunc func(req *http.Request) bool
|
||||
isRefreshTokenExpiredFunc func(session SessionData) bool
|
||||
processLogoutFunc func(rw http.ResponseWriter, req *http.Request)
|
||||
excludedURLs map[string]struct{}
|
||||
allowedRolesAndGroups map[string]struct{}
|
||||
redirURLPath string
|
||||
logoutURLPath string
|
||||
refreshGracePeriod time.Duration
|
||||
initComplete chan struct{}
|
||||
issuerURL string
|
||||
firstRequestReceived bool
|
||||
metadataRefreshStarted bool
|
||||
firstRequestMutex sync.Mutex
|
||||
providerURL string
|
||||
goroutineWG *sync.WaitGroup
|
||||
startTokenCleanupFunc func()
|
||||
startMetadataRefreshFunc func(string)
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Debug(msg string)
|
||||
Debugf(format string, args ...interface{})
|
||||
Error(msg string)
|
||||
Errorf(format string, args ...interface{})
|
||||
Info(msg string)
|
||||
Infof(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// SessionManager interface for session operations
|
||||
type SessionManager interface {
|
||||
CleanupOldCookies(rw http.ResponseWriter, req *http.Request)
|
||||
GetSession(req *http.Request) (SessionData, error)
|
||||
}
|
||||
|
||||
// SessionData interface for session data operations
|
||||
type SessionData interface {
|
||||
GetEmail() string
|
||||
GetAccessToken() string
|
||||
GetIDToken() string
|
||||
GetRefreshToken() string
|
||||
Clear(req *http.Request, rw http.ResponseWriter) error
|
||||
ResetRedirectCount()
|
||||
returnToPoolSafely()
|
||||
}
|
||||
|
||||
// AuthHandler interface for authentication operations
|
||||
type AuthHandler interface {
|
||||
InitiateAuthentication(rw http.ResponseWriter, req *http.Request, session SessionData, redirectURL string,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge func() (string, error))
|
||||
}
|
||||
|
||||
// OAuthHandler interface for OAuth callback operations
|
||||
type OAuthHandler interface {
|
||||
HandleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string)
|
||||
}
|
||||
|
||||
// URLHelper interface for URL operations
|
||||
type URLHelper interface {
|
||||
DetermineExcludedURL(currentRequest string, excludedURLs map[string]struct{}) bool
|
||||
DetermineScheme(req *http.Request) string
|
||||
DetermineHost(req *http.Request) string
|
||||
}
|
||||
|
||||
// TokenVerifier interface for token verification
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(token string) error
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new authentication middleware
|
||||
func NewAuthMiddleware(
|
||||
logger Logger,
|
||||
next http.Handler,
|
||||
sessionManager SessionManager,
|
||||
authHandler AuthHandler,
|
||||
oauthHandler OAuthHandler,
|
||||
urlHelper URLHelper,
|
||||
tokenVerifier TokenVerifier,
|
||||
extractClaimsFunc func(string) (map[string]interface{}, error),
|
||||
extractGroupsAndRolesFunc func(string) ([]string, []string, error),
|
||||
sendErrorResponseFunc func(http.ResponseWriter, *http.Request, string, int),
|
||||
refreshTokenFunc func(http.ResponseWriter, *http.Request, SessionData) bool,
|
||||
isUserAuthenticatedFunc func(SessionData) (bool, bool, bool),
|
||||
isAllowedDomainFunc func(string) bool,
|
||||
isAjaxRequestFunc func(*http.Request) bool,
|
||||
isRefreshTokenExpiredFunc func(SessionData) bool,
|
||||
processLogoutFunc func(http.ResponseWriter, *http.Request),
|
||||
excludedURLs map[string]struct{},
|
||||
allowedRolesAndGroups map[string]struct{},
|
||||
redirURLPath, logoutURLPath string,
|
||||
refreshGracePeriod time.Duration,
|
||||
initComplete chan struct{},
|
||||
issuerURL, providerURL string,
|
||||
goroutineWG *sync.WaitGroup,
|
||||
startTokenCleanupFunc func(),
|
||||
startMetadataRefreshFunc func(string),
|
||||
) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
logger: logger,
|
||||
next: next,
|
||||
sessionManager: sessionManager,
|
||||
authHandler: authHandler,
|
||||
oauthHandler: oauthHandler,
|
||||
urlHelper: urlHelper,
|
||||
tokenVerifier: tokenVerifier,
|
||||
extractClaimsFunc: extractClaimsFunc,
|
||||
extractGroupsAndRolesFunc: extractGroupsAndRolesFunc,
|
||||
sendErrorResponseFunc: sendErrorResponseFunc,
|
||||
refreshTokenFunc: refreshTokenFunc,
|
||||
isUserAuthenticatedFunc: isUserAuthenticatedFunc,
|
||||
isAllowedDomainFunc: isAllowedDomainFunc,
|
||||
isAjaxRequestFunc: isAjaxRequestFunc,
|
||||
isRefreshTokenExpiredFunc: isRefreshTokenExpiredFunc,
|
||||
processLogoutFunc: processLogoutFunc,
|
||||
excludedURLs: excludedURLs,
|
||||
allowedRolesAndGroups: allowedRolesAndGroups,
|
||||
redirURLPath: redirURLPath,
|
||||
logoutURLPath: logoutURLPath,
|
||||
refreshGracePeriod: refreshGracePeriod,
|
||||
initComplete: initComplete,
|
||||
issuerURL: issuerURL,
|
||||
providerURL: providerURL,
|
||||
goroutineWG: goroutineWG,
|
||||
startTokenCleanupFunc: startTokenCleanupFunc,
|
||||
startMetadataRefreshFunc: startMetadataRefreshFunc,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements the main OIDC authentication middleware
|
||||
func (m *AuthMiddleware) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
if !strings.HasPrefix(req.URL.Path, "/health") {
|
||||
m.firstRequestMutex.Lock()
|
||||
if !m.firstRequestReceived {
|
||||
m.firstRequestReceived = true
|
||||
m.logger.Debug("Starting background tasks on first request")
|
||||
m.startTokenCleanupFunc()
|
||||
|
||||
if !m.metadataRefreshStarted && m.providerURL != "" {
|
||||
m.metadataRefreshStarted = true
|
||||
if m.goroutineWG != nil {
|
||||
m.goroutineWG.Add(1)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if m.goroutineWG != nil {
|
||||
m.goroutineWG.Done()
|
||||
}
|
||||
// Recover from panics to prevent goroutine leaks
|
||||
if r := recover(); r != nil {
|
||||
m.logger.Errorf("Start metadata refresh goroutine panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
m.startMetadataRefreshFunc(m.providerURL)
|
||||
}()
|
||||
}
|
||||
}
|
||||
m.firstRequestMutex.Unlock()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-m.initComplete:
|
||||
if m.issuerURL == "" {
|
||||
m.logger.Error("OIDC provider metadata initialization failed or incomplete")
|
||||
m.sendErrorResponseFunc(rw, req, "OIDC provider metadata initialization failed - please check provider availability and configuration", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
case <-req.Context().Done():
|
||||
m.logger.Debug("Request cancelled while waiting for OIDC initialization")
|
||||
m.sendErrorResponseFunc(rw, req, "Request cancelled", http.StatusRequestTimeout)
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
m.logger.Error("Timeout waiting for OIDC initialization")
|
||||
m.sendErrorResponseFunc(rw, req, "Timeout waiting for OIDC provider initialization - please try again later", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if m.urlHelper.DetermineExcludedURL(req.URL.Path, m.excludedURLs) {
|
||||
m.logger.Debugf("Request path %s excluded by configuration, bypassing OIDC", req.URL.Path)
|
||||
m.next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
acceptHeader := req.Header.Get("Accept")
|
||||
if strings.Contains(acceptHeader, "text/event-stream") {
|
||||
m.logger.Debugf("Request accepts text/event-stream (%s), bypassing OIDC", acceptHeader)
|
||||
m.next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
m.sessionManager.CleanupOldCookies(rw, req)
|
||||
|
||||
session, err := m.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
m.logger.Errorf("Error getting session: %v. Initiating authentication.", err)
|
||||
cleanReq := req.Clone(req.Context())
|
||||
session, _ = m.sessionManager.GetSession(cleanReq)
|
||||
if session != nil {
|
||||
defer session.returnToPoolSafely()
|
||||
if clearErr := session.Clear(cleanReq, rw); clearErr != nil {
|
||||
m.logger.Errorf("Error clearing potentially corrupted session: %v", clearErr)
|
||||
}
|
||||
} else {
|
||||
m.logger.Error("Critical session error: Failed to get even a new session.")
|
||||
m.sendErrorResponseFunc(rw, req, "Critical session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
scheme := m.urlHelper.DetermineScheme(req)
|
||||
host := m.urlHelper.DetermineHost(req)
|
||||
redirectURL := buildFullURL(scheme, host, m.redirURLPath)
|
||||
m.authHandler.InitiateAuthentication(rw, req, session, redirectURL,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
return
|
||||
}
|
||||
|
||||
defer session.returnToPoolSafely()
|
||||
|
||||
scheme := m.urlHelper.DetermineScheme(req)
|
||||
host := m.urlHelper.DetermineHost(req)
|
||||
redirectURL := buildFullURL(scheme, host, m.redirURLPath)
|
||||
|
||||
if req.URL.Path == m.logoutURLPath {
|
||||
m.processLogoutFunc(rw, req)
|
||||
return
|
||||
}
|
||||
if req.URL.Path == m.redirURLPath {
|
||||
m.oauthHandler.HandleCallback(rw, req, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
authenticated, needsRefresh, expired := m.isUserAuthenticatedFunc(session)
|
||||
|
||||
if expired {
|
||||
m.logger.Debug("Session token is definitively expired or invalid, initiating re-auth")
|
||||
m.handleExpiredToken(rw, req, session, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
email := session.GetEmail()
|
||||
if authenticated && email != "" {
|
||||
if !m.isAllowedDomainFunc(email) {
|
||||
m.logger.Infof("User with email %s is not from an allowed domain", email)
|
||||
errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", m.logoutURLPath)
|
||||
m.sendErrorResponseFunc(rw, req, errorMsg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if authenticated && !needsRefresh {
|
||||
m.logger.Debug("User authenticated and token valid, proceeding to process authorized request")
|
||||
if accessToken := session.GetAccessToken(); accessToken != "" {
|
||||
if strings.Count(accessToken, ".") == 2 {
|
||||
if err := m.tokenVerifier.VerifyToken(accessToken); err != nil {
|
||||
m.logger.Errorf("Access token validation failed: %v", err)
|
||||
m.handleExpiredToken(rw, req, session, redirectURL)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
m.logger.Debugf("Access token appears opaque, skipping JWT verification for it.")
|
||||
}
|
||||
}
|
||||
m.processAuthorizedRequest(rw, req, session, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
m.handleRefreshFlow(rw, req, session, redirectURL, needsRefresh, authenticated)
|
||||
}
|
||||
|
||||
// handleExpiredToken handles expired tokens by initiating re-authentication
|
||||
func (m *AuthMiddleware) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session SessionData, redirectURL string) {
|
||||
session.ResetRedirectCount()
|
||||
m.authHandler.InitiateAuthentication(rw, req, session, redirectURL,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
}
|
||||
|
||||
// handleRefreshFlow handles token refresh flow or initiates authentication
|
||||
func (m *AuthMiddleware) handleRefreshFlow(rw http.ResponseWriter, req *http.Request, session SessionData, redirectURL string, needsRefresh, authenticated bool) {
|
||||
refreshTokenPresent := session.GetRefreshToken() != ""
|
||||
isAjaxRequest := m.isAjaxRequestFunc(req)
|
||||
refreshTokenExpired := refreshTokenPresent && m.isRefreshTokenExpiredFunc(session)
|
||||
shouldAttemptRefresh := needsRefresh && refreshTokenPresent && !refreshTokenExpired
|
||||
|
||||
// If AJAX request and refresh token expired, return 401 immediately
|
||||
if isAjaxRequest && refreshTokenExpired {
|
||||
m.logger.Debug("AJAX request with expired refresh token, returning 401")
|
||||
m.sendErrorResponseFunc(rw, req, "Session expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if shouldAttemptRefresh {
|
||||
m.handleTokenRefresh(rw, req, session, redirectURL, needsRefresh, authenticated, isAjaxRequest)
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Debugf("Initiating full OIDC authentication flow (authenticated=%v, needsRefresh=%v, refreshTokenPresent=%v)", authenticated, needsRefresh, refreshTokenPresent)
|
||||
|
||||
// If AJAX request without valid authentication, return 401
|
||||
if isAjaxRequest {
|
||||
m.logger.Debug("AJAX request requires authentication, sending 401 Unauthorized")
|
||||
m.sendErrorResponseFunc(rw, req, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset redirect count when starting fresh authentication flow
|
||||
session.ResetRedirectCount()
|
||||
m.authHandler.InitiateAuthentication(rw, req, session, redirectURL,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
}
|
||||
|
||||
// handleTokenRefresh handles the token refresh process
|
||||
func (m *AuthMiddleware) handleTokenRefresh(rw http.ResponseWriter, req *http.Request, session SessionData, redirectURL string, needsRefresh, authenticated, isAjaxRequest bool) {
|
||||
if needsRefresh && authenticated {
|
||||
m.logger.Debug("Session token needs proactive refresh, attempting refresh")
|
||||
} else if needsRefresh && !authenticated {
|
||||
m.logger.Debug("ID token invalid/expired, but refresh token found. Attempting refresh.")
|
||||
}
|
||||
|
||||
refreshed := m.refreshTokenFunc(rw, req, session)
|
||||
if refreshed {
|
||||
email := session.GetEmail()
|
||||
if email != "" && !m.isAllowedDomainFunc(email) {
|
||||
m.logger.Infof("User with refreshed token email %s is not from an allowed domain", email)
|
||||
errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", m.logoutURLPath)
|
||||
m.sendErrorResponseFunc(rw, req, errorMsg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Debug("Token refresh successful, proceeding to process authorized request")
|
||||
m.processAuthorizedRequest(rw, req, session, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Debug("Token refresh failed, requiring re-authentication")
|
||||
if isAjaxRequest {
|
||||
m.logger.Debug("AJAX request with failed token refresh, sending 401 Unauthorized")
|
||||
m.sendErrorResponseFunc(rw, req, "Token refresh failed", http.StatusUnauthorized)
|
||||
} else {
|
||||
m.logger.Debug("Browser request with failed token refresh, initiating re-auth")
|
||||
// Reset redirect count when starting fresh auth after failed refresh to prevent redirect loops
|
||||
session.ResetRedirectCount()
|
||||
m.authHandler.InitiateAuthentication(rw, req, session, redirectURL,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
}
|
||||
}
|
||||
|
||||
// processAuthorizedRequest processes requests for authenticated users
|
||||
func (m *AuthMiddleware) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session SessionData, redirectURL string) {
|
||||
email := session.GetEmail()
|
||||
if email == "" {
|
||||
m.logger.Info("No email found in session during final processing, initiating re-auth")
|
||||
// Reset redirect count to prevent loops when session is invalid
|
||||
session.ResetRedirectCount()
|
||||
m.authHandler.InitiateAuthentication(rw, req, session, redirectURL,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
return
|
||||
}
|
||||
|
||||
tokenForClaims := session.GetIDToken()
|
||||
if tokenForClaims == "" {
|
||||
tokenForClaims = session.GetAccessToken()
|
||||
if tokenForClaims == "" && len(m.allowedRolesAndGroups) > 0 {
|
||||
m.logger.Error("No token available but roles/groups checks are required")
|
||||
// Reset redirect count to prevent loops when token is missing
|
||||
session.ResetRedirectCount()
|
||||
m.authHandler.InitiateAuthentication(rw, req, session, redirectURL,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize empty slices
|
||||
var groups, roles []string
|
||||
|
||||
if tokenForClaims != "" {
|
||||
var err error
|
||||
groups, roles, err = m.extractGroupsAndRolesFunc(tokenForClaims)
|
||||
if err != nil && len(m.allowedRolesAndGroups) > 0 {
|
||||
m.logger.Errorf("Failed to extract groups and roles: %v", err)
|
||||
// Reset redirect count to prevent loops when claim extraction fails
|
||||
session.ResetRedirectCount()
|
||||
m.authHandler.InitiateAuthentication(rw, req, session, redirectURL,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
return
|
||||
} else if err == nil {
|
||||
if len(groups) > 0 {
|
||||
req.Header.Set("X-User-Groups", strings.Join(groups, ","))
|
||||
}
|
||||
if len(roles) > 0 {
|
||||
req.Header.Set("X-User-Roles", strings.Join(roles, ","))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.allowedRolesAndGroups) > 0 {
|
||||
allowed := false
|
||||
for _, roleOrGroup := range append(groups, roles...) {
|
||||
if _, ok := m.allowedRolesAndGroups[roleOrGroup]; ok {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
m.logger.Infof("User with email %s does not have any allowed roles or groups", email)
|
||||
errorMsg := fmt.Sprintf("Access denied: You do not have any of the allowed roles or groups. To log out, visit: %s", m.logoutURLPath)
|
||||
m.sendErrorResponseFunc(rw, req, errorMsg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("X-Forwarded-User", email)
|
||||
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
|
||||
req.Header.Set("X-Auth-Request-User", email)
|
||||
if idToken := session.GetIDToken(); idToken != "" {
|
||||
req.Header.Set("X-Auth-Request-Token", idToken)
|
||||
}
|
||||
|
||||
m.next.ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
// buildFullURL constructs a full URL from scheme, host, and path components
|
||||
func buildFullURL(scheme, host, path string) string {
|
||||
return fmt.Sprintf("%s://%s%s", scheme, host, path)
|
||||
}
|
||||
|
||||
// These functions need to be provided by the calling code or injected as dependencies
|
||||
func generateNonce() (string, error) {
|
||||
// This function needs to be implemented or injected
|
||||
return "", fmt.Errorf("generateNonce not implemented")
|
||||
}
|
||||
|
||||
func generateCodeVerifier() (string, error) {
|
||||
// This function needs to be implemented or injected
|
||||
return "", fmt.Errorf("generateCodeVerifier not implemented")
|
||||
}
|
||||
|
||||
func deriveCodeChallenge() (string, error) {
|
||||
// This function needs to be implemented or injected
|
||||
return "", fmt.Errorf("deriveCodeChallenge not implemented")
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
type mockLogger struct {
|
||||
debugLogs []string
|
||||
infoLogs []string
|
||||
errorLogs []string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockLogger) Debug(msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = append(m.debugLogs, msg)
|
||||
}
|
||||
|
||||
func (m *mockLogger) Debugf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Error(msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorLogs = append(m.errorLogs, msg)
|
||||
}
|
||||
|
||||
func (m *mockLogger) Errorf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Info(msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = append(m.infoLogs, msg)
|
||||
}
|
||||
|
||||
func (m *mockLogger) Infof(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future debug log verification tests
|
||||
func (m *mockLogger) getDebugLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.debugLogs))
|
||||
copy(result, m.debugLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future error log verification tests
|
||||
func (m *mockLogger) getErrorLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.errorLogs))
|
||||
copy(result, m.errorLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future test isolation
|
||||
func (m *mockLogger) reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = nil
|
||||
m.infoLogs = nil
|
||||
m.errorLogs = nil
|
||||
}
|
||||
|
||||
type mockSessionManager struct {
|
||||
cleanupCalled bool
|
||||
sessionData *mockSessionData
|
||||
sessionError error
|
||||
callCount int // Track how many times GetSession is called
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) CleanupOldCookies(rw http.ResponseWriter, req *http.Request) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cleanupCalled = true
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) GetSession(req *http.Request) (SessionData, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.callCount++
|
||||
|
||||
// First call returns error, second call (for clean request) returns valid session
|
||||
if m.sessionError != nil && m.callCount == 1 {
|
||||
return nil, m.sessionError
|
||||
}
|
||||
|
||||
return m.sessionData, nil
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) setSessionError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sessionError = err
|
||||
m.callCount = 0 // Reset call count when setting error
|
||||
}
|
||||
|
||||
type mockSessionData struct {
|
||||
email string
|
||||
accessToken string
|
||||
idToken string
|
||||
refreshToken string
|
||||
clearError error
|
||||
mu sync.RWMutex
|
||||
returned bool
|
||||
}
|
||||
|
||||
func newMockSessionData() *mockSessionData {
|
||||
return &mockSessionData{}
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetEmail() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.email
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetAccessToken() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.accessToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetIDToken() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.idToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetRefreshToken() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.refreshToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) Clear(req *http.Request, rw http.ResponseWriter) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.clearError
|
||||
}
|
||||
|
||||
func (m *mockSessionData) ResetRedirectCount() {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
func (m *mockSessionData) returnToPoolSafely() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.returned = true
|
||||
}
|
||||
|
||||
func (m *mockSessionData) setEmail(email string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.email = email
|
||||
}
|
||||
|
||||
func (m *mockSessionData) setAccessToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.accessToken = token
|
||||
}
|
||||
|
||||
func (m *mockSessionData) setIDToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.idToken = token
|
||||
}
|
||||
|
||||
func (m *mockSessionData) setRefreshToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.refreshToken = token
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future session clear testing
|
||||
func (m *mockSessionData) setClearError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.clearError = err
|
||||
}
|
||||
|
||||
type mockAuthHandler struct {
|
||||
initiateCalled bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockAuthHandler) InitiateAuthentication(rw http.ResponseWriter, req *http.Request, session SessionData, redirectURL string,
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge func() (string, error)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.initiateCalled = true
|
||||
http.Redirect(rw, req, "https://provider.example.com/auth", http.StatusFound)
|
||||
}
|
||||
|
||||
func (m *mockAuthHandler) wasInitiateCalled() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.initiateCalled
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future test isolation
|
||||
func (m *mockAuthHandler) reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.initiateCalled = false
|
||||
}
|
||||
|
||||
type mockOAuthHandler struct {
|
||||
handleCallbackCalled bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockOAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.handleCallbackCalled = true
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write([]byte("callback handled"))
|
||||
}
|
||||
|
||||
func (m *mockOAuthHandler) wasHandleCallbackCalled() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.handleCallbackCalled
|
||||
}
|
||||
|
||||
type mockURLHelper struct {
|
||||
excludedResult bool
|
||||
scheme string
|
||||
host string
|
||||
}
|
||||
|
||||
func (m *mockURLHelper) DetermineExcludedURL(currentRequest string, excludedURLs map[string]struct{}) bool {
|
||||
return m.excludedResult
|
||||
}
|
||||
|
||||
func (m *mockURLHelper) DetermineScheme(req *http.Request) string {
|
||||
if m.scheme != "" {
|
||||
return m.scheme
|
||||
}
|
||||
return "https"
|
||||
}
|
||||
|
||||
func (m *mockURLHelper) DetermineHost(req *http.Request) string {
|
||||
if m.host != "" {
|
||||
return m.host
|
||||
}
|
||||
return "example.com"
|
||||
}
|
||||
|
||||
type mockTokenVerifier struct {
|
||||
verifyError error
|
||||
}
|
||||
|
||||
func (m *mockTokenVerifier) VerifyToken(token string) error {
|
||||
return m.verifyError
|
||||
}
|
||||
|
||||
type mockHandler struct {
|
||||
serveHTTPCalled bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.serveHTTPCalled = true
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write([]byte("next handler called"))
|
||||
}
|
||||
|
||||
func (m *mockHandler) wasServeHTTPCalled() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.serveHTTPCalled
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future test isolation
|
||||
func (m *mockHandler) reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.serveHTTPCalled = false
|
||||
}
|
||||
|
||||
// Test helper functions
|
||||
func createTestMiddleware() *AuthMiddleware {
|
||||
logger := &mockLogger{}
|
||||
nextHandler := &mockHandler{}
|
||||
sessionManager := &mockSessionManager{sessionData: newMockSessionData()}
|
||||
authHandler := &mockAuthHandler{}
|
||||
oauthHandler := &mockOAuthHandler{}
|
||||
urlHelper := &mockURLHelper{}
|
||||
tokenVerifier := &mockTokenVerifier{}
|
||||
|
||||
extractClaimsFunc := func(tokenString string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{"sub": "test-user", "email": "[email protected]"}, nil
|
||||
}
|
||||
|
||||
extractGroupsAndRolesFunc := func(tokenString string) ([]string, []string, error) {
|
||||
return []string{"group1"}, []string{"role1"}, nil
|
||||
}
|
||||
|
||||
sendErrorResponseFunc := func(rw http.ResponseWriter, req *http.Request, message string, code int) {
|
||||
http.Error(rw, message, code)
|
||||
}
|
||||
|
||||
refreshTokenFunc := func(rw http.ResponseWriter, req *http.Request, session SessionData) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
isUserAuthenticatedFunc := func(session SessionData) (bool, bool, bool) {
|
||||
return true, false, false // authenticated, needsRefresh, expired
|
||||
}
|
||||
|
||||
isAllowedDomainFunc := func(email string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
isAjaxRequestFunc := func(req *http.Request) bool {
|
||||
return strings.Contains(req.Header.Get("Accept"), "application/json") ||
|
||||
strings.Contains(req.Header.Get("Content-Type"), "application/json")
|
||||
}
|
||||
|
||||
isRefreshTokenExpiredFunc := func(session SessionData) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
processLogoutFunc := func(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write([]byte("logged out"))
|
||||
}
|
||||
|
||||
excludedURLs := make(map[string]struct{})
|
||||
allowedRolesAndGroups := make(map[string]struct{})
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete) // Initialize as complete
|
||||
var goroutineWG sync.WaitGroup
|
||||
|
||||
startTokenCleanupFunc := func() {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
startMetadataRefreshFunc := func(providerURL string) {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
return NewAuthMiddleware(
|
||||
logger,
|
||||
nextHandler,
|
||||
sessionManager,
|
||||
authHandler,
|
||||
oauthHandler,
|
||||
urlHelper,
|
||||
tokenVerifier,
|
||||
extractClaimsFunc,
|
||||
extractGroupsAndRolesFunc,
|
||||
sendErrorResponseFunc,
|
||||
refreshTokenFunc,
|
||||
isUserAuthenticatedFunc,
|
||||
isAllowedDomainFunc,
|
||||
isAjaxRequestFunc,
|
||||
isRefreshTokenExpiredFunc,
|
||||
processLogoutFunc,
|
||||
excludedURLs,
|
||||
allowedRolesAndGroups,
|
||||
"/auth/callback",
|
||||
"/auth/logout",
|
||||
30*time.Second,
|
||||
initComplete,
|
||||
"https://provider.example.com",
|
||||
"https://provider.example.com",
|
||||
&goroutineWG,
|
||||
startTokenCleanupFunc,
|
||||
startMetadataRefreshFunc,
|
||||
)
|
||||
}
|
||||
|
||||
func TestNewAuthMiddleware(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
|
||||
if middleware == nil {
|
||||
t.Fatal("NewAuthMiddleware returned nil")
|
||||
}
|
||||
|
||||
if middleware.logger == nil {
|
||||
t.Error("Expected logger to be set")
|
||||
}
|
||||
|
||||
if middleware.next == nil {
|
||||
t.Error("Expected next handler to be set")
|
||||
}
|
||||
|
||||
if middleware.redirURLPath != "/auth/callback" {
|
||||
t.Errorf("Expected redirURLPath to be '/auth/callback', got %s", middleware.redirURLPath)
|
||||
}
|
||||
|
||||
if middleware.logoutURLPath != "/auth/logout" {
|
||||
t.Errorf("Expected logoutURLPath to be '/auth/logout', got %s", middleware.logoutURLPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_ExcludedURL(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
urlHelper := &mockURLHelper{excludedResult: true}
|
||||
middleware.urlHelper = urlHelper
|
||||
|
||||
req := httptest.NewRequest("GET", "/excluded/path", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
nextHandler := middleware.next.(*mockHandler)
|
||||
if !nextHandler.wasServeHTTPCalled() {
|
||||
t.Error("Expected next handler to be called for excluded URL")
|
||||
}
|
||||
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_EventStreamBypass(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
nextHandler := middleware.next.(*mockHandler)
|
||||
if !nextHandler.wasServeHTTPCalled() {
|
||||
t.Error("Expected next handler to be called for event stream request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_InitializationTimeout(t *testing.T) {
|
||||
t.Skip("Skipping timeout test to avoid long delays")
|
||||
logger := &mockLogger{}
|
||||
nextHandler := &mockHandler{}
|
||||
sessionManager := &mockSessionManager{sessionData: newMockSessionData()}
|
||||
authHandler := &mockAuthHandler{}
|
||||
oauthHandler := &mockOAuthHandler{}
|
||||
urlHelper := &mockURLHelper{}
|
||||
tokenVerifier := &mockTokenVerifier{}
|
||||
|
||||
// Create initComplete channel but don't close it to simulate timeout
|
||||
initComplete := make(chan struct{})
|
||||
var goroutineWG sync.WaitGroup
|
||||
|
||||
middleware := NewAuthMiddleware(
|
||||
logger,
|
||||
nextHandler,
|
||||
sessionManager,
|
||||
authHandler,
|
||||
oauthHandler,
|
||||
urlHelper,
|
||||
tokenVerifier,
|
||||
func(string) (map[string]interface{}, error) { return nil, nil },
|
||||
func(string) ([]string, []string, error) { return nil, nil, nil },
|
||||
func(rw http.ResponseWriter, req *http.Request, message string, code int) {
|
||||
http.Error(rw, message, code)
|
||||
},
|
||||
func(http.ResponseWriter, *http.Request, SessionData) bool { return false },
|
||||
func(SessionData) (bool, bool, bool) { return false, false, false },
|
||||
func(string) bool { return true },
|
||||
func(*http.Request) bool { return false },
|
||||
func(SessionData) bool { return false },
|
||||
func(http.ResponseWriter, *http.Request) {},
|
||||
make(map[string]struct{}),
|
||||
make(map[string]struct{}),
|
||||
"/auth/callback",
|
||||
"/auth/logout",
|
||||
10*time.Millisecond, // Very short timeout for testing
|
||||
initComplete,
|
||||
"",
|
||||
"https://provider.example.com",
|
||||
&goroutineWG,
|
||||
func() {},
|
||||
func(string) {},
|
||||
)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusServiceUnavailable, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_SessionError(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.setSessionError(fmt.Errorf("session error"))
|
||||
|
||||
// Ensure we have valid session data for the second call
|
||||
sessionManager.sessionData = newMockSessionData()
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
authHandler := middleware.authHandler.(*mockAuthHandler)
|
||||
if !authHandler.wasInitiateCalled() {
|
||||
t.Error("Expected InitiateAuthentication to be called when session error occurs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_LogoutPath(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/logout", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for logout, got %d", rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "logged out") {
|
||||
t.Errorf("Expected logout response, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_CallbackPath(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?code=test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
oauthHandler := middleware.oauthHandler.(*mockOAuthHandler)
|
||||
if !oauthHandler.wasHandleCallbackCalled() {
|
||||
t.Error("Expected HandleCallback to be called for callback path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_AuthenticatedUser(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionData.setAccessToken("valid.jwt.token")
|
||||
sessionData.setIDToken("id.jwt.token")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
nextHandler := middleware.next.(*mockHandler)
|
||||
if !nextHandler.wasServeHTTPCalled() {
|
||||
t.Error("Expected next handler to be called for authenticated user")
|
||||
}
|
||||
|
||||
// Check that user headers are set
|
||||
if req.Header.Get("X-Forwarded-User") != "[email protected]" {
|
||||
t.Errorf("Expected X-Forwarded-User header to be set to '[email protected]', got %s", req.Header.Get("X-Forwarded-User"))
|
||||
}
|
||||
|
||||
if req.Header.Get("X-Auth-Request-User") != "[email protected]" {
|
||||
t.Errorf("Expected X-Auth-Request-User header to be set to '[email protected]', got %s", req.Header.Get("X-Auth-Request-User"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_DomainNotAllowed(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
// Set isAllowedDomainFunc to return false
|
||||
middleware.isAllowedDomainFunc = func(email string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status %d for disallowed domain, got %d", http.StatusForbidden, rw.Code)
|
||||
}
|
||||
|
||||
responseBody := rw.Body.String()
|
||||
if !strings.Contains(responseBody, "Access denied") {
|
||||
t.Errorf("Expected access denied message, got %s", responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_TokenRefreshNeeded(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionData.setRefreshToken("refresh-token")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
// Set authenticated but needs refresh
|
||||
middleware.isUserAuthenticatedFunc = func(session SessionData) (bool, bool, bool) {
|
||||
return true, true, false // authenticated, needsRefresh, not expired
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
nextHandler := middleware.next.(*mockHandler)
|
||||
if !nextHandler.wasServeHTTPCalled() {
|
||||
t.Error("Expected next handler to be called after successful token refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_AjaxRequestWithoutAuth(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
// Set user as not authenticated
|
||||
middleware.isUserAuthenticatedFunc = func(session SessionData) (bool, bool, bool) {
|
||||
return false, false, false
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/data", nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status %d for AJAX request without auth, got %d", http.StatusUnauthorized, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_ExpiredToken(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
// Set token as expired
|
||||
middleware.isUserAuthenticatedFunc = func(session SessionData) (bool, bool, bool) {
|
||||
return false, false, true // not authenticated, no refresh needed, expired
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
authHandler := middleware.authHandler.(*mockAuthHandler)
|
||||
if !authHandler.wasInitiateCalled() {
|
||||
t.Error("Expected InitiateAuthentication to be called for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_RoleBasedAccess(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionData.setIDToken("id.jwt.token")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
// Set required roles
|
||||
middleware.allowedRolesAndGroups = map[string]struct{}{
|
||||
"admin": {},
|
||||
"group1": {},
|
||||
}
|
||||
|
||||
// User has group1 which is allowed
|
||||
middleware.extractGroupsAndRolesFunc = func(tokenString string) ([]string, []string, error) {
|
||||
return []string{"group1"}, []string{"user"}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
nextHandler := middleware.next.(*mockHandler)
|
||||
if !nextHandler.wasServeHTTPCalled() {
|
||||
t.Error("Expected next handler to be called for user with allowed role")
|
||||
}
|
||||
|
||||
// Check that group headers are set
|
||||
if req.Header.Get("X-User-Groups") != "group1" {
|
||||
t.Errorf("Expected X-User-Groups header to be set to 'group1', got %s", req.Header.Get("X-User-Groups"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_RoleBasedAccessDenied(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionData.setIDToken("id.jwt.token")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
// Set required roles that user doesn't have
|
||||
middleware.allowedRolesAndGroups = map[string]struct{}{
|
||||
"admin": {},
|
||||
}
|
||||
|
||||
// User doesn't have admin role
|
||||
middleware.extractGroupsAndRolesFunc = func(tokenString string) ([]string, []string, error) {
|
||||
return []string{"group1"}, []string{"user"}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status %d for user without required role, got %d", http.StatusForbidden, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTokenRefresh_FailedRefresh(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionData.setRefreshToken("refresh-token")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
// Set refresh to fail
|
||||
middleware.refreshTokenFunc = func(rw http.ResponseWriter, req *http.Request, session SessionData) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Set authenticated but needs refresh
|
||||
middleware.isUserAuthenticatedFunc = func(session SessionData) (bool, bool, bool) {
|
||||
return true, true, false
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
authHandler := middleware.authHandler.(*mockAuthHandler)
|
||||
if !authHandler.wasInitiateCalled() {
|
||||
t.Error("Expected InitiateAuthentication to be called after failed token refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFullURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
scheme string
|
||||
host string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{"https", "example.com", "/callback", "https://example.com/callback"},
|
||||
{"http", "localhost:8080", "/auth", "http://localhost:8080/auth"},
|
||||
{"https", "api.example.com", "/oauth/callback", "https://api.example.com/oauth/callback"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%s_%s_%s", tt.scheme, tt.host, tt.path), func(t *testing.T) {
|
||||
result := buildFullURL(tt.scheme, tt.host, tt.path)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentRequests(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionData.setAccessToken("valid.jwt.token")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
const numGoroutines = 10
|
||||
const numRequests = 5
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, numGoroutines*numRequests)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numRequests; j++ {
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/test-%d-%d", id, j), nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusOK {
|
||||
errors <- fmt.Errorf("goroutine %d request %d: expected status 200, got %d", id, j, rw.Code)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
var errorCount int
|
||||
for err := range errors {
|
||||
t.Error(err)
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Errorf("Found %d errors in concurrent request test", errorCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_TokenVerificationFailure(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionData.setAccessToken("invalid.jwt.token")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
// Set token verifier to return error
|
||||
tokenVerifier := middleware.tokenVerifier.(*mockTokenVerifier)
|
||||
tokenVerifier.verifyError = fmt.Errorf("token verification failed")
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
authHandler := middleware.authHandler.(*mockAuthHandler)
|
||||
if !authHandler.wasInitiateCalled() {
|
||||
t.Error("Expected InitiateAuthentication to be called after token verification failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_HealthCheckBypass(t *testing.T) {
|
||||
middleware := createTestMiddleware()
|
||||
|
||||
// Set up URL helper to return excluded result for health check
|
||||
urlHelper := &mockURLHelper{excludedResult: true}
|
||||
middleware.urlHelper = urlHelper
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
nextHandler := middleware.next.(*mockHandler)
|
||||
if !nextHandler.wasServeHTTPCalled() {
|
||||
t.Error("Expected next handler to be called for health check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_RequestCancellation(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
nextHandler := &mockHandler{}
|
||||
sessionManager := &mockSessionManager{sessionData: newMockSessionData()}
|
||||
|
||||
// Create initComplete channel but don't close it to simulate slow initialization
|
||||
initComplete := make(chan struct{})
|
||||
var goroutineWG sync.WaitGroup
|
||||
|
||||
middleware := NewAuthMiddleware(
|
||||
logger,
|
||||
nextHandler,
|
||||
sessionManager,
|
||||
&mockAuthHandler{},
|
||||
&mockOAuthHandler{},
|
||||
&mockURLHelper{},
|
||||
&mockTokenVerifier{},
|
||||
func(string) (map[string]interface{}, error) { return nil, nil },
|
||||
func(string) ([]string, []string, error) { return nil, nil, nil },
|
||||
func(rw http.ResponseWriter, req *http.Request, message string, code int) {
|
||||
http.Error(rw, message, code)
|
||||
},
|
||||
func(http.ResponseWriter, *http.Request, SessionData) bool { return false },
|
||||
func(SessionData) (bool, bool, bool) { return false, false, false },
|
||||
func(string) bool { return true },
|
||||
func(*http.Request) bool { return false },
|
||||
func(SessionData) bool { return false },
|
||||
func(http.ResponseWriter, *http.Request) {},
|
||||
make(map[string]struct{}),
|
||||
make(map[string]struct{}),
|
||||
"/auth/callback",
|
||||
"/auth/logout",
|
||||
30*time.Second,
|
||||
initComplete,
|
||||
"https://provider.example.com",
|
||||
"https://provider.example.com",
|
||||
&goroutineWG,
|
||||
func() {},
|
||||
func(string) {},
|
||||
)
|
||||
|
||||
// Create context that gets cancelled
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req := httptest.NewRequest("GET", "/test", nil).WithContext(ctx)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
// Cancel context immediately
|
||||
cancel()
|
||||
|
||||
middleware.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusRequestTimeout {
|
||||
t.Errorf("Expected status %d for cancelled request, got %d", http.StatusRequestTimeout, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkServeHTTP_AuthenticatedUser(b *testing.B) {
|
||||
middleware := createTestMiddleware()
|
||||
sessionData := newMockSessionData()
|
||||
sessionData.setEmail("[email protected]")
|
||||
sessionData.setAccessToken("valid.jwt.token")
|
||||
sessionManager := middleware.sessionManager.(*mockSessionManager)
|
||||
sessionManager.sessionData = sessionData
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
middleware.ServeHTTP(rw, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServeHTTP_ExcludedURL(b *testing.B) {
|
||||
middleware := createTestMiddleware()
|
||||
urlHelper := &mockURLHelper{excludedResult: true}
|
||||
middleware.urlHelper = urlHelper
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest("GET", "/excluded", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
middleware.ServeHTTP(rw, req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package recovery provides error recovery and resilience mechanisms
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrorRecoveryMechanism defines the interface for error recovery strategies.
|
||||
// It provides a common contract for implementing various resilience patterns
|
||||
// (circuit breaker, retry, graceful degradation) to handle transient failures
|
||||
// and protect downstream services from cascading failures.
|
||||
type ErrorRecoveryMechanism interface {
|
||||
// ExecuteWithContext executes a function with error recovery mechanisms
|
||||
ExecuteWithContext(ctx context.Context, fn func() error) error
|
||||
// GetMetrics returns metrics about the recovery mechanism's performance
|
||||
GetMetrics() map[string]interface{}
|
||||
// Reset resets the mechanism to its initial state
|
||||
Reset()
|
||||
// IsAvailable returns whether the mechanism is available for requests
|
||||
IsAvailable() bool
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Infof(format string, args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
Debugf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// BaseRecoveryMechanism provides common functionality and metrics tracking
|
||||
// for all error recovery mechanisms. It handles request/failure/success counting,
|
||||
// timing information, and logging capabilities for derived recovery mechanisms.
|
||||
type BaseRecoveryMechanism struct {
|
||||
// startTime tracks when the mechanism was created
|
||||
startTime time.Time
|
||||
// lastFailureTime records the most recent failure timestamp
|
||||
lastFailureTime time.Time
|
||||
// lastSuccessTime records the most recent success timestamp
|
||||
lastSuccessTime time.Time
|
||||
// logger for debugging and monitoring
|
||||
logger Logger
|
||||
// name identifies this recovery mechanism instance
|
||||
name string
|
||||
// totalRequests counts all requests processed
|
||||
totalRequests int64
|
||||
// totalFailures counts failed requests
|
||||
totalFailures int64
|
||||
// totalSuccesses counts successful requests
|
||||
totalSuccesses int64
|
||||
// mutex protects shared state access
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewBaseRecoveryMechanism creates a new base recovery mechanism with the given name and logger.
|
||||
// This serves as the foundation for specific recovery mechanism implementations.
|
||||
func NewBaseRecoveryMechanism(name string, logger Logger) *BaseRecoveryMechanism {
|
||||
if logger == nil {
|
||||
logger = NewNoOpLogger()
|
||||
}
|
||||
|
||||
return &BaseRecoveryMechanism{
|
||||
name: name,
|
||||
logger: logger,
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordRequest increments the total request counter.
|
||||
// This method is thread-safe using atomic operations.
|
||||
func (b *BaseRecoveryMechanism) RecordRequest() {
|
||||
atomic.AddInt64(&b.totalRequests, 1)
|
||||
}
|
||||
|
||||
// RecordSuccess increments the success counter and updates the last success timestamp.
|
||||
// This method is thread-safe using atomic operations for counters
|
||||
// and mutex protection for timestamp updates.
|
||||
func (b *BaseRecoveryMechanism) RecordSuccess() {
|
||||
atomic.AddInt64(&b.totalSuccesses, 1)
|
||||
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
b.lastSuccessTime = time.Now()
|
||||
}
|
||||
|
||||
// RecordFailure increments the failure counter and updates the last failure timestamp.
|
||||
// This method is thread-safe using atomic operations for counters
|
||||
// and mutex protection for timestamp updates.
|
||||
func (b *BaseRecoveryMechanism) RecordFailure() {
|
||||
atomic.AddInt64(&b.totalFailures, 1)
|
||||
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
b.lastFailureTime = time.Now()
|
||||
}
|
||||
|
||||
// GetBaseMetrics returns basic metrics collected by the base recovery mechanism.
|
||||
// This includes request counts, success/failure rates, and timing information.
|
||||
func (b *BaseRecoveryMechanism) GetBaseMetrics() map[string]interface{} {
|
||||
b.mutex.RLock()
|
||||
defer b.mutex.RUnlock()
|
||||
|
||||
totalReqs := atomic.LoadInt64(&b.totalRequests)
|
||||
totalSucc := atomic.LoadInt64(&b.totalSuccesses)
|
||||
totalFail := atomic.LoadInt64(&b.totalFailures)
|
||||
|
||||
metrics := map[string]interface{}{
|
||||
"name": b.name,
|
||||
"total_requests": totalReqs,
|
||||
"total_successes": totalSucc,
|
||||
"total_failures": totalFail,
|
||||
"start_time": b.startTime,
|
||||
}
|
||||
|
||||
if totalReqs > 0 {
|
||||
metrics["success_rate"] = float64(totalSucc) / float64(totalReqs)
|
||||
metrics["failure_rate"] = float64(totalFail) / float64(totalReqs)
|
||||
}
|
||||
|
||||
if !b.lastSuccessTime.IsZero() {
|
||||
metrics["last_success_time"] = b.lastSuccessTime
|
||||
metrics["time_since_last_success"] = time.Since(b.lastSuccessTime)
|
||||
}
|
||||
|
||||
if !b.lastFailureTime.IsZero() {
|
||||
metrics["last_failure_time"] = b.lastFailureTime
|
||||
metrics["time_since_last_failure"] = time.Since(b.lastFailureTime)
|
||||
}
|
||||
|
||||
metrics["uptime"] = time.Since(b.startTime)
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// LogInfo logs an info message if a logger is available
|
||||
func (b *BaseRecoveryMechanism) LogInfo(format string, args ...interface{}) {
|
||||
if b.logger != nil {
|
||||
b.logger.Infof(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogError logs an error message if a logger is available
|
||||
func (b *BaseRecoveryMechanism) LogError(format string, args ...interface{}) {
|
||||
if b.logger != nil {
|
||||
b.logger.Errorf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogDebug logs a debug message if a logger is available
|
||||
func (b *BaseRecoveryMechanism) LogDebug(format string, args ...interface{}) {
|
||||
if b.logger != nil {
|
||||
b.logger.Debugf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorHandler provides centralized error handling and recovery coordination
|
||||
type ErrorHandler struct {
|
||||
mechanisms []ErrorRecoveryMechanism
|
||||
logger Logger
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewErrorHandler creates a new error handler with the given mechanisms
|
||||
func NewErrorHandler(logger Logger, mechanisms ...ErrorRecoveryMechanism) *ErrorHandler {
|
||||
return &ErrorHandler{
|
||||
mechanisms: mechanisms,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// AddMechanism adds a recovery mechanism to the handler
|
||||
func (eh *ErrorHandler) AddMechanism(mechanism ErrorRecoveryMechanism) {
|
||||
eh.mutex.Lock()
|
||||
defer eh.mutex.Unlock()
|
||||
eh.mechanisms = append(eh.mechanisms, mechanism)
|
||||
}
|
||||
|
||||
// ExecuteWithRecovery executes a function with all configured recovery mechanisms
|
||||
func (eh *ErrorHandler) ExecuteWithRecovery(ctx context.Context, fn func() error) error {
|
||||
eh.mutex.RLock()
|
||||
mechanisms := make([]ErrorRecoveryMechanism, len(eh.mechanisms))
|
||||
copy(mechanisms, eh.mechanisms)
|
||||
eh.mutex.RUnlock()
|
||||
|
||||
// If no mechanisms are configured, execute directly
|
||||
if len(mechanisms) == 0 {
|
||||
return fn()
|
||||
}
|
||||
|
||||
// Chain the mechanisms - each wraps the next
|
||||
var wrappedFn func() error = fn
|
||||
for i := len(mechanisms) - 1; i >= 0; i-- {
|
||||
mechanism := mechanisms[i]
|
||||
currentFn := wrappedFn
|
||||
wrappedFn = func() error {
|
||||
return mechanism.ExecuteWithContext(ctx, currentFn)
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedFn()
|
||||
}
|
||||
|
||||
// GetAllMetrics returns metrics from all configured mechanisms
|
||||
func (eh *ErrorHandler) GetAllMetrics() map[string]interface{} {
|
||||
eh.mutex.RLock()
|
||||
defer eh.mutex.RUnlock()
|
||||
|
||||
allMetrics := make(map[string]interface{})
|
||||
for i, mechanism := range eh.mechanisms {
|
||||
mechanismKey := "mechanism_" + string(rune(i))
|
||||
allMetrics[mechanismKey] = mechanism.GetMetrics()
|
||||
}
|
||||
|
||||
return allMetrics
|
||||
}
|
||||
|
||||
// ResetAll resets all configured mechanisms
|
||||
func (eh *ErrorHandler) ResetAll() {
|
||||
eh.mutex.RLock()
|
||||
defer eh.mutex.RUnlock()
|
||||
|
||||
for _, mechanism := range eh.mechanisms {
|
||||
mechanism.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// IsHealthy returns true if all mechanisms are available
|
||||
func (eh *ErrorHandler) IsHealthy() bool {
|
||||
eh.mutex.RLock()
|
||||
defer eh.mutex.RUnlock()
|
||||
|
||||
for _, mechanism := range eh.mechanisms {
|
||||
if !mechanism.IsAvailable() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// NoOpLogger provides a logger that does nothing
|
||||
type NoOpLogger struct{}
|
||||
|
||||
// NewNoOpLogger creates a new no-op logger
|
||||
func NewNoOpLogger() *NoOpLogger {
|
||||
return &NoOpLogger{}
|
||||
}
|
||||
|
||||
// Infof does nothing
|
||||
func (l *NoOpLogger) Infof(format string, args ...interface{}) {}
|
||||
|
||||
// Errorf does nothing
|
||||
func (l *NoOpLogger) Errorf(format string, args ...interface{}) {}
|
||||
|
||||
// Debugf does nothing
|
||||
func (l *NoOpLogger) Debugf(format string, args ...interface{}) {}
|
||||
@@ -0,0 +1,827 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
type mockLogger struct {
|
||||
infoLogs []string
|
||||
errorLogs []string
|
||||
debugLogs []string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockLogger) Infof(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Errorf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) Debugf(format string, args ...interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (m *mockLogger) getInfoLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.infoLogs))
|
||||
copy(result, m.infoLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockLogger) getErrorLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.errorLogs))
|
||||
copy(result, m.errorLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockLogger) getDebugLogs() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.debugLogs))
|
||||
copy(result, m.debugLogs)
|
||||
return result
|
||||
}
|
||||
|
||||
//lint:ignore U1000 May be needed for future test isolation
|
||||
func (m *mockLogger) reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = nil
|
||||
m.errorLogs = nil
|
||||
m.debugLogs = nil
|
||||
}
|
||||
|
||||
type mockRecoveryMechanism struct {
|
||||
name string
|
||||
executeError error
|
||||
isAvailable bool
|
||||
resetCalled bool
|
||||
executeCallCount int64
|
||||
metrics map[string]interface{}
|
||||
executeFunc func(ctx context.Context, fn func() error) error
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newMockRecoveryMechanism(name string) *mockRecoveryMechanism {
|
||||
return &mockRecoveryMechanism{
|
||||
name: name,
|
||||
isAvailable: true,
|
||||
metrics: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRecoveryMechanism) ExecuteWithContext(ctx context.Context, fn func() error) error {
|
||||
if m.executeFunc != nil {
|
||||
return m.executeFunc(ctx, fn)
|
||||
}
|
||||
|
||||
atomic.AddInt64(&m.executeCallCount, 1)
|
||||
|
||||
m.mu.RLock()
|
||||
err := m.executeError
|
||||
m.mu.RUnlock()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (m *mockRecoveryMechanism) GetMetrics() map[string]interface{} {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range m.metrics {
|
||||
result[k] = v
|
||||
}
|
||||
result["name"] = m.name
|
||||
result["execute_call_count"] = atomic.LoadInt64(&m.executeCallCount)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockRecoveryMechanism) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.resetCalled = true
|
||||
atomic.StoreInt64(&m.executeCallCount, 0)
|
||||
}
|
||||
|
||||
func (m *mockRecoveryMechanism) IsAvailable() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.isAvailable
|
||||
}
|
||||
|
||||
func (m *mockRecoveryMechanism) setExecuteError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.executeError = err
|
||||
}
|
||||
|
||||
func (m *mockRecoveryMechanism) setAvailable(available bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.isAvailable = available
|
||||
}
|
||||
|
||||
func (m *mockRecoveryMechanism) wasResetCalled() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.resetCalled
|
||||
}
|
||||
|
||||
func (m *mockRecoveryMechanism) getExecuteCallCount() int64 {
|
||||
return atomic.LoadInt64(&m.executeCallCount)
|
||||
}
|
||||
|
||||
func TestNewBaseRecoveryMechanism(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loggerName string
|
||||
logger Logger
|
||||
}{
|
||||
{
|
||||
name: "with mock logger",
|
||||
loggerName: "test-mechanism",
|
||||
logger: &mockLogger{},
|
||||
},
|
||||
{
|
||||
name: "with nil logger",
|
||||
loggerName: "test-mechanism-nil",
|
||||
logger: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mechanism := NewBaseRecoveryMechanism(tt.loggerName, tt.logger)
|
||||
|
||||
if mechanism == nil {
|
||||
t.Fatal("NewBaseRecoveryMechanism returned nil")
|
||||
}
|
||||
|
||||
if mechanism.name != tt.loggerName {
|
||||
t.Errorf("Expected name %s, got %s", tt.loggerName, mechanism.name)
|
||||
}
|
||||
|
||||
if tt.logger == nil && mechanism.logger == nil {
|
||||
t.Error("Expected NoOpLogger to be created when logger is nil")
|
||||
}
|
||||
|
||||
if mechanism.startTime.IsZero() {
|
||||
t.Error("Expected startTime to be set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecoveryMechanism_RecordMetrics(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := NewBaseRecoveryMechanism("test", logger)
|
||||
|
||||
// Test initial state
|
||||
if atomic.LoadInt64(&mechanism.totalRequests) != 0 {
|
||||
t.Error("Expected initial totalRequests to be 0")
|
||||
}
|
||||
if atomic.LoadInt64(&mechanism.totalSuccesses) != 0 {
|
||||
t.Error("Expected initial totalSuccesses to be 0")
|
||||
}
|
||||
if atomic.LoadInt64(&mechanism.totalFailures) != 0 {
|
||||
t.Error("Expected initial totalFailures to be 0")
|
||||
}
|
||||
|
||||
// Record some metrics
|
||||
mechanism.RecordRequest()
|
||||
mechanism.RecordSuccess()
|
||||
|
||||
if atomic.LoadInt64(&mechanism.totalRequests) != 1 {
|
||||
t.Error("Expected totalRequests to be 1")
|
||||
}
|
||||
if atomic.LoadInt64(&mechanism.totalSuccesses) != 1 {
|
||||
t.Error("Expected totalSuccesses to be 1")
|
||||
}
|
||||
|
||||
mechanism.RecordRequest()
|
||||
mechanism.RecordFailure()
|
||||
|
||||
if atomic.LoadInt64(&mechanism.totalRequests) != 2 {
|
||||
t.Error("Expected totalRequests to be 2")
|
||||
}
|
||||
if atomic.LoadInt64(&mechanism.totalFailures) != 1 {
|
||||
t.Error("Expected totalFailures to be 1")
|
||||
}
|
||||
|
||||
// Check timestamps are set
|
||||
if mechanism.lastSuccessTime.IsZero() {
|
||||
t.Error("Expected lastSuccessTime to be set")
|
||||
}
|
||||
if mechanism.lastFailureTime.IsZero() {
|
||||
t.Error("Expected lastFailureTime to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecoveryMechanism_GetBaseMetrics(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := NewBaseRecoveryMechanism("test-metrics", logger)
|
||||
|
||||
// Record some activity
|
||||
mechanism.RecordRequest()
|
||||
mechanism.RecordRequest()
|
||||
mechanism.RecordSuccess()
|
||||
mechanism.RecordFailure()
|
||||
|
||||
metrics := mechanism.GetBaseMetrics()
|
||||
|
||||
expectedFields := []string{
|
||||
"name", "total_requests", "total_successes", "total_failures",
|
||||
"start_time", "success_rate", "failure_rate", "uptime",
|
||||
"last_success_time", "time_since_last_success",
|
||||
"last_failure_time", "time_since_last_failure",
|
||||
}
|
||||
|
||||
for _, field := range expectedFields {
|
||||
if _, exists := metrics[field]; !exists {
|
||||
t.Errorf("Expected metric field %s to exist", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Check specific values
|
||||
if metrics["name"] != "test-metrics" {
|
||||
t.Errorf("Expected name to be 'test-metrics', got %v", metrics["name"])
|
||||
}
|
||||
|
||||
if metrics["total_requests"] != int64(2) {
|
||||
t.Errorf("Expected total_requests to be 2, got %v", metrics["total_requests"])
|
||||
}
|
||||
|
||||
if metrics["total_successes"] != int64(1) {
|
||||
t.Errorf("Expected total_successes to be 1, got %v", metrics["total_successes"])
|
||||
}
|
||||
|
||||
if metrics["total_failures"] != int64(1) {
|
||||
t.Errorf("Expected total_failures to be 1, got %v", metrics["total_failures"])
|
||||
}
|
||||
|
||||
if metrics["success_rate"] != float64(0.5) {
|
||||
t.Errorf("Expected success_rate to be 0.5, got %v", metrics["success_rate"])
|
||||
}
|
||||
|
||||
if metrics["failure_rate"] != float64(0.5) {
|
||||
t.Errorf("Expected failure_rate to be 0.5, got %v", metrics["failure_rate"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecoveryMechanism_GetBaseMetrics_NoRequests(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := NewBaseRecoveryMechanism("test-empty", logger)
|
||||
|
||||
metrics := mechanism.GetBaseMetrics()
|
||||
|
||||
// Should not have success_rate or failure_rate when no requests
|
||||
if _, exists := metrics["success_rate"]; exists {
|
||||
t.Error("Expected success_rate to not exist when no requests")
|
||||
}
|
||||
if _, exists := metrics["failure_rate"]; exists {
|
||||
t.Error("Expected failure_rate to not exist when no requests")
|
||||
}
|
||||
|
||||
// Should not have timestamp fields when no successes/failures
|
||||
if _, exists := metrics["last_success_time"]; exists {
|
||||
t.Error("Expected last_success_time to not exist when no successes")
|
||||
}
|
||||
if _, exists := metrics["last_failure_time"]; exists {
|
||||
t.Error("Expected last_failure_time to not exist when no failures")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecoveryMechanism_Logging(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := NewBaseRecoveryMechanism("test-logging", logger)
|
||||
|
||||
mechanism.LogInfo("test info: %s", "value")
|
||||
mechanism.LogError("test error: %d", 42)
|
||||
mechanism.LogDebug("test debug: %v", true)
|
||||
|
||||
infoLogs := logger.getInfoLogs()
|
||||
if len(infoLogs) != 1 || infoLogs[0] != "test info: value" {
|
||||
t.Errorf("Expected info log 'test info: value', got %v", infoLogs)
|
||||
}
|
||||
|
||||
errorLogs := logger.getErrorLogs()
|
||||
if len(errorLogs) != 1 || errorLogs[0] != "test error: 42" {
|
||||
t.Errorf("Expected error log 'test error: 42', got %v", errorLogs)
|
||||
}
|
||||
|
||||
debugLogs := logger.getDebugLogs()
|
||||
if len(debugLogs) != 1 || debugLogs[0] != "test debug: true" {
|
||||
t.Errorf("Expected debug log 'test debug: true', got %v", debugLogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecoveryMechanism_LoggingWithNilLogger(t *testing.T) {
|
||||
mechanism := NewBaseRecoveryMechanism("test-nil-logger", nil)
|
||||
|
||||
// Should not panic when logger is nil (uses NoOpLogger)
|
||||
mechanism.LogInfo("test info")
|
||||
mechanism.LogError("test error")
|
||||
mechanism.LogDebug("test debug")
|
||||
}
|
||||
|
||||
func TestNewErrorHandler(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism1 := newMockRecoveryMechanism("mech1")
|
||||
mechanism2 := newMockRecoveryMechanism("mech2")
|
||||
|
||||
handler := NewErrorHandler(logger, mechanism1, mechanism2)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("NewErrorHandler returned nil")
|
||||
}
|
||||
|
||||
if handler.logger != logger {
|
||||
t.Error("Expected logger to be set")
|
||||
}
|
||||
|
||||
if len(handler.mechanisms) != 2 {
|
||||
t.Errorf("Expected 2 mechanisms, got %d", len(handler.mechanisms))
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_AddMechanism(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewErrorHandler(logger)
|
||||
|
||||
if len(handler.mechanisms) != 0 {
|
||||
t.Errorf("Expected 0 initial mechanisms, got %d", len(handler.mechanisms))
|
||||
}
|
||||
|
||||
mechanism := newMockRecoveryMechanism("test")
|
||||
handler.AddMechanism(mechanism)
|
||||
|
||||
if len(handler.mechanisms) != 1 {
|
||||
t.Errorf("Expected 1 mechanism after adding, got %d", len(handler.mechanisms))
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_ExecuteWithRecovery_NoMechanisms(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewErrorHandler(logger)
|
||||
|
||||
callCount := 0
|
||||
testFunc := func() error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := handler.ExecuteWithRecovery(ctx, testFunc)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected function to be called once, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_ExecuteWithRecovery_WithMechanisms(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism1 := newMockRecoveryMechanism("mech1")
|
||||
mechanism2 := newMockRecoveryMechanism("mech2")
|
||||
handler := NewErrorHandler(logger, mechanism1, mechanism2)
|
||||
|
||||
callCount := 0
|
||||
testFunc := func() error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := handler.ExecuteWithRecovery(ctx, testFunc)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected function to be called once, got %d", callCount)
|
||||
}
|
||||
|
||||
// Both mechanisms should have been called
|
||||
if mechanism1.getExecuteCallCount() != 1 {
|
||||
t.Errorf("Expected mechanism1 to be called once, got %d", mechanism1.getExecuteCallCount())
|
||||
}
|
||||
if mechanism2.getExecuteCallCount() != 1 {
|
||||
t.Errorf("Expected mechanism2 to be called once, got %d", mechanism2.getExecuteCallCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_ExecuteWithRecovery_MechanismError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := newMockRecoveryMechanism("failing-mech")
|
||||
mechanism.setExecuteError(fmt.Errorf("mechanism failed"))
|
||||
handler := NewErrorHandler(logger, mechanism)
|
||||
|
||||
callCount := 0
|
||||
testFunc := func() error {
|
||||
callCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := handler.ExecuteWithRecovery(ctx, testFunc)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error from mechanism")
|
||||
}
|
||||
|
||||
if err.Error() != "mechanism failed" {
|
||||
t.Errorf("Expected 'mechanism failed', got %v", err)
|
||||
}
|
||||
|
||||
// Function should not be called if mechanism fails
|
||||
if callCount != 0 {
|
||||
t.Errorf("Expected function not to be called, got %d calls", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_ExecuteWithRecovery_FunctionError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := newMockRecoveryMechanism("mech")
|
||||
handler := NewErrorHandler(logger, mechanism)
|
||||
|
||||
testFunc := func() error {
|
||||
return fmt.Errorf("function failed")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := handler.ExecuteWithRecovery(ctx, testFunc)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error from function")
|
||||
}
|
||||
|
||||
if err.Error() != "function failed" {
|
||||
t.Errorf("Expected 'function failed', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_GetAllMetrics(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism1 := newMockRecoveryMechanism("mech1")
|
||||
mechanism2 := newMockRecoveryMechanism("mech2")
|
||||
handler := NewErrorHandler(logger, mechanism1, mechanism2)
|
||||
|
||||
// Add some custom metrics
|
||||
mechanism1.metrics["custom1"] = "value1"
|
||||
mechanism2.metrics["custom2"] = "value2"
|
||||
|
||||
metrics := handler.GetAllMetrics()
|
||||
|
||||
if len(metrics) != 2 {
|
||||
t.Errorf("Expected 2 mechanism metrics, got %d", len(metrics))
|
||||
}
|
||||
|
||||
mechanism0Key := "mechanism_" + string(rune(0))
|
||||
mechanism0Metrics, exists := metrics[mechanism0Key]
|
||||
if !exists {
|
||||
t.Errorf("Expected %s metrics to exist", mechanism0Key)
|
||||
} else {
|
||||
mechanismMap, ok := mechanism0Metrics.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Expected mechanism metrics to be a map")
|
||||
} else {
|
||||
if mechanismMap["name"] != "mech1" {
|
||||
t.Errorf("Expected name 'mech1', got %v", mechanismMap["name"])
|
||||
}
|
||||
if mechanismMap["custom1"] != "value1" {
|
||||
t.Errorf("Expected custom1 'value1', got %v", mechanismMap["custom1"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mechanism1Key := "mechanism_" + string(rune(1))
|
||||
mechanism1Metrics, exists := metrics[mechanism1Key]
|
||||
if !exists {
|
||||
t.Errorf("Expected %s metrics to exist", mechanism1Key)
|
||||
} else {
|
||||
mechanismMap, ok := mechanism1Metrics.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Expected mechanism metrics to be a map")
|
||||
} else {
|
||||
if mechanismMap["name"] != "mech2" {
|
||||
t.Errorf("Expected name 'mech2', got %v", mechanismMap["name"])
|
||||
}
|
||||
if mechanismMap["custom2"] != "value2" {
|
||||
t.Errorf("Expected custom2 'value2', got %v", mechanismMap["custom2"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_ResetAll(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism1 := newMockRecoveryMechanism("mech1")
|
||||
mechanism2 := newMockRecoveryMechanism("mech2")
|
||||
handler := NewErrorHandler(logger, mechanism1, mechanism2)
|
||||
|
||||
handler.ResetAll()
|
||||
|
||||
if !mechanism1.wasResetCalled() {
|
||||
t.Error("Expected mechanism1 to be reset")
|
||||
}
|
||||
if !mechanism2.wasResetCalled() {
|
||||
t.Error("Expected mechanism2 to be reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_IsHealthy(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism1 := newMockRecoveryMechanism("mech1")
|
||||
mechanism2 := newMockRecoveryMechanism("mech2")
|
||||
handler := NewErrorHandler(logger, mechanism1, mechanism2)
|
||||
|
||||
// Initially both are available
|
||||
if !handler.IsHealthy() {
|
||||
t.Error("Expected handler to be healthy when all mechanisms are available")
|
||||
}
|
||||
|
||||
// Make one mechanism unavailable
|
||||
mechanism1.setAvailable(false)
|
||||
if handler.IsHealthy() {
|
||||
t.Error("Expected handler to be unhealthy when one mechanism is unavailable")
|
||||
}
|
||||
|
||||
// Make both unavailable
|
||||
mechanism2.setAvailable(false)
|
||||
if handler.IsHealthy() {
|
||||
t.Error("Expected handler to be unhealthy when all mechanisms are unavailable")
|
||||
}
|
||||
|
||||
// Make both available again
|
||||
mechanism1.setAvailable(true)
|
||||
mechanism2.setAvailable(true)
|
||||
if !handler.IsHealthy() {
|
||||
t.Error("Expected handler to be healthy when all mechanisms are available again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_IsHealthy_NoMechanisms(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewErrorHandler(logger)
|
||||
|
||||
// Should be healthy with no mechanisms
|
||||
if !handler.IsHealthy() {
|
||||
t.Error("Expected handler to be healthy with no mechanisms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoOpLogger(t *testing.T) {
|
||||
logger := NewNoOpLogger()
|
||||
|
||||
if logger == nil {
|
||||
t.Fatal("NewNoOpLogger returned nil")
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
logger.Infof("test info: %s", "value")
|
||||
logger.Errorf("test error: %d", 42)
|
||||
logger.Debugf("test debug: %v", true)
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
baseMechanism := NewBaseRecoveryMechanism("concurrent-test", logger)
|
||||
|
||||
const numGoroutines = 10
|
||||
const numOperations = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, numGoroutines)
|
||||
|
||||
// Test concurrent metric recording
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numOperations; j++ {
|
||||
baseMechanism.RecordRequest()
|
||||
if j%2 == 0 {
|
||||
baseMechanism.RecordSuccess()
|
||||
} else {
|
||||
baseMechanism.RecordFailure()
|
||||
}
|
||||
|
||||
// Intermittently get metrics to test concurrent reads
|
||||
if j%10 == 0 {
|
||||
baseMechanism.GetBaseMetrics()
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
var errorCount int
|
||||
for err := range errors {
|
||||
t.Error(err)
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Errorf("Found %d errors in concurrent access test", errorCount)
|
||||
}
|
||||
|
||||
// Verify final counts
|
||||
expectedRequests := int64(numGoroutines * numOperations)
|
||||
expectedSuccesses := int64(numGoroutines * numOperations / 2)
|
||||
expectedFailures := int64(numGoroutines * numOperations / 2)
|
||||
|
||||
if atomic.LoadInt64(&baseMechanism.totalRequests) != expectedRequests {
|
||||
t.Errorf("Expected %d total requests, got %d", expectedRequests,
|
||||
atomic.LoadInt64(&baseMechanism.totalRequests))
|
||||
}
|
||||
|
||||
if atomic.LoadInt64(&baseMechanism.totalSuccesses) != expectedSuccesses {
|
||||
t.Errorf("Expected %d total successes, got %d", expectedSuccesses,
|
||||
atomic.LoadInt64(&baseMechanism.totalSuccesses))
|
||||
}
|
||||
|
||||
if atomic.LoadInt64(&baseMechanism.totalFailures) != expectedFailures {
|
||||
t.Errorf("Expected %d total failures, got %d", expectedFailures,
|
||||
atomic.LoadInt64(&baseMechanism.totalFailures))
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_ConcurrentAccess(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism1 := newMockRecoveryMechanism("concurrent-mech1")
|
||||
mechanism2 := newMockRecoveryMechanism("concurrent-mech2")
|
||||
handler := NewErrorHandler(logger, mechanism1, mechanism2)
|
||||
|
||||
const numGoroutines = 5
|
||||
const numExecutions = 20
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := int64(0)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numExecutions; j++ {
|
||||
err := handler.ExecuteWithRecovery(context.Background(), func() error {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error in goroutine %d: %v", id, err)
|
||||
}
|
||||
|
||||
// Add some mechanisms concurrently
|
||||
if j == 0 {
|
||||
newMech := newMockRecoveryMechanism(fmt.Sprintf("dynamic-mech-%d", id))
|
||||
handler.AddMechanism(newMech)
|
||||
}
|
||||
|
||||
// Get metrics concurrently
|
||||
if j%5 == 0 {
|
||||
handler.GetAllMetrics()
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
expectedSuccesses := int64(numGoroutines * numExecutions)
|
||||
if atomic.LoadInt64(&successCount) != expectedSuccesses {
|
||||
t.Errorf("Expected %d successes, got %d", expectedSuccesses,
|
||||
atomic.LoadInt64(&successCount))
|
||||
}
|
||||
|
||||
// Should have original 2 mechanisms plus 5 added dynamically
|
||||
if len(handler.mechanisms) != 7 {
|
||||
t.Errorf("Expected 7 mechanisms after concurrent additions, got %d",
|
||||
len(handler.mechanisms))
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_ContextCancellation(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := newMockRecoveryMechanism("context-test")
|
||||
handler := NewErrorHandler(logger, mechanism)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
executedCount := 0
|
||||
testFunc := func() error {
|
||||
executedCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
// The mechanism implementation should handle context cancellation
|
||||
// For our mock, we'll make it check context cancellation
|
||||
mechanism.executeFunc = func(ctx context.Context, fn func() error) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
atomic.AddInt64(&mechanism.executeCallCount, 1)
|
||||
return fn()
|
||||
}
|
||||
}
|
||||
|
||||
err := handler.ExecuteWithRecovery(ctx, testFunc)
|
||||
|
||||
if err != context.Canceled {
|
||||
t.Errorf("Expected context.Canceled, got %v", err)
|
||||
}
|
||||
|
||||
if executedCount != 0 {
|
||||
t.Errorf("Expected function not to execute when context is cancelled, got %d executions", executedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkBaseRecoveryMechanism_RecordRequest(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := NewBaseRecoveryMechanism("benchmark", logger)
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
mechanism.RecordRequest()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkBaseRecoveryMechanism_RecordSuccess(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := NewBaseRecoveryMechanism("benchmark", logger)
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
mechanism.RecordSuccess()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkBaseRecoveryMechanism_GetBaseMetrics(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := NewBaseRecoveryMechanism("benchmark", logger)
|
||||
|
||||
// Add some data
|
||||
for i := 0; i < 100; i++ {
|
||||
mechanism.RecordRequest()
|
||||
if i%2 == 0 {
|
||||
mechanism.RecordSuccess()
|
||||
} else {
|
||||
mechanism.RecordFailure()
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
mechanism.GetBaseMetrics()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkErrorHandler_ExecuteWithRecovery(b *testing.B) {
|
||||
logger := &mockLogger{}
|
||||
mechanism := newMockRecoveryMechanism("benchmark")
|
||||
handler := NewErrorHandler(logger, mechanism)
|
||||
|
||||
testFunc := func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
handler.ExecuteWithRecovery(ctx, testFunc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// Package chunking provides session chunking functionality for large tokens
|
||||
package chunking
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCookieSize = 1200
|
||||
)
|
||||
|
||||
// TokenConfig defines validation and storage parameters for different token types.
|
||||
// It specifies size limits, format requirements, and security constraints to ensure
|
||||
// tokens can be safely stored in browser cookies while maintaining security.
|
||||
type TokenConfig struct {
|
||||
Type string
|
||||
MinLength int
|
||||
MaxLength int
|
||||
MaxChunks int
|
||||
MaxChunkSize int
|
||||
AllowOpaqueTokens bool
|
||||
RequireJWTFormat bool
|
||||
}
|
||||
|
||||
// Predefined configurations for each token type
|
||||
var (
|
||||
AccessTokenConfig = TokenConfig{
|
||||
Type: "access",
|
||||
MinLength: 5,
|
||||
MaxLength: 100 * 1024,
|
||||
MaxChunks: 25,
|
||||
MaxChunkSize: maxCookieSize,
|
||||
AllowOpaqueTokens: true,
|
||||
RequireJWTFormat: false,
|
||||
}
|
||||
|
||||
RefreshTokenConfig = TokenConfig{
|
||||
Type: "refresh",
|
||||
MinLength: 5,
|
||||
MaxLength: 50 * 1024,
|
||||
MaxChunks: 15,
|
||||
MaxChunkSize: maxCookieSize,
|
||||
AllowOpaqueTokens: true,
|
||||
RequireJWTFormat: false,
|
||||
}
|
||||
|
||||
IDTokenConfig = TokenConfig{
|
||||
Type: "id",
|
||||
MinLength: 5,
|
||||
MaxLength: 75 * 1024,
|
||||
MaxChunks: 20,
|
||||
MaxChunkSize: maxCookieSize,
|
||||
AllowOpaqueTokens: false,
|
||||
RequireJWTFormat: true,
|
||||
}
|
||||
)
|
||||
|
||||
// TokenRetrievalResult represents the outcome of a token retrieval operation.
|
||||
// It contains either the successfully retrieved token or an error describing
|
||||
// what went wrong during retrieval.
|
||||
type TokenRetrievalResult struct {
|
||||
Error error
|
||||
Token string
|
||||
}
|
||||
|
||||
// SessionEntry represents a session with expiration tracking
|
||||
type SessionEntry struct {
|
||||
Session *sessions.Session
|
||||
ExpiresAt time.Time
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// 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 handles the complex logic of storing and retrieving large tokens
|
||||
// across multiple HTTP cookies. It provides comprehensive validation, security checks,
|
||||
// and error handling to ensure data integrity and prevent security vulnerabilities
|
||||
// throughout the process.
|
||||
type ChunkManager struct {
|
||||
logger Logger
|
||||
mutex *sync.RWMutex
|
||||
// sessionMap provides bounded session storage to prevent memory leaks
|
||||
sessionMap map[string]*SessionEntry
|
||||
maxSessions int
|
||||
sessionTTL time.Duration
|
||||
lastCleanup time.Time
|
||||
}
|
||||
|
||||
// NewChunkManager creates a new ChunkManager instance with proper initialization.
|
||||
// It sets up logging and synchronization primitives for safe concurrent access.
|
||||
func NewChunkManager(logger Logger) *ChunkManager {
|
||||
if logger == nil {
|
||||
logger = NewNoOpLogger()
|
||||
}
|
||||
|
||||
return &ChunkManager{
|
||||
logger: logger,
|
||||
mutex: &sync.RWMutex{},
|
||||
sessionMap: make(map[string]*SessionEntry),
|
||||
maxSessions: 1000, // Reasonable limit to prevent memory leaks
|
||||
sessionTTL: 24 * time.Hour,
|
||||
lastCleanup: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken retrieves a token from either a single cookie or multiple chunk cookies.
|
||||
// It handles both compressed and uncompressed tokens and performs comprehensive
|
||||
// validation throughout the retrieval process.
|
||||
func (cm *ChunkManager) GetToken(
|
||||
mainSession *sessions.Session,
|
||||
chunks map[int]*sessions.Session,
|
||||
config TokenConfig,
|
||||
compressor TokenCompressor,
|
||||
) TokenRetrievalResult {
|
||||
|
||||
// Try to get token from main session first
|
||||
if mainSession != nil {
|
||||
if tokenValue, ok := mainSession.Values[config.Type+"_token"].(string); ok && tokenValue != "" {
|
||||
cm.logger.Debugf("Found %s token in main session", config.Type)
|
||||
|
||||
// Check if token is compressed
|
||||
decompressed := compressor.DecompressToken(tokenValue)
|
||||
if decompressed != tokenValue {
|
||||
cm.logger.Debugf("Decompressed %s token", config.Type)
|
||||
return cm.processSingleToken(decompressed, true, config)
|
||||
}
|
||||
|
||||
return cm.processSingleToken(tokenValue, false, config)
|
||||
}
|
||||
}
|
||||
|
||||
// If not in main session, try chunks
|
||||
if len(chunks) == 0 {
|
||||
return TokenRetrievalResult{
|
||||
Error: nil,
|
||||
Token: "",
|
||||
}
|
||||
}
|
||||
|
||||
cm.logger.Debugf("Found %d chunks for %s token, processing", len(chunks), config.Type)
|
||||
return cm.processChunkedToken(chunks, config, compressor)
|
||||
}
|
||||
|
||||
// processSingleToken validates and processes a single token
|
||||
func (cm *ChunkManager) processSingleToken(token string, compressed bool, config TokenConfig) TokenRetrievalResult {
|
||||
if compressed {
|
||||
cm.logger.Debugf("Processing compressed %s token (length: %d)", config.Type, len(token))
|
||||
} else {
|
||||
cm.logger.Debugf("Processing single %s token (length: %d)", config.Type, len(token))
|
||||
}
|
||||
|
||||
return cm.validateToken(token, config)
|
||||
}
|
||||
|
||||
// validateToken performs comprehensive validation on a token
|
||||
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
|
||||
if token == "" {
|
||||
return TokenRetrievalResult{Error: nil, Token: ""}
|
||||
}
|
||||
|
||||
validator := NewTokenValidator()
|
||||
|
||||
// Basic validation
|
||||
if err := validator.ValidateTokenSize(token, config); err != nil {
|
||||
cm.logger.Errorf("Token size validation failed for %s: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
|
||||
// Format validation
|
||||
if config.RequireJWTFormat {
|
||||
if err := validator.ValidateJWTFormat(token, config.Type); err != nil {
|
||||
cm.logger.Errorf("JWT format validation failed for %s: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
} else if !config.AllowOpaqueTokens {
|
||||
if err := validator.ValidateJWTFormat(token, config.Type); err != nil {
|
||||
cm.logger.Errorf("Token format validation failed for %s: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
}
|
||||
|
||||
// Content validation
|
||||
if err := validator.ValidateTokenContent(token, config); err != nil {
|
||||
cm.logger.Errorf("Token content validation failed for %s: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
|
||||
cm.logger.Debugf("Successfully validated %s token", config.Type)
|
||||
return TokenRetrievalResult{Error: nil, Token: token}
|
||||
}
|
||||
|
||||
// processChunkedToken reconstructs a token from multiple chunks
|
||||
func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig, compressor TokenCompressor) TokenRetrievalResult {
|
||||
if len(chunks) > config.MaxChunks {
|
||||
return TokenRetrievalResult{
|
||||
Error: &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "too many chunks",
|
||||
Details: "chunk count exceeds maximum allowed",
|
||||
},
|
||||
Token: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct token from chunks
|
||||
reconstructedToken, err := cm.reconstructTokenFromChunks(chunks, config)
|
||||
if err != nil {
|
||||
cm.logger.Errorf("Failed to reconstruct %s token from chunks: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
|
||||
// Try decompression
|
||||
decompressedToken := compressor.DecompressToken(reconstructedToken)
|
||||
if decompressedToken != reconstructedToken {
|
||||
cm.logger.Debugf("Decompressed reconstructed %s token", config.Type)
|
||||
return cm.validateToken(decompressedToken, config)
|
||||
}
|
||||
|
||||
return cm.validateToken(reconstructedToken, config)
|
||||
}
|
||||
|
||||
// reconstructTokenFromChunks reconstructs a token from ordered chunks
|
||||
func (cm *ChunkManager) reconstructTokenFromChunks(chunks map[int]*sessions.Session, config TokenConfig) (string, error) {
|
||||
if len(chunks) == 0 {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "no chunks found",
|
||||
Details: "no chunk sessions available for reconstruction",
|
||||
}
|
||||
}
|
||||
|
||||
// Find the maximum chunk index to determine total chunks
|
||||
maxIndex := -1
|
||||
for index := range chunks {
|
||||
if index > maxIndex {
|
||||
maxIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
if maxIndex < 0 {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid chunk indices",
|
||||
Details: "no valid chunk indices found",
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct token by concatenating chunks in order
|
||||
var tokenBuilder strings.Builder
|
||||
for i := 0; i <= maxIndex; i++ {
|
||||
chunk, exists := chunks[i]
|
||||
if !exists || chunk == nil {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "missing chunk",
|
||||
Details: fmt.Sprintf("chunk %d is missing", i),
|
||||
}
|
||||
}
|
||||
|
||||
chunkValue, ok := chunk.Values["value"].(string)
|
||||
if !ok || chunkValue == "" {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "empty chunk",
|
||||
Details: fmt.Sprintf("chunk %d has no value", i),
|
||||
}
|
||||
}
|
||||
|
||||
tokenBuilder.WriteString(chunkValue)
|
||||
}
|
||||
|
||||
reconstructed := tokenBuilder.String()
|
||||
if reconstructed == "" {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "empty reconstructed token",
|
||||
Details: "all chunks were present but resulted in empty token",
|
||||
}
|
||||
}
|
||||
|
||||
cm.logger.Debugf("Successfully reconstructed %s token from %d chunks (length: %d)",
|
||||
config.Type, len(chunks), len(reconstructed))
|
||||
|
||||
return reconstructed, nil
|
||||
}
|
||||
|
||||
// CleanupExpiredSessions removes expired sessions from the session map
|
||||
func (cm *ChunkManager) CleanupExpiredSessions() {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Only cleanup if enough time has passed
|
||||
if now.Sub(cm.lastCleanup) < time.Hour {
|
||||
return
|
||||
}
|
||||
|
||||
cm.lastCleanup = now
|
||||
cleaned := 0
|
||||
|
||||
for key, entry := range cm.sessionMap {
|
||||
if now.After(entry.ExpiresAt) || now.Sub(entry.LastUsed) > cm.sessionTTL {
|
||||
delete(cm.sessionMap, key)
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned > 0 {
|
||||
cm.logger.Debugf("Cleaned up %d expired sessions", cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
// StoreSession stores a session in the session map with expiration tracking
|
||||
func (cm *ChunkManager) StoreSession(key string, session *sessions.Session) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
// Enforce maximum session limit
|
||||
if len(cm.sessionMap) >= cm.maxSessions {
|
||||
// Remove oldest entry
|
||||
var oldestKey string
|
||||
var oldestTime time.Time = time.Now()
|
||||
|
||||
for k, entry := range cm.sessionMap {
|
||||
if entry.LastUsed.Before(oldestTime) {
|
||||
oldestTime = entry.LastUsed
|
||||
oldestKey = k
|
||||
}
|
||||
}
|
||||
|
||||
if oldestKey != "" {
|
||||
delete(cm.sessionMap, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
cm.sessionMap[key] = &SessionEntry{
|
||||
Session: session,
|
||||
ExpiresAt: time.Now().Add(cm.sessionTTL),
|
||||
LastUsed: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetSession retrieves a session from the session map
|
||||
func (cm *ChunkManager) GetSession(key string) *sessions.Session {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
entry, exists := cm.sessionMap[key]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update last used time
|
||||
entry.LastUsed = time.Now()
|
||||
return entry.Session
|
||||
}
|
||||
|
||||
// TokenCompressor interface for token compression operations
|
||||
type TokenCompressor interface {
|
||||
CompressToken(token string) string
|
||||
DecompressToken(compressed string) string
|
||||
}
|
||||
|
||||
// ChunkError represents errors that occur during chunk operations
|
||||
type ChunkError struct {
|
||||
Type string
|
||||
Reason string
|
||||
Details string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (ce *ChunkError) Error() string {
|
||||
return fmt.Sprintf("%s chunk error: %s - %s", ce.Type, ce.Reason, ce.Details)
|
||||
}
|
||||
|
||||
// NoOpLogger provides a no-op logger implementation
|
||||
type NoOpLogger struct{}
|
||||
|
||||
// NewNoOpLogger creates a new no-op logger
|
||||
func NewNoOpLogger() *NoOpLogger {
|
||||
return &NoOpLogger{}
|
||||
}
|
||||
|
||||
// Debug does nothing
|
||||
func (l *NoOpLogger) Debug(msg string) {}
|
||||
|
||||
// Debugf does nothing
|
||||
func (l *NoOpLogger) Debugf(format string, args ...interface{}) {}
|
||||
|
||||
// Error does nothing
|
||||
func (l *NoOpLogger) Error(msg string) {}
|
||||
|
||||
// Errorf does nothing
|
||||
func (l *NoOpLogger) Errorf(format string, args ...interface{}) {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
// Package chunking provides chunk serialization functionality
|
||||
package chunking
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChunkSerializer handles serialization and deserialization of token chunks
|
||||
type ChunkSerializer struct {
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewChunkSerializer creates a new chunk serializer
|
||||
func NewChunkSerializer(logger Logger) *ChunkSerializer {
|
||||
return &ChunkSerializer{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SerializeTokenToChunks splits a token into chunks suitable for cookie storage
|
||||
func (cs *ChunkSerializer) SerializeTokenToChunks(token string, config TokenConfig) ([]ChunkData, error) {
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("cannot serialize empty token")
|
||||
}
|
||||
|
||||
if len(token) < config.MinLength {
|
||||
return nil, fmt.Errorf("token too short: %d < %d", len(token), config.MinLength)
|
||||
}
|
||||
|
||||
if len(token) > config.MaxLength {
|
||||
return nil, fmt.Errorf("token too long: %d > %d", len(token), config.MaxLength)
|
||||
}
|
||||
|
||||
// Calculate optimal chunk size
|
||||
chunkSize := config.MaxChunkSize
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = maxCookieSize
|
||||
}
|
||||
|
||||
// Estimate number of chunks needed
|
||||
estimatedChunks := (len(token) + chunkSize - 1) / chunkSize
|
||||
if estimatedChunks > config.MaxChunks {
|
||||
return nil, fmt.Errorf("token requires too many chunks: %d > %d", estimatedChunks, config.MaxChunks)
|
||||
}
|
||||
|
||||
// Split token into chunks
|
||||
chunks := make([]ChunkData, 0, estimatedChunks)
|
||||
remaining := token
|
||||
|
||||
chunkIndex := 0
|
||||
for len(remaining) > 0 {
|
||||
if chunkIndex >= config.MaxChunks {
|
||||
return nil, fmt.Errorf("exceeded maximum chunk count during serialization")
|
||||
}
|
||||
|
||||
// Determine chunk size for this iteration
|
||||
currentChunkSize := chunkSize
|
||||
if len(remaining) < currentChunkSize {
|
||||
currentChunkSize = len(remaining)
|
||||
}
|
||||
|
||||
// Extract chunk
|
||||
chunkContent := remaining[:currentChunkSize]
|
||||
remaining = remaining[currentChunkSize:]
|
||||
|
||||
// Create chunk data
|
||||
chunkData := ChunkData{
|
||||
Index: chunkIndex,
|
||||
Content: chunkContent,
|
||||
Total: estimatedChunks, // Will be updated after all chunks are created
|
||||
Checksum: cs.calculateChecksum(chunkContent),
|
||||
}
|
||||
|
||||
chunks = append(chunks, chunkData)
|
||||
chunkIndex++
|
||||
}
|
||||
|
||||
// Update total count in all chunks
|
||||
actualChunks := len(chunks)
|
||||
for i := range chunks {
|
||||
chunks[i].Total = actualChunks
|
||||
}
|
||||
|
||||
cs.logger.Debugf("Serialized %s token into %d chunks", config.Type, len(chunks))
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
// DeserializeTokenFromChunks reconstructs a token from chunk data
|
||||
func (cs *ChunkSerializer) DeserializeTokenFromChunks(chunks []ChunkData, config TokenConfig) (string, error) {
|
||||
if len(chunks) == 0 {
|
||||
return "", fmt.Errorf("no chunks provided for deserialization")
|
||||
}
|
||||
|
||||
if len(chunks) > config.MaxChunks {
|
||||
return "", fmt.Errorf("too many chunks: %d > %d", len(chunks), config.MaxChunks)
|
||||
}
|
||||
|
||||
// Validate chunk consistency
|
||||
expectedTotal := chunks[0].Total
|
||||
for i, chunk := range chunks {
|
||||
if chunk.Total != expectedTotal {
|
||||
return "", fmt.Errorf("chunk %d has inconsistent total count: %d != %d", i, chunk.Total, expectedTotal)
|
||||
}
|
||||
}
|
||||
|
||||
if len(chunks) != expectedTotal {
|
||||
return "", fmt.Errorf("chunk count mismatch: got %d, expected %d", len(chunks), expectedTotal)
|
||||
}
|
||||
|
||||
// Sort chunks by index
|
||||
orderedChunks := make([]ChunkData, expectedTotal)
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Index < 0 || chunk.Index >= expectedTotal {
|
||||
return "", fmt.Errorf("invalid chunk index: %d (total: %d)", chunk.Index, expectedTotal)
|
||||
}
|
||||
|
||||
if orderedChunks[chunk.Index].Content != "" {
|
||||
return "", fmt.Errorf("duplicate chunk index: %d", chunk.Index)
|
||||
}
|
||||
|
||||
orderedChunks[chunk.Index] = chunk
|
||||
}
|
||||
|
||||
// Verify all chunks are present
|
||||
for i, chunk := range orderedChunks {
|
||||
if chunk.Content == "" {
|
||||
return "", fmt.Errorf("missing chunk at index: %d", i)
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
expectedChecksum := cs.calculateChecksum(chunk.Content)
|
||||
if chunk.Checksum != expectedChecksum {
|
||||
return "", fmt.Errorf("chunk %d checksum mismatch", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct token
|
||||
var tokenBuilder strings.Builder
|
||||
tokenBuilder.Grow(len(chunks) * config.MaxChunkSize) // Pre-allocate capacity
|
||||
|
||||
for _, chunk := range orderedChunks {
|
||||
tokenBuilder.WriteString(chunk.Content)
|
||||
}
|
||||
|
||||
reconstructedToken := tokenBuilder.String()
|
||||
|
||||
// Final validation
|
||||
if len(reconstructedToken) < config.MinLength {
|
||||
return "", fmt.Errorf("reconstructed token too short: %d < %d", len(reconstructedToken), config.MinLength)
|
||||
}
|
||||
|
||||
if len(reconstructedToken) > config.MaxLength {
|
||||
return "", fmt.Errorf("reconstructed token too long: %d > %d", len(reconstructedToken), config.MaxLength)
|
||||
}
|
||||
|
||||
cs.logger.Debugf("Deserialized %s token from %d chunks (length: %d)", config.Type, len(chunks), len(reconstructedToken))
|
||||
return reconstructedToken, nil
|
||||
}
|
||||
|
||||
// EncodeChunk encodes chunk data for cookie storage
|
||||
func (cs *ChunkSerializer) EncodeChunk(chunk ChunkData) (string, error) {
|
||||
// Create a simple format: index:total:checksum:content
|
||||
encoded := fmt.Sprintf("%d:%d:%s:%s", chunk.Index, chunk.Total, chunk.Checksum, chunk.Content)
|
||||
|
||||
// Base64 encode the entire chunk for safe cookie storage
|
||||
return base64.StdEncoding.EncodeToString([]byte(encoded)), nil
|
||||
}
|
||||
|
||||
// DecodeChunk decodes chunk data from cookie storage
|
||||
func (cs *ChunkSerializer) DecodeChunk(encoded string) (ChunkData, error) {
|
||||
// Base64 decode
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return ChunkData{}, fmt.Errorf("failed to base64 decode chunk: %w", err)
|
||||
}
|
||||
|
||||
// Parse the format: index:total:checksum:content
|
||||
parts := strings.SplitN(string(decoded), ":", 4)
|
||||
if len(parts) != 4 {
|
||||
return ChunkData{}, fmt.Errorf("invalid chunk format: expected 4 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
var index, total int
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &index); err != nil {
|
||||
return ChunkData{}, fmt.Errorf("invalid chunk index: %w", err)
|
||||
}
|
||||
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &total); err != nil {
|
||||
return ChunkData{}, fmt.Errorf("invalid chunk total: %w", err)
|
||||
}
|
||||
|
||||
checksum := parts[2]
|
||||
content := parts[3]
|
||||
|
||||
return ChunkData{
|
||||
Index: index,
|
||||
Total: total,
|
||||
Content: content,
|
||||
Checksum: checksum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateChunkIntegrity validates the integrity of chunk data
|
||||
func (cs *ChunkSerializer) ValidateChunkIntegrity(chunk ChunkData) error {
|
||||
if chunk.Index < 0 {
|
||||
return fmt.Errorf("negative chunk index: %d", chunk.Index)
|
||||
}
|
||||
|
||||
if chunk.Total <= 0 {
|
||||
return fmt.Errorf("invalid total chunks: %d", chunk.Total)
|
||||
}
|
||||
|
||||
if chunk.Index >= chunk.Total {
|
||||
return fmt.Errorf("chunk index %d exceeds total %d", chunk.Index, chunk.Total)
|
||||
}
|
||||
|
||||
if chunk.Content == "" {
|
||||
return fmt.Errorf("empty chunk content at index %d", chunk.Index)
|
||||
}
|
||||
|
||||
if chunk.Checksum == "" {
|
||||
return fmt.Errorf("empty chunk checksum at index %d", chunk.Index)
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
expectedChecksum := cs.calculateChecksum(chunk.Content)
|
||||
if chunk.Checksum != expectedChecksum {
|
||||
return fmt.Errorf("chunk %d checksum mismatch: expected %s, got %s",
|
||||
chunk.Index, expectedChecksum, chunk.Checksum)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateChecksum calculates a simple checksum for chunk content
|
||||
func (cs *ChunkSerializer) calculateChecksum(content string) string {
|
||||
// Simple checksum using length and first/last characters
|
||||
if len(content) == 0 {
|
||||
return "empty"
|
||||
}
|
||||
|
||||
checksum := fmt.Sprintf("len%d", len(content))
|
||||
if len(content) >= 1 {
|
||||
checksum += fmt.Sprintf("_first%d", int(content[0]))
|
||||
}
|
||||
if len(content) >= 2 {
|
||||
checksum += fmt.Sprintf("_last%d", int(content[len(content)-1]))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
// ChunkData represents a single chunk of token data
|
||||
type ChunkData struct {
|
||||
Index int // Position of this chunk in the sequence
|
||||
Total int // Total number of chunks for this token
|
||||
Content string // The actual chunk content
|
||||
Checksum string // Simple checksum for integrity verification
|
||||
}
|
||||
|
||||
// EstimateChunkCount estimates how many chunks a token will need
|
||||
func (cs *ChunkSerializer) EstimateChunkCount(tokenLength int, chunkSize int) int {
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = maxCookieSize
|
||||
}
|
||||
|
||||
return (tokenLength + chunkSize - 1) / chunkSize
|
||||
}
|
||||
|
||||
// MaxTokenSizeForChunks calculates the maximum token size that can fit in the given number of chunks
|
||||
func (cs *ChunkSerializer) MaxTokenSizeForChunks(maxChunks int, chunkSize int) int {
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = maxCookieSize
|
||||
}
|
||||
|
||||
return maxChunks * chunkSize
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// Package chunking provides chunk validation functionality
|
||||
package chunking
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// TokenValidator provides comprehensive validation for tokens and chunks
|
||||
type TokenValidator struct{}
|
||||
|
||||
// NewTokenValidator creates a new token validator
|
||||
func NewTokenValidator() *TokenValidator {
|
||||
return &TokenValidator{}
|
||||
}
|
||||
|
||||
// ValidateTokenSize validates that a token is within size limits
|
||||
func (tv *TokenValidator) ValidateTokenSize(token string, config TokenConfig) error {
|
||||
if len(token) == 0 {
|
||||
return nil // Empty token is allowed
|
||||
}
|
||||
|
||||
if len(token) < config.MinLength {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "token too short",
|
||||
Details: fmt.Sprintf("length %d < minimum %d", len(token), config.MinLength),
|
||||
}
|
||||
}
|
||||
|
||||
if len(token) > config.MaxLength {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "token too long",
|
||||
Details: fmt.Sprintf("length %d > maximum %d", len(token), config.MaxLength),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJWTFormat validates that a token has proper JWT format
|
||||
func (tv *TokenValidator) ValidateJWTFormat(token string, tokenType string) error {
|
||||
if token == "" {
|
||||
return nil // Empty token is not an error
|
||||
}
|
||||
|
||||
// JWT tokens must have exactly 3 parts separated by dots
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return &ValidationError{
|
||||
Type: tokenType,
|
||||
Reason: "invalid JWT format",
|
||||
Details: fmt.Sprintf("expected 3 parts, got %d", len(parts)),
|
||||
}
|
||||
}
|
||||
|
||||
// Each part must be non-empty
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return &ValidationError{
|
||||
Type: tokenType,
|
||||
Reason: "empty JWT part",
|
||||
Details: fmt.Sprintf("part %d is empty", i+1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each part is valid base64
|
||||
for i, part := range parts {
|
||||
if err := tv.validateBase64JWT(part); err != nil {
|
||||
return &ValidationError{
|
||||
Type: tokenType,
|
||||
Reason: "invalid base64 in JWT part",
|
||||
Details: fmt.Sprintf("part %d: %v", i+1, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTokenContent performs comprehensive content validation
|
||||
func (tv *TokenValidator) ValidateTokenContent(token string, config TokenConfig) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate character set
|
||||
if err := tv.validateCharacterSet(token, config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate token structure based on type
|
||||
if config.RequireJWTFormat {
|
||||
return tv.validateJWTContent(token, config)
|
||||
} else if config.AllowOpaqueTokens {
|
||||
return tv.validateOpaqueTokenContent(token, config)
|
||||
} else {
|
||||
// Try JWT first, then fall back to opaque validation
|
||||
if err := tv.validateJWTContent(token, config); err != nil {
|
||||
return tv.validateOpaqueTokenContent(token, config)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateCharacterSet validates the character set of a token
|
||||
func (tv *TokenValidator) validateCharacterSet(token string, config TokenConfig) error {
|
||||
for i, r := range token {
|
||||
if !tv.isValidTokenCharacter(r) {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid character",
|
||||
Details: fmt.Sprintf("invalid character at position %d: %c (0x%X)", i, r, r),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidTokenCharacter checks if a character is valid in a token
|
||||
func (tv *TokenValidator) isValidTokenCharacter(r rune) bool {
|
||||
// Allow alphanumeric characters
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Allow common token characters
|
||||
validChars := ".-_~:/?#[]@!$&'()*+,;="
|
||||
return strings.ContainsRune(validChars, r)
|
||||
}
|
||||
|
||||
// validateJWTContent validates the content of a JWT token
|
||||
func (tv *TokenValidator) validateJWTContent(token string, config TokenConfig) error {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid JWT structure",
|
||||
Details: "JWT must have exactly 3 parts",
|
||||
}
|
||||
}
|
||||
|
||||
// Validate header
|
||||
if err := tv.validateJWTHeader(parts[0], config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate payload
|
||||
if err := tv.validateJWTPayload(parts[1], config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate signature
|
||||
if err := tv.validateJWTSignature(parts[2], config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateJWTHeader validates a JWT header
|
||||
func (tv *TokenValidator) validateJWTHeader(header string, config TokenConfig) error {
|
||||
decoded, err := tv.base64URLDecode(header)
|
||||
if err != nil {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid header encoding",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
var headerData map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &headerData); err != nil {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid header JSON",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if _, ok := headerData["alg"]; !ok {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "missing algorithm",
|
||||
Details: "JWT header must contain 'alg' field",
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := headerData["typ"]; !ok {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "missing type",
|
||||
Details: "JWT header must contain 'typ' field",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateJWTPayload validates a JWT payload
|
||||
func (tv *TokenValidator) validateJWTPayload(payload string, config TokenConfig) error {
|
||||
decoded, err := tv.base64URLDecode(payload)
|
||||
if err != nil {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid payload encoding",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
var payloadData map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &payloadData); err != nil {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid payload JSON",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// For ID tokens, check required claims
|
||||
if config.Type == "id" {
|
||||
requiredClaims := []string{"iss", "sub", "aud", "exp", "iat"}
|
||||
for _, claim := range requiredClaims {
|
||||
if _, ok := payloadData[claim]; !ok {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "missing required claim",
|
||||
Details: fmt.Sprintf("ID token must contain '%s' claim", claim),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateJWTSignature validates a JWT signature part
|
||||
func (tv *TokenValidator) validateJWTSignature(signature string, config TokenConfig) error {
|
||||
if signature == "" {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "empty signature",
|
||||
Details: "JWT signature cannot be empty",
|
||||
}
|
||||
}
|
||||
|
||||
// Just validate it's valid base64URL
|
||||
_, err := tv.base64URLDecode(signature)
|
||||
if err != nil {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid signature encoding",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOpaqueTokenContent validates opaque token content
|
||||
func (tv *TokenValidator) validateOpaqueTokenContent(token string, config TokenConfig) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Basic sanity checks for opaque tokens
|
||||
if len(token) < 8 {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "token too short for opaque token",
|
||||
Details: "opaque tokens should be at least 8 characters",
|
||||
}
|
||||
}
|
||||
|
||||
// Check for reasonable entropy
|
||||
if tv.hasLowEntropy(token) {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "low entropy",
|
||||
Details: "token appears to have low entropy",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasLowEntropy checks if a token has suspiciously low entropy
|
||||
func (tv *TokenValidator) hasLowEntropy(token string) bool {
|
||||
if len(token) < 8 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Count unique characters
|
||||
uniqueChars := make(map[rune]bool)
|
||||
for _, r := range token {
|
||||
uniqueChars[r] = true
|
||||
}
|
||||
|
||||
// If less than 50% of characters are unique, consider it low entropy
|
||||
entropyRatio := float64(len(uniqueChars)) / float64(len(token))
|
||||
return entropyRatio < 0.5
|
||||
}
|
||||
|
||||
// validateBase64JWT validates base64URL encoding
|
||||
func (tv *TokenValidator) validateBase64JWT(data string) error {
|
||||
_, err := tv.base64URLDecode(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// base64URLDecode decodes base64URL encoded data
|
||||
func (tv *TokenValidator) base64URLDecode(data string) ([]byte, error) {
|
||||
// Add padding if needed
|
||||
switch len(data) % 4 {
|
||||
case 2:
|
||||
data += "=="
|
||||
case 3:
|
||||
data += "="
|
||||
}
|
||||
|
||||
// Replace URL-safe characters
|
||||
data = strings.ReplaceAll(data, "-", "+")
|
||||
data = strings.ReplaceAll(data, "_", "/")
|
||||
|
||||
return base64.StdEncoding.DecodeString(data)
|
||||
}
|
||||
|
||||
// ValidateChunkStructure validates the structure of chunk data
|
||||
func (tv *TokenValidator) ValidateChunkStructure(chunks []ChunkData, config TokenConfig) error {
|
||||
if len(chunks) == 0 {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "no chunks provided",
|
||||
Details: "chunk list is empty",
|
||||
}
|
||||
}
|
||||
|
||||
if len(chunks) > config.MaxChunks {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "too many chunks",
|
||||
Details: fmt.Sprintf("got %d chunks, maximum is %d", len(chunks), config.MaxChunks),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each chunk
|
||||
expectedTotal := chunks[0].Total
|
||||
seenIndices := make(map[int]bool)
|
||||
|
||||
for i, chunk := range chunks {
|
||||
// Check for duplicate indices
|
||||
if seenIndices[chunk.Index] {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "duplicate chunk index",
|
||||
Details: fmt.Sprintf("chunk index %d appears multiple times", chunk.Index),
|
||||
}
|
||||
}
|
||||
seenIndices[chunk.Index] = true
|
||||
|
||||
// Validate individual chunk
|
||||
if err := tv.validateChunkData(chunk, expectedTotal, config); err != nil {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid chunk data",
|
||||
Details: fmt.Sprintf("chunk %d: %v", i, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing indices
|
||||
for i := 0; i < expectedTotal; i++ {
|
||||
if !seenIndices[i] {
|
||||
return &ValidationError{
|
||||
Type: config.Type,
|
||||
Reason: "missing chunk index",
|
||||
Details: fmt.Sprintf("chunk with index %d is missing", i),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateChunkData validates individual chunk data
|
||||
func (tv *TokenValidator) validateChunkData(chunk ChunkData, expectedTotal int, config TokenConfig) error {
|
||||
if chunk.Index < 0 {
|
||||
return fmt.Errorf("negative index: %d", chunk.Index)
|
||||
}
|
||||
|
||||
if chunk.Total != expectedTotal {
|
||||
return fmt.Errorf("inconsistent total: got %d, expected %d", chunk.Total, expectedTotal)
|
||||
}
|
||||
|
||||
if chunk.Index >= chunk.Total {
|
||||
return fmt.Errorf("index %d exceeds total %d", chunk.Index, chunk.Total)
|
||||
}
|
||||
|
||||
if chunk.Content == "" {
|
||||
return fmt.Errorf("empty content")
|
||||
}
|
||||
|
||||
if len(chunk.Content) > config.MaxChunkSize {
|
||||
return fmt.Errorf("chunk too large: %d > %d", len(chunk.Content), config.MaxChunkSize)
|
||||
}
|
||||
|
||||
if chunk.Checksum == "" {
|
||||
return fmt.Errorf("empty checksum")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidationError represents a validation error
|
||||
type ValidationError struct {
|
||||
Type string
|
||||
Reason string
|
||||
Details string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (ve *ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s validation error: %s - %s", ve.Type, ve.Reason, ve.Details)
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// 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
|
||||
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, logger Logger, chunkManager ChunkManager) (*SessionManager, error) {
|
||||
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,
|
||||
cookieDomain: cookieDomain,
|
||||
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, 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)
|
||||
}
|
||||
|
||||
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(absoluteSessionTimeout.Seconds()),
|
||||
Secure: isSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie name functions
|
||||
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
@@ -0,0 +1,264 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,900 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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.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
@@ -0,0 +1,300 @@
|
||||
// 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
@@ -0,0 +1,113 @@
|
||||
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// CacheInterface defines the common cache operations
|
||||
type CacheInterface interface {
|
||||
Set(key string, value interface{}, ttl time.Duration)
|
||||
Get(key string) (interface{}, bool)
|
||||
Delete(key string)
|
||||
SetMaxSize(size int)
|
||||
Cleanup()
|
||||
Close()
|
||||
}
|
||||
|
||||
// TokenVerifier interface defines token verification capabilities.
|
||||
// Implementations should validate token format, signature, and claims.
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(token string) error
|
||||
}
|
||||
|
||||
// JWTVerifier interface defines JWT-specific verification capabilities.
|
||||
// Implementations should validate JWT structure, signature using JWKs, and standard claims.
|
||||
type JWTVerifier interface {
|
||||
VerifyJWTSignatureAndClaims(jwt *JWT, token string) error
|
||||
}
|
||||
|
||||
// TokenExchanger interface defines OAuth 2.0 and OpenID Connect token exchange capabilities.
|
||||
// Implementations should handle authorization code exchange, refresh tokens, and revocation
|
||||
// according to the OAuth 2.0 and OpenID Connect specifications.
|
||||
type TokenExchanger interface {
|
||||
ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error)
|
||||
GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error)
|
||||
RevokeTokenWithProvider(token, tokenType string) error
|
||||
}
|
||||
|
||||
// ProviderMetadata represents OIDC provider configuration data.
|
||||
// This data is typically retrieved from the provider's .well-known/openid-configuration endpoint
|
||||
// and contains essential URLs for authentication, token exchange, and key retrieval.
|
||||
type ProviderMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthURL string `json:"authorization_endpoint"`
|
||||
TokenURL string `json:"token_endpoint"`
|
||||
JWKSURL string `json:"jwks_uri"`
|
||||
RevokeURL string `json:"revocation_endpoint"`
|
||||
EndSessionURL string `json:"end_session_endpoint"`
|
||||
}
|
||||
|
||||
// TraefikOidc is the main middleware struct that implements OIDC authentication for Traefik.
|
||||
// It integrates with various OIDC providers, manages sessions, caches tokens, and handles
|
||||
// the complete authentication flow. It's designed to work seamlessly with Traefik's
|
||||
// plugin system and provides flexible configuration options.
|
||||
type TraefikOidc struct {
|
||||
jwkCache JWKCacheInterface
|
||||
jwtVerifier JWTVerifier
|
||||
ctx context.Context
|
||||
tokenVerifier TokenVerifier
|
||||
next http.Handler
|
||||
tokenExchanger TokenExchanger
|
||||
initComplete chan struct{}
|
||||
limiter *rate.Limiter
|
||||
tokenBlacklist CacheInterface
|
||||
headerTemplates map[string]*template.Template
|
||||
sessionManager *SessionManager
|
||||
tokenCleanupStopChan chan struct{}
|
||||
excludedURLs map[string]struct{}
|
||||
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
||||
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
|
||||
metadataCache *MetadataCache
|
||||
allowedRolesAndGroups map[string]struct{}
|
||||
allowedUsers map[string]struct{}
|
||||
allowedUserDomains map[string]struct{}
|
||||
tokenCache *TokenCache
|
||||
httpClient *http.Client
|
||||
tokenHTTPClient *http.Client
|
||||
logger *Logger
|
||||
metadataRefreshStopChan chan struct{}
|
||||
cancelFunc context.CancelFunc
|
||||
errorRecoveryManager *ErrorRecoveryManager
|
||||
goroutineWG *sync.WaitGroup
|
||||
clientSecret string
|
||||
clientID string
|
||||
name string
|
||||
redirURLPath string
|
||||
logoutURLPath string
|
||||
tokenURL string
|
||||
authURL string
|
||||
endSessionURL string
|
||||
postLogoutRedirectURI string
|
||||
scheme string
|
||||
jwksURL string
|
||||
issuerURL string
|
||||
revocationURL string
|
||||
providerURL string
|
||||
scopes []string
|
||||
refreshGracePeriod time.Duration
|
||||
shutdownOnce sync.Once
|
||||
firstRequestMutex sync.Mutex
|
||||
forceHTTPS bool
|
||||
enablePKCE bool
|
||||
overrideScopes bool
|
||||
suppressDiagnosticLogs bool
|
||||
firstRequestReceived bool
|
||||
metadataRefreshStarted bool
|
||||
}
|
||||
Reference in New Issue
Block a user