mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
Cleanup [dec2025] (#101)
* Cleanup excessive comments. * Remove leftovers hanging around from previous refactor * Improve test coverage
This commit is contained in:
@@ -1,219 +0,0 @@
|
||||
// Package errors provides unified error handling for OIDC operations
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrorCode represents specific error types
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
// Authentication errors
|
||||
ErrCodeAuthenticationFailed ErrorCode = "AUTH_FAILED"
|
||||
ErrCodeTokenExpired ErrorCode = "TOKEN_EXPIRED"
|
||||
ErrCodeTokenInvalid ErrorCode = "TOKEN_INVALID"
|
||||
ErrCodeSessionExpired ErrorCode = "SESSION_EXPIRED"
|
||||
ErrCodeCSRFMismatch ErrorCode = "CSRF_MISMATCH"
|
||||
ErrCodeNonceMismatch ErrorCode = "NONCE_MISMATCH"
|
||||
|
||||
// Configuration errors
|
||||
ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID"
|
||||
ErrCodeProviderUnreachable ErrorCode = "PROVIDER_UNREACHABLE"
|
||||
ErrCodeMetadataFailed ErrorCode = "METADATA_FAILED"
|
||||
|
||||
// Network errors
|
||||
ErrCodeNetworkTimeout ErrorCode = "NETWORK_TIMEOUT"
|
||||
ErrCodeRateLimited ErrorCode = "RATE_LIMITED"
|
||||
ErrCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
|
||||
|
||||
// Validation errors
|
||||
ErrCodeValidationFailed ErrorCode = "VALIDATION_FAILED"
|
||||
ErrCodeDomainNotAllowed ErrorCode = "DOMAIN_NOT_ALLOWED"
|
||||
ErrCodeUserNotAllowed ErrorCode = "USER_NOT_ALLOWED"
|
||||
ErrCodeRoleNotAllowed ErrorCode = "ROLE_NOT_ALLOWED"
|
||||
)
|
||||
|
||||
// OIDCError represents a structured error with context
|
||||
type OIDCError struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Internal error `json:"-"` // Internal error, not exposed
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *OIDCError) Error() string {
|
||||
if e.Details != "" {
|
||||
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Details)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the internal error for error wrapping
|
||||
func (e *OIDCError) Unwrap() error {
|
||||
return e.Internal
|
||||
}
|
||||
|
||||
// IsRetryable indicates if the error is temporary and can be retried
|
||||
func (e *OIDCError) IsRetryable() bool {
|
||||
return e.Code == ErrCodeNetworkTimeout ||
|
||||
e.Code == ErrCodeServiceUnavailable ||
|
||||
e.Code == ErrCodeProviderUnreachable
|
||||
}
|
||||
|
||||
// IsAuthenticationError indicates if this is an authentication-related error
|
||||
func (e *OIDCError) IsAuthenticationError() bool {
|
||||
return e.Code == ErrCodeAuthenticationFailed ||
|
||||
e.Code == ErrCodeTokenExpired ||
|
||||
e.Code == ErrCodeTokenInvalid ||
|
||||
e.Code == ErrCodeSessionExpired ||
|
||||
e.Code == ErrCodeCSRFMismatch ||
|
||||
e.Code == ErrCodeNonceMismatch
|
||||
}
|
||||
|
||||
// IsAuthorizationError indicates if this is an authorization-related error
|
||||
func (e *OIDCError) IsAuthorizationError() bool {
|
||||
return e.Code == ErrCodeDomainNotAllowed ||
|
||||
e.Code == ErrCodeUserNotAllowed ||
|
||||
e.Code == ErrCodeRoleNotAllowed
|
||||
}
|
||||
|
||||
// ToJSON converts the error to a JSON response
|
||||
func (e *OIDCError) ToJSON() map[string]any {
|
||||
result := map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": string(e.Code),
|
||||
"message": e.Message,
|
||||
},
|
||||
}
|
||||
|
||||
if e.Details != "" {
|
||||
errorMap, _ := result["error"].(map[string]any) // Safe to ignore: type assertion from known type
|
||||
errorMap["details"] = e.Details
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Error constructors for common scenarios
|
||||
|
||||
// NewAuthenticationError creates an authentication-related error
|
||||
func NewAuthenticationError(code ErrorCode, message string, internal error) *OIDCError {
|
||||
status := http.StatusUnauthorized
|
||||
if code == ErrCodeSessionExpired {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
HTTPStatus: status,
|
||||
Internal: internal,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuthorizationError creates an authorization-related error
|
||||
func NewAuthorizationError(code ErrorCode, message string, details string) *OIDCError {
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
|
||||
// NewConfigurationError creates a configuration-related error
|
||||
func NewConfigurationError(code ErrorCode, message string, internal error) *OIDCError {
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
Internal: internal,
|
||||
}
|
||||
}
|
||||
|
||||
// NewNetworkError creates a network-related error
|
||||
func NewNetworkError(code ErrorCode, message string, internal error) *OIDCError {
|
||||
status := http.StatusServiceUnavailable
|
||||
if code == ErrCodeRateLimited {
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
HTTPStatus: status,
|
||||
Internal: internal,
|
||||
}
|
||||
}
|
||||
|
||||
// NewValidationError creates a validation-related error
|
||||
func NewValidationError(code ErrorCode, message string, details string) *OIDCError {
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience functions for common error patterns
|
||||
|
||||
// WrapAuthenticationError wraps an existing error as an authentication error
|
||||
func WrapAuthenticationError(err error, message string) *OIDCError {
|
||||
return NewAuthenticationError(ErrCodeAuthenticationFailed, message, err)
|
||||
}
|
||||
|
||||
// WrapTokenError wraps a token-related error
|
||||
func WrapTokenError(err error, tokenType string) *OIDCError {
|
||||
message := fmt.Sprintf("Token validation failed: %s", tokenType)
|
||||
return NewAuthenticationError(ErrCodeTokenInvalid, message, err)
|
||||
}
|
||||
|
||||
// WrapProviderError wraps a provider communication error
|
||||
func WrapProviderError(err error, providerURL string) *OIDCError {
|
||||
message := fmt.Sprintf("Provider communication failed: %s", providerURL)
|
||||
return NewNetworkError(ErrCodeProviderUnreachable, message, err)
|
||||
}
|
||||
|
||||
// IsOIDCError checks if an error is an OIDCError
|
||||
func IsOIDCError(err error) (*OIDCError, bool) {
|
||||
oidcErr, ok := err.(*OIDCError)
|
||||
return oidcErr, ok
|
||||
}
|
||||
|
||||
// GetHTTPStatus extracts HTTP status from error, defaulting to 500
|
||||
func GetHTTPStatus(err error) int {
|
||||
if oidcErr, ok := IsOIDCError(err); ok {
|
||||
return oidcErr.HTTPStatus
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// FormatUserMessage creates a user-friendly error message
|
||||
func FormatUserMessage(err error) string {
|
||||
if oidcErr, ok := IsOIDCError(err); ok {
|
||||
switch oidcErr.Code {
|
||||
case ErrCodeDomainNotAllowed:
|
||||
return "Your email domain is not authorized for this application"
|
||||
case ErrCodeUserNotAllowed:
|
||||
return "Your account is not authorized for this application"
|
||||
case ErrCodeRoleNotAllowed:
|
||||
return "You do not have the required permissions for this application"
|
||||
case ErrCodeSessionExpired:
|
||||
return "Your session has expired. Please log in again"
|
||||
case ErrCodeTokenExpired:
|
||||
return "Your authentication has expired. Please log in again"
|
||||
case ErrCodeProviderUnreachable:
|
||||
return "Authentication service is temporarily unavailable. Please try again later"
|
||||
case ErrCodeRateLimited:
|
||||
return "Too many requests. Please wait a moment and try again"
|
||||
default:
|
||||
return "Authentication failed. Please try again"
|
||||
}
|
||||
}
|
||||
return "An unexpected error occurred. Please try again"
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOIDCError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oidcErr *OIDCError
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Error with details",
|
||||
oidcErr: &OIDCError{
|
||||
Code: ErrCodeTokenInvalid,
|
||||
Message: "Token validation failed",
|
||||
Details: "JWT signature invalid",
|
||||
},
|
||||
expected: "TOKEN_INVALID: Token validation failed (JWT signature invalid)",
|
||||
},
|
||||
{
|
||||
name: "Error without details",
|
||||
oidcErr: &OIDCError{
|
||||
Code: ErrCodeAuthenticationFailed,
|
||||
Message: "Authentication failed",
|
||||
},
|
||||
expected: "AUTH_FAILED: Authentication failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.oidcErr.Error()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCError_Unwrap(t *testing.T) {
|
||||
internalErr := errors.New("internal error")
|
||||
oidcErr := &OIDCError{
|
||||
Code: ErrCodeTokenInvalid,
|
||||
Message: "Token validation failed",
|
||||
Internal: internalErr,
|
||||
}
|
||||
|
||||
unwrapped := oidcErr.Unwrap()
|
||||
if unwrapped != internalErr {
|
||||
t.Errorf("Expected internal error, got %v", unwrapped)
|
||||
}
|
||||
|
||||
// Test with nil internal error
|
||||
oidcErrNoInternal := &OIDCError{
|
||||
Code: ErrCodeTokenInvalid,
|
||||
Message: "Token validation failed",
|
||||
}
|
||||
|
||||
unwrappedNil := oidcErrNoInternal.Unwrap()
|
||||
if unwrappedNil != nil {
|
||||
t.Errorf("Expected nil, got %v", unwrappedNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCError_IsRetryable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code ErrorCode
|
||||
expected bool
|
||||
}{
|
||||
{"Network timeout", ErrCodeNetworkTimeout, true},
|
||||
{"Service unavailable", ErrCodeServiceUnavailable, true},
|
||||
{"Provider unreachable", ErrCodeProviderUnreachable, true},
|
||||
{"Authentication failed", ErrCodeAuthenticationFailed, false},
|
||||
{"Token invalid", ErrCodeTokenInvalid, false},
|
||||
{"Rate limited", ErrCodeRateLimited, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
oidcErr := &OIDCError{Code: tt.code}
|
||||
result := oidcErr.IsRetryable()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCError_IsAuthenticationError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code ErrorCode
|
||||
expected bool
|
||||
}{
|
||||
{"Authentication failed", ErrCodeAuthenticationFailed, true},
|
||||
{"Token expired", ErrCodeTokenExpired, true},
|
||||
{"Token invalid", ErrCodeTokenInvalid, true},
|
||||
{"Session expired", ErrCodeSessionExpired, true},
|
||||
{"CSRF mismatch", ErrCodeCSRFMismatch, true},
|
||||
{"Nonce mismatch", ErrCodeNonceMismatch, true},
|
||||
{"Config invalid", ErrCodeConfigInvalid, false},
|
||||
{"Domain not allowed", ErrCodeDomainNotAllowed, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
oidcErr := &OIDCError{Code: tt.code}
|
||||
result := oidcErr.IsAuthenticationError()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCError_IsAuthorizationError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code ErrorCode
|
||||
expected bool
|
||||
}{
|
||||
{"Domain not allowed", ErrCodeDomainNotAllowed, true},
|
||||
{"User not allowed", ErrCodeUserNotAllowed, true},
|
||||
{"Role not allowed", ErrCodeRoleNotAllowed, true},
|
||||
{"Authentication failed", ErrCodeAuthenticationFailed, false},
|
||||
{"Token expired", ErrCodeTokenExpired, false},
|
||||
{"Config invalid", ErrCodeConfigInvalid, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
oidcErr := &OIDCError{Code: tt.code}
|
||||
result := oidcErr.IsAuthorizationError()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCError_ToJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oidcErr *OIDCError
|
||||
expected map[string]any
|
||||
}{
|
||||
{
|
||||
name: "Error with details",
|
||||
oidcErr: &OIDCError{
|
||||
Code: ErrCodeTokenInvalid,
|
||||
Message: "Token validation failed",
|
||||
Details: "JWT signature invalid",
|
||||
},
|
||||
expected: map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "TOKEN_INVALID",
|
||||
"message": "Token validation failed",
|
||||
"details": "JWT signature invalid",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Error without details",
|
||||
oidcErr: &OIDCError{
|
||||
Code: ErrCodeAuthenticationFailed,
|
||||
Message: "Authentication failed",
|
||||
},
|
||||
expected: map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "AUTH_FAILED",
|
||||
"message": "Authentication failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.oidcErr.ToJSON()
|
||||
if !reflect.DeepEqual(result, tt.expected) {
|
||||
t.Errorf("Expected %+v, got %+v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthenticationError(t *testing.T) {
|
||||
internalErr := errors.New("internal error")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code ErrorCode
|
||||
message string
|
||||
internal error
|
||||
expectedHTTP int
|
||||
}{
|
||||
{
|
||||
name: "Regular auth error",
|
||||
code: ErrCodeAuthenticationFailed,
|
||||
message: "Auth failed",
|
||||
internal: internalErr,
|
||||
expectedHTTP: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "Session expired error",
|
||||
code: ErrCodeSessionExpired,
|
||||
message: "Session expired",
|
||||
internal: internalErr,
|
||||
expectedHTTP: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewAuthenticationError(tt.code, tt.message, tt.internal)
|
||||
|
||||
if err.Code != tt.code {
|
||||
t.Errorf("Expected code %s, got %s", tt.code, err.Code)
|
||||
}
|
||||
if err.Message != tt.message {
|
||||
t.Errorf("Expected message '%s', got '%s'", tt.message, err.Message)
|
||||
}
|
||||
if err.Internal != tt.internal {
|
||||
t.Errorf("Expected internal error %v, got %v", tt.internal, err.Internal)
|
||||
}
|
||||
if err.HTTPStatus != tt.expectedHTTP {
|
||||
t.Errorf("Expected HTTP status %d, got %d", tt.expectedHTTP, err.HTTPStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthorizationError(t *testing.T) {
|
||||
err := NewAuthorizationError(ErrCodeDomainNotAllowed, "Domain not allowed", "example.com not in whitelist")
|
||||
|
||||
if err.Code != ErrCodeDomainNotAllowed {
|
||||
t.Errorf("Expected code %s, got %s", ErrCodeDomainNotAllowed, err.Code)
|
||||
}
|
||||
if err.Message != "Domain not allowed" {
|
||||
t.Errorf("Expected message 'Domain not allowed', got '%s'", err.Message)
|
||||
}
|
||||
if err.Details != "example.com not in whitelist" {
|
||||
t.Errorf("Expected details 'example.com not in whitelist', got '%s'", err.Details)
|
||||
}
|
||||
if err.HTTPStatus != http.StatusForbidden {
|
||||
t.Errorf("Expected HTTP status %d, got %d", http.StatusForbidden, err.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConfigurationError(t *testing.T) {
|
||||
internalErr := errors.New("config parse error")
|
||||
err := NewConfigurationError(ErrCodeConfigInvalid, "Invalid config", internalErr)
|
||||
|
||||
if err.Code != ErrCodeConfigInvalid {
|
||||
t.Errorf("Expected code %s, got %s", ErrCodeConfigInvalid, err.Code)
|
||||
}
|
||||
if err.HTTPStatus != http.StatusInternalServerError {
|
||||
t.Errorf("Expected HTTP status %d, got %d", http.StatusInternalServerError, err.HTTPStatus)
|
||||
}
|
||||
if err.Internal != internalErr {
|
||||
t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNetworkError(t *testing.T) {
|
||||
internalErr := errors.New("network error")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code ErrorCode
|
||||
expectedHTTP int
|
||||
}{
|
||||
{
|
||||
name: "Rate limited",
|
||||
code: ErrCodeRateLimited,
|
||||
expectedHTTP: http.StatusTooManyRequests,
|
||||
},
|
||||
{
|
||||
name: "Service unavailable",
|
||||
code: ErrCodeServiceUnavailable,
|
||||
expectedHTTP: http.StatusServiceUnavailable,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewNetworkError(tt.code, "Network error", internalErr)
|
||||
|
||||
if err.Code != tt.code {
|
||||
t.Errorf("Expected code %s, got %s", tt.code, err.Code)
|
||||
}
|
||||
if err.HTTPStatus != tt.expectedHTTP {
|
||||
t.Errorf("Expected HTTP status %d, got %d", tt.expectedHTTP, err.HTTPStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidationError(t *testing.T) {
|
||||
err := NewValidationError(ErrCodeValidationFailed, "Validation failed", "field 'email' is required")
|
||||
|
||||
if err.Code != ErrCodeValidationFailed {
|
||||
t.Errorf("Expected code %s, got %s", ErrCodeValidationFailed, err.Code)
|
||||
}
|
||||
if err.HTTPStatus != http.StatusBadRequest {
|
||||
t.Errorf("Expected HTTP status %d, got %d", http.StatusBadRequest, err.HTTPStatus)
|
||||
}
|
||||
if err.Details != "field 'email' is required" {
|
||||
t.Errorf("Expected details 'field 'email' is required', got '%s'", err.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapAuthenticationError(t *testing.T) {
|
||||
internalErr := errors.New("original error")
|
||||
err := WrapAuthenticationError(internalErr, "Custom auth message")
|
||||
|
||||
if err.Code != ErrCodeAuthenticationFailed {
|
||||
t.Errorf("Expected code %s, got %s", ErrCodeAuthenticationFailed, err.Code)
|
||||
}
|
||||
if err.Message != "Custom auth message" {
|
||||
t.Errorf("Expected message 'Custom auth message', got '%s'", err.Message)
|
||||
}
|
||||
if err.Internal != internalErr {
|
||||
t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapTokenError(t *testing.T) {
|
||||
internalErr := errors.New("token error")
|
||||
err := WrapTokenError(internalErr, "ID token")
|
||||
|
||||
if err.Code != ErrCodeTokenInvalid {
|
||||
t.Errorf("Expected code %s, got %s", ErrCodeTokenInvalid, err.Code)
|
||||
}
|
||||
if err.Message != "Token validation failed: ID token" {
|
||||
t.Errorf("Expected message 'Token validation failed: ID token', got '%s'", err.Message)
|
||||
}
|
||||
if err.Internal != internalErr {
|
||||
t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapProviderError(t *testing.T) {
|
||||
internalErr := errors.New("provider error")
|
||||
err := WrapProviderError(internalErr, "https://provider.example.com")
|
||||
|
||||
if err.Code != ErrCodeProviderUnreachable {
|
||||
t.Errorf("Expected code %s, got %s", ErrCodeProviderUnreachable, err.Code)
|
||||
}
|
||||
if err.Message != "Provider communication failed: https://provider.example.com" {
|
||||
t.Errorf("Expected specific message, got '%s'", err.Message)
|
||||
}
|
||||
if err.Internal != internalErr {
|
||||
t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOIDCError(t *testing.T) {
|
||||
// Test with OIDCError
|
||||
oidcErr := &OIDCError{Code: ErrCodeTokenInvalid, Message: "test"}
|
||||
result, ok := IsOIDCError(oidcErr)
|
||||
if !ok {
|
||||
t.Error("Expected IsOIDCError to return true for OIDCError")
|
||||
}
|
||||
if result != oidcErr {
|
||||
t.Error("Expected to get the same OIDCError back")
|
||||
}
|
||||
|
||||
// Test with regular error
|
||||
regularErr := errors.New("regular error")
|
||||
result, ok = IsOIDCError(regularErr)
|
||||
if ok {
|
||||
t.Error("Expected IsOIDCError to return false for regular error")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for regular error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHTTPStatus(t *testing.T) {
|
||||
// Test with OIDCError
|
||||
oidcErr := &OIDCError{
|
||||
Code: ErrCodeTokenInvalid,
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
status := GetHTTPStatus(oidcErr)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("Expected %d, got %d", http.StatusUnauthorized, status)
|
||||
}
|
||||
|
||||
// Test with regular error
|
||||
regularErr := errors.New("regular error")
|
||||
status = GetHTTPStatus(regularErr)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Errorf("Expected %d, got %d", http.StatusInternalServerError, status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatUserMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Domain not allowed",
|
||||
err: &OIDCError{Code: ErrCodeDomainNotAllowed},
|
||||
expected: "Your email domain is not authorized for this application",
|
||||
},
|
||||
{
|
||||
name: "User not allowed",
|
||||
err: &OIDCError{Code: ErrCodeUserNotAllowed},
|
||||
expected: "Your account is not authorized for this application",
|
||||
},
|
||||
{
|
||||
name: "Role not allowed",
|
||||
err: &OIDCError{Code: ErrCodeRoleNotAllowed},
|
||||
expected: "You do not have the required permissions for this application",
|
||||
},
|
||||
{
|
||||
name: "Session expired",
|
||||
err: &OIDCError{Code: ErrCodeSessionExpired},
|
||||
expected: "Your session has expired. Please log in again",
|
||||
},
|
||||
{
|
||||
name: "Token expired",
|
||||
err: &OIDCError{Code: ErrCodeTokenExpired},
|
||||
expected: "Your authentication has expired. Please log in again",
|
||||
},
|
||||
{
|
||||
name: "Provider unreachable",
|
||||
err: &OIDCError{Code: ErrCodeProviderUnreachable},
|
||||
expected: "Authentication service is temporarily unavailable. Please try again later",
|
||||
},
|
||||
{
|
||||
name: "Rate limited",
|
||||
err: &OIDCError{Code: ErrCodeRateLimited},
|
||||
expected: "Too many requests. Please wait a moment and try again",
|
||||
},
|
||||
{
|
||||
name: "Unknown OIDC error",
|
||||
err: &OIDCError{Code: ErrCodeConfigInvalid},
|
||||
expected: "Authentication failed. Please try again",
|
||||
},
|
||||
{
|
||||
name: "Regular error",
|
||||
err: errors.New("regular error"),
|
||||
expected: "An unexpected error occurred. Please try again",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := FormatUserMessage(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCodes(t *testing.T) {
|
||||
// Test that all error codes are defined correctly
|
||||
codes := []ErrorCode{
|
||||
ErrCodeAuthenticationFailed,
|
||||
ErrCodeTokenExpired,
|
||||
ErrCodeTokenInvalid,
|
||||
ErrCodeSessionExpired,
|
||||
ErrCodeCSRFMismatch,
|
||||
ErrCodeNonceMismatch,
|
||||
ErrCodeConfigInvalid,
|
||||
ErrCodeProviderUnreachable,
|
||||
ErrCodeMetadataFailed,
|
||||
ErrCodeNetworkTimeout,
|
||||
ErrCodeRateLimited,
|
||||
ErrCodeServiceUnavailable,
|
||||
ErrCodeValidationFailed,
|
||||
ErrCodeDomainNotAllowed,
|
||||
ErrCodeUserNotAllowed,
|
||||
ErrCodeRoleNotAllowed,
|
||||
}
|
||||
|
||||
for _, code := range codes {
|
||||
if string(code) == "" {
|
||||
t.Errorf("Error code %v is empty", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorConstructorCompleteness(t *testing.T) {
|
||||
// Test each constructor function to ensure they set all required fields
|
||||
internalErr := errors.New("test error")
|
||||
|
||||
// Test NewAuthenticationError
|
||||
authErr := NewAuthenticationError(ErrCodeAuthenticationFailed, "auth message", internalErr)
|
||||
if authErr.Code == "" || authErr.Message == "" || authErr.HTTPStatus == 0 {
|
||||
t.Error("NewAuthenticationError did not set all required fields")
|
||||
}
|
||||
|
||||
// Test NewAuthorizationError
|
||||
authzErr := NewAuthorizationError(ErrCodeDomainNotAllowed, "authz message", "details")
|
||||
if authzErr.Code == "" || authzErr.Message == "" || authzErr.HTTPStatus == 0 {
|
||||
t.Error("NewAuthorizationError did not set all required fields")
|
||||
}
|
||||
|
||||
// Test NewConfigurationError
|
||||
configErr := NewConfigurationError(ErrCodeConfigInvalid, "config message", internalErr)
|
||||
if configErr.Code == "" || configErr.Message == "" || configErr.HTTPStatus == 0 {
|
||||
t.Error("NewConfigurationError did not set all required fields")
|
||||
}
|
||||
|
||||
// Test NewNetworkError
|
||||
netErr := NewNetworkError(ErrCodeNetworkTimeout, "network message", internalErr)
|
||||
if netErr.Code == "" || netErr.Message == "" || netErr.HTTPStatus == 0 {
|
||||
t.Error("NewNetworkError did not set all required fields")
|
||||
}
|
||||
|
||||
// Test NewValidationError
|
||||
validErr := NewValidationError(ErrCodeValidationFailed, "validation message", "details")
|
||||
if validErr.Code == "" || validErr.Message == "" || validErr.HTTPStatus == 0 {
|
||||
t.Error("NewValidationError did not set all required fields")
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
// Package handlers provides authentication flow management
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthFlowHandler manages the complete OIDC authentication flow
|
||||
type AuthFlowHandler struct {
|
||||
sessionHandler *SessionHandler
|
||||
tokenHandler TokenHandler
|
||||
logger Logger
|
||||
excludedURLs map[string]struct{}
|
||||
initComplete chan struct{}
|
||||
issuerURL string
|
||||
}
|
||||
|
||||
// TokenHandler interface for token operations
|
||||
type TokenHandler interface {
|
||||
VerifyToken(token string) error
|
||||
RefreshToken(refreshToken string) (*TokenResponse, error)
|
||||
}
|
||||
|
||||
// TokenResponse represents token exchange response
|
||||
type TokenResponse struct {
|
||||
IDToken string `json:"id_token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// AuthFlowResult represents the result of authentication flow processing
|
||||
type AuthFlowResult struct {
|
||||
Authenticated bool
|
||||
RequiresAuth bool
|
||||
RequiresRefresh bool
|
||||
Error error
|
||||
RedirectURL string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
// NewAuthFlowHandler creates a new authentication flow handler
|
||||
func NewAuthFlowHandler(sessionHandler *SessionHandler, tokenHandler TokenHandler, logger Logger, excludedURLs map[string]struct{}, initComplete chan struct{}, issuerURL string) *AuthFlowHandler {
|
||||
return &AuthFlowHandler{
|
||||
sessionHandler: sessionHandler,
|
||||
tokenHandler: tokenHandler,
|
||||
logger: logger,
|
||||
excludedURLs: excludedURLs,
|
||||
initComplete: initComplete,
|
||||
issuerURL: issuerURL,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessRequest handles the main authentication flow
|
||||
func (h *AuthFlowHandler) ProcessRequest(rw http.ResponseWriter, req *http.Request) AuthFlowResult {
|
||||
// Check if URL should be excluded
|
||||
if h.shouldExcludeURL(req.URL.Path) {
|
||||
h.logger.Debugf("Request path %s excluded by configuration, bypassing OIDC", req.URL.Path)
|
||||
return AuthFlowResult{Authenticated: true}
|
||||
}
|
||||
|
||||
// Check for streaming requests
|
||||
if h.isStreamingRequest(req) {
|
||||
h.logger.Debugf("Streaming request detected, bypassing OIDC")
|
||||
return AuthFlowResult{Authenticated: true}
|
||||
}
|
||||
|
||||
// Wait for initialization
|
||||
if !h.waitForInitialization(req) {
|
||||
return AuthFlowResult{
|
||||
Error: ErrInitializationTimeout,
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
// Get and validate session
|
||||
session, err := h.sessionHandler.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Error getting session: %v", err)
|
||||
return AuthFlowResult{
|
||||
RequiresAuth: true,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
defer session.ReturnToPoolSafely()
|
||||
|
||||
// Clean up old cookies
|
||||
h.sessionHandler.sessionManager.CleanupOldCookies(rw, req)
|
||||
|
||||
// Validate session
|
||||
validationResult := h.sessionHandler.ValidateSession(session)
|
||||
if !validationResult.Valid {
|
||||
if validationResult.NeedsAuth {
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
return AuthFlowResult{
|
||||
Error: ErrSessionInvalid,
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
// Check token validity and refresh if needed
|
||||
return h.validateAndRefreshTokens(session, req, rw)
|
||||
}
|
||||
|
||||
// shouldExcludeURL checks if a URL should bypass authentication
|
||||
func (h *AuthFlowHandler) shouldExcludeURL(path string) bool {
|
||||
for excludedURL := range h.excludedURLs {
|
||||
if len(path) >= len(excludedURL) && path[:len(excludedURL)] == excludedURL {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isStreamingRequest checks if request is a streaming request that should bypass auth
|
||||
func (h *AuthFlowHandler) isStreamingRequest(req *http.Request) bool {
|
||||
acceptHeader := req.Header.Get("Accept")
|
||||
return acceptHeader == "text/event-stream"
|
||||
}
|
||||
|
||||
// waitForInitialization waits for OIDC provider initialization
|
||||
func (h *AuthFlowHandler) waitForInitialization(req *http.Request) bool {
|
||||
select {
|
||||
case <-h.initComplete:
|
||||
if h.issuerURL == "" {
|
||||
h.logger.Error("OIDC provider metadata initialization failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case <-req.Context().Done():
|
||||
h.logger.Debug("Request canceled while waiting for OIDC initialization")
|
||||
return false
|
||||
case <-time.After(30 * time.Second):
|
||||
h.logger.Error("Timeout waiting for OIDC initialization")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validateAndRefreshTokens handles token validation and refresh logic
|
||||
func (h *AuthFlowHandler) validateAndRefreshTokens(session Session, req *http.Request, rw http.ResponseWriter) AuthFlowResult {
|
||||
// Check access token if present
|
||||
if accessToken := session.GetAccessToken(); accessToken != "" {
|
||||
if err := h.tokenHandler.VerifyToken(accessToken); err != nil {
|
||||
h.logger.Errorf("Access token validation failed: %v", err)
|
||||
|
||||
// Try refresh if refresh token is available
|
||||
if refreshToken := session.GetRefreshToken(); refreshToken != "" {
|
||||
return h.attemptTokenRefresh(session, req, rw)
|
||||
}
|
||||
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
}
|
||||
|
||||
// Check ID token
|
||||
if idToken := session.GetIDToken(); idToken != "" {
|
||||
if err := h.tokenHandler.VerifyToken(idToken); err != nil {
|
||||
h.logger.Errorf("ID token validation failed: %v", err)
|
||||
|
||||
// Try refresh if refresh token is available
|
||||
if refreshToken := session.GetRefreshToken(); refreshToken != "" {
|
||||
return h.attemptTokenRefresh(session, req, rw)
|
||||
}
|
||||
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthFlowResult{Authenticated: true}
|
||||
}
|
||||
|
||||
// attemptTokenRefresh tries to refresh tokens
|
||||
func (h *AuthFlowHandler) attemptTokenRefresh(session Session, req *http.Request, rw http.ResponseWriter) AuthFlowResult {
|
||||
refreshToken := session.GetRefreshToken()
|
||||
if refreshToken == "" {
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
|
||||
// Check if this is an AJAX request
|
||||
if h.sessionHandler.IsAjaxRequest(req) {
|
||||
return AuthFlowResult{
|
||||
Error: ErrSessionExpiredAjax,
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.tokenHandler.RefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Token refresh failed: %v", err)
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
|
||||
// Update session with new tokens would be handled here
|
||||
// Implementation depends on the actual session interface
|
||||
|
||||
if err := session.Save(req, rw); err != nil {
|
||||
h.logger.Errorf("Failed to save refreshed session: %v", err)
|
||||
return AuthFlowResult{
|
||||
Error: err,
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
return AuthFlowResult{Authenticated: true}
|
||||
}
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrInitializationTimeout = &AuthFlowError{Code: "INIT_TIMEOUT", Message: "OIDC initialization timeout"}
|
||||
ErrSessionInvalid = &AuthFlowError{Code: "SESSION_INVALID", Message: "Invalid session"}
|
||||
ErrSessionExpiredAjax = &AuthFlowError{Code: "SESSION_EXPIRED_AJAX", Message: "Session expired for AJAX request"}
|
||||
)
|
||||
|
||||
// AuthFlowError represents authentication flow errors
|
||||
type AuthFlowError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthFlowError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock implementations that embed SessionHandler
|
||||
type MockSessionHandlerWrapper struct {
|
||||
*SessionHandler
|
||||
}
|
||||
|
||||
func NewMockSessionHandlerWrapper() *MockSessionHandlerWrapper {
|
||||
sessionManager := &MockSessionManager{}
|
||||
logger := &MockLogger{}
|
||||
|
||||
sessionHandler := NewSessionHandler(
|
||||
sessionManager,
|
||||
logger,
|
||||
"/logout",
|
||||
"https://example.com/post-logout",
|
||||
"https://provider.example.com/logout",
|
||||
"test-client-id",
|
||||
)
|
||||
|
||||
return &MockSessionHandlerWrapper{
|
||||
SessionHandler: sessionHandler,
|
||||
}
|
||||
}
|
||||
|
||||
type MockSessionManager struct {
|
||||
session Session
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockSessionManager) GetSession(req *http.Request) (Session, error) {
|
||||
return m.session, m.err
|
||||
}
|
||||
|
||||
func (m *MockSessionManager) CleanupOldCookies(rw http.ResponseWriter, req *http.Request) {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
type MockSession struct {
|
||||
authenticated bool
|
||||
email string
|
||||
idToken string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
saveError error
|
||||
clearError error
|
||||
}
|
||||
|
||||
func (m *MockSession) GetAuthenticated() bool { return m.authenticated }
|
||||
func (m *MockSession) SetAuthenticated(auth bool) error { m.authenticated = auth; return nil }
|
||||
func (m *MockSession) GetEmail() string { return m.email }
|
||||
func (m *MockSession) SetEmail(email string) { m.email = email }
|
||||
func (m *MockSession) GetIDToken() string { return m.idToken }
|
||||
func (m *MockSession) GetAccessToken() string { return m.accessToken }
|
||||
func (m *MockSession) GetRefreshToken() string { return m.refreshToken }
|
||||
func (m *MockSession) SetRefreshToken(token string) { m.refreshToken = token }
|
||||
func (m *MockSession) Clear(req *http.Request, rw http.ResponseWriter) error { return m.clearError }
|
||||
func (m *MockSession) Save(req *http.Request, rw http.ResponseWriter) error { return m.saveError }
|
||||
func (m *MockSession) ReturnToPoolSafely() {}
|
||||
|
||||
type MockTokenHandler struct {
|
||||
verifyError error
|
||||
refreshError error
|
||||
tokenResponse *TokenResponse
|
||||
}
|
||||
|
||||
func (m *MockTokenHandler) VerifyToken(token string) error {
|
||||
return m.verifyError
|
||||
}
|
||||
|
||||
func (m *MockTokenHandler) RefreshToken(refreshToken string) (*TokenResponse, error) {
|
||||
return m.tokenResponse, m.refreshError
|
||||
}
|
||||
|
||||
type MockLogger struct {
|
||||
debugMessages []string
|
||||
errorMessages []string
|
||||
}
|
||||
|
||||
func (m *MockLogger) Debug(msg string) {
|
||||
m.debugMessages = append(m.debugMessages, msg)
|
||||
}
|
||||
|
||||
func (m *MockLogger) Debugf(format string, args ...interface{}) {
|
||||
m.debugMessages = append(m.debugMessages, format)
|
||||
}
|
||||
|
||||
func (m *MockLogger) Info(msg string) {}
|
||||
|
||||
func (m *MockLogger) Infof(format string, args ...interface{}) {}
|
||||
|
||||
func (m *MockLogger) Error(msg string) {
|
||||
m.errorMessages = append(m.errorMessages, msg)
|
||||
}
|
||||
|
||||
func (m *MockLogger) Errorf(format string, args ...interface{}) {
|
||||
m.errorMessages = append(m.errorMessages, format)
|
||||
}
|
||||
|
||||
func TestNewAuthFlowHandler(t *testing.T) {
|
||||
sessionHandler := NewMockSessionHandlerWrapper()
|
||||
tokenHandler := &MockTokenHandler{}
|
||||
logger := &MockLogger{}
|
||||
excludedURLs := map[string]struct{}{"/health": {}}
|
||||
initComplete := make(chan struct{})
|
||||
issuerURL := "https://issuer.example.com"
|
||||
|
||||
handler := NewAuthFlowHandler(sessionHandler.SessionHandler, tokenHandler, logger, excludedURLs, initComplete, issuerURL)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("NewAuthFlowHandler returned nil")
|
||||
}
|
||||
|
||||
if handler.sessionHandler == nil {
|
||||
t.Error("SessionHandler not set correctly")
|
||||
}
|
||||
|
||||
if handler.tokenHandler != tokenHandler {
|
||||
t.Error("TokenHandler not set correctly")
|
||||
}
|
||||
|
||||
if handler.logger != logger {
|
||||
t.Error("Logger not set correctly")
|
||||
}
|
||||
|
||||
if handler.issuerURL != issuerURL {
|
||||
t.Error("IssuerURL not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowHandler_shouldExcludeURL(t *testing.T) {
|
||||
excludedURLs := map[string]struct{}{
|
||||
"/health": {},
|
||||
"/metrics": {},
|
||||
"/api/public": {},
|
||||
}
|
||||
|
||||
handler := &AuthFlowHandler{excludedURLs: excludedURLs}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"/health", true},
|
||||
{"/health/check", true},
|
||||
{"/metrics", true},
|
||||
{"/metrics/prometheus", true},
|
||||
{"/api/public", true},
|
||||
{"/api/public/endpoint", true},
|
||||
{"/api/private", false},
|
||||
{"/login", false},
|
||||
{"/dashboard", false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := handler.shouldExcludeURL(test.path)
|
||||
if result != test.expected {
|
||||
t.Errorf("For path '%s': expected %v, got %v", test.path, test.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowHandler_isStreamingRequest(t *testing.T) {
|
||||
handler := &AuthFlowHandler{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accept string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "SSE request",
|
||||
accept: "text/event-stream",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Regular HTML request",
|
||||
accept: "text/html,application/xhtml+xml",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "JSON request",
|
||||
accept: "application/json",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty accept header",
|
||||
accept: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Accept", test.accept)
|
||||
|
||||
result := handler.isStreamingRequest(req)
|
||||
if result != test.expected {
|
||||
t.Errorf("Expected %v, got %v", test.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowHandler_waitForInitialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupHandler func() (*AuthFlowHandler, context.CancelFunc)
|
||||
expectedResult bool
|
||||
}{
|
||||
{
|
||||
name: "Initialization complete successfully",
|
||||
setupHandler: func() (*AuthFlowHandler, context.CancelFunc) {
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete) // Already complete
|
||||
handler := &AuthFlowHandler{
|
||||
initComplete: initComplete,
|
||||
issuerURL: "https://issuer.example.com",
|
||||
}
|
||||
return handler, nil
|
||||
},
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Initialization complete but no issuer URL",
|
||||
setupHandler: func() (*AuthFlowHandler, context.CancelFunc) {
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
handler := &AuthFlowHandler{
|
||||
initComplete: initComplete,
|
||||
issuerURL: "",
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
return handler, nil
|
||||
},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "Request canceled",
|
||||
setupHandler: func() (*AuthFlowHandler, context.CancelFunc) {
|
||||
initComplete := make(chan struct{})
|
||||
handler := &AuthFlowHandler{
|
||||
initComplete: initComplete,
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
return handler, cancel
|
||||
},
|
||||
expectedResult: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler, cancelFunc := test.setupHandler()
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if cancelFunc != nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req = req.WithContext(ctx)
|
||||
cancel() // Cancel immediately
|
||||
}
|
||||
|
||||
result := handler.waitForInitialization(req)
|
||||
if result != test.expectedResult {
|
||||
t.Errorf("Expected %v, got %v", test.expectedResult, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowHandler_ProcessRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupRequest func() *http.Request
|
||||
setupHandler func() *AuthFlowHandler
|
||||
expectedResult AuthFlowResult
|
||||
}{
|
||||
{
|
||||
name: "Excluded URL bypasses authentication",
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest("GET", "/health", nil)
|
||||
},
|
||||
setupHandler: func() *AuthFlowHandler {
|
||||
return &AuthFlowHandler{
|
||||
excludedURLs: map[string]struct{}{"/health": {}},
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
},
|
||||
expectedResult: AuthFlowResult{Authenticated: true},
|
||||
},
|
||||
{
|
||||
name: "Streaming request bypasses authentication",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/events", nil)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
return req
|
||||
},
|
||||
setupHandler: func() *AuthFlowHandler {
|
||||
return &AuthFlowHandler{
|
||||
excludedURLs: map[string]struct{}{},
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
},
|
||||
expectedResult: AuthFlowResult{Authenticated: true},
|
||||
},
|
||||
{
|
||||
name: "Initialization timeout",
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest("GET", "/dashboard", nil)
|
||||
},
|
||||
setupHandler: func() *AuthFlowHandler {
|
||||
return &AuthFlowHandler{
|
||||
excludedURLs: map[string]struct{}{},
|
||||
initComplete: make(chan struct{}), // Never closes
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
},
|
||||
expectedResult: AuthFlowResult{
|
||||
Error: ErrInitializationTimeout,
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := test.setupRequest()
|
||||
handler := test.setupHandler()
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
// For timeout test, use context with timeout
|
||||
if test.name == "Initialization timeout" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
|
||||
result := handler.ProcessRequest(rw, req)
|
||||
|
||||
if result.Authenticated != test.expectedResult.Authenticated {
|
||||
t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated)
|
||||
}
|
||||
|
||||
if result.StatusCode != test.expectedResult.StatusCode {
|
||||
t.Errorf("Expected StatusCode %d, got %d", test.expectedResult.StatusCode, result.StatusCode)
|
||||
}
|
||||
|
||||
if test.expectedResult.Error != nil && result.Error == nil {
|
||||
t.Error("Expected error but got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowHandler_validateAndRefreshTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
session *MockSession
|
||||
tokenHandler *MockTokenHandler
|
||||
expectedResult AuthFlowResult
|
||||
}{
|
||||
{
|
||||
name: "Valid access token",
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
accessToken: "valid-access-token",
|
||||
},
|
||||
tokenHandler: &MockTokenHandler{
|
||||
verifyError: nil,
|
||||
},
|
||||
expectedResult: AuthFlowResult{Authenticated: true},
|
||||
},
|
||||
{
|
||||
name: "Invalid access token, successful refresh",
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
accessToken: "invalid-access-token",
|
||||
refreshToken: "valid-refresh-token",
|
||||
},
|
||||
tokenHandler: &MockTokenHandler{
|
||||
verifyError: errors.New("token expired"),
|
||||
refreshError: nil,
|
||||
tokenResponse: &TokenResponse{
|
||||
IDToken: "new-id-token",
|
||||
AccessToken: "new-access-token",
|
||||
},
|
||||
},
|
||||
expectedResult: AuthFlowResult{Authenticated: true},
|
||||
},
|
||||
{
|
||||
name: "Invalid access token, no refresh token",
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
accessToken: "invalid-access-token",
|
||||
refreshToken: "",
|
||||
},
|
||||
tokenHandler: &MockTokenHandler{
|
||||
verifyError: errors.New("token expired"),
|
||||
},
|
||||
expectedResult: AuthFlowResult{RequiresAuth: true},
|
||||
},
|
||||
{
|
||||
name: "Valid ID token only",
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
idToken: "valid-id-token",
|
||||
},
|
||||
tokenHandler: &MockTokenHandler{
|
||||
verifyError: nil,
|
||||
},
|
||||
expectedResult: AuthFlowResult{Authenticated: true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler := &AuthFlowHandler{
|
||||
tokenHandler: test.tokenHandler,
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
result := handler.validateAndRefreshTokens(test.session, req, rw)
|
||||
|
||||
if result.Authenticated != test.expectedResult.Authenticated {
|
||||
t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated)
|
||||
}
|
||||
|
||||
if result.RequiresAuth != test.expectedResult.RequiresAuth {
|
||||
t.Errorf("Expected RequiresAuth %v, got %v", test.expectedResult.RequiresAuth, result.RequiresAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowHandler_attemptTokenRefresh(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
session *MockSession
|
||||
tokenHandler *MockTokenHandler
|
||||
isAjax bool
|
||||
expectedResult AuthFlowResult
|
||||
}{
|
||||
{
|
||||
name: "No refresh token",
|
||||
session: &MockSession{
|
||||
refreshToken: "",
|
||||
},
|
||||
tokenHandler: &MockTokenHandler{},
|
||||
expectedResult: AuthFlowResult{RequiresAuth: true},
|
||||
},
|
||||
{
|
||||
name: "AJAX request with expired session",
|
||||
session: &MockSession{
|
||||
refreshToken: "refresh-token",
|
||||
},
|
||||
tokenHandler: &MockTokenHandler{},
|
||||
isAjax: true,
|
||||
expectedResult: AuthFlowResult{
|
||||
Error: ErrSessionExpiredAjax,
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Successful token refresh",
|
||||
session: &MockSession{
|
||||
refreshToken: "valid-refresh-token",
|
||||
},
|
||||
tokenHandler: &MockTokenHandler{
|
||||
refreshError: nil,
|
||||
tokenResponse: &TokenResponse{
|
||||
IDToken: "new-id-token",
|
||||
AccessToken: "new-access-token",
|
||||
},
|
||||
},
|
||||
expectedResult: AuthFlowResult{Authenticated: true},
|
||||
},
|
||||
{
|
||||
name: "Failed token refresh",
|
||||
session: &MockSession{
|
||||
refreshToken: "invalid-refresh-token",
|
||||
},
|
||||
tokenHandler: &MockTokenHandler{
|
||||
refreshError: errors.New("refresh failed"),
|
||||
},
|
||||
expectedResult: AuthFlowResult{RequiresAuth: true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
sessionHandlerWrapper := NewMockSessionHandlerWrapper()
|
||||
handler := &AuthFlowHandler{
|
||||
sessionHandler: sessionHandlerWrapper.SessionHandler,
|
||||
tokenHandler: test.tokenHandler,
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if test.isAjax {
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
result := handler.attemptTokenRefresh(test.session, req, rw)
|
||||
|
||||
if result.Authenticated != test.expectedResult.Authenticated {
|
||||
t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated)
|
||||
}
|
||||
|
||||
if result.RequiresAuth != test.expectedResult.RequiresAuth {
|
||||
t.Errorf("Expected RequiresAuth %v, got %v", test.expectedResult.RequiresAuth, result.RequiresAuth)
|
||||
}
|
||||
|
||||
if result.StatusCode != test.expectedResult.StatusCode {
|
||||
t.Errorf("Expected StatusCode %d, got %d", test.expectedResult.StatusCode, result.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowError_Error(t *testing.T) {
|
||||
err := &AuthFlowError{
|
||||
Code: "TEST_ERROR",
|
||||
Message: "This is a test error",
|
||||
}
|
||||
|
||||
expected := "This is a test error"
|
||||
result := err.Error()
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowResult(t *testing.T) {
|
||||
// Test AuthFlowResult struct
|
||||
result := AuthFlowResult{
|
||||
Authenticated: true,
|
||||
RequiresAuth: false,
|
||||
RequiresRefresh: false,
|
||||
Error: nil,
|
||||
RedirectURL: "https://example.com",
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
if !result.Authenticated {
|
||||
t.Error("Expected Authenticated to be true")
|
||||
}
|
||||
|
||||
if result.RequiresAuth {
|
||||
t.Error("Expected RequiresAuth to be false")
|
||||
}
|
||||
|
||||
if result.StatusCode != 200 {
|
||||
t.Errorf("Expected StatusCode 200, got %d", result.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenResponse(t *testing.T) {
|
||||
response := &TokenResponse{
|
||||
IDToken: "id-token-value",
|
||||
AccessToken: "access-token-value",
|
||||
RefreshToken: "refresh-token-value",
|
||||
ExpiresIn: 3600,
|
||||
}
|
||||
|
||||
if response.IDToken != "id-token-value" {
|
||||
t.Errorf("Expected IDToken 'id-token-value', got '%s'", response.IDToken)
|
||||
}
|
||||
|
||||
if response.ExpiresIn != 3600 {
|
||||
t.Errorf("Expected ExpiresIn 3600, got %d", response.ExpiresIn)
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
// Package handlers provides HTTP request handlers for OIDC operations
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SessionHandler manages session-related HTTP operations
|
||||
type SessionHandler struct {
|
||||
sessionManager SessionManager
|
||||
logger Logger
|
||||
logoutURLPath string
|
||||
postLogoutRedirectURI string
|
||||
endSessionURL string
|
||||
clientID string
|
||||
}
|
||||
|
||||
// SessionManager interface for session operations
|
||||
type SessionManager interface {
|
||||
GetSession(req *http.Request) (Session, error)
|
||||
CleanupOldCookies(rw http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
|
||||
// Session interface for session data
|
||||
type Session interface {
|
||||
GetAuthenticated() bool
|
||||
SetAuthenticated(bool) error
|
||||
GetEmail() string
|
||||
SetEmail(string)
|
||||
GetIDToken() string
|
||||
GetAccessToken() string
|
||||
GetRefreshToken() string
|
||||
SetRefreshToken(string)
|
||||
Clear(req *http.Request, rw http.ResponseWriter) error
|
||||
Save(req *http.Request, rw http.ResponseWriter) error
|
||||
ReturnToPoolSafely()
|
||||
}
|
||||
|
||||
// Logger interface for logging operations
|
||||
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{})
|
||||
}
|
||||
|
||||
// NewSessionHandler creates a new session handler
|
||||
func NewSessionHandler(sessionManager SessionManager, logger Logger, logoutURLPath, postLogoutRedirectURI, endSessionURL, clientID string) *SessionHandler {
|
||||
return &SessionHandler{
|
||||
sessionManager: sessionManager,
|
||||
logger: logger,
|
||||
logoutURLPath: logoutURLPath,
|
||||
postLogoutRedirectURI: postLogoutRedirectURI,
|
||||
endSessionURL: endSessionURL,
|
||||
clientID: clientID,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLogout processes logout requests
|
||||
func (h *SessionHandler) HandleLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
h.logger.Debug("Processing logout request")
|
||||
|
||||
session, err := h.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Error getting session during logout: %v", err)
|
||||
// Continue with logout even if session retrieval fails
|
||||
}
|
||||
|
||||
var idToken string
|
||||
if session != nil {
|
||||
defer session.ReturnToPoolSafely()
|
||||
idToken = session.GetIDToken()
|
||||
|
||||
// Clear the session
|
||||
if err := session.Clear(req, rw); err != nil {
|
||||
h.logger.Errorf("Error clearing session during logout: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build logout URL
|
||||
logoutURL := h.buildLogoutURL(idToken)
|
||||
|
||||
h.logger.Debugf("Redirecting to logout URL: %s", logoutURL)
|
||||
http.Redirect(rw, req, logoutURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// buildLogoutURL constructs the provider logout URL
|
||||
func (h *SessionHandler) buildLogoutURL(idToken string) string {
|
||||
if h.endSessionURL == "" {
|
||||
// If no end session URL, redirect to post-logout redirect URI
|
||||
return h.postLogoutRedirectURI
|
||||
}
|
||||
|
||||
logoutURL := h.endSessionURL
|
||||
|
||||
// Add query parameters
|
||||
params := make([]string, 0, 3)
|
||||
|
||||
if idToken != "" {
|
||||
params = append(params, fmt.Sprintf("id_token_hint=%s", idToken))
|
||||
}
|
||||
|
||||
if h.postLogoutRedirectURI != "" {
|
||||
params = append(params, fmt.Sprintf("post_logout_redirect_uri=%s", h.postLogoutRedirectURI))
|
||||
}
|
||||
|
||||
if h.clientID != "" {
|
||||
params = append(params, fmt.Sprintf("client_id=%s", h.clientID))
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
separator := "?"
|
||||
if strings.Contains(logoutURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
logoutURL += separator + strings.Join(params, "&")
|
||||
}
|
||||
|
||||
return logoutURL
|
||||
}
|
||||
|
||||
// ValidateSession checks if a session is valid and authenticated
|
||||
func (h *SessionHandler) ValidateSession(session Session) SessionValidationResult {
|
||||
if session == nil {
|
||||
return SessionValidationResult{
|
||||
Valid: false,
|
||||
NeedsAuth: true,
|
||||
ErrorMessage: "session is nil",
|
||||
}
|
||||
}
|
||||
|
||||
if !session.GetAuthenticated() {
|
||||
return SessionValidationResult{
|
||||
Valid: false,
|
||||
NeedsAuth: true,
|
||||
ErrorMessage: "session not authenticated",
|
||||
}
|
||||
}
|
||||
|
||||
email := session.GetEmail()
|
||||
if email == "" {
|
||||
return SessionValidationResult{
|
||||
Valid: false,
|
||||
NeedsAuth: true,
|
||||
ErrorMessage: "no email in session",
|
||||
}
|
||||
}
|
||||
|
||||
return SessionValidationResult{
|
||||
Valid: true,
|
||||
NeedsAuth: false,
|
||||
}
|
||||
}
|
||||
|
||||
// SessionValidationResult represents the result of session validation
|
||||
type SessionValidationResult struct {
|
||||
Valid bool
|
||||
NeedsAuth bool
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// CleanupExpiredSession clears an expired session
|
||||
func (h *SessionHandler) CleanupExpiredSession(rw http.ResponseWriter, req *http.Request, session Session) error {
|
||||
h.logger.Debug("Cleaning up expired session")
|
||||
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear all session data
|
||||
if err := session.SetAuthenticated(false); err != nil {
|
||||
h.logger.Errorf("Failed to set authenticated to false: %v", err)
|
||||
}
|
||||
|
||||
session.SetEmail("")
|
||||
session.SetRefreshToken("")
|
||||
|
||||
// Save the cleared session
|
||||
if err := session.Save(req, rw); err != nil {
|
||||
h.logger.Errorf("Failed to save cleared session: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAjaxRequest determines if the request is an AJAX/XHR request
|
||||
func (h *SessionHandler) IsAjaxRequest(req *http.Request) bool {
|
||||
// Check X-Requested-With header (commonly used by jQuery and other libraries)
|
||||
if req.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check Accept header for JSON preference
|
||||
accept := req.Header.Get("Accept")
|
||||
if strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for fetch API indication
|
||||
if req.Header.Get("Sec-Fetch-Mode") == "cors" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SendErrorResponse sends an appropriate error response based on request type
|
||||
func (h *SessionHandler) SendErrorResponse(rw http.ResponseWriter, req *http.Request, message string, statusCode int) {
|
||||
if h.IsAjaxRequest(req) {
|
||||
// For AJAX requests, send JSON response
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(statusCode)
|
||||
_, _ = fmt.Fprintf(rw, `{"error": "%s"}`, message) // Safe to ignore: writing error response
|
||||
} else {
|
||||
// For browser requests, send HTML response
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(statusCode)
|
||||
_, _ = fmt.Fprintf(rw, `<html><body><h1>Error %d</h1><p>%s</p></body></html>`, statusCode, message) // Safe to ignore: writing error response
|
||||
}
|
||||
}
|
||||
|
||||
// SetSecurityHeaders sets standard security headers
|
||||
func (h *SessionHandler) SetSecurityHeaders(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.Header().Set("X-Frame-Options", "DENY")
|
||||
rw.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
rw.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
rw.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// Handle CORS for AJAX requests
|
||||
origin := req.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
rw.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
rw.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
rw.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
rw.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
|
||||
if req.Method == "OPTIONS" {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewSessionHandler(t *testing.T) {
|
||||
sessionManager := &MockSessionManager{}
|
||||
logger := &MockLogger{}
|
||||
logoutURLPath := "/logout"
|
||||
postLogoutRedirectURI := "https://example.com/post-logout"
|
||||
endSessionURL := "https://provider.example.com/logout"
|
||||
clientID := "test-client-id"
|
||||
|
||||
handler := NewSessionHandler(
|
||||
sessionManager,
|
||||
logger,
|
||||
logoutURLPath,
|
||||
postLogoutRedirectURI,
|
||||
endSessionURL,
|
||||
clientID,
|
||||
)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("NewSessionHandler returned nil")
|
||||
}
|
||||
|
||||
if handler.sessionManager != sessionManager {
|
||||
t.Error("SessionManager not set correctly")
|
||||
}
|
||||
|
||||
if handler.logger != logger {
|
||||
t.Error("Logger not set correctly")
|
||||
}
|
||||
|
||||
if handler.logoutURLPath != logoutURLPath {
|
||||
t.Error("LogoutURLPath not set correctly")
|
||||
}
|
||||
|
||||
if handler.postLogoutRedirectURI != postLogoutRedirectURI {
|
||||
t.Error("PostLogoutRedirectURI not set correctly")
|
||||
}
|
||||
|
||||
if handler.endSessionURL != endSessionURL {
|
||||
t.Error("EndSessionURL not set correctly")
|
||||
}
|
||||
|
||||
if handler.clientID != clientID {
|
||||
t.Error("ClientID not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHandler_HandleLogout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupSession func() *MockSession
|
||||
setupManager func() *MockSessionManager
|
||||
expectedCode int
|
||||
expectedURL string
|
||||
}{
|
||||
{
|
||||
name: "Successful logout with ID token",
|
||||
setupSession: func() *MockSession {
|
||||
return &MockSession{
|
||||
authenticated: true,
|
||||
idToken: "test-id-token",
|
||||
}
|
||||
},
|
||||
setupManager: func() *MockSessionManager {
|
||||
return &MockSessionManager{
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
idToken: "test-id-token",
|
||||
},
|
||||
}
|
||||
},
|
||||
expectedCode: http.StatusFound,
|
||||
expectedURL: "https://provider.example.com/logout?id_token_hint=test-id-token&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
|
||||
},
|
||||
{
|
||||
name: "Logout without ID token",
|
||||
setupSession: func() *MockSession {
|
||||
return &MockSession{
|
||||
authenticated: true,
|
||||
idToken: "",
|
||||
}
|
||||
},
|
||||
setupManager: func() *MockSessionManager {
|
||||
return &MockSessionManager{
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
idToken: "",
|
||||
},
|
||||
}
|
||||
},
|
||||
expectedCode: http.StatusFound,
|
||||
expectedURL: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
|
||||
},
|
||||
{
|
||||
name: "Session retrieval error",
|
||||
setupSession: func() *MockSession { return nil },
|
||||
setupManager: func() *MockSessionManager {
|
||||
return &MockSessionManager{
|
||||
err: fmt.Errorf("session error"),
|
||||
}
|
||||
},
|
||||
expectedCode: http.StatusFound,
|
||||
expectedURL: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler := &SessionHandler{
|
||||
sessionManager: test.setupManager(),
|
||||
logger: &MockLogger{},
|
||||
logoutURLPath: "/logout",
|
||||
postLogoutRedirectURI: "https://example.com/post-logout",
|
||||
endSessionURL: "https://provider.example.com/logout",
|
||||
clientID: "test-client-id",
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/logout", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.HandleLogout(rw, req)
|
||||
|
||||
if rw.Code != test.expectedCode {
|
||||
t.Errorf("Expected status code %d, got %d", test.expectedCode, rw.Code)
|
||||
}
|
||||
|
||||
location := rw.Header().Get("Location")
|
||||
if location != test.expectedURL {
|
||||
t.Errorf("Expected location '%s', got '%s'", test.expectedURL, location)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHandler_buildLogoutURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endSessionURL string
|
||||
postLogoutRedirectURI string
|
||||
clientID string
|
||||
idToken string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Complete logout URL with all parameters",
|
||||
endSessionURL: "https://provider.example.com/logout",
|
||||
postLogoutRedirectURI: "https://example.com/post-logout",
|
||||
clientID: "test-client-id",
|
||||
idToken: "test-id-token",
|
||||
expected: "https://provider.example.com/logout?id_token_hint=test-id-token&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
|
||||
},
|
||||
{
|
||||
name: "Logout URL without ID token",
|
||||
endSessionURL: "https://provider.example.com/logout",
|
||||
postLogoutRedirectURI: "https://example.com/post-logout",
|
||||
clientID: "test-client-id",
|
||||
idToken: "",
|
||||
expected: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
|
||||
},
|
||||
{
|
||||
name: "No end session URL",
|
||||
endSessionURL: "",
|
||||
postLogoutRedirectURI: "https://example.com/post-logout",
|
||||
clientID: "test-client-id",
|
||||
idToken: "test-id-token",
|
||||
expected: "https://example.com/post-logout",
|
||||
},
|
||||
{
|
||||
name: "End session URL with existing query parameters",
|
||||
endSessionURL: "https://provider.example.com/logout?foo=bar",
|
||||
postLogoutRedirectURI: "https://example.com/post-logout",
|
||||
clientID: "test-client-id",
|
||||
idToken: "",
|
||||
expected: "https://provider.example.com/logout?foo=bar&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler := &SessionHandler{
|
||||
endSessionURL: test.endSessionURL,
|
||||
postLogoutRedirectURI: test.postLogoutRedirectURI,
|
||||
clientID: test.clientID,
|
||||
}
|
||||
|
||||
result := handler.buildLogoutURL(test.idToken)
|
||||
if result != test.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", test.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHandler_ValidateSession(t *testing.T) {
|
||||
handler := &SessionHandler{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
session Session
|
||||
expectedValid bool
|
||||
expectedAuth bool
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "Nil session",
|
||||
session: nil,
|
||||
expectedValid: false,
|
||||
expectedAuth: true,
|
||||
expectedMessage: "session is nil",
|
||||
},
|
||||
{
|
||||
name: "Not authenticated session",
|
||||
session: &MockSession{
|
||||
authenticated: false,
|
||||
},
|
||||
expectedValid: false,
|
||||
expectedAuth: true,
|
||||
expectedMessage: "session not authenticated",
|
||||
},
|
||||
{
|
||||
name: "Authenticated session without email",
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
email: "",
|
||||
},
|
||||
expectedValid: false,
|
||||
expectedAuth: true,
|
||||
expectedMessage: "no email in session",
|
||||
},
|
||||
{
|
||||
name: "Valid authenticated session with email",
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
email: "[email protected]",
|
||||
},
|
||||
expectedValid: true,
|
||||
expectedAuth: false,
|
||||
expectedMessage: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := handler.ValidateSession(test.session)
|
||||
|
||||
if result.Valid != test.expectedValid {
|
||||
t.Errorf("Expected Valid %v, got %v", test.expectedValid, result.Valid)
|
||||
}
|
||||
|
||||
if result.NeedsAuth != test.expectedAuth {
|
||||
t.Errorf("Expected NeedsAuth %v, got %v", test.expectedAuth, result.NeedsAuth)
|
||||
}
|
||||
|
||||
if result.ErrorMessage != test.expectedMessage {
|
||||
t.Errorf("Expected ErrorMessage '%s', got '%s'", test.expectedMessage, result.ErrorMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHandler_CleanupExpiredSession(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
session *MockSession
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Successful cleanup",
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
email: "[email protected]",
|
||||
refreshToken: "refresh-token",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Save error during cleanup",
|
||||
session: &MockSession{
|
||||
authenticated: true,
|
||||
email: "[email protected]",
|
||||
refreshToken: "refresh-token",
|
||||
saveError: fmt.Errorf("save failed"),
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler := &SessionHandler{
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
err := handler.CleanupExpiredSession(rw, req, test.session)
|
||||
|
||||
if test.expectError && err == nil {
|
||||
t.Error("Expected error but got nil")
|
||||
}
|
||||
|
||||
if !test.expectError && err != nil {
|
||||
t.Errorf("Expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if test.session != nil && !test.expectError {
|
||||
if test.session.authenticated {
|
||||
t.Error("Expected session authenticated to be false after cleanup")
|
||||
}
|
||||
|
||||
if test.session.email != "" {
|
||||
t.Error("Expected session email to be empty after cleanup")
|
||||
}
|
||||
|
||||
if test.session.refreshToken != "" {
|
||||
t.Error("Expected session refresh token to be empty after cleanup")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test nil session separately
|
||||
t.Run("Nil session", func(t *testing.T) {
|
||||
handler := &SessionHandler{
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
var nilSession Session = nil
|
||||
err := handler.CleanupExpiredSession(rw, req, nilSession)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for nil session, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionHandler_IsAjaxRequest(t *testing.T) {
|
||||
handler := &SessionHandler{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "XMLHttpRequest header",
|
||||
headers: map[string]string{
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "JSON Accept header without HTML",
|
||||
headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "JSON Accept header with HTML",
|
||||
headers: map[string]string{
|
||||
"Accept": "application/json, text/html",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Fetch API CORS mode",
|
||||
headers: map[string]string{
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Regular browser request",
|
||||
headers: map[string]string{
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "No special headers",
|
||||
headers: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
for key, value := range test.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
result := handler.IsAjaxRequest(req)
|
||||
if result != test.expected {
|
||||
t.Errorf("Expected %v, got %v", test.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHandler_SendErrorResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isAjax bool
|
||||
message string
|
||||
statusCode int
|
||||
expectedContentType string
|
||||
expectedBodyContains string
|
||||
}{
|
||||
{
|
||||
name: "AJAX error response",
|
||||
isAjax: true,
|
||||
message: "Authentication failed",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
expectedContentType: "application/json",
|
||||
expectedBodyContains: `{"error": "Authentication failed"}`,
|
||||
},
|
||||
{
|
||||
name: "Browser error response",
|
||||
isAjax: false,
|
||||
message: "Session expired",
|
||||
statusCode: http.StatusForbidden,
|
||||
expectedContentType: "text/html",
|
||||
expectedBodyContains: "<h1>Error 403</h1>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler := &SessionHandler{}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if test.isAjax {
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.SendErrorResponse(rw, req, test.message, test.statusCode)
|
||||
|
||||
if rw.Code != test.statusCode {
|
||||
t.Errorf("Expected status code %d, got %d", test.statusCode, rw.Code)
|
||||
}
|
||||
|
||||
contentType := rw.Header().Get("Content-Type")
|
||||
if contentType != test.expectedContentType {
|
||||
t.Errorf("Expected Content-Type '%s', got '%s'", test.expectedContentType, contentType)
|
||||
}
|
||||
|
||||
body := rw.Body.String()
|
||||
if !strings.Contains(body, test.expectedBodyContains) {
|
||||
t.Errorf("Expected body to contain '%s', got '%s'", test.expectedBodyContains, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHandler_SetSecurityHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
origin string
|
||||
expectedCORS bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "Regular request without CORS",
|
||||
method: "GET",
|
||||
origin: "",
|
||||
expectedCORS: false,
|
||||
expectedStatus: 0, // No status written
|
||||
},
|
||||
{
|
||||
name: "CORS request with origin",
|
||||
method: "GET",
|
||||
origin: "https://example.com",
|
||||
expectedCORS: true,
|
||||
expectedStatus: 0,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS preflight request",
|
||||
method: "OPTIONS",
|
||||
origin: "https://example.com",
|
||||
expectedCORS: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler := &SessionHandler{}
|
||||
|
||||
req := httptest.NewRequest(test.method, "/", nil)
|
||||
if test.origin != "" {
|
||||
req.Header.Set("Origin", test.origin)
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
handler.SetSecurityHeaders(rw, req)
|
||||
|
||||
// Check standard security headers
|
||||
expectedSecurityHeaders := map[string]string{
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
}
|
||||
|
||||
for header, expectedValue := range expectedSecurityHeaders {
|
||||
actualValue := rw.Header().Get(header)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Expected %s header '%s', got '%s'", header, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Check CORS headers
|
||||
if test.expectedCORS {
|
||||
corsOrigin := rw.Header().Get("Access-Control-Allow-Origin")
|
||||
if corsOrigin != test.origin {
|
||||
t.Errorf("Expected CORS origin '%s', got '%s'", test.origin, corsOrigin)
|
||||
}
|
||||
|
||||
corsCredentials := rw.Header().Get("Access-Control-Allow-Credentials")
|
||||
if corsCredentials != "true" {
|
||||
t.Errorf("Expected CORS credentials 'true', got '%s'", corsCredentials)
|
||||
}
|
||||
|
||||
corsMethods := rw.Header().Get("Access-Control-Allow-Methods")
|
||||
if corsMethods != "GET, POST, OPTIONS" {
|
||||
t.Errorf("Expected CORS methods 'GET, POST, OPTIONS', got '%s'", corsMethods)
|
||||
}
|
||||
|
||||
corsHeaders := rw.Header().Get("Access-Control-Allow-Headers")
|
||||
if corsHeaders != "Authorization, Content-Type" {
|
||||
t.Errorf("Expected CORS headers 'Authorization, Content-Type', got '%s'", corsHeaders)
|
||||
}
|
||||
} else {
|
||||
corsOrigin := rw.Header().Get("Access-Control-Allow-Origin")
|
||||
if corsOrigin != "" {
|
||||
t.Errorf("Expected no CORS origin header, got '%s'", corsOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
// Check status code for OPTIONS requests
|
||||
if test.expectedStatus > 0 {
|
||||
if rw.Code != test.expectedStatus {
|
||||
t.Errorf("Expected status code %d, got %d", test.expectedStatus, rw.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionValidationResult(t *testing.T) {
|
||||
result := SessionValidationResult{
|
||||
Valid: true,
|
||||
NeedsAuth: false,
|
||||
ErrorMessage: "test message",
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
t.Error("Expected Valid to be true")
|
||||
}
|
||||
|
||||
if result.NeedsAuth {
|
||||
t.Error("Expected NeedsAuth to be false")
|
||||
}
|
||||
|
||||
if result.ErrorMessage != "test message" {
|
||||
t.Errorf("Expected ErrorMessage 'test message', got '%s'", result.ErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config provides configuration for creating HTTP clients
|
||||
type Config struct {
|
||||
// Timeout for the entire request
|
||||
Timeout time.Duration
|
||||
// MaxRedirects allowed (0 means follow Go's default of 10)
|
||||
MaxRedirects int
|
||||
// UseCookieJar enables cookie jar for the client
|
||||
UseCookieJar bool
|
||||
// Connection settings
|
||||
DialTimeout time.Duration
|
||||
KeepAlive time.Duration
|
||||
TLSHandshakeTimeout time.Duration
|
||||
ResponseHeaderTimeout time.Duration
|
||||
ExpectContinueTimeout time.Duration
|
||||
IdleConnTimeout time.Duration
|
||||
// Connection pool settings
|
||||
MaxIdleConns int
|
||||
MaxIdleConnsPerHost int
|
||||
MaxConnsPerHost int
|
||||
// Buffer settings
|
||||
WriteBufferSize int
|
||||
ReadBufferSize int
|
||||
// Feature flags
|
||||
ForceHTTP2 bool
|
||||
DisableKeepAlives bool
|
||||
DisableCompression bool
|
||||
// TLS configuration
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// ClientType defines the type of HTTP client for optimized behavior
|
||||
type ClientType string
|
||||
|
||||
const (
|
||||
ClientTypeDefault ClientType = "default"
|
||||
ClientTypeToken ClientType = "token"
|
||||
ClientTypeAPI ClientType = "api"
|
||||
ClientTypeProxy ClientType = "proxy"
|
||||
)
|
||||
|
||||
// PresetConfigs provides pre-configured settings for different client types
|
||||
var PresetConfigs = map[ClientType]Config{
|
||||
ClientTypeDefault: {
|
||||
Timeout: 10 * time.Second, // Reduced from 30s to prevent slowloris attacks
|
||||
MaxRedirects: 5, // Reduced from 10 to prevent redirect loops
|
||||
UseCookieJar: false,
|
||||
DialTimeout: 3 * time.Second,
|
||||
KeepAlive: 15 * time.Second,
|
||||
TLSHandshakeTimeout: 2 * time.Second,
|
||||
ResponseHeaderTimeout: 3 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
IdleConnTimeout: 5 * time.Second,
|
||||
MaxIdleConns: 20, // Reduced from 100 to limit resource usage
|
||||
MaxIdleConnsPerHost: 2, // Reduced from 10 to prevent connection exhaustion
|
||||
MaxConnsPerHost: 5, // Reduced from 10 to limit concurrent connections
|
||||
WriteBufferSize: 4096,
|
||||
ReadBufferSize: 4096,
|
||||
ForceHTTP2: true,
|
||||
DisableKeepAlives: false,
|
||||
DisableCompression: false,
|
||||
},
|
||||
ClientTypeToken: {
|
||||
Timeout: 10 * time.Second,
|
||||
MaxRedirects: 50, // Token endpoints may redirect more
|
||||
UseCookieJar: true,
|
||||
DialTimeout: 3 * time.Second,
|
||||
KeepAlive: 15 * time.Second,
|
||||
TLSHandshakeTimeout: 2 * time.Second,
|
||||
ResponseHeaderTimeout: 3 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
IdleConnTimeout: 5 * time.Second,
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
MaxConnsPerHost: 5,
|
||||
WriteBufferSize: 4096,
|
||||
ReadBufferSize: 4096,
|
||||
ForceHTTP2: true,
|
||||
DisableKeepAlives: false,
|
||||
DisableCompression: false,
|
||||
},
|
||||
ClientTypeAPI: {
|
||||
Timeout: 30 * time.Second, // Longer for API operations
|
||||
MaxRedirects: 10,
|
||||
UseCookieJar: false,
|
||||
DialTimeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
MaxIdleConns: 50,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
MaxConnsPerHost: 10,
|
||||
WriteBufferSize: 8192,
|
||||
ReadBufferSize: 8192,
|
||||
ForceHTTP2: true,
|
||||
DisableKeepAlives: false,
|
||||
DisableCompression: false,
|
||||
},
|
||||
ClientTypeProxy: {
|
||||
Timeout: 60 * time.Second, // Proxy needs longer timeouts
|
||||
MaxRedirects: 0, // Proxy should not follow redirects
|
||||
UseCookieJar: false,
|
||||
DialTimeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
WriteBufferSize: 16384,
|
||||
ReadBufferSize: 16384,
|
||||
ForceHTTP2: true,
|
||||
DisableKeepAlives: false,
|
||||
DisableCompression: true, // Proxy should not modify content
|
||||
},
|
||||
}
|
||||
|
||||
// Factory provides methods for creating configured HTTP clients
|
||||
type Factory struct {
|
||||
pool *TransportPool
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for HTTP client operations
|
||||
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{})
|
||||
}
|
||||
|
||||
var (
|
||||
globalFactory *Factory
|
||||
globalFactoryOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalFactory returns the singleton HTTP client factory
|
||||
func GetGlobalFactory(logger Logger) *Factory {
|
||||
globalFactoryOnce.Do(func() {
|
||||
globalFactory = NewFactory(logger)
|
||||
})
|
||||
return globalFactory
|
||||
}
|
||||
|
||||
// NewFactory creates a new HTTP client factory
|
||||
func NewFactory(logger Logger) *Factory {
|
||||
if logger == nil {
|
||||
logger = &noOpLogger{}
|
||||
}
|
||||
return &Factory{
|
||||
pool: GetGlobalTransportPool(),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateClient creates an HTTP client with the specified configuration
|
||||
func (f *Factory) CreateClient(config Config) (*http.Client, error) {
|
||||
// Validate configuration
|
||||
if err := f.ValidateConfig(&config); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
// Apply TLS configuration if not provided
|
||||
if config.TLSConfig == nil {
|
||||
config.TLSConfig = f.createSecureTLSConfig()
|
||||
}
|
||||
|
||||
// Get or create transport from pool
|
||||
transport := f.pool.GetOrCreateTransport(config)
|
||||
if transport == nil {
|
||||
return nil, fmt.Errorf("failed to create transport: client limit exceeded")
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: config.Timeout,
|
||||
}
|
||||
|
||||
// Configure redirect policy
|
||||
if config.MaxRedirects > 0 {
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= config.MaxRedirects {
|
||||
return fmt.Errorf("stopped after %d redirects", config.MaxRedirects)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Add cookie jar if requested
|
||||
if config.UseCookieJar {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cookie jar: %w", err)
|
||||
}
|
||||
client.Jar = jar
|
||||
}
|
||||
|
||||
f.logger.Debugf("Created HTTP client with config: timeout=%v, maxRedirects=%d", config.Timeout, config.MaxRedirects)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// CreateClientWithPreset creates an HTTP client using a preset configuration
|
||||
func (f *Factory) CreateClientWithPreset(clientType ClientType) (*http.Client, error) {
|
||||
config, ok := PresetConfigs[clientType]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown client type: %s", clientType)
|
||||
}
|
||||
return f.CreateClient(config)
|
||||
}
|
||||
|
||||
// CreateDefault creates a default HTTP client
|
||||
func (f *Factory) CreateDefault() (*http.Client, error) {
|
||||
return f.CreateClientWithPreset(ClientTypeDefault)
|
||||
}
|
||||
|
||||
// CreateToken creates an HTTP client optimized for token operations
|
||||
func (f *Factory) CreateToken() (*http.Client, error) {
|
||||
return f.CreateClientWithPreset(ClientTypeToken)
|
||||
}
|
||||
|
||||
// CreateAPI creates an HTTP client optimized for API operations
|
||||
func (f *Factory) CreateAPI() (*http.Client, error) {
|
||||
return f.CreateClientWithPreset(ClientTypeAPI)
|
||||
}
|
||||
|
||||
// CreateProxy creates an HTTP client optimized for proxy operations
|
||||
func (f *Factory) CreateProxy() (*http.Client, error) {
|
||||
return f.CreateClientWithPreset(ClientTypeProxy)
|
||||
}
|
||||
|
||||
// ValidateConfig validates HTTP client configuration parameters
|
||||
func (f *Factory) ValidateConfig(config *Config) error {
|
||||
// Validate connection pool limits
|
||||
if config.MaxIdleConns < 0 {
|
||||
return fmt.Errorf("MaxIdleConns cannot be negative: %d", config.MaxIdleConns)
|
||||
}
|
||||
if config.MaxIdleConns > 1000 {
|
||||
return fmt.Errorf("MaxIdleConns too high (max 1000): %d", config.MaxIdleConns)
|
||||
}
|
||||
|
||||
if config.MaxIdleConnsPerHost < 0 {
|
||||
return fmt.Errorf("MaxIdleConnsPerHost cannot be negative: %d", config.MaxIdleConnsPerHost)
|
||||
}
|
||||
if config.MaxIdleConnsPerHost > 100 {
|
||||
return fmt.Errorf("MaxIdleConnsPerHost too high (max 100): %d", config.MaxIdleConnsPerHost)
|
||||
}
|
||||
|
||||
if config.MaxConnsPerHost < 0 {
|
||||
return fmt.Errorf("MaxConnsPerHost cannot be negative: %d", config.MaxConnsPerHost)
|
||||
}
|
||||
if config.MaxConnsPerHost > 200 {
|
||||
return fmt.Errorf("MaxConnsPerHost too high (max 200): %d", config.MaxConnsPerHost)
|
||||
}
|
||||
|
||||
// Validate timeouts
|
||||
if config.Timeout < 0 {
|
||||
return fmt.Errorf("timeout cannot be negative")
|
||||
}
|
||||
if config.Timeout > 5*time.Minute {
|
||||
return fmt.Errorf("timeout too long (max 5 minutes): %v", config.Timeout)
|
||||
}
|
||||
|
||||
// Validate buffer sizes
|
||||
if config.WriteBufferSize < 0 || config.ReadBufferSize < 0 {
|
||||
return fmt.Errorf("buffer sizes cannot be negative")
|
||||
}
|
||||
if config.WriteBufferSize > 1024*1024 || config.ReadBufferSize > 1024*1024 {
|
||||
return fmt.Errorf("buffer sizes too large (max 1MB)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSecureTLSConfig creates a secure TLS configuration
|
||||
func (f *Factory) createSecureTLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12, // SECURITY: Enforce TLS 1.2 minimum
|
||||
MaxVersion: tls.VersionTLS13, // Support up to TLS 1.3
|
||||
CipherSuites: []uint16{
|
||||
// TLS 1.3 cipher suites (automatically selected when TLS 1.3 is negotiated)
|
||||
// TLS 1.2 secure cipher suites
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
InsecureSkipVerify: false, // SECURITY: Always verify certificates
|
||||
// #nosec G402 -- PreferServerCipherSuites is deprecated in Go 1.17+ but setting it to false is safe
|
||||
PreferServerCipherSuites: false, // Let client choose best cipher
|
||||
}
|
||||
}
|
||||
|
||||
// TransportPool manages a pool of shared HTTP transports
|
||||
type TransportPool struct {
|
||||
mu sync.RWMutex
|
||||
transports map[string]*sharedTransport
|
||||
maxConns int
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// Resource limits
|
||||
clientCount int32 // Track total HTTP clients
|
||||
maxClients int32 // Limit total clients
|
||||
}
|
||||
|
||||
type sharedTransport struct {
|
||||
transport *http.Transport
|
||||
refCount int32
|
||||
lastUsed time.Time
|
||||
config Config
|
||||
}
|
||||
|
||||
var (
|
||||
globalTransportPool *TransportPool
|
||||
globalTransportPoolOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalTransportPool returns the singleton transport pool instance
|
||||
func GetGlobalTransportPool() *TransportPool {
|
||||
globalTransportPoolOnce.Do(func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
globalTransportPool = &TransportPool{
|
||||
transports: make(map[string]*sharedTransport),
|
||||
maxConns: 20, // Reduced from 100 to prevent resource exhaustion
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
clientCount: 0,
|
||||
maxClients: 5, // Maximum 5 HTTP clients
|
||||
}
|
||||
// Start cleanup goroutine with context cancellation
|
||||
go globalTransportPool.cleanupIdleTransports(ctx)
|
||||
})
|
||||
return globalTransportPool
|
||||
}
|
||||
|
||||
// GetOrCreateTransport gets or creates a shared transport with the given config
|
||||
func (p *TransportPool) GetOrCreateTransport(config Config) *http.Transport {
|
||||
// Check client limit before creating new transport
|
||||
if atomic.LoadInt32(&p.clientCount) >= p.maxClients {
|
||||
// Try to return existing transport if limit reached
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
for _, shared := range p.transports {
|
||||
if shared != nil && shared.transport != nil {
|
||||
atomic.AddInt32(&shared.refCount, 1)
|
||||
shared.lastUsed = time.Now()
|
||||
return shared.transport
|
||||
}
|
||||
}
|
||||
// If no transport available, return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
key := p.configKey(config)
|
||||
|
||||
if shared, exists := p.transports[key]; exists {
|
||||
atomic.AddInt32(&shared.refCount, 1)
|
||||
shared.lastUsed = time.Now()
|
||||
return shared.transport
|
||||
}
|
||||
|
||||
// Create new transport
|
||||
transport := p.createTransport(config)
|
||||
|
||||
p.transports[key] = &sharedTransport{
|
||||
transport: transport,
|
||||
refCount: 1,
|
||||
lastUsed: time.Now(),
|
||||
config: config,
|
||||
}
|
||||
|
||||
atomic.AddInt32(&p.clientCount, 1)
|
||||
return transport
|
||||
}
|
||||
|
||||
// createTransport creates a new HTTP transport with the given configuration
|
||||
func (p *TransportPool) createTransport(config Config) *http.Transport {
|
||||
// Create secure TLS config if not provided
|
||||
tlsConfig := config.TLSConfig
|
||||
if tlsConfig == nil {
|
||||
tlsConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: config.DialTimeout,
|
||||
KeepAlive: config.KeepAlive,
|
||||
}).DialContext,
|
||||
TLSClientConfig: tlsConfig,
|
||||
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
|
||||
ResponseHeaderTimeout: config.ResponseHeaderTimeout,
|
||||
ExpectContinueTimeout: config.ExpectContinueTimeout,
|
||||
IdleConnTimeout: config.IdleConnTimeout,
|
||||
MaxIdleConns: config.MaxIdleConns,
|
||||
MaxIdleConnsPerHost: config.MaxIdleConnsPerHost,
|
||||
MaxConnsPerHost: config.MaxConnsPerHost,
|
||||
WriteBufferSize: config.WriteBufferSize,
|
||||
ReadBufferSize: config.ReadBufferSize,
|
||||
ForceAttemptHTTP2: config.ForceHTTP2,
|
||||
DisableKeepAlives: config.DisableKeepAlives,
|
||||
DisableCompression: config.DisableCompression,
|
||||
}
|
||||
}
|
||||
|
||||
// configKey generates a unique key for the configuration
|
||||
func (p *TransportPool) configKey(config Config) string {
|
||||
return fmt.Sprintf("%v-%d-%d-%d-%d-%v-%v-%v",
|
||||
config.Timeout,
|
||||
config.MaxIdleConns,
|
||||
config.MaxIdleConnsPerHost,
|
||||
config.MaxConnsPerHost,
|
||||
config.MaxRedirects,
|
||||
config.ForceHTTP2,
|
||||
config.DisableKeepAlives,
|
||||
config.DisableCompression,
|
||||
)
|
||||
}
|
||||
|
||||
// cleanupIdleTransports periodically cleans up idle transports
|
||||
func (p *TransportPool) cleanupIdleTransports(ctx context.Context) {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.cleanupIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupIdle removes idle transports with zero references
|
||||
func (p *TransportPool) cleanupIdle() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
var toRemove []string
|
||||
|
||||
for key, shared := range p.transports {
|
||||
if atomic.LoadInt32(&shared.refCount) == 0 && now.Sub(shared.lastUsed) > 10*time.Minute {
|
||||
if shared.transport != nil {
|
||||
shared.transport.CloseIdleConnections()
|
||||
}
|
||||
toRemove = append(toRemove, key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range toRemove {
|
||||
delete(p.transports, key)
|
||||
atomic.AddInt32(&p.clientCount, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// Release decrements the reference count for a transport
|
||||
func (p *TransportPool) Release(transport *http.Transport) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
for _, shared := range p.transports {
|
||||
if shared.transport == transport {
|
||||
atomic.AddInt32(&shared.refCount, -1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the transport pool
|
||||
func (p *TransportPool) Close() error {
|
||||
p.cancel()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for key, shared := range p.transports {
|
||||
if shared.transport != nil {
|
||||
shared.transport.CloseIdleConnections()
|
||||
}
|
||||
delete(p.transports, key)
|
||||
}
|
||||
|
||||
atomic.StoreInt32(&p.clientCount, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// noOpLogger provides a no-op logger implementation
|
||||
type noOpLogger struct{}
|
||||
|
||||
func (l *noOpLogger) Debug(msg string) {}
|
||||
func (l *noOpLogger) Debugf(format string, args ...interface{}) {}
|
||||
func (l *noOpLogger) Info(msg string) {}
|
||||
func (l *noOpLogger) Infof(format string, args ...interface{}) {}
|
||||
func (l *noOpLogger) Error(msg string) {}
|
||||
func (l *noOpLogger) Errorf(format string, args ...interface{}) {}
|
||||
|
||||
// Compatibility functions for backward compatibility
|
||||
|
||||
// CreateDefaultHTTPClient creates a default HTTP client
|
||||
func CreateDefaultHTTPClient() *http.Client {
|
||||
factory := GetGlobalFactory(nil)
|
||||
client, _ := factory.CreateDefault()
|
||||
return client
|
||||
}
|
||||
|
||||
// CreateTokenHTTPClient creates an HTTP client optimized for token operations
|
||||
func CreateTokenHTTPClient() *http.Client {
|
||||
factory := GetGlobalFactory(nil)
|
||||
client, _ := factory.CreateToken()
|
||||
return client
|
||||
}
|
||||
|
||||
// CreateHTTPClientWithConfig creates an HTTP client with custom configuration
|
||||
func CreateHTTPClientWithConfig(config Config) *http.Client {
|
||||
factory := GetGlobalFactory(nil)
|
||||
client, _ := factory.CreateClient(config)
|
||||
return client
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCreateProxy tests the CreateProxy method
|
||||
func TestCreateProxy(t *testing.T) {
|
||||
factory := NewFactory(nil)
|
||||
client, err := factory.CreateProxy()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create proxy client: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil proxy client")
|
||||
}
|
||||
|
||||
// Verify proxy configuration specifics
|
||||
if client.Timeout != 60*time.Second {
|
||||
t.Errorf("Expected proxy timeout to be 60s, got %v", client.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConfigEdgeCases tests additional validation scenarios
|
||||
func TestValidateConfigEdgeCases(t *testing.T) {
|
||||
factory := NewFactory(nil)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
config Config
|
||||
shouldFail bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Negative MaxIdleConnsPerHost",
|
||||
config: Config{
|
||||
MaxIdleConnsPerHost: -1,
|
||||
},
|
||||
shouldFail: true,
|
||||
errorMsg: "MaxIdleConnsPerHost cannot be negative",
|
||||
},
|
||||
{
|
||||
name: "Excessive MaxIdleConnsPerHost",
|
||||
config: Config{
|
||||
MaxIdleConnsPerHost: 200,
|
||||
},
|
||||
shouldFail: true,
|
||||
errorMsg: "MaxIdleConnsPerHost too high",
|
||||
},
|
||||
{
|
||||
name: "Negative MaxConnsPerHost",
|
||||
config: Config{
|
||||
MaxConnsPerHost: -1,
|
||||
},
|
||||
shouldFail: true,
|
||||
errorMsg: "MaxConnsPerHost cannot be negative",
|
||||
},
|
||||
{
|
||||
name: "Excessive MaxConnsPerHost",
|
||||
config: Config{
|
||||
MaxConnsPerHost: 300,
|
||||
},
|
||||
shouldFail: true,
|
||||
errorMsg: "MaxConnsPerHost too high",
|
||||
},
|
||||
{
|
||||
name: "Negative WriteBufferSize",
|
||||
config: Config{
|
||||
WriteBufferSize: -1,
|
||||
},
|
||||
shouldFail: true,
|
||||
errorMsg: "buffer sizes cannot be negative",
|
||||
},
|
||||
{
|
||||
name: "Negative ReadBufferSize",
|
||||
config: Config{
|
||||
ReadBufferSize: -1,
|
||||
},
|
||||
shouldFail: true,
|
||||
errorMsg: "buffer sizes cannot be negative",
|
||||
},
|
||||
{
|
||||
name: "Excessive WriteBufferSize",
|
||||
config: Config{
|
||||
WriteBufferSize: 2 * 1024 * 1024,
|
||||
},
|
||||
shouldFail: true,
|
||||
errorMsg: "buffer sizes too large",
|
||||
},
|
||||
{
|
||||
name: "Excessive ReadBufferSize",
|
||||
config: Config{
|
||||
ReadBufferSize: 2 * 1024 * 1024,
|
||||
},
|
||||
shouldFail: true,
|
||||
errorMsg: "buffer sizes too large",
|
||||
},
|
||||
{
|
||||
name: "Valid edge values",
|
||||
config: Config{
|
||||
MaxIdleConns: 1000,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
MaxConnsPerHost: 200,
|
||||
Timeout: 5 * time.Minute,
|
||||
WriteBufferSize: 1024 * 1024,
|
||||
ReadBufferSize: 1024 * 1024,
|
||||
},
|
||||
shouldFail: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := factory.ValidateConfig(&tc.config)
|
||||
if tc.shouldFail {
|
||||
if err == nil {
|
||||
t.Fatalf("Expected validation to fail with message containing: %s", tc.errorMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransportPoolClose tests the Close method of TransportPool
|
||||
func TestTransportPoolClose(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
pool := &TransportPool{
|
||||
transports: make(map[string]*sharedTransport),
|
||||
maxConns: 20,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
clientCount: 0,
|
||||
maxClients: 5,
|
||||
}
|
||||
|
||||
// Create some transports
|
||||
config := PresetConfigs[ClientTypeDefault]
|
||||
transport1 := pool.GetOrCreateTransport(config)
|
||||
if transport1 == nil {
|
||||
t.Fatal("Failed to create transport")
|
||||
}
|
||||
|
||||
// Modify config slightly to create a different transport
|
||||
config.Timeout = 20 * time.Second
|
||||
transport2 := pool.GetOrCreateTransport(config)
|
||||
if transport2 == nil {
|
||||
t.Fatal("Failed to create second transport")
|
||||
}
|
||||
|
||||
// Verify transports were created
|
||||
pool.mu.RLock()
|
||||
initialCount := len(pool.transports)
|
||||
pool.mu.RUnlock()
|
||||
if initialCount == 0 {
|
||||
t.Fatal("Expected transports to be created")
|
||||
}
|
||||
|
||||
// Close the pool
|
||||
err := pool.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to close pool: %v", err)
|
||||
}
|
||||
|
||||
// Verify all transports were removed
|
||||
pool.mu.RLock()
|
||||
finalCount := len(pool.transports)
|
||||
pool.mu.RUnlock()
|
||||
if finalCount != 0 {
|
||||
t.Fatalf("Expected 0 transports after close, got %d", finalCount)
|
||||
}
|
||||
|
||||
// Verify client count was reset
|
||||
if pool.clientCount != 0 {
|
||||
t.Fatalf("Expected client count to be 0 after close, got %d", pool.clientCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoOpLogger tests the no-op logger implementation
|
||||
func TestNoOpLogger(t *testing.T) {
|
||||
logger := &noOpLogger{}
|
||||
|
||||
// These should not panic or cause any issues
|
||||
logger.Debug("test debug")
|
||||
logger.Debugf("test debug %s", "formatted")
|
||||
logger.Info("test info")
|
||||
logger.Infof("test info %s", "formatted")
|
||||
logger.Error("test error")
|
||||
logger.Errorf("test error %s", "formatted")
|
||||
|
||||
// Test using logger with factory
|
||||
factory := NewFactory(logger)
|
||||
client, err := factory.CreateDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client with no-op logger: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateClientWithCustomTLS tests creating client with custom TLS config
|
||||
func TestCreateClientWithCustomTLS(t *testing.T) {
|
||||
factory := NewFactory(nil)
|
||||
|
||||
customTLS := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
config := Config{
|
||||
Timeout: 10 * time.Second,
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
MaxConnsPerHost: 5,
|
||||
TLSConfig: customTLS,
|
||||
}
|
||||
|
||||
client, err := factory.CreateClient(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client with custom TLS: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateClientWithMaxRedirects tests redirect limiting
|
||||
func TestCreateClientWithMaxRedirects(t *testing.T) {
|
||||
redirectCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
redirectCount++
|
||||
if redirectCount <= 3 {
|
||||
http.Redirect(w, r, "/redirect", http.StatusFound)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("final"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
factory := NewFactory(nil)
|
||||
|
||||
// Test with max redirects = 2 (should fail)
|
||||
config := Config{
|
||||
Timeout: 10 * time.Second,
|
||||
MaxRedirects: 2,
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
MaxConnsPerHost: 5,
|
||||
}
|
||||
|
||||
client, err := factory.CreateClient(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client: %v", err)
|
||||
}
|
||||
|
||||
redirectCount = 0
|
||||
_, err = client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected redirect limit error")
|
||||
}
|
||||
|
||||
// Test with max redirects = 5 (should succeed)
|
||||
config.MaxRedirects = 5
|
||||
client, err = factory.CreateClient(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client: %v", err)
|
||||
}
|
||||
|
||||
redirectCount = 0
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransportPoolMaxClientsLimit tests the max clients limitation
|
||||
func TestTransportPoolMaxClientsLimit(t *testing.T) {
|
||||
pool := &TransportPool{
|
||||
transports: make(map[string]*sharedTransport),
|
||||
maxConns: 20,
|
||||
clientCount: 0,
|
||||
maxClients: 2, // Set low limit for testing
|
||||
}
|
||||
|
||||
// Create transports up to the limit
|
||||
configs := []Config{
|
||||
{Timeout: 10 * time.Second},
|
||||
{Timeout: 20 * time.Second},
|
||||
{Timeout: 30 * time.Second}, // This should not create a new transport
|
||||
}
|
||||
|
||||
for i, config := range configs {
|
||||
transport := pool.GetOrCreateTransport(config)
|
||||
if i < 2 {
|
||||
if transport == nil {
|
||||
t.Fatalf("Expected transport %d to be created", i)
|
||||
}
|
||||
// Transport created successfully within limit
|
||||
} else {
|
||||
// When limit is reached, should return existing transport or nil
|
||||
if transport == nil {
|
||||
// This is acceptable - nil when limit reached
|
||||
t.Log("Transport creation blocked due to client limit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify client count doesn't exceed limit
|
||||
if pool.clientCount > pool.maxClients {
|
||||
t.Fatalf("Client count %d exceeds max %d", pool.clientCount, pool.maxClients)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupIdleTransportsContext tests cleanup goroutine with context
|
||||
func TestCleanupIdleTransportsContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
pool := &TransportPool{
|
||||
transports: make(map[string]*sharedTransport),
|
||||
maxConns: 20,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
clientCount: 0,
|
||||
maxClients: 5,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
pool.cleanupIdleTransports(ctx)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Cancel context to stop cleanup
|
||||
cancel()
|
||||
|
||||
// Wait for goroutine to exit
|
||||
select {
|
||||
case <-done:
|
||||
// Success - goroutine exited
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Cleanup goroutine did not exit after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactoryWithLogger tests factory creation with custom logger
|
||||
func TestFactoryWithLogger(t *testing.T) {
|
||||
// Create a mock logger that implements the Logger interface
|
||||
logger := &MockLogger{}
|
||||
|
||||
factory := NewFactory(logger)
|
||||
if factory.logger == nil {
|
||||
t.Fatal("Expected logger to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// MockLogger for testing
|
||||
type MockLogger struct {
|
||||
debugCalled bool
|
||||
debugfCalled bool
|
||||
infoCalled bool
|
||||
infofCalled bool
|
||||
errorCalled bool
|
||||
errorfCalled bool
|
||||
}
|
||||
|
||||
func (m *MockLogger) Debug(msg string) { m.debugCalled = true }
|
||||
func (m *MockLogger) Debugf(format string, args ...interface{}) { m.debugfCalled = true }
|
||||
func (m *MockLogger) Info(msg string) { m.infoCalled = true }
|
||||
func (m *MockLogger) Infof(format string, args ...interface{}) { m.infofCalled = true }
|
||||
func (m *MockLogger) Error(msg string) { m.errorCalled = true }
|
||||
func (m *MockLogger) Errorf(format string, args ...interface{}) { m.errorfCalled = true }
|
||||
|
||||
// TestCreateClientLogging tests that logger is called during client creation
|
||||
func TestCreateClientLogging(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
factory := NewFactory(logger)
|
||||
|
||||
client, err := factory.CreateDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
|
||||
// Verify logger was called
|
||||
if !logger.debugfCalled {
|
||||
t.Error("Expected Debugf to be called during client creation")
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFactoryCreateClient(t *testing.T) {
|
||||
factory := NewFactory(nil)
|
||||
|
||||
// Test creating default client
|
||||
client, err := factory.CreateDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create default client: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
|
||||
// Test creating token client
|
||||
tokenClient, err := factory.CreateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create token client: %v", err)
|
||||
}
|
||||
if tokenClient == nil {
|
||||
t.Fatal("Expected non-nil token client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryCreateClientWithPreset(t *testing.T) {
|
||||
factory := NewFactory(nil)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
clientType ClientType
|
||||
shouldFail bool
|
||||
}{
|
||||
{"Default", ClientTypeDefault, false},
|
||||
{"Token", ClientTypeToken, false},
|
||||
{"API", ClientTypeAPI, false},
|
||||
{"Proxy", ClientTypeProxy, false},
|
||||
{"Invalid", ClientType("invalid"), true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, err := factory.CreateClientWithPreset(tc.clientType)
|
||||
if tc.shouldFail {
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid client type")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create %s client: %v", tc.clientType, err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryValidateConfig(t *testing.T) {
|
||||
factory := NewFactory(nil)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
config Config
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
name: "Valid config",
|
||||
config: PresetConfigs[ClientTypeDefault],
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
name: "Negative MaxIdleConns",
|
||||
config: Config{
|
||||
MaxIdleConns: -1,
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Excessive MaxIdleConns",
|
||||
config: Config{
|
||||
MaxIdleConns: 2000,
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Negative timeout",
|
||||
config: Config{
|
||||
Timeout: -1 * time.Second,
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Excessive timeout",
|
||||
config: Config{
|
||||
Timeout: 10 * time.Minute,
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := factory.ValidateConfig(&tc.config)
|
||||
if tc.shouldFail && err == nil {
|
||||
t.Fatal("Expected validation to fail")
|
||||
}
|
||||
if !tc.shouldFail && err != nil {
|
||||
t.Fatalf("Unexpected validation error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportPoolConcurrency(t *testing.T) {
|
||||
pool := &TransportPool{
|
||||
transports: make(map[string]*sharedTransport),
|
||||
maxConns: 20,
|
||||
clientCount: 0,
|
||||
maxClients: 5,
|
||||
}
|
||||
|
||||
config := PresetConfigs[ClientTypeDefault]
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 10
|
||||
|
||||
// Test concurrent transport creation
|
||||
wg.Add(numGoroutines)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
transport := pool.GetOrCreateTransport(config)
|
||||
if transport != nil {
|
||||
// Simulate usage
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
pool.Release(transport)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Verify client count is within limits
|
||||
clientCount := atomic.LoadInt32(&pool.clientCount)
|
||||
if clientCount > pool.maxClients {
|
||||
t.Fatalf("Client count %d exceeds max %d", clientCount, pool.maxClients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientRequests(t *testing.T) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
factory := NewFactory(nil)
|
||||
client, err := factory.CreateDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client: %v", err)
|
||||
}
|
||||
|
||||
// Make request
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWithCookieJar(t *testing.T) {
|
||||
config := PresetConfigs[ClientTypeToken]
|
||||
if !config.UseCookieJar {
|
||||
t.Skip("Token client should have cookie jar enabled")
|
||||
}
|
||||
|
||||
factory := NewFactory(nil)
|
||||
client, err := factory.CreateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create token client: %v", err)
|
||||
}
|
||||
|
||||
if client.Jar == nil {
|
||||
t.Fatal("Expected cookie jar to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportPoolCleanup(t *testing.T) {
|
||||
pool := &TransportPool{
|
||||
transports: make(map[string]*sharedTransport),
|
||||
maxConns: 20,
|
||||
clientCount: 0,
|
||||
maxClients: 5,
|
||||
}
|
||||
|
||||
config := PresetConfigs[ClientTypeDefault]
|
||||
|
||||
// Create transport
|
||||
transport := pool.GetOrCreateTransport(config)
|
||||
if transport == nil {
|
||||
t.Fatal("Failed to create transport")
|
||||
}
|
||||
|
||||
// Release transport
|
||||
pool.Release(transport)
|
||||
|
||||
// Simulate idle time
|
||||
pool.mu.Lock()
|
||||
for _, shared := range pool.transports {
|
||||
shared.lastUsed = time.Now().Add(-11 * time.Minute)
|
||||
atomic.StoreInt32(&shared.refCount, 0)
|
||||
}
|
||||
pool.mu.Unlock()
|
||||
|
||||
// Run cleanup
|
||||
pool.cleanupIdle()
|
||||
|
||||
// Verify transport was removed
|
||||
pool.mu.RLock()
|
||||
count := len(pool.transports)
|
||||
pool.mu.RUnlock()
|
||||
|
||||
if count != 0 {
|
||||
t.Fatalf("Expected 0 transports after cleanup, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalFactorySingleton(t *testing.T) {
|
||||
factory1 := GetGlobalFactory(nil)
|
||||
factory2 := GetGlobalFactory(nil)
|
||||
|
||||
if factory1 != factory2 {
|
||||
t.Fatal("Expected singleton factory instances to be the same")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityFunctions(t *testing.T) {
|
||||
// Test CreateDefaultHTTPClient
|
||||
defaultClient := CreateDefaultHTTPClient()
|
||||
if defaultClient == nil {
|
||||
t.Fatal("Expected non-nil default client")
|
||||
}
|
||||
|
||||
// Test CreateTokenHTTPClient
|
||||
tokenClient := CreateTokenHTTPClient()
|
||||
if tokenClient == nil {
|
||||
t.Fatal("Expected non-nil token client")
|
||||
}
|
||||
|
||||
// Test CreateHTTPClientWithConfig
|
||||
config := PresetConfigs[ClientTypeAPI]
|
||||
apiClient := CreateHTTPClientWithConfig(config)
|
||||
if apiClient == nil {
|
||||
t.Fatal("Expected non-nil API client")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFactoryCreateClient(b *testing.B) {
|
||||
factory := NewFactory(nil)
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
client, err := factory.CreateDefault()
|
||||
if err != nil || client == nil {
|
||||
b.Fatal("Failed to create client")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTransportPoolGetOrCreate(b *testing.B) {
|
||||
pool := GetGlobalTransportPool()
|
||||
config := PresetConfigs[ClientTypeDefault]
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
transport := pool.GetOrCreateTransport(config)
|
||||
if transport != nil {
|
||||
pool.Release(transport)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// LegacyLoggerAdapter wraps the old Logger struct from the main package
|
||||
// to implement the new unified Logger interface. This allows for gradual
|
||||
// migration of the codebase to the new logger interface.
|
||||
type LegacyLoggerAdapter struct {
|
||||
logError *log.Logger
|
||||
logInfo *log.Logger
|
||||
logDebug *log.Logger
|
||||
}
|
||||
|
||||
// NewLegacyAdapter creates a new adapter from the old logger components
|
||||
func NewLegacyAdapter(logError, logInfo, logDebug *log.Logger) Logger {
|
||||
if logError == nil || logInfo == nil || logDebug == nil {
|
||||
return GetNoOpLogger()
|
||||
}
|
||||
return &LegacyLoggerAdapter{
|
||||
logError: logError,
|
||||
logInfo: logInfo,
|
||||
logDebug: logDebug,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (l *LegacyLoggerAdapter) Debug(msg string) {
|
||||
l.logDebug.Print(msg)
|
||||
}
|
||||
|
||||
// Debugf logs a formatted debug message
|
||||
func (l *LegacyLoggerAdapter) Debugf(format string, args ...interface{}) {
|
||||
l.logDebug.Printf(format, args...)
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func (l *LegacyLoggerAdapter) Info(msg string) {
|
||||
l.logInfo.Print(msg)
|
||||
}
|
||||
|
||||
// Infof logs a formatted info message
|
||||
func (l *LegacyLoggerAdapter) Infof(format string, args ...interface{}) {
|
||||
l.logInfo.Printf(format, args...)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (l *LegacyLoggerAdapter) Error(msg string) {
|
||||
l.logError.Print(msg)
|
||||
}
|
||||
|
||||
// Errorf logs a formatted error message
|
||||
func (l *LegacyLoggerAdapter) Errorf(format string, args ...interface{}) {
|
||||
l.logError.Printf(format, args...)
|
||||
}
|
||||
|
||||
// Printf logs a formatted message at info level
|
||||
func (l *LegacyLoggerAdapter) Printf(format string, args ...interface{}) {
|
||||
l.logInfo.Printf(format, args...)
|
||||
}
|
||||
|
||||
// Println logs a message at info level
|
||||
func (l *LegacyLoggerAdapter) Println(args ...interface{}) {
|
||||
l.logInfo.Print(args...)
|
||||
}
|
||||
|
||||
// Fatalf logs a formatted error message and panics
|
||||
func (l *LegacyLoggerAdapter) Fatalf(format string, args ...interface{}) {
|
||||
l.logError.Printf(format, args...)
|
||||
panic(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// WithField returns the same logger (no structured logging support in legacy adapter)
|
||||
func (l *LegacyLoggerAdapter) WithField(key string, value interface{}) Logger {
|
||||
return l
|
||||
}
|
||||
|
||||
// WithFields returns the same logger (no structured logging support in legacy adapter)
|
||||
func (l *LegacyLoggerAdapter) WithFields(fields map[string]interface{}) Logger {
|
||||
return l
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Factory creates and manages logger instances with singleton support
|
||||
// for common logger types to reduce memory allocation.
|
||||
type Factory struct {
|
||||
mu sync.RWMutex
|
||||
defaultLogger Logger
|
||||
noOpLogger Logger
|
||||
loggers map[string]Logger
|
||||
defaultLogLevel string
|
||||
}
|
||||
|
||||
var (
|
||||
// globalFactory is the singleton factory instance
|
||||
globalFactory *Factory
|
||||
// factoryOnce ensures the factory is created only once
|
||||
factoryOnce sync.Once
|
||||
)
|
||||
|
||||
// GetFactory returns the global logger factory instance
|
||||
func GetFactory() *Factory {
|
||||
factoryOnce.Do(func() {
|
||||
globalFactory = &Factory{
|
||||
loggers: make(map[string]Logger),
|
||||
defaultLogLevel: "info",
|
||||
}
|
||||
})
|
||||
return globalFactory
|
||||
}
|
||||
|
||||
// SetDefaultLogLevel sets the default log level for new loggers
|
||||
func (f *Factory) SetDefaultLogLevel(level string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.defaultLogLevel = level
|
||||
}
|
||||
|
||||
// GetLogger returns a logger for the given name, creating one if it doesn't exist
|
||||
func (f *Factory) GetLogger(name string) Logger {
|
||||
f.mu.RLock()
|
||||
if logger, exists := f.loggers[name]; exists {
|
||||
f.mu.RUnlock()
|
||||
return logger
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
// Create new logger
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Double check after acquiring write lock
|
||||
if logger, exists := f.loggers[name]; exists {
|
||||
return logger
|
||||
}
|
||||
|
||||
logger := f.createLogger(name)
|
||||
f.loggers[name] = logger
|
||||
return logger
|
||||
}
|
||||
|
||||
// createLogger creates a new logger instance
|
||||
func (f *Factory) createLogger(name string) Logger {
|
||||
if name == "noop" || name == "no-op" || name == "discard" {
|
||||
return GetNoOpLogger()
|
||||
}
|
||||
|
||||
// Create logger with appropriate outputs based on environment
|
||||
var errorOut, infoOut, debugOut io.Writer
|
||||
|
||||
if os.Getenv("OIDC_LOG_TO_FILE") == "true" {
|
||||
// Log to files if configured
|
||||
errorOut = getOrCreateLogFile("error.log")
|
||||
infoOut = getOrCreateLogFile("info.log")
|
||||
debugOut = getOrCreateLogFile("debug.log")
|
||||
} else {
|
||||
// Default to stdout/stderr
|
||||
errorOut = os.Stderr
|
||||
infoOut = os.Stdout
|
||||
debugOut = os.Stdout
|
||||
}
|
||||
|
||||
return NewStandardLogger(f.defaultLogLevel, errorOut, infoOut, debugOut)
|
||||
}
|
||||
|
||||
// GetDefaultLogger returns the default logger instance
|
||||
func (f *Factory) GetDefaultLogger() Logger {
|
||||
f.mu.RLock()
|
||||
if f.defaultLogger != nil {
|
||||
f.mu.RUnlock()
|
||||
return f.defaultLogger
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.defaultLogger == nil {
|
||||
f.defaultLogger = f.createLogger("default")
|
||||
}
|
||||
|
||||
return f.defaultLogger
|
||||
}
|
||||
|
||||
// GetNoOpLogger returns the singleton no-op logger
|
||||
func (f *Factory) GetNoOpLogger() Logger {
|
||||
f.mu.RLock()
|
||||
if f.noOpLogger != nil {
|
||||
f.mu.RUnlock()
|
||||
return f.noOpLogger
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.noOpLogger == nil {
|
||||
f.noOpLogger = GetNoOpLogger()
|
||||
}
|
||||
|
||||
return f.noOpLogger
|
||||
}
|
||||
|
||||
// Clear removes all cached loggers (useful for testing)
|
||||
func (f *Factory) Clear() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.loggers = make(map[string]Logger)
|
||||
f.defaultLogger = nil
|
||||
// Don't clear noOpLogger as it's a singleton
|
||||
}
|
||||
|
||||
// getOrCreateLogFile returns a file writer for the given log file
|
||||
func getOrCreateLogFile(filename string) io.Writer {
|
||||
logDir := os.Getenv("OIDC_LOG_DIR")
|
||||
if logDir == "" {
|
||||
logDir = "/var/log/traefik-oidc"
|
||||
}
|
||||
|
||||
// Ensure log directory exists
|
||||
// #nosec G301 -- log directory needs to be readable by monitoring tools
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
// Fall back to stderr if we can't create the directory
|
||||
return os.Stderr
|
||||
}
|
||||
|
||||
filepath := logDir + "/" + filename
|
||||
// #nosec G302 G304 -- log files need to be readable; path is from trusted env var
|
||||
file, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
// Fall back to stderr if we can't open the file
|
||||
return os.Stderr
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
// Global convenience functions
|
||||
|
||||
// New creates a new logger with the specified level
|
||||
func New(level string) Logger {
|
||||
return GetFactory().GetLogger(level)
|
||||
}
|
||||
|
||||
// Default returns the default logger
|
||||
func Default() Logger {
|
||||
return GetFactory().GetDefaultLogger()
|
||||
}
|
||||
|
||||
// NoOp returns a no-op logger
|
||||
func NoOp() Logger {
|
||||
return GetFactory().GetNoOpLogger()
|
||||
}
|
||||
|
||||
// WithLevel creates a new logger with the specified level
|
||||
func WithLevel(level string) Logger {
|
||||
return NewStandardLogger(level, os.Stderr, os.Stdout, os.Stdout)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// Package logger provides a unified logging interface for the entire application.
|
||||
// It consolidates all the duplicate logger interfaces into a single, comprehensive
|
||||
// interface that supports different log levels and structured logging.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Logger is the unified interface for all logging operations in the application.
|
||||
// It combines all the methods from the various logger interfaces that were
|
||||
// previously scattered across different packages.
|
||||
type Logger interface {
|
||||
// Basic logging methods
|
||||
Debug(msg string)
|
||||
Debugf(format string, args ...interface{})
|
||||
Info(msg string)
|
||||
Infof(format string, args ...interface{})
|
||||
Error(msg string)
|
||||
Errorf(format string, args ...interface{})
|
||||
|
||||
// Additional methods for compatibility with existing code
|
||||
Printf(format string, args ...interface{})
|
||||
Println(args ...interface{})
|
||||
Fatalf(format string, args ...interface{})
|
||||
|
||||
// Structured logging support
|
||||
WithField(key string, value interface{}) Logger
|
||||
WithFields(fields map[string]interface{}) Logger
|
||||
}
|
||||
|
||||
// StandardLogger implements the Logger interface using Go's standard log package.
|
||||
// It provides thread-safe logging with different output streams for different log levels.
|
||||
type StandardLogger struct {
|
||||
mu sync.RWMutex
|
||||
logError *log.Logger
|
||||
logInfo *log.Logger
|
||||
logDebug *log.Logger
|
||||
fields map[string]interface{}
|
||||
level LogLevel
|
||||
}
|
||||
|
||||
// LogLevel represents the logging level
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
// LogLevelDebug enables all log messages
|
||||
LogLevelDebug LogLevel = iota
|
||||
// LogLevelInfo enables info and error messages
|
||||
LogLevelInfo
|
||||
// LogLevelError enables only error messages
|
||||
LogLevelError
|
||||
// LogLevelNone disables all logging
|
||||
LogLevelNone
|
||||
)
|
||||
|
||||
// ParseLogLevel converts a string log level to LogLevel
|
||||
func ParseLogLevel(level string) LogLevel {
|
||||
switch level {
|
||||
case "debug", "DEBUG":
|
||||
return LogLevelDebug
|
||||
case "info", "INFO":
|
||||
return LogLevelInfo
|
||||
case "error", "ERROR":
|
||||
return LogLevelError
|
||||
case "none", "NONE":
|
||||
return LogLevelNone
|
||||
default:
|
||||
return LogLevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// NewStandardLogger creates a new StandardLogger with the specified log level
|
||||
func NewStandardLogger(level string, errorOutput, infoOutput, debugOutput io.Writer) *StandardLogger {
|
||||
logLevel := ParseLogLevel(level)
|
||||
|
||||
if errorOutput == nil {
|
||||
errorOutput = io.Discard
|
||||
}
|
||||
if infoOutput == nil {
|
||||
infoOutput = io.Discard
|
||||
}
|
||||
if debugOutput == nil {
|
||||
debugOutput = io.Discard
|
||||
}
|
||||
|
||||
return &StandardLogger{
|
||||
logError: log.New(errorOutput, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile),
|
||||
logInfo: log.New(infoOutput, "INFO: ", log.Ldate|log.Ltime),
|
||||
logDebug: log.New(debugOutput, "DEBUG: ", log.Ldate|log.Ltime|log.Lshortfile),
|
||||
fields: make(map[string]interface{}),
|
||||
level: logLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (l *StandardLogger) Debug(msg string) {
|
||||
if l.level <= LogLevelDebug {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
if len(l.fields) > 0 {
|
||||
msg = l.formatWithFields(msg)
|
||||
}
|
||||
l.logDebug.Print(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Debugf logs a formatted debug message
|
||||
func (l *StandardLogger) Debugf(format string, args ...interface{}) {
|
||||
if l.level <= LogLevelDebug {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if len(l.fields) > 0 {
|
||||
msg = l.formatWithFields(msg)
|
||||
}
|
||||
l.logDebug.Print(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func (l *StandardLogger) Info(msg string) {
|
||||
if l.level <= LogLevelInfo {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
if len(l.fields) > 0 {
|
||||
msg = l.formatWithFields(msg)
|
||||
}
|
||||
l.logInfo.Print(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Infof logs a formatted info message
|
||||
func (l *StandardLogger) Infof(format string, args ...interface{}) {
|
||||
if l.level <= LogLevelInfo {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if len(l.fields) > 0 {
|
||||
msg = l.formatWithFields(msg)
|
||||
}
|
||||
l.logInfo.Print(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (l *StandardLogger) Error(msg string) {
|
||||
if l.level <= LogLevelError {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
if len(l.fields) > 0 {
|
||||
msg = l.formatWithFields(msg)
|
||||
}
|
||||
l.logError.Print(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Errorf logs a formatted error message
|
||||
func (l *StandardLogger) Errorf(format string, args ...interface{}) {
|
||||
if l.level <= LogLevelError {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if len(l.fields) > 0 {
|
||||
msg = l.formatWithFields(msg)
|
||||
}
|
||||
l.logError.Print(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Printf logs a formatted message at info level
|
||||
func (l *StandardLogger) Printf(format string, args ...interface{}) {
|
||||
l.Infof(format, args...)
|
||||
}
|
||||
|
||||
// Println logs a message at info level
|
||||
func (l *StandardLogger) Println(args ...interface{}) {
|
||||
l.Info(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
// Fatalf logs a formatted error message and exits the program
|
||||
func (l *StandardLogger) Fatalf(format string, args ...interface{}) {
|
||||
l.Errorf(format, args...)
|
||||
panic(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// WithField returns a new logger with an additional field
|
||||
func (l *StandardLogger) WithField(key string, value interface{}) Logger {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
newLogger := &StandardLogger{
|
||||
logError: l.logError,
|
||||
logInfo: l.logInfo,
|
||||
logDebug: l.logDebug,
|
||||
fields: make(map[string]interface{}, len(l.fields)+1),
|
||||
level: l.level,
|
||||
}
|
||||
|
||||
for k, v := range l.fields {
|
||||
newLogger.fields[k] = v
|
||||
}
|
||||
newLogger.fields[key] = value
|
||||
|
||||
return newLogger
|
||||
}
|
||||
|
||||
// WithFields returns a new logger with additional fields
|
||||
func (l *StandardLogger) WithFields(fields map[string]interface{}) Logger {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
newLogger := &StandardLogger{
|
||||
logError: l.logError,
|
||||
logInfo: l.logInfo,
|
||||
logDebug: l.logDebug,
|
||||
fields: make(map[string]interface{}, len(l.fields)+len(fields)),
|
||||
level: l.level,
|
||||
}
|
||||
|
||||
for k, v := range l.fields {
|
||||
newLogger.fields[k] = v
|
||||
}
|
||||
for k, v := range fields {
|
||||
newLogger.fields[k] = v
|
||||
}
|
||||
|
||||
return newLogger
|
||||
}
|
||||
|
||||
// formatWithFields formats a message with structured fields
|
||||
func (l *StandardLogger) formatWithFields(msg string) string {
|
||||
if len(l.fields) == 0 {
|
||||
return msg
|
||||
}
|
||||
|
||||
fieldsStr := ""
|
||||
for k, v := range l.fields {
|
||||
if fieldsStr != "" {
|
||||
fieldsStr += " "
|
||||
}
|
||||
fieldsStr += fmt.Sprintf("%s=%v", k, v)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s [%s]", msg, fieldsStr)
|
||||
}
|
||||
|
||||
// NoOpLogger is a logger that discards all output.
|
||||
// It's useful for testing and for cases where logging should be disabled.
|
||||
type NoOpLogger struct{}
|
||||
|
||||
// Debug discards the message
|
||||
func (n *NoOpLogger) Debug(msg string) {}
|
||||
|
||||
// Debugf discards the formatted message
|
||||
func (n *NoOpLogger) Debugf(format string, args ...interface{}) {}
|
||||
|
||||
// Info discards the message
|
||||
func (n *NoOpLogger) Info(msg string) {}
|
||||
|
||||
// Infof discards the formatted message
|
||||
func (n *NoOpLogger) Infof(format string, args ...interface{}) {}
|
||||
|
||||
// Error discards the message
|
||||
func (n *NoOpLogger) Error(msg string) {}
|
||||
|
||||
// Errorf discards the formatted message
|
||||
func (n *NoOpLogger) Errorf(format string, args ...interface{}) {}
|
||||
|
||||
// Printf discards the formatted message
|
||||
func (n *NoOpLogger) Printf(format string, args ...interface{}) {}
|
||||
|
||||
// Println discards the message
|
||||
func (n *NoOpLogger) Println(args ...interface{}) {}
|
||||
|
||||
// Fatalf discards the message and does not exit
|
||||
func (n *NoOpLogger) Fatalf(format string, args ...interface{}) {}
|
||||
|
||||
// WithField returns the same NoOpLogger
|
||||
func (n *NoOpLogger) WithField(key string, value interface{}) Logger {
|
||||
return n
|
||||
}
|
||||
|
||||
// WithFields returns the same NoOpLogger
|
||||
func (n *NoOpLogger) WithFields(fields map[string]interface{}) Logger {
|
||||
return n
|
||||
}
|
||||
|
||||
var (
|
||||
// singletonNoOpLogger is the global instance of the no-op logger
|
||||
singletonNoOpLogger *NoOpLogger
|
||||
// noOpLoggerOnce ensures the singleton is created only once
|
||||
noOpLoggerOnce sync.Once
|
||||
)
|
||||
|
||||
// GetNoOpLogger returns the singleton no-op logger instance.
|
||||
// This reduces memory allocation by reusing the same no-op logger
|
||||
// instance across the entire application.
|
||||
func GetNoOpLogger() Logger {
|
||||
noOpLoggerOnce.Do(func() {
|
||||
singletonNoOpLogger = &NoOpLogger{}
|
||||
})
|
||||
return singletonNoOpLogger
|
||||
}
|
||||
|
||||
// DefaultLogger creates a default logger based on the provided configuration
|
||||
func DefaultLogger(level string) Logger {
|
||||
return NewStandardLogger(level, log.Writer(), log.Writer(), log.Writer())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RequestContext holds request processing context
|
||||
type RequestContext struct {
|
||||
Writer http.ResponseWriter
|
||||
Request *http.Request
|
||||
RedirectURL string
|
||||
Scheme string
|
||||
Host string
|
||||
}
|
||||
|
||||
// RequestProcessor handles common request processing operations
|
||||
type RequestProcessor struct {
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for logging operations
|
||||
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{})
|
||||
}
|
||||
|
||||
// NewRequestProcessor creates a new request processor
|
||||
func NewRequestProcessor(logger Logger) *RequestProcessor {
|
||||
return &RequestProcessor{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequestContext creates a request context with scheme and host detection
|
||||
func (rp *RequestProcessor) BuildRequestContext(rw http.ResponseWriter, req *http.Request, redirectPath string) *RequestContext {
|
||||
scheme := rp.determineScheme(req)
|
||||
host := rp.determineHost(req)
|
||||
redirectURL := buildFullURL(scheme, host, redirectPath)
|
||||
|
||||
return &RequestContext{
|
||||
Writer: rw,
|
||||
Request: req,
|
||||
RedirectURL: redirectURL,
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
}
|
||||
}
|
||||
|
||||
// IsHealthCheckRequest checks if request is a health check
|
||||
func (rp *RequestProcessor) IsHealthCheckRequest(req *http.Request) bool {
|
||||
return strings.HasPrefix(req.URL.Path, "/health")
|
||||
}
|
||||
|
||||
// IsEventStreamRequest checks if request expects event stream
|
||||
func (rp *RequestProcessor) IsEventStreamRequest(req *http.Request) bool {
|
||||
acceptHeader := req.Header.Get("Accept")
|
||||
return strings.Contains(acceptHeader, "text/event-stream")
|
||||
}
|
||||
|
||||
// IsAjaxRequest determines if this is an AJAX request
|
||||
func (rp *RequestProcessor) IsAjaxRequest(req *http.Request) bool {
|
||||
xhr := req.Header.Get("X-Requested-With")
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
accept := req.Header.Get("Accept")
|
||||
|
||||
return xhr == "XMLHttpRequest" ||
|
||||
strings.Contains(contentType, "application/json") ||
|
||||
strings.Contains(accept, "application/json")
|
||||
}
|
||||
|
||||
// WaitForInitialization waits for OIDC provider initialization with timeout
|
||||
func (rp *RequestProcessor) WaitForInitialization(req *http.Request, initComplete <-chan struct{}) error {
|
||||
select {
|
||||
case <-initComplete:
|
||||
return nil
|
||||
case <-req.Context().Done():
|
||||
rp.logger.Debug("Request canceled while waiting for OIDC initialization")
|
||||
return fmt.Errorf("request canceled")
|
||||
case <-time.After(30 * time.Second):
|
||||
rp.logger.Error("Timeout waiting for OIDC initialization")
|
||||
return fmt.Errorf("timeout waiting for OIDC provider initialization")
|
||||
}
|
||||
}
|
||||
|
||||
// determineScheme determines the URL scheme for building redirect URLs
|
||||
func (rp *RequestProcessor) 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
|
||||
func (rp *RequestProcessor) determineHost(req *http.Request) string {
|
||||
if host := req.Header.Get("X-Forwarded-Host"); host != "" {
|
||||
return host
|
||||
}
|
||||
return req.Host
|
||||
}
|
||||
|
||||
// buildFullURL constructs a complete URL from scheme, host, and path components
|
||||
func buildFullURL(scheme, host, path string) string {
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s%s", scheme, host, path)
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockLogger implements the Logger interface for testing
|
||||
type MockLogger struct {
|
||||
DebugCalls []string
|
||||
DebugfCalls []string
|
||||
ErrorCalls []string
|
||||
ErrorfCalls []string
|
||||
InfoCalls []string
|
||||
InfofCalls []string
|
||||
}
|
||||
|
||||
func (m *MockLogger) Debug(msg string) {
|
||||
m.DebugCalls = append(m.DebugCalls, msg)
|
||||
}
|
||||
|
||||
func (m *MockLogger) Debugf(format string, args ...interface{}) {
|
||||
m.DebugfCalls = append(m.DebugfCalls, format)
|
||||
}
|
||||
|
||||
func (m *MockLogger) Error(msg string) {
|
||||
m.ErrorCalls = append(m.ErrorCalls, msg)
|
||||
}
|
||||
|
||||
func (m *MockLogger) Errorf(format string, args ...interface{}) {
|
||||
m.ErrorfCalls = append(m.ErrorfCalls, format)
|
||||
}
|
||||
|
||||
func (m *MockLogger) Info(msg string) {
|
||||
m.InfoCalls = append(m.InfoCalls, msg)
|
||||
}
|
||||
|
||||
func (m *MockLogger) Infof(format string, args ...interface{}) {
|
||||
m.InfofCalls = append(m.InfofCalls, format)
|
||||
}
|
||||
|
||||
// TestNewRequestProcessor tests the constructor
|
||||
func TestNewRequestProcessor(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
processor := NewRequestProcessor(logger)
|
||||
|
||||
if processor == nil {
|
||||
t.Error("Expected NewRequestProcessor to return non-nil processor")
|
||||
return
|
||||
}
|
||||
|
||||
if processor.logger != logger {
|
||||
t.Error("Expected processor to use provided logger")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRequestContext tests request context building
|
||||
func TestBuildRequestContext(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
processor := NewRequestProcessor(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupRequest func() (*http.Request, http.ResponseWriter)
|
||||
redirectPath string
|
||||
expectedURL string
|
||||
expectedHost string
|
||||
}{
|
||||
{
|
||||
name: "Basic HTTP request",
|
||||
setupRequest: func() (*http.Request, http.ResponseWriter) {
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
return req, rw
|
||||
},
|
||||
redirectPath: "/callback",
|
||||
expectedURL: "http://example.com/callback",
|
||||
expectedHost: "example.com",
|
||||
},
|
||||
{
|
||||
name: "HTTPS request with TLS",
|
||||
setupRequest: func() (*http.Request, http.ResponseWriter) {
|
||||
req := httptest.NewRequest("GET", "https://secure.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{} // Simulate TLS
|
||||
rw := httptest.NewRecorder()
|
||||
return req, rw
|
||||
},
|
||||
redirectPath: "/auth",
|
||||
expectedURL: "https://secure.com/auth",
|
||||
expectedHost: "secure.com",
|
||||
},
|
||||
{
|
||||
name: "Request with X-Forwarded-Proto header",
|
||||
setupRequest: func() (*http.Request, http.ResponseWriter) {
|
||||
req := httptest.NewRequest("GET", "http://internal.com/test", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
rw := httptest.NewRecorder()
|
||||
return req, rw
|
||||
},
|
||||
redirectPath: "/callback",
|
||||
expectedURL: "https://internal.com/callback",
|
||||
expectedHost: "internal.com",
|
||||
},
|
||||
{
|
||||
name: "Request with X-Forwarded-Host header",
|
||||
setupRequest: func() (*http.Request, http.ResponseWriter) {
|
||||
req := httptest.NewRequest("GET", "http://internal.com/test", nil)
|
||||
req.Header.Set("X-Forwarded-Host", "public.com")
|
||||
rw := httptest.NewRecorder()
|
||||
return req, rw
|
||||
},
|
||||
redirectPath: "/callback",
|
||||
expectedURL: "http://public.com/callback",
|
||||
expectedHost: "public.com",
|
||||
},
|
||||
{
|
||||
name: "Request with both forwarded headers",
|
||||
setupRequest: func() (*http.Request, http.ResponseWriter) {
|
||||
req := httptest.NewRequest("GET", "http://internal.com/test", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "public.com")
|
||||
rw := httptest.NewRecorder()
|
||||
return req, rw
|
||||
},
|
||||
redirectPath: "/auth",
|
||||
expectedURL: "https://public.com/auth",
|
||||
expectedHost: "public.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, rw := tt.setupRequest()
|
||||
ctx := processor.BuildRequestContext(rw, req, tt.redirectPath)
|
||||
|
||||
if ctx == nil {
|
||||
t.Error("Expected BuildRequestContext to return non-nil context")
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Writer != rw {
|
||||
t.Error("Expected context writer to match provided writer")
|
||||
}
|
||||
|
||||
if ctx.Request != req {
|
||||
t.Error("Expected context request to match provided request")
|
||||
}
|
||||
|
||||
if ctx.RedirectURL != tt.expectedURL {
|
||||
t.Errorf("Expected redirect URL '%s', got '%s'", tt.expectedURL, ctx.RedirectURL)
|
||||
}
|
||||
|
||||
if ctx.Host != tt.expectedHost {
|
||||
t.Errorf("Expected host '%s', got '%s'", tt.expectedHost, ctx.Host)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsHealthCheckRequest tests health check detection
|
||||
func TestIsHealthCheckRequest(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
processor := NewRequestProcessor(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Health check path",
|
||||
path: "/health",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Health check subpath",
|
||||
path: "/health/status",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Health check with query params",
|
||||
path: "/health?check=db",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Not a health check",
|
||||
path: "/api/users",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Health-related path (matches prefix)",
|
||||
path: "/healthiness",
|
||||
expected: true, // HasPrefix behavior - this actually matches
|
||||
},
|
||||
{
|
||||
name: "Root path",
|
||||
path: "/",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com"+tt.path, nil)
|
||||
result := processor.IsHealthCheckRequest(req)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected IsHealthCheckRequest to return %v for path '%s', got %v", tt.expected, tt.path, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsEventStreamRequest tests event stream detection
|
||||
func TestIsEventStreamRequest(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
processor := NewRequestProcessor(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
acceptHeader string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Event stream accept header",
|
||||
acceptHeader: "text/event-stream",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Event stream with other types",
|
||||
acceptHeader: "text/html, text/event-stream, application/json",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "JSON accept header",
|
||||
acceptHeader: "application/json",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HTML accept header",
|
||||
acceptHeader: "text/html,application/xhtml+xml",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty accept header",
|
||||
acceptHeader: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Similar but not event stream",
|
||||
acceptHeader: "text/event-source",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
if tt.acceptHeader != "" {
|
||||
req.Header.Set("Accept", tt.acceptHeader)
|
||||
}
|
||||
|
||||
result := processor.IsEventStreamRequest(req)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected IsEventStreamRequest to return %v for accept header '%s', got %v", tt.expected, tt.acceptHeader, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsAjaxRequest tests AJAX request detection
|
||||
func TestIsAjaxRequest(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
processor := NewRequestProcessor(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupHeader func(*http.Request)
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "XMLHttpRequest header",
|
||||
setupHeader: func(req *http.Request) {
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "JSON content type",
|
||||
setupHeader: func(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "JSON content type with charset",
|
||||
setupHeader: func(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "JSON accept header",
|
||||
setupHeader: func(req *http.Request) {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "JSON accept with other types",
|
||||
setupHeader: func(req *http.Request) {
|
||||
req.Header.Set("Accept", "text/html, application/json, application/xml")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Multiple AJAX indicators",
|
||||
setupHeader: func(req *http.Request) {
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Regular HTML request",
|
||||
setupHeader: func(req *http.Request) {
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Form submission",
|
||||
setupHeader: func(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "No special headers",
|
||||
setupHeader: func(req *http.Request) {},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "http://example.com/api", nil)
|
||||
tt.setupHeader(req)
|
||||
|
||||
result := processor.IsAjaxRequest(req)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected IsAjaxRequest to return %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForInitialization tests initialization waiting
|
||||
func TestWaitForInitialization(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
processor := NewRequestProcessor(logger)
|
||||
|
||||
t.Run("Initialization completes successfully", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
initComplete := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
close(initComplete)
|
||||
}()
|
||||
|
||||
err := processor.WaitForInitialization(req, initComplete)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when initialization completes, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Request context canceled", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
req = req.WithContext(ctx)
|
||||
initComplete := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
err := processor.WaitForInitialization(req, initComplete)
|
||||
if err == nil {
|
||||
t.Error("Expected error when request context is canceled")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "request canceled") {
|
||||
t.Errorf("Expected 'request canceled' error, got: %v", err)
|
||||
}
|
||||
|
||||
if len(logger.DebugCalls) == 0 {
|
||||
t.Error("Expected debug log when request is canceled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Initialization timeout", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping timeout test in short mode")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
initComplete := make(chan struct{}) // Never closes
|
||||
|
||||
// Note: This test takes 30 seconds due to hardcoded timeout in implementation
|
||||
start := time.Now()
|
||||
err := processor.WaitForInitialization(req, initComplete)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "timeout") {
|
||||
t.Errorf("Expected timeout error, got: %v", err)
|
||||
}
|
||||
|
||||
// The timeout should be around 30 seconds, allow some variance
|
||||
if duration < 29*time.Second || duration > 31*time.Second {
|
||||
t.Errorf("Expected timeout after ~30 seconds, but got %v", duration)
|
||||
}
|
||||
|
||||
if len(logger.ErrorCalls) == 0 {
|
||||
t.Error("Expected error log when timeout occurs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDetermineScheme tests scheme determination
|
||||
func TestDetermineScheme(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
processor := NewRequestProcessor(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*http.Request)
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "X-Forwarded-Proto HTTPS",
|
||||
setup: func(req *http.Request) {
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
},
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Proto HTTP",
|
||||
setup: func(req *http.Request) {
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
},
|
||||
expected: "http",
|
||||
},
|
||||
{
|
||||
name: "TLS connection without header",
|
||||
setup: func(req *http.Request) {
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
},
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "No TLS, no header",
|
||||
setup: func(req *http.Request) {
|
||||
// No special setup
|
||||
},
|
||||
expected: "http",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Proto takes precedence over TLS",
|
||||
setup: func(req *http.Request) {
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
},
|
||||
expected: "http",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
tt.setup(req)
|
||||
|
||||
result := processor.determineScheme(req)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected scheme '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermineHost tests host determination
|
||||
func TestDetermineHost(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
processor := NewRequestProcessor(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*http.Request)
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "X-Forwarded-Host header present",
|
||||
setup: func(req *http.Request) {
|
||||
req.Header.Set("X-Forwarded-Host", "public.example.com")
|
||||
},
|
||||
expected: "public.example.com",
|
||||
},
|
||||
{
|
||||
name: "No X-Forwarded-Host, use req.Host",
|
||||
setup: func(req *http.Request) {
|
||||
// No special setup, will use req.Host
|
||||
},
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "Empty X-Forwarded-Host, fallback to req.Host",
|
||||
setup: func(req *http.Request) {
|
||||
req.Header.Set("X-Forwarded-Host", "")
|
||||
},
|
||||
expected: "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
tt.setup(req)
|
||||
|
||||
result := processor.determineHost(req)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected host '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFullURL tests URL building
|
||||
func TestBuildFullURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scheme string
|
||||
host string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Basic URL construction",
|
||||
scheme: "https",
|
||||
host: "example.com",
|
||||
path: "/callback",
|
||||
expected: "https://example.com/callback",
|
||||
},
|
||||
{
|
||||
name: "Path without leading slash",
|
||||
scheme: "http",
|
||||
host: "test.com",
|
||||
path: "auth",
|
||||
expected: "http://test.com/auth",
|
||||
},
|
||||
{
|
||||
name: "Absolute HTTP URL in path",
|
||||
scheme: "https",
|
||||
host: "example.com",
|
||||
path: "http://other.com/callback",
|
||||
expected: "http://other.com/callback",
|
||||
},
|
||||
{
|
||||
name: "Absolute HTTPS URL in path",
|
||||
scheme: "http",
|
||||
host: "example.com",
|
||||
path: "https://secure.com/auth",
|
||||
expected: "https://secure.com/auth",
|
||||
},
|
||||
{
|
||||
name: "Root path",
|
||||
scheme: "https",
|
||||
host: "example.com:8080",
|
||||
path: "/",
|
||||
expected: "https://example.com:8080/",
|
||||
},
|
||||
{
|
||||
name: "Empty path",
|
||||
scheme: "https",
|
||||
host: "example.com",
|
||||
path: "",
|
||||
expected: "https://example.com/",
|
||||
},
|
||||
{
|
||||
name: "Path with query parameters",
|
||||
scheme: "https",
|
||||
host: "example.com",
|
||||
path: "/callback?state=abc123",
|
||||
expected: "https://example.com/callback?state=abc123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildFullURL(tt.scheme, tt.host, tt.path)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected URL '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestContext tests the RequestContext struct
|
||||
func TestRequestContext(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
ctx := &RequestContext{
|
||||
Writer: rw,
|
||||
Request: req,
|
||||
RedirectURL: "https://example.com/callback",
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
}
|
||||
|
||||
if ctx.Writer != rw {
|
||||
t.Error("Expected Writer to be set correctly")
|
||||
}
|
||||
|
||||
if ctx.Request != req {
|
||||
t.Error("Expected Request to be set correctly")
|
||||
}
|
||||
|
||||
if ctx.RedirectURL != "https://example.com/callback" {
|
||||
t.Error("Expected RedirectURL to be set correctly")
|
||||
}
|
||||
|
||||
if ctx.Scheme != "https" {
|
||||
t.Error("Expected Scheme to be set correctly")
|
||||
}
|
||||
|
||||
if ctx.Host != "example.com" {
|
||||
t.Error("Expected Host to be set correctly")
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
// Package patterns provides cached compiled regex patterns for performance optimization
|
||||
package patterns
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RegexCache manages compiled regex patterns with thread-safe access
|
||||
type RegexCache struct {
|
||||
patterns map[string]*regexp.Regexp
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewRegexCache creates a new regex cache instance
|
||||
func NewRegexCache() *RegexCache {
|
||||
return &RegexCache{
|
||||
patterns: make(map[string]*regexp.Regexp),
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a compiled regex pattern, compiling and caching it if not present
|
||||
func (c *RegexCache) Get(pattern string) (*regexp.Regexp, error) {
|
||||
// First try read lock for existing pattern
|
||||
c.mu.RLock()
|
||||
if regex, exists := c.patterns[pattern]; exists {
|
||||
c.mu.RUnlock()
|
||||
return regex, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Pattern not found, acquire write lock to compile and cache
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Double-check in case another goroutine compiled it while we waited
|
||||
if regex, exists := c.patterns[pattern]; exists {
|
||||
return regex, nil
|
||||
}
|
||||
|
||||
// Compile the pattern
|
||||
regex, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the compiled pattern
|
||||
c.patterns[pattern] = regex
|
||||
return regex, nil
|
||||
}
|
||||
|
||||
// MustGet is like Get but panics if the pattern cannot be compiled
|
||||
func (c *RegexCache) MustGet(pattern string) *regexp.Regexp {
|
||||
regex, err := c.Get(pattern)
|
||||
if err != nil {
|
||||
panic("regex compilation failed for pattern '" + pattern + "': " + err.Error())
|
||||
}
|
||||
return regex
|
||||
}
|
||||
|
||||
// Precompile compiles and caches multiple patterns at once
|
||||
func (c *RegexCache) Precompile(patterns []string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if _, exists := c.patterns[pattern]; !exists {
|
||||
regex, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.patterns[pattern] = regex
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the number of cached patterns
|
||||
func (c *RegexCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.patterns)
|
||||
}
|
||||
|
||||
// Clear removes all cached patterns
|
||||
func (c *RegexCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.patterns = make(map[string]*regexp.Regexp)
|
||||
}
|
||||
|
||||
// Global regex cache instance
|
||||
var globalCache = NewRegexCache()
|
||||
|
||||
// Common regex patterns used throughout the OIDC implementation
|
||||
const (
|
||||
// Email validation pattern (RFC 5322 compliant)
|
||||
EmailPattern = `^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`
|
||||
|
||||
// Domain validation pattern
|
||||
DomainPattern = `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`
|
||||
|
||||
// URL validation pattern (http/https)
|
||||
URLPattern = `^https?://[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(/.*)?$`
|
||||
|
||||
// JWT token pattern (three base64url parts separated by dots)
|
||||
JWTPattern = `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`
|
||||
|
||||
// Bearer token pattern (Authorization header)
|
||||
// #nosec G101 -- This is a regex pattern for validation, not a hardcoded credential
|
||||
BearerTokenPattern = `^Bearer\s+([A-Za-z0-9._~+/-]+=*)$`
|
||||
|
||||
// Client ID pattern (alphanumeric with common separators)
|
||||
ClientIDPattern = `^[a-zA-Z0-9._-]+$`
|
||||
|
||||
// Scope pattern (space-separated alphanumeric with underscores)
|
||||
ScopePattern = `^[a-zA-Z0-9_]+(\s+[a-zA-Z0-9_]+)*$`
|
||||
|
||||
// Session ID pattern (hexadecimal)
|
||||
SessionIDPattern = `^[a-fA-F0-9]{32,128}$`
|
||||
|
||||
// CSRF token pattern (base64url)
|
||||
// #nosec G101 -- This is a regex pattern for validation, not a hardcoded credential
|
||||
CSRFTokenPattern = `^[A-Za-z0-9_-]+$`
|
||||
|
||||
// Nonce pattern (base64url)
|
||||
NoncePattern = `^[A-Za-z0-9_-]+$`
|
||||
|
||||
// Code verifier pattern for PKCE (base64url, 43-128 chars)
|
||||
CodeVerifierPattern = `^[A-Za-z0-9_-]{43,128}$`
|
||||
|
||||
// Authorization code pattern (base64url)
|
||||
AuthCodePattern = `^[A-Za-z0-9._~+/-]+=*$`
|
||||
|
||||
// Redirect URI validation (must be absolute HTTP/HTTPS URL)
|
||||
RedirectURIPattern = `^https?://[^\s/$.?#].[^\s]*$`
|
||||
|
||||
// User-Agent pattern for bot detection
|
||||
BotUserAgentPattern = `(?i)(bot|crawler|spider|scraper|curl|wget|python|java|go-http)`
|
||||
|
||||
// IP address pattern (IPv4)
|
||||
IPv4Pattern = `^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`
|
||||
|
||||
// Tenant ID pattern (UUID format for Azure, etc.)
|
||||
TenantIDPattern = `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`
|
||||
)
|
||||
|
||||
// Precompiled common patterns for immediate use
|
||||
var (
|
||||
EmailRegex *regexp.Regexp
|
||||
DomainRegex *regexp.Regexp
|
||||
URLRegex *regexp.Regexp
|
||||
JWTRegex *regexp.Regexp
|
||||
BearerTokenRegex *regexp.Regexp
|
||||
ClientIDRegex *regexp.Regexp
|
||||
ScopeRegex *regexp.Regexp
|
||||
SessionIDRegex *regexp.Regexp
|
||||
CSRFTokenRegex *regexp.Regexp
|
||||
NonceRegex *regexp.Regexp
|
||||
CodeVerifierRegex *regexp.Regexp
|
||||
AuthCodeRegex *regexp.Regexp
|
||||
RedirectURIRegex *regexp.Regexp
|
||||
BotUserAgentRegex *regexp.Regexp
|
||||
IPv4Regex *regexp.Regexp
|
||||
TenantIDRegex *regexp.Regexp
|
||||
)
|
||||
|
||||
// Initialize precompiled patterns
|
||||
func init() {
|
||||
commonPatterns := []string{
|
||||
EmailPattern,
|
||||
DomainPattern,
|
||||
URLPattern,
|
||||
JWTPattern,
|
||||
BearerTokenPattern,
|
||||
ClientIDPattern,
|
||||
ScopePattern,
|
||||
SessionIDPattern,
|
||||
CSRFTokenPattern,
|
||||
NoncePattern,
|
||||
CodeVerifierPattern,
|
||||
AuthCodePattern,
|
||||
RedirectURIPattern,
|
||||
BotUserAgentPattern,
|
||||
IPv4Pattern,
|
||||
TenantIDPattern,
|
||||
}
|
||||
|
||||
if err := globalCache.Precompile(commonPatterns); err != nil {
|
||||
panic("Failed to precompile common regex patterns: " + err.Error())
|
||||
}
|
||||
|
||||
// Assign precompiled patterns to global variables for easy access
|
||||
EmailRegex = globalCache.MustGet(EmailPattern)
|
||||
DomainRegex = globalCache.MustGet(DomainPattern)
|
||||
URLRegex = globalCache.MustGet(URLPattern)
|
||||
JWTRegex = globalCache.MustGet(JWTPattern)
|
||||
BearerTokenRegex = globalCache.MustGet(BearerTokenPattern)
|
||||
ClientIDRegex = globalCache.MustGet(ClientIDPattern)
|
||||
ScopeRegex = globalCache.MustGet(ScopePattern)
|
||||
SessionIDRegex = globalCache.MustGet(SessionIDPattern)
|
||||
CSRFTokenRegex = globalCache.MustGet(CSRFTokenPattern)
|
||||
NonceRegex = globalCache.MustGet(NoncePattern)
|
||||
CodeVerifierRegex = globalCache.MustGet(CodeVerifierPattern)
|
||||
AuthCodeRegex = globalCache.MustGet(AuthCodePattern)
|
||||
RedirectURIRegex = globalCache.MustGet(RedirectURIPattern)
|
||||
BotUserAgentRegex = globalCache.MustGet(BotUserAgentPattern)
|
||||
IPv4Regex = globalCache.MustGet(IPv4Pattern)
|
||||
TenantIDRegex = globalCache.MustGet(TenantIDPattern)
|
||||
}
|
||||
|
||||
// Global helper functions for common validations
|
||||
|
||||
// ValidateEmail checks if an email address is valid
|
||||
func ValidateEmail(email string) bool {
|
||||
return EmailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
// ValidateDomain checks if a domain name is valid
|
||||
func ValidateDomain(domain string) bool {
|
||||
return DomainRegex.MatchString(domain)
|
||||
}
|
||||
|
||||
// ValidateURL checks if a URL is valid (http/https)
|
||||
func ValidateURL(url string) bool {
|
||||
return URLRegex.MatchString(url)
|
||||
}
|
||||
|
||||
// ValidateJWT checks if a token has valid JWT format
|
||||
func ValidateJWT(token string) bool {
|
||||
return JWTRegex.MatchString(token)
|
||||
}
|
||||
|
||||
// ExtractBearerToken extracts the token from a Bearer authorization header
|
||||
func ExtractBearerToken(authHeader string) (string, bool) {
|
||||
matches := BearerTokenRegex.FindStringSubmatch(authHeader)
|
||||
if len(matches) == 2 {
|
||||
return matches[1], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ValidateClientID checks if a client ID has valid format
|
||||
func ValidateClientID(clientID string) bool {
|
||||
return ClientIDRegex.MatchString(clientID)
|
||||
}
|
||||
|
||||
// ValidateScopes checks if scopes string has valid format
|
||||
func ValidateScopes(scopes string) bool {
|
||||
return ScopeRegex.MatchString(scopes)
|
||||
}
|
||||
|
||||
// ValidateSessionID checks if a session ID has valid format
|
||||
func ValidateSessionID(sessionID string) bool {
|
||||
return SessionIDRegex.MatchString(sessionID)
|
||||
}
|
||||
|
||||
// ValidateCSRFToken checks if a CSRF token has valid format
|
||||
func ValidateCSRFToken(token string) bool {
|
||||
return CSRFTokenRegex.MatchString(token)
|
||||
}
|
||||
|
||||
// ValidateNonce checks if a nonce has valid format
|
||||
func ValidateNonce(nonce string) bool {
|
||||
return NonceRegex.MatchString(nonce)
|
||||
}
|
||||
|
||||
// ValidateCodeVerifier checks if a PKCE code verifier has valid format
|
||||
func ValidateCodeVerifier(verifier string) bool {
|
||||
return CodeVerifierRegex.MatchString(verifier)
|
||||
}
|
||||
|
||||
// ValidateAuthCode checks if an authorization code has valid format
|
||||
func ValidateAuthCode(code string) bool {
|
||||
return AuthCodeRegex.MatchString(code)
|
||||
}
|
||||
|
||||
// ValidateRedirectURI checks if a redirect URI is valid
|
||||
func ValidateRedirectURI(uri string) bool {
|
||||
return RedirectURIRegex.MatchString(uri)
|
||||
}
|
||||
|
||||
// IsBotUserAgent checks if a User-Agent suggests an automated client
|
||||
func IsBotUserAgent(userAgent string) bool {
|
||||
return BotUserAgentRegex.MatchString(userAgent)
|
||||
}
|
||||
|
||||
// ValidateIPv4 checks if an IP address is valid IPv4
|
||||
func ValidateIPv4(ip string) bool {
|
||||
return IPv4Regex.MatchString(ip)
|
||||
}
|
||||
|
||||
// ValidateTenantID checks if a tenant ID has valid UUID format
|
||||
func ValidateTenantID(tenantID string) bool {
|
||||
return TenantIDRegex.MatchString(tenantID)
|
||||
}
|
||||
|
||||
// GetGlobalCache returns the global regex cache instance
|
||||
func GetGlobalCache() *RegexCache {
|
||||
return globalCache
|
||||
}
|
||||
|
||||
// CompilePattern compiles a pattern using the global cache
|
||||
func CompilePattern(pattern string) (*regexp.Regexp, error) {
|
||||
return globalCache.Get(pattern)
|
||||
}
|
||||
|
||||
// MustCompilePattern compiles a pattern using the global cache, panicking on error
|
||||
func MustCompilePattern(pattern string) *regexp.Regexp {
|
||||
return globalCache.MustGet(pattern)
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
package patterns
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegexCache_Get(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
|
||||
pattern := `^test\d+$`
|
||||
|
||||
// First call should compile and cache
|
||||
regex1, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get regex: %v", err)
|
||||
}
|
||||
|
||||
// Second call should return cached version
|
||||
regex2, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cached regex: %v", err)
|
||||
}
|
||||
|
||||
// Should be the same instance
|
||||
if regex1 != regex2 {
|
||||
t.Error("Expected same regex instance from cache")
|
||||
}
|
||||
|
||||
// Test the regex works
|
||||
if !regex1.MatchString("test123") {
|
||||
t.Error("Regex should match 'test123'")
|
||||
}
|
||||
|
||||
if regex1.MatchString("test") {
|
||||
t.Error("Regex should not match 'test'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexCache_ConcurrentAccess(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
pattern := `^concurrent\d+$`
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]*regexp.Regexp, 10)
|
||||
errors := make([]error, 10)
|
||||
|
||||
// Launch multiple goroutines to access the same pattern
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
regex, err := cache.Get(pattern)
|
||||
results[index] = regex
|
||||
errors[index] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Check all succeeded
|
||||
for i, err := range errors {
|
||||
if err != nil {
|
||||
t.Fatalf("Goroutine %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All should return the same instance
|
||||
first := results[0]
|
||||
for i, regex := range results[1:] {
|
||||
if regex != first {
|
||||
t.Errorf("Goroutine %d got different regex instance", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexCache_InvalidPattern(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
|
||||
_, err := cache.Get(`[invalid`)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid regex pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexCache_Precompile(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
|
||||
patterns := []string{
|
||||
`^test1$`,
|
||||
`^test2$`,
|
||||
`^test3$`,
|
||||
}
|
||||
|
||||
err := cache.Precompile(patterns)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to precompile patterns: %v", err)
|
||||
}
|
||||
|
||||
if cache.Size() != 3 {
|
||||
t.Errorf("Expected cache size 3, got %d", cache.Size())
|
||||
}
|
||||
|
||||
// Should be able to get precompiled patterns without error
|
||||
for _, pattern := range patterns {
|
||||
_, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get precompiled pattern %s: %v", pattern, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationFunctions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
function func(string) bool
|
||||
valid []string
|
||||
invalid []string
|
||||
}{
|
||||
{
|
||||
name: "ValidateEmail",
|
||||
function: ValidateEmail,
|
||||
valid: []string{"[email protected]", "[email protected]", "[email protected]"},
|
||||
invalid: []string{"invalid-email", "@domain.com", "user@", ""},
|
||||
},
|
||||
{
|
||||
name: "ValidateDomain",
|
||||
function: ValidateDomain,
|
||||
valid: []string{"example.com", "sub.domain.org", "test.co.uk"},
|
||||
invalid: []string{"", "invalid..domain", ".example.com", "domain."},
|
||||
},
|
||||
{
|
||||
name: "ValidateJWT",
|
||||
function: ValidateJWT,
|
||||
valid: []string{"eyJ0.eyJ1.sig", "a.b.c"},
|
||||
invalid: []string{"invalid", "a.b", "a.b.c.d", ""},
|
||||
},
|
||||
{
|
||||
name: "ValidateClientID",
|
||||
function: ValidateClientID,
|
||||
valid: []string{"client123", "my-client_id", "123.456"},
|
||||
invalid: []string{"", "client with spaces", "client@invalid"},
|
||||
},
|
||||
{
|
||||
name: "ValidateURL",
|
||||
function: ValidateURL,
|
||||
valid: []string{"https://example.com", "https://sub.domain.org/path", "http://localhost", "https://example.com/path?query=value", "http://192.168.1.1"},
|
||||
invalid: []string{"", "ftp://example.com", "not-a-url", "https://", "example.com", "http://localhost:8080"},
|
||||
},
|
||||
{
|
||||
name: "ValidateScopes",
|
||||
function: ValidateScopes,
|
||||
valid: []string{"openid", "openid profile", "read write admin", "user_info"},
|
||||
invalid: []string{"", "scope-with-dash", "scope@invalid", "scope with.dot", " "},
|
||||
},
|
||||
{
|
||||
name: "ValidateSessionID",
|
||||
function: ValidateSessionID,
|
||||
valid: []string{"a1b2c3d4e5f6789012345678901234567890abcdef", "ABCDEF1234567890abcdef1234567890", "0123456789abcdef0123456789abcdef"},
|
||||
invalid: []string{"", "too-short", "contains-invalid-chars!", "g123456789abcdef0123456789abcdef", "1234567890abcdef1234567890abcde"},
|
||||
},
|
||||
{
|
||||
name: "ValidateCSRFToken",
|
||||
function: ValidateCSRFToken,
|
||||
valid: []string{"abc123", "ABC_123-xyz", "token-value_123", "_valid-token_"},
|
||||
invalid: []string{"", "token with spaces", "token@invalid", "token.with.dots!", "token/with/slash"},
|
||||
},
|
||||
{
|
||||
name: "ValidateNonce",
|
||||
function: ValidateNonce,
|
||||
valid: []string{"abc123", "ABC_123-xyz", "nonce-value_123", "_valid-nonce_"},
|
||||
invalid: []string{"", "nonce with spaces", "nonce@invalid", "nonce.with.dots!", "nonce/with/slash"},
|
||||
},
|
||||
{
|
||||
name: "ValidateCodeVerifier",
|
||||
function: ValidateCodeVerifier,
|
||||
valid: []string{"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"},
|
||||
invalid: []string{"", "too-short", "short", "verifier with spaces", "verifier@invalid", "a"},
|
||||
},
|
||||
{
|
||||
name: "ValidateAuthCode",
|
||||
function: ValidateAuthCode,
|
||||
valid: []string{"auth_code_123", "ABC.123-xyz/code+value=", "simple-code"},
|
||||
invalid: []string{"", "code with spaces", "code@invalid"},
|
||||
},
|
||||
{
|
||||
name: "ValidateRedirectURI",
|
||||
function: ValidateRedirectURI,
|
||||
valid: []string{"https://example.com/callback", "http://localhost:8080/auth", "https://app.example.org/oauth/callback", "http://127.0.0.1:3000"},
|
||||
invalid: []string{"", "ftp://example.com", "not-a-url", "example.com/callback", "https://"},
|
||||
},
|
||||
{
|
||||
name: "ValidateIPv4",
|
||||
function: ValidateIPv4,
|
||||
valid: []string{"192.168.1.1", "10.0.0.1", "127.0.0.1", "255.255.255.255", "0.0.0.0"},
|
||||
invalid: []string{"", "256.1.1.1", "192.168.1", "192.168.1.1.1", "not-an-ip"},
|
||||
},
|
||||
{
|
||||
name: "ValidateTenantID",
|
||||
function: ValidateTenantID,
|
||||
valid: []string{"12345678-1234-1234-1234-123456789abc", "ABCDEF12-3456-7890-ABCD-EF1234567890"},
|
||||
invalid: []string{"", "not-a-uuid", "12345678-1234-1234-1234", "12345678-1234-1234-1234-123456789abcd", "123456781234123412341234567890ab"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, valid := range tt.valid {
|
||||
if !tt.function(valid) {
|
||||
t.Errorf("%s should be valid: %s", tt.name, valid)
|
||||
}
|
||||
}
|
||||
|
||||
for _, invalid := range tt.invalid {
|
||||
if tt.function(invalid) {
|
||||
t.Errorf("%s should be invalid: %s", tt.name, invalid)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
header string
|
||||
expected string
|
||||
valid bool
|
||||
}{
|
||||
{"Bearer abc123", "abc123", true},
|
||||
{"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", true},
|
||||
{"bearer token123", "", false}, // case sensitive
|
||||
{"Basic abc123", "", false},
|
||||
{"Bearer", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
token, valid := ExtractBearerToken(tt.header)
|
||||
if valid != tt.valid {
|
||||
t.Errorf("ExtractBearerToken(%q) valid = %v, want %v", tt.header, valid, tt.valid)
|
||||
}
|
||||
if token != tt.expected {
|
||||
t.Errorf("ExtractBearerToken(%q) token = %q, want %q", tt.header, token, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRegexCache_Get(b *testing.B) {
|
||||
cache := NewRegexCache()
|
||||
pattern := `^benchmark\d+$`
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkRegexCache_Validation(b *testing.B) {
|
||||
email := "[email protected]"
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
ValidateEmail(email)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkRegex_DirectCompile(b *testing.B) {
|
||||
pattern := `^benchmark\d+$`
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexCache_Clear(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
|
||||
// Add some patterns to the cache
|
||||
patterns := []string{`^test1$`, `^test2$`, `^test3$`}
|
||||
for _, pattern := range patterns {
|
||||
_, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add pattern %s: %v", pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify cache has patterns
|
||||
if cache.Size() != 3 {
|
||||
t.Errorf("Expected cache size 3, got %d", cache.Size())
|
||||
}
|
||||
|
||||
// Clear the cache
|
||||
cache.Clear()
|
||||
|
||||
// Verify cache is empty
|
||||
if cache.Size() != 0 {
|
||||
t.Errorf("Expected cache size 0 after clear, got %d", cache.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBotUserAgent(t *testing.T) {
|
||||
tests := []struct {
|
||||
userAgent string
|
||||
isBot bool
|
||||
}{
|
||||
{"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", true},
|
||||
{"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", true},
|
||||
{"facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", false},
|
||||
{"crawler-bot/1.0", true},
|
||||
{"spider-agent/2.0", true},
|
||||
{"curl/7.68.0", true},
|
||||
{"wget/1.20.3", true},
|
||||
{"python-requests/2.25.1", true},
|
||||
{"Go-http-client/1.1", true},
|
||||
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", false},
|
||||
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.userAgent, func(t *testing.T) {
|
||||
result := IsBotUserAgent(tt.userAgent)
|
||||
if result != tt.isBot {
|
||||
t.Errorf("IsBotUserAgent(%q) = %v, want %v", tt.userAgent, result, tt.isBot)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGlobalCache(t *testing.T) {
|
||||
cache := GetGlobalCache()
|
||||
if cache == nil {
|
||||
t.Error("GetGlobalCache() should not return nil")
|
||||
}
|
||||
|
||||
// Should return the same instance
|
||||
cache2 := GetGlobalCache()
|
||||
if cache != cache2 {
|
||||
t.Error("GetGlobalCache() should return the same instance")
|
||||
}
|
||||
|
||||
// Should have precompiled patterns
|
||||
if cache.Size() == 0 {
|
||||
t.Error("Global cache should have precompiled patterns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePattern(t *testing.T) {
|
||||
pattern := `^test_compile\d+$`
|
||||
|
||||
regex, err := CompilePattern(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("CompilePattern failed: %v", err)
|
||||
}
|
||||
|
||||
if !regex.MatchString("test_compile123") {
|
||||
t.Error("Compiled pattern should match 'test_compile123'")
|
||||
}
|
||||
|
||||
if regex.MatchString("test_compile") {
|
||||
t.Error("Compiled pattern should not match 'test_compile'")
|
||||
}
|
||||
|
||||
// Test invalid pattern
|
||||
_, err = CompilePattern(`[invalid`)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustCompilePattern(t *testing.T) {
|
||||
pattern := `^test_must_compile\d+$`
|
||||
|
||||
regex := MustCompilePattern(pattern)
|
||||
if regex == nil {
|
||||
t.Fatal("MustCompilePattern should not return nil")
|
||||
}
|
||||
|
||||
if !regex.MatchString("test_must_compile456") {
|
||||
t.Error("Compiled pattern should match 'test_must_compile456'")
|
||||
}
|
||||
|
||||
// Test that it panics with invalid pattern
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustCompilePattern should panic with invalid pattern")
|
||||
}
|
||||
}()
|
||||
MustCompilePattern(`[invalid`)
|
||||
}
|
||||
|
||||
func TestAdditionalValidationEdgeCases(t *testing.T) {
|
||||
// Test edge cases for ValidateURL
|
||||
t.Run("ValidateURL_EdgeCases", func(t *testing.T) {
|
||||
edgeCases := []struct {
|
||||
url string
|
||||
valid bool
|
||||
}{
|
||||
{"https://a.b", true},
|
||||
{"http://localhost", true},
|
||||
{"https://example.com/path?query=value#fragment", true},
|
||||
{"http://192.168.0.1:8080/api", false},
|
||||
{"https://", false},
|
||||
{"http://", false},
|
||||
{"https://example", true},
|
||||
}
|
||||
|
||||
for _, tc := range edgeCases {
|
||||
result := ValidateURL(tc.url)
|
||||
if result != tc.valid {
|
||||
t.Errorf("ValidateURL(%q) = %v, want %v", tc.url, result, tc.valid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test edge cases for ValidateScopes
|
||||
t.Run("ValidateScopes_EdgeCases", func(t *testing.T) {
|
||||
edgeCases := []struct {
|
||||
scopes string
|
||||
valid bool
|
||||
}{
|
||||
{"a", true},
|
||||
{"a b", true},
|
||||
{"openid profile email", true},
|
||||
{"user_profile", true},
|
||||
{"read_all write_all", true},
|
||||
{"scope-with-dash", false},
|
||||
{"scope.with.dot", false},
|
||||
{"scope@email", false},
|
||||
{" scope", false},
|
||||
{"scope ", false},
|
||||
{"a b", true}, // pattern allows multiple spaces
|
||||
}
|
||||
|
||||
for _, tc := range edgeCases {
|
||||
result := ValidateScopes(tc.scopes)
|
||||
if result != tc.valid {
|
||||
t.Errorf("ValidateScopes(%q) = %v, want %v", tc.scopes, result, tc.valid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test edge cases for ValidateSessionID
|
||||
t.Run("ValidateSessionID_EdgeCases", func(t *testing.T) {
|
||||
edgeCases := []struct {
|
||||
sessionID string
|
||||
valid bool
|
||||
}{
|
||||
{"12345678901234567890123456789012", true}, // 32 chars (min)
|
||||
{"1234567890123456789012345678901", false}, // 31 chars (too short)
|
||||
{string(make([]byte, 128)), false}, // 128 non-hex chars
|
||||
{"abcdef1234567890ABCDEF1234567890" + string(make([]byte, 96)), false}, // 128+ chars with non-hex
|
||||
}
|
||||
|
||||
// Generate valid 128-char hex string (max length)
|
||||
validLongHex := ""
|
||||
for i := 0; i < 128; i++ {
|
||||
validLongHex += "a"
|
||||
}
|
||||
edgeCases = append(edgeCases, struct {
|
||||
sessionID string
|
||||
valid bool
|
||||
}{validLongHex, true})
|
||||
|
||||
for _, tc := range edgeCases {
|
||||
result := ValidateSessionID(tc.sessionID)
|
||||
if result != tc.valid {
|
||||
t.Errorf("ValidateSessionID(%q) = %v, want %v", tc.sessionID, result, tc.valid)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// RETRY CONFIG TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestDefaultRetryConfig(t *testing.T) {
|
||||
config := DefaultRetryConfig()
|
||||
|
||||
if config.MaxAttempts != 3 {
|
||||
t.Errorf("Expected MaxAttempts to be 3, got %d", config.MaxAttempts)
|
||||
}
|
||||
|
||||
if config.InitialDelay != 100*time.Millisecond {
|
||||
t.Errorf("Expected InitialDelay to be 100ms, got %v", config.InitialDelay)
|
||||
}
|
||||
|
||||
if config.MaxDelay != 30*time.Second {
|
||||
t.Errorf("Expected MaxDelay to be 30s, got %v", config.MaxDelay)
|
||||
}
|
||||
|
||||
if config.Multiplier != 2.0 {
|
||||
t.Errorf("Expected Multiplier to be 2.0, got %f", config.Multiplier)
|
||||
}
|
||||
|
||||
if config.RandomizationFactor != 0.1 {
|
||||
t.Errorf("Expected RandomizationFactor to be 0.1, got %f", config.RandomizationFactor)
|
||||
}
|
||||
|
||||
if len(config.RetryableErrors) != 3 {
|
||||
t.Errorf("Expected 3 retryable errors, got %d", len(config.RetryableErrors))
|
||||
}
|
||||
|
||||
if len(config.RetryableStatusCodes) != 6 {
|
||||
t.Errorf("Expected 6 retryable status codes, got %d", len(config.RetryableStatusCodes))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RETRY EXECUTOR TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestNewRetryExecutor(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := DefaultRetryConfig()
|
||||
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
if executor == nil {
|
||||
t.Fatal("Expected NewRetryExecutor to return non-nil")
|
||||
}
|
||||
|
||||
if executor.config.MaxAttempts != 3 {
|
||||
t.Errorf("Expected MaxAttempts to be 3, got %d", executor.config.MaxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRetryExecutor_InvalidConfig(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
|
||||
// Test with invalid MaxAttempts
|
||||
config := RetryConfig{
|
||||
MaxAttempts: 0, // Invalid
|
||||
Multiplier: 0, // Invalid
|
||||
}
|
||||
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
if executor.config.MaxAttempts != 1 {
|
||||
t.Errorf("Expected MaxAttempts to be corrected to 1, got %d", executor.config.MaxAttempts)
|
||||
}
|
||||
|
||||
if executor.config.Multiplier != 1.0 {
|
||||
t.Errorf("Expected Multiplier to be corrected to 1.0, got %f", executor.config.Multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_ExecuteWithContext_Success(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := DefaultRetryConfig()
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
callCount := 0
|
||||
err := executor.ExecuteWithContext(context.Background(), func() error {
|
||||
callCount++
|
||||
return nil
|
||||
})
|
||||
|
||||
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 TestRetryExecutor_ExecuteWithContext_Retry(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1 * time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
RetryableErrors: []string{"connection refused"},
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
callCount := 0
|
||||
err := executor.ExecuteWithContext(context.Background(), func() error {
|
||||
callCount++
|
||||
if callCount < 3 {
|
||||
return errors.New("connection refused")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected success after retries, got %v", err)
|
||||
}
|
||||
|
||||
if callCount != 3 {
|
||||
t.Errorf("Expected function to be called 3 times, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_ExecuteWithContext_MaxRetriesExhausted(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1 * time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
RetryableErrors: []string{"timeout"},
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
callCount := 0
|
||||
err := executor.ExecuteWithContext(context.Background(), func() error {
|
||||
callCount++
|
||||
return errors.New("timeout")
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error after max retries exhausted")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "all retry attempts failed") {
|
||||
t.Errorf("Expected 'all retry attempts failed' error, got %v", err)
|
||||
}
|
||||
|
||||
if callCount != 3 {
|
||||
t.Errorf("Expected function to be called 3 times, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_ExecuteWithContext_NonRetryableError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1 * time.Millisecond,
|
||||
RetryableErrors: []string{"timeout"},
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
callCount := 0
|
||||
err := executor.ExecuteWithContext(context.Background(), func() error {
|
||||
callCount++
|
||||
return errors.New("permanent error")
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-retryable error")
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected function to be called once (non-retryable), got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_ExecuteWithContext_ContextCancelled(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1 * time.Second, // Long delay
|
||||
RetryableErrors: []string{"timeout"},
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
callCount := 0
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
var execErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
execErr = executor.ExecuteWithContext(ctx, func() error {
|
||||
callCount++
|
||||
return errors.New("timeout")
|
||||
})
|
||||
}()
|
||||
|
||||
// Cancel after a short delay
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if execErr == nil {
|
||||
t.Error("Expected error when context is cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_ExecuteWithContext_ContextCancelledBeforeStart(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := DefaultRetryConfig()
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
err := executor.ExecuteWithContext(ctx, func() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error when context is already cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_Execute(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := DefaultRetryConfig()
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
called := false
|
||||
err := executor.Execute(context.Background(), func() error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if !called {
|
||||
t.Error("Expected function to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_isRetryableError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
RetryableErrors: []string{"connection refused", "timeout"},
|
||||
RetryableStatusCodes: []int{500, 503},
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{"nil error", nil, false},
|
||||
{"connection refused", errors.New("connection refused"), true},
|
||||
{"timeout", errors.New("TIMEOUT"), true}, // case insensitive
|
||||
{"EOF", errors.New("EOF"), false},
|
||||
{"random error", errors.New("something else"), false},
|
||||
{"context cancelled", context.Canceled, false},
|
||||
{"context deadline exceeded", context.DeadlineExceeded, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := executor.isRetryableError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_isRetryableError_HTTPError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
RetryableStatusCodes: []int{500, 503},
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
expected bool
|
||||
}{
|
||||
{"500 error", 500, true},
|
||||
{"503 error", 503, true},
|
||||
{"502 error (5xx)", 502, true},
|
||||
{"400 error", 400, false},
|
||||
{"401 error", 401, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
httpErr := &HTTPError{StatusCode: tt.statusCode}
|
||||
result := executor.isRetryableError(httpErr)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_isRetryableError_OIDCError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := DefaultRetryConfig()
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
// Test retryable OIDC error
|
||||
retryableErr := &OIDCError{Code: "temporarily_unavailable", Description: "Server busy"}
|
||||
if !executor.isRetryableError(retryableErr) {
|
||||
t.Error("Expected temporarily_unavailable to be retryable")
|
||||
}
|
||||
|
||||
// Test non-retryable OIDC error
|
||||
nonRetryableErr := &OIDCError{Code: "invalid_token", Description: "Token expired"}
|
||||
if executor.isRetryableError(nonRetryableErr) {
|
||||
t.Error("Expected invalid_token to not be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_calculateDelay(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
InitialDelay: 100 * time.Millisecond,
|
||||
MaxDelay: 1 * time.Second,
|
||||
Multiplier: 2.0,
|
||||
RandomizationFactor: 0.0, // No jitter for predictable tests
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
// Test exponential backoff without jitter
|
||||
delay1 := executor.calculateDelay(1)
|
||||
if delay1 != 100*time.Millisecond {
|
||||
t.Errorf("Expected 100ms for attempt 1, got %v", delay1)
|
||||
}
|
||||
|
||||
delay2 := executor.calculateDelay(2)
|
||||
if delay2 != 200*time.Millisecond {
|
||||
t.Errorf("Expected 200ms for attempt 2, got %v", delay2)
|
||||
}
|
||||
|
||||
delay3 := executor.calculateDelay(3)
|
||||
if delay3 != 400*time.Millisecond {
|
||||
t.Errorf("Expected 400ms for attempt 3, got %v", delay3)
|
||||
}
|
||||
|
||||
// Test max delay cap
|
||||
delay10 := executor.calculateDelay(10)
|
||||
if delay10 > 1*time.Second {
|
||||
t.Errorf("Expected delay capped at 1s, got %v", delay10)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_calculateDelay_WithJitter(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
InitialDelay: 100 * time.Millisecond,
|
||||
MaxDelay: 1 * time.Second,
|
||||
Multiplier: 2.0,
|
||||
RandomizationFactor: 0.5, // 50% jitter
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
// With jitter, delay should be within range
|
||||
baseDelay := 100 * time.Millisecond
|
||||
minExpected := time.Duration(float64(baseDelay) * 0.5)
|
||||
maxExpected := time.Duration(float64(baseDelay) * 1.5)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
delay := executor.calculateDelay(1)
|
||||
if delay < minExpected || delay > maxExpected {
|
||||
t.Errorf("Delay %v outside expected range [%v, %v]", delay, minExpected, maxExpected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_Reset(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := DefaultRetryConfig()
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
// Generate some metrics
|
||||
executor.ExecuteWithContext(context.Background(), func() error { return nil })
|
||||
executor.ExecuteWithContext(context.Background(), func() error { return nil })
|
||||
|
||||
// Reset
|
||||
executor.Reset()
|
||||
|
||||
if atomic.LoadInt64(&executor.totalRetries) != 0 {
|
||||
t.Error("Expected totalRetries to be 0 after reset")
|
||||
}
|
||||
|
||||
if atomic.LoadInt64(&executor.maxRetriesHit) != 0 {
|
||||
t.Error("Expected maxRetriesHit to be 0 after reset")
|
||||
}
|
||||
|
||||
if atomic.LoadInt64(&executor.totalRequests) != 0 {
|
||||
t.Error("Expected totalRequests to be 0 after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_IsAvailable(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := DefaultRetryConfig()
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
if !executor.IsAvailable() {
|
||||
t.Error("Expected IsAvailable to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_GetMetrics(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := DefaultRetryConfig()
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
// Generate some metrics
|
||||
executor.ExecuteWithContext(context.Background(), func() error { return nil })
|
||||
|
||||
metrics := executor.GetMetrics()
|
||||
|
||||
// Check required fields
|
||||
if _, ok := metrics["totalRetries"]; !ok {
|
||||
t.Error("Expected 'totalRetries' in metrics")
|
||||
}
|
||||
|
||||
if _, ok := metrics["maxRetriesHit"]; !ok {
|
||||
t.Error("Expected 'maxRetriesHit' in metrics")
|
||||
}
|
||||
|
||||
if _, ok := metrics["config"]; !ok {
|
||||
t.Error("Expected 'config' in metrics")
|
||||
}
|
||||
|
||||
if _, ok := metrics["lastRetryTime"]; !ok {
|
||||
t.Error("Expected 'lastRetryTime' in metrics")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_GetMetrics_WithRetries(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1 * time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
RetryableErrors: []string{"retry"},
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
// Generate retries
|
||||
callCount := 0
|
||||
executor.ExecuteWithContext(context.Background(), func() error {
|
||||
callCount++
|
||||
if callCount < 2 {
|
||||
return errors.New("retry me")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
metrics := executor.GetMetrics()
|
||||
|
||||
totalRetries := metrics["totalRetries"].(int64)
|
||||
if totalRetries < 1 {
|
||||
t.Errorf("Expected at least 1 retry, got %d", totalRetries)
|
||||
}
|
||||
|
||||
// Check for average retries calculation
|
||||
if _, ok := metrics["averageRetriesPerRequest"]; !ok {
|
||||
t.Error("Expected 'averageRetriesPerRequest' in metrics")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RECOVERY METRICS TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestNewRecoveryMetrics(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
|
||||
if rm == nil {
|
||||
t.Fatal("Expected NewRecoveryMetrics to return non-nil")
|
||||
}
|
||||
|
||||
if rm.mechanisms == nil {
|
||||
t.Error("Expected mechanisms map to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_RegisterMechanism(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism("circuit_breaker", cb)
|
||||
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
|
||||
if _, exists := rm.mechanisms["circuit_breaker"]; !exists {
|
||||
t.Error("Expected mechanism to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_UnregisterMechanism(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism("circuit_breaker", cb)
|
||||
rm.UnregisterMechanism("circuit_breaker")
|
||||
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
|
||||
if _, exists := rm.mechanisms["circuit_breaker"]; exists {
|
||||
t.Error("Expected mechanism to be unregistered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_GetAllMetrics(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism("circuit_breaker", cb)
|
||||
|
||||
re := NewRetryExecutor(DefaultRetryConfig(), logger)
|
||||
rm.RegisterMechanism("retry_executor", re)
|
||||
|
||||
metrics := rm.GetAllMetrics()
|
||||
|
||||
if _, ok := metrics["circuit_breaker"]; !ok {
|
||||
t.Error("Expected 'circuit_breaker' in metrics")
|
||||
}
|
||||
|
||||
if _, ok := metrics["retry_executor"]; !ok {
|
||||
t.Error("Expected 'retry_executor' in metrics")
|
||||
}
|
||||
|
||||
if _, ok := metrics["summary"]; !ok {
|
||||
t.Error("Expected 'summary' in metrics")
|
||||
}
|
||||
|
||||
summary := metrics["summary"].(map[string]interface{})
|
||||
if summary["totalMechanisms"] != 2 {
|
||||
t.Errorf("Expected 2 mechanisms, got %v", summary["totalMechanisms"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_GetAllMetrics_WithActivity(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism("circuit_breaker", cb)
|
||||
|
||||
// Generate some activity
|
||||
cb.Execute(func() error { return nil })
|
||||
cb.Execute(func() error { return nil })
|
||||
|
||||
metrics := rm.GetAllMetrics()
|
||||
summary := metrics["summary"].(map[string]interface{})
|
||||
|
||||
// Should have success rate calculated
|
||||
if _, ok := summary["overallSuccessRate"]; !ok {
|
||||
t.Error("Expected 'overallSuccessRate' in summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_GetMechanismMetrics(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism("circuit_breaker", cb)
|
||||
|
||||
// Test existing mechanism
|
||||
metrics, ok := rm.GetMechanismMetrics("circuit_breaker")
|
||||
if !ok {
|
||||
t.Error("Expected to find circuit_breaker mechanism")
|
||||
}
|
||||
if metrics == nil {
|
||||
t.Error("Expected metrics to be non-nil")
|
||||
}
|
||||
|
||||
// Test non-existing mechanism
|
||||
_, ok = rm.GetMechanismMetrics("non_existent")
|
||||
if ok {
|
||||
t.Error("Expected to not find non_existent mechanism")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_HealthCheck(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
// Test with healthy mechanism
|
||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism("circuit_breaker", cb)
|
||||
|
||||
health := rm.HealthCheck()
|
||||
|
||||
if health["status"] != "healthy" {
|
||||
t.Errorf("Expected status 'healthy', got %v", health["status"])
|
||||
}
|
||||
|
||||
mechanisms := health["mechanisms"].(map[string]interface{})
|
||||
if mechanisms["circuit_breaker"] != "healthy" {
|
||||
t.Errorf("Expected circuit_breaker to be 'healthy', got %v", mechanisms["circuit_breaker"])
|
||||
}
|
||||
|
||||
if health["healthy"] != 1 {
|
||||
t.Errorf("Expected 1 healthy, got %v", health["healthy"])
|
||||
}
|
||||
|
||||
if health["unhealthy"] != 0 {
|
||||
t.Errorf("Expected 0 unhealthy, got %v", health["unhealthy"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_HealthCheck_Degraded(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
// Add a healthy mechanism
|
||||
cb1 := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism("healthy_cb", cb1)
|
||||
|
||||
// Add an unhealthy mechanism (trip the circuit breaker)
|
||||
config := CircuitBreakerConfig{
|
||||
FailureThreshold: 1,
|
||||
SuccessThreshold: 10,
|
||||
Timeout: 1 * time.Hour,
|
||||
MaxRequests: 1,
|
||||
}
|
||||
cb2 := NewCircuitBreaker(config, logger)
|
||||
cb2.Execute(func() error { return errors.New("fail") })
|
||||
rm.RegisterMechanism("unhealthy_cb", cb2)
|
||||
|
||||
health := rm.HealthCheck()
|
||||
|
||||
if health["status"] != "degraded" {
|
||||
t.Errorf("Expected status 'degraded', got %v", health["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_HealthCheck_Unhealthy(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
// Add only an unhealthy mechanism
|
||||
config := CircuitBreakerConfig{
|
||||
FailureThreshold: 1,
|
||||
SuccessThreshold: 10,
|
||||
Timeout: 1 * time.Hour,
|
||||
MaxRequests: 1,
|
||||
}
|
||||
cb := NewCircuitBreaker(config, logger)
|
||||
cb.Execute(func() error { return errors.New("fail") })
|
||||
rm.RegisterMechanism("unhealthy_cb", cb)
|
||||
|
||||
health := rm.HealthCheck()
|
||||
|
||||
if health["status"] != "unhealthy" {
|
||||
t.Errorf("Expected status 'unhealthy', got %v", health["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryMetrics_HTTPMetricsHandler(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism("circuit_breaker", cb)
|
||||
|
||||
handler := rm.HTTPMetricsHandler()
|
||||
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if body == "" {
|
||||
t.Error("Expected non-empty response body")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONCURRENT ACCESS TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestRecoveryMetrics_ConcurrentAccess(t *testing.T) {
|
||||
rm := NewRecoveryMetrics()
|
||||
logger := &mockLogger{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent registrations
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
||||
rm.RegisterMechanism(string(rune('a'+idx)), cb)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent reads
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = rm.GetAllMetrics()
|
||||
_ = rm.HealthCheck()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify no race conditions
|
||||
health := rm.HealthCheck()
|
||||
if health == nil {
|
||||
t.Error("Expected HealthCheck to return non-nil after concurrent access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryExecutor_ConcurrentExecution(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
config := RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1 * time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
RetryableErrors: []string{"retry"},
|
||||
}
|
||||
executor := NewRetryExecutor(config, logger)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := int64(0)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := executor.ExecuteWithContext(context.Background(), func() error {
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if successCount != 100 {
|
||||
t.Errorf("Expected 100 successes, got %d", successCount)
|
||||
}
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
// Package security provides security-related middleware and utilities
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityHeadersConfig configures security headers
|
||||
type SecurityHeadersConfig struct {
|
||||
// Content Security Policy
|
||||
ContentSecurityPolicy string
|
||||
|
||||
// HSTS settings
|
||||
StrictTransportSecurity string
|
||||
StrictTransportSecurityMaxAge int // seconds
|
||||
StrictTransportSecuritySubdomains bool
|
||||
StrictTransportSecurityPreload bool
|
||||
|
||||
// Frame options
|
||||
FrameOptions string // DENY, SAMEORIGIN, or ALLOW-FROM uri
|
||||
|
||||
// Content type options
|
||||
ContentTypeOptions string // nosniff
|
||||
|
||||
// XSS protection
|
||||
XSSProtection string // 1; mode=block
|
||||
|
||||
// Referrer policy
|
||||
ReferrerPolicy string
|
||||
|
||||
// Permissions policy
|
||||
PermissionsPolicy string
|
||||
|
||||
// Cross-origin settings
|
||||
CrossOriginEmbedderPolicy string
|
||||
CrossOriginOpenerPolicy string
|
||||
CrossOriginResourcePolicy string
|
||||
|
||||
// CORS settings
|
||||
CORSEnabled bool
|
||||
CORSAllowedOrigins []string
|
||||
CORSAllowedMethods []string
|
||||
CORSAllowedHeaders []string
|
||||
CORSAllowCredentials bool
|
||||
CORSMaxAge int // seconds
|
||||
|
||||
// Custom headers
|
||||
CustomHeaders map[string]string
|
||||
|
||||
// Security features
|
||||
DisableServerHeader bool
|
||||
DisablePoweredByHeader bool
|
||||
|
||||
// Development mode (less strict for local development)
|
||||
DevelopmentMode bool
|
||||
}
|
||||
|
||||
// DefaultSecurityConfig returns a secure default configuration
|
||||
func DefaultSecurityConfig() *SecurityHeadersConfig {
|
||||
return &SecurityHeadersConfig{
|
||||
ContentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';",
|
||||
|
||||
StrictTransportSecurityMaxAge: 31536000, // 1 year
|
||||
StrictTransportSecuritySubdomains: true,
|
||||
StrictTransportSecurityPreload: true,
|
||||
|
||||
FrameOptions: "DENY",
|
||||
ContentTypeOptions: "nosniff",
|
||||
XSSProtection: "1; mode=block",
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
|
||||
PermissionsPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()",
|
||||
|
||||
CrossOriginEmbedderPolicy: "require-corp",
|
||||
CrossOriginOpenerPolicy: "same-origin",
|
||||
CrossOriginResourcePolicy: "same-origin",
|
||||
|
||||
CORSEnabled: false,
|
||||
CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
CORSAllowedHeaders: []string{"Authorization", "Content-Type", "X-Requested-With"},
|
||||
CORSMaxAge: 86400, // 24 hours
|
||||
|
||||
DisableServerHeader: true,
|
||||
DisablePoweredByHeader: true,
|
||||
|
||||
DevelopmentMode: false,
|
||||
}
|
||||
}
|
||||
|
||||
// DevelopmentSecurityConfig returns a configuration suitable for development
|
||||
func DevelopmentSecurityConfig() *SecurityHeadersConfig {
|
||||
config := DefaultSecurityConfig()
|
||||
|
||||
// Relax CSP for development
|
||||
config.ContentSecurityPolicy = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: http:; connect-src 'self' ws: wss:;"
|
||||
|
||||
// Allow framing for development tools
|
||||
config.FrameOptions = "SAMEORIGIN"
|
||||
|
||||
// Enable CORS for local development
|
||||
config.CORSEnabled = true
|
||||
config.CORSAllowedOrigins = []string{"http://localhost:*", "http://127.0.0.1:*"}
|
||||
config.CORSAllowCredentials = true
|
||||
|
||||
// Relax cross-origin policies
|
||||
config.CrossOriginEmbedderPolicy = ""
|
||||
config.CrossOriginOpenerPolicy = "unsafe-none"
|
||||
config.CrossOriginResourcePolicy = "cross-origin"
|
||||
|
||||
config.DevelopmentMode = true
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// SecurityHeadersMiddleware applies security headers to HTTP responses
|
||||
type SecurityHeadersMiddleware struct {
|
||||
config *SecurityHeadersConfig
|
||||
}
|
||||
|
||||
// NewSecurityHeadersMiddleware creates a new security headers middleware
|
||||
func NewSecurityHeadersMiddleware(config *SecurityHeadersConfig) *SecurityHeadersMiddleware {
|
||||
if config == nil {
|
||||
config = DefaultSecurityConfig()
|
||||
}
|
||||
|
||||
return &SecurityHeadersMiddleware{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Apply applies security headers to the response
|
||||
func (m *SecurityHeadersMiddleware) Apply(rw http.ResponseWriter, req *http.Request) {
|
||||
headers := rw.Header()
|
||||
|
||||
// Content Security Policy
|
||||
if m.config.ContentSecurityPolicy != "" {
|
||||
headers.Set("Content-Security-Policy", m.config.ContentSecurityPolicy)
|
||||
}
|
||||
|
||||
// HSTS (only for HTTPS)
|
||||
if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
hstsValue := m.buildHSTSHeader()
|
||||
if hstsValue != "" {
|
||||
headers.Set("Strict-Transport-Security", hstsValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Frame options
|
||||
if m.config.FrameOptions != "" {
|
||||
headers.Set("X-Frame-Options", m.config.FrameOptions)
|
||||
}
|
||||
|
||||
// Content type options
|
||||
if m.config.ContentTypeOptions != "" {
|
||||
headers.Set("X-Content-Type-Options", m.config.ContentTypeOptions)
|
||||
}
|
||||
|
||||
// XSS protection
|
||||
if m.config.XSSProtection != "" {
|
||||
headers.Set("X-XSS-Protection", m.config.XSSProtection)
|
||||
}
|
||||
|
||||
// Referrer policy
|
||||
if m.config.ReferrerPolicy != "" {
|
||||
headers.Set("Referrer-Policy", m.config.ReferrerPolicy)
|
||||
}
|
||||
|
||||
// Permissions policy
|
||||
if m.config.PermissionsPolicy != "" {
|
||||
headers.Set("Permissions-Policy", m.config.PermissionsPolicy)
|
||||
}
|
||||
|
||||
// Cross-origin policies
|
||||
if m.config.CrossOriginEmbedderPolicy != "" {
|
||||
headers.Set("Cross-Origin-Embedder-Policy", m.config.CrossOriginEmbedderPolicy)
|
||||
}
|
||||
|
||||
if m.config.CrossOriginOpenerPolicy != "" {
|
||||
headers.Set("Cross-Origin-Opener-Policy", m.config.CrossOriginOpenerPolicy)
|
||||
}
|
||||
|
||||
if m.config.CrossOriginResourcePolicy != "" {
|
||||
headers.Set("Cross-Origin-Resource-Policy", m.config.CrossOriginResourcePolicy)
|
||||
}
|
||||
|
||||
// CORS headers
|
||||
if m.config.CORSEnabled {
|
||||
m.applyCORSHeaders(rw, req)
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
for name, value := range m.config.CustomHeaders {
|
||||
headers.Set(name, value)
|
||||
}
|
||||
|
||||
// Remove server identification headers
|
||||
if m.config.DisableServerHeader {
|
||||
headers.Del("Server")
|
||||
}
|
||||
|
||||
if m.config.DisablePoweredByHeader {
|
||||
headers.Del("X-Powered-By")
|
||||
}
|
||||
|
||||
// Add security timestamp for debugging
|
||||
if m.config.DevelopmentMode {
|
||||
headers.Set("X-Security-Headers-Applied", time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// buildHSTSHeader constructs the HSTS header value
|
||||
func (m *SecurityHeadersMiddleware) buildHSTSHeader() string {
|
||||
if m.config.StrictTransportSecurityMaxAge <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
"max-age=" + string(rune(m.config.StrictTransportSecurityMaxAge)),
|
||||
}
|
||||
|
||||
if m.config.StrictTransportSecuritySubdomains {
|
||||
parts = append(parts, "includeSubDomains")
|
||||
}
|
||||
|
||||
if m.config.StrictTransportSecurityPreload {
|
||||
parts = append(parts, "preload")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// applyCORSHeaders applies CORS headers based on the request
|
||||
func (m *SecurityHeadersMiddleware) applyCORSHeaders(rw http.ResponseWriter, req *http.Request) {
|
||||
headers := rw.Header()
|
||||
origin := req.Header.Get("Origin")
|
||||
|
||||
// Check if origin is allowed
|
||||
if origin != "" && m.isOriginAllowed(origin) {
|
||||
headers.Set("Access-Control-Allow-Origin", origin)
|
||||
} else if len(m.config.CORSAllowedOrigins) == 1 && m.config.CORSAllowedOrigins[0] == "*" {
|
||||
headers.Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
// Set other CORS headers
|
||||
if len(m.config.CORSAllowedMethods) > 0 {
|
||||
headers.Set("Access-Control-Allow-Methods", strings.Join(m.config.CORSAllowedMethods, ", "))
|
||||
}
|
||||
|
||||
if len(m.config.CORSAllowedHeaders) > 0 {
|
||||
headers.Set("Access-Control-Allow-Headers", strings.Join(m.config.CORSAllowedHeaders, ", "))
|
||||
}
|
||||
|
||||
if m.config.CORSAllowCredentials {
|
||||
headers.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
if m.config.CORSMaxAge > 0 {
|
||||
headers.Set("Access-Control-Max-Age", string(rune(m.config.CORSMaxAge)))
|
||||
}
|
||||
|
||||
// Handle preflight requests
|
||||
if req.Method == "OPTIONS" {
|
||||
headers.Set("Access-Control-Allow-Methods", strings.Join(m.config.CORSAllowedMethods, ", "))
|
||||
headers.Set("Access-Control-Allow-Headers", strings.Join(m.config.CORSAllowedHeaders, ", "))
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// isOriginAllowed checks if the origin is in the allowed list
|
||||
func (m *SecurityHeadersMiddleware) isOriginAllowed(origin string) bool {
|
||||
for _, allowed := range m.config.CORSAllowedOrigins {
|
||||
if m.matchOrigin(origin, allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchOrigin checks if an origin matches an allowed pattern
|
||||
func (m *SecurityHeadersMiddleware) matchOrigin(origin, pattern string) bool {
|
||||
// Exact match
|
||||
if origin == pattern {
|
||||
return true
|
||||
}
|
||||
|
||||
// Wildcard subdomain match (e.g., "https://*.example.com")
|
||||
if strings.Contains(pattern, "*") {
|
||||
// Simple wildcard matching for subdomains
|
||||
if strings.HasPrefix(pattern, "https://*.") {
|
||||
domain := strings.TrimPrefix(pattern, "https://*.")
|
||||
if strings.HasSuffix(origin, "."+domain) || origin == "https://"+domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(pattern, "http://*.") {
|
||||
domain := strings.TrimPrefix(pattern, "http://*.")
|
||||
if strings.HasSuffix(origin, "."+domain) || origin == "http://"+domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Port wildcard match (e.g., "http://localhost:*")
|
||||
if strings.HasSuffix(pattern, ":*") {
|
||||
prefix := strings.TrimSuffix(pattern, ":*")
|
||||
if strings.HasPrefix(origin, prefix+":") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Wrap wraps an HTTP handler with security headers
|
||||
func (m *SecurityHeadersMiddleware) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
m.Apply(rw, req)
|
||||
next.ServeHTTP(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
// SecurityHeadersHandler is a convenience function that creates and applies security headers
|
||||
func SecurityHeadersHandler(config *SecurityHeadersConfig) func(http.ResponseWriter, *http.Request) {
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
return middleware.Apply
|
||||
}
|
||||
|
||||
// Common security header presets
|
||||
|
||||
// StrictSecurityConfig returns a very strict security configuration
|
||||
func StrictSecurityConfig() *SecurityHeadersConfig {
|
||||
config := DefaultSecurityConfig()
|
||||
|
||||
// Very strict CSP
|
||||
config.ContentSecurityPolicy = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||
|
||||
// Stricter frame options
|
||||
config.FrameOptions = "DENY"
|
||||
|
||||
// Disable CORS entirely
|
||||
config.CORSEnabled = false
|
||||
|
||||
// Very strict cross-origin policies
|
||||
config.CrossOriginEmbedderPolicy = "require-corp"
|
||||
config.CrossOriginOpenerPolicy = "same-origin"
|
||||
config.CrossOriginResourcePolicy = "same-site"
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// APISecurityConfig returns a configuration suitable for APIs
|
||||
func APISecurityConfig() *SecurityHeadersConfig {
|
||||
config := DefaultSecurityConfig()
|
||||
|
||||
// API-friendly CSP
|
||||
config.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none';"
|
||||
|
||||
// Enable CORS for APIs
|
||||
config.CORSEnabled = true
|
||||
config.CORSAllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}
|
||||
config.CORSAllowedHeaders = []string{"Authorization", "Content-Type", "X-Requested-With", "X-API-Key"}
|
||||
|
||||
// API-appropriate policies
|
||||
config.CrossOriginResourcePolicy = "cross-origin"
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// ValidateConfig validates the security configuration
|
||||
func (c *SecurityHeadersConfig) Validate() error {
|
||||
// Validate HSTS max age
|
||||
if c.StrictTransportSecurityMaxAge < 0 {
|
||||
c.StrictTransportSecurityMaxAge = 0
|
||||
}
|
||||
|
||||
// Validate CORS max age
|
||||
if c.CORSMaxAge < 0 {
|
||||
c.CORSMaxAge = 0
|
||||
}
|
||||
|
||||
// Validate frame options
|
||||
validFrameOptions := []string{"DENY", "SAMEORIGIN", ""}
|
||||
isValidFrameOption := false
|
||||
for _, valid := range validFrameOptions {
|
||||
if c.FrameOptions == valid || strings.HasPrefix(c.FrameOptions, "ALLOW-FROM ") {
|
||||
isValidFrameOption = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isValidFrameOption {
|
||||
c.FrameOptions = "DENY"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyToResponseWriter is a helper function to quickly apply security headers
|
||||
func ApplySecurityHeaders(rw http.ResponseWriter, req *http.Request, config *SecurityHeadersConfig) {
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
middleware.Apply(rw, req)
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultSecurityConfig(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
|
||||
if config.ContentSecurityPolicy == "" {
|
||||
t.Error("Expected default CSP to be set")
|
||||
}
|
||||
|
||||
if config.FrameOptions != "DENY" {
|
||||
t.Errorf("Expected frame options to be DENY, got %s", config.FrameOptions)
|
||||
}
|
||||
|
||||
if !config.DisableServerHeader {
|
||||
t.Error("Expected server header to be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_Apply(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
// Create a mock request (HTTPS)
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{} // Mock TLS
|
||||
|
||||
// Create a response recorder
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Apply security headers
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
headers := rr.Header()
|
||||
|
||||
// Check that security headers are set
|
||||
if headers.Get("Content-Security-Policy") == "" {
|
||||
t.Error("Expected CSP header to be set")
|
||||
}
|
||||
|
||||
if headers.Get("X-Frame-Options") != "DENY" {
|
||||
t.Errorf("Expected X-Frame-Options to be DENY, got %s", headers.Get("X-Frame-Options"))
|
||||
}
|
||||
|
||||
if headers.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Errorf("Expected X-Content-Type-Options to be nosniff, got %s", headers.Get("X-Content-Type-Options"))
|
||||
}
|
||||
|
||||
if headers.Get("X-XSS-Protection") != "1; mode=block" {
|
||||
t.Errorf("Expected X-XSS-Protection to be '1; mode=block', got %s", headers.Get("X-XSS-Protection"))
|
||||
}
|
||||
|
||||
// Check HSTS for HTTPS requests
|
||||
hsts := headers.Get("Strict-Transport-Security")
|
||||
if hsts == "" {
|
||||
t.Error("Expected HSTS header for HTTPS request")
|
||||
}
|
||||
|
||||
if !strings.Contains(hsts, "max-age=") {
|
||||
t.Error("Expected HSTS header to contain max-age")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_HTTPSOnly(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
// Test HTTP request (no HSTS)
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
if rr.Header().Get("Strict-Transport-Security") != "" {
|
||||
t.Error("Expected no HSTS header for HTTP request")
|
||||
}
|
||||
|
||||
// Test HTTPS request (with HSTS)
|
||||
req = httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rr = httptest.NewRecorder()
|
||||
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
if rr.Header().Get("Strict-Transport-Security") == "" {
|
||||
t.Error("Expected HSTS header for HTTPS request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSHeaders(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
config.CORSEnabled = true
|
||||
config.CORSAllowedOrigins = []string{"https://example.com", "https://*.test.com"}
|
||||
config.CORSAllowCredentials = true
|
||||
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
expectedOrigin string
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
origin: "https://example.com",
|
||||
expectedOrigin: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain match",
|
||||
origin: "https://api.test.com",
|
||||
expectedOrigin: "https://api.test.com",
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
origin: "https://malicious.com",
|
||||
expectedOrigin: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
if tt.origin != "" {
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
actualOrigin := rr.Header().Get("Access-Control-Allow-Origin")
|
||||
if actualOrigin != tt.expectedOrigin {
|
||||
t.Errorf("Expected origin %s, got %s", tt.expectedOrigin, actualOrigin)
|
||||
}
|
||||
|
||||
if tt.expectedOrigin != "" {
|
||||
// Should have credentials header
|
||||
if rr.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Error("Expected credentials header for allowed origin")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSPreflight(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
config.CORSEnabled = true
|
||||
config.CORSAllowedOrigins = []string{"*"}
|
||||
config.CORSAllowedMethods = []string{"GET", "POST", "OPTIONS"}
|
||||
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "https://example.com/test", nil)
|
||||
req.Header.Set("Origin", "https://other.com")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
if rr.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Error("Expected wildcard origin for preflight request")
|
||||
}
|
||||
|
||||
if rr.Header().Get("Access-Control-Allow-Methods") == "" {
|
||||
t.Error("Expected methods header for preflight request")
|
||||
}
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for preflight, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginMatching(t *testing.T) {
|
||||
config := &SecurityHeadersConfig{
|
||||
CORSEnabled: true,
|
||||
CORSAllowedOrigins: []string{
|
||||
"https://example.com",
|
||||
"https://*.example.com",
|
||||
"http://localhost:*",
|
||||
},
|
||||
}
|
||||
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
tests := []struct {
|
||||
origin string
|
||||
expected bool
|
||||
}{
|
||||
{"https://example.com", true},
|
||||
{"https://api.example.com", true},
|
||||
{"https://sub.api.example.com", true},
|
||||
{"http://localhost:3000", true},
|
||||
{"http://localhost:8080", true},
|
||||
{"https://malicious.com", false},
|
||||
{"http://example.com", false}, // Different scheme
|
||||
{"https://example.com.evil.com", false}, // Domain suffix attack
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.origin, func(t *testing.T) {
|
||||
result := middleware.isOriginAllowed(tt.origin)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Origin %s: expected %v, got %v", tt.origin, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentMode(t *testing.T) {
|
||||
config := DevelopmentSecurityConfig()
|
||||
|
||||
if !config.DevelopmentMode {
|
||||
t.Error("Expected development mode to be enabled")
|
||||
}
|
||||
|
||||
if !config.CORSEnabled {
|
||||
t.Error("Expected CORS to be enabled in development mode")
|
||||
}
|
||||
|
||||
if config.FrameOptions != "SAMEORIGIN" {
|
||||
t.Errorf("Expected frame options to be SAMEORIGIN in dev mode, got %s", config.FrameOptions)
|
||||
}
|
||||
|
||||
// Should be less strict CSP
|
||||
if strings.Contains(config.ContentSecurityPolicy, "'none'") {
|
||||
t.Error("Expected less strict CSP in development mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictSecurityConfig(t *testing.T) {
|
||||
config := StrictSecurityConfig()
|
||||
|
||||
if !strings.Contains(config.ContentSecurityPolicy, "'none'") {
|
||||
t.Error("Expected very strict CSP with 'none' defaults")
|
||||
}
|
||||
|
||||
if config.CORSEnabled {
|
||||
t.Error("Expected CORS to be disabled in strict mode")
|
||||
}
|
||||
|
||||
if config.FrameOptions != "DENY" {
|
||||
t.Error("Expected frame options to be DENY in strict mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISecurityConfig(t *testing.T) {
|
||||
config := APISecurityConfig()
|
||||
|
||||
if !config.CORSEnabled {
|
||||
t.Error("Expected CORS to be enabled for API config")
|
||||
}
|
||||
|
||||
methods := config.CORSAllowedMethods
|
||||
expectedMethods := []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}
|
||||
|
||||
for _, method := range expectedMethods {
|
||||
found := false
|
||||
for _, allowed := range methods {
|
||||
if allowed == method {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected method %s to be allowed in API config", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareWrap(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
// Create a simple handler
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Wrap with security middleware
|
||||
wrappedHandler := middleware.Wrap(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
|
||||
// Check response
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
if rr.Body.String() != "OK" {
|
||||
t.Errorf("Expected body 'OK', got %s", rr.Body.String())
|
||||
}
|
||||
|
||||
// Check security headers were applied
|
||||
if rr.Header().Get("X-Frame-Options") == "" {
|
||||
t.Error("Expected security headers to be applied by wrapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
config := &SecurityHeadersConfig{
|
||||
StrictTransportSecurityMaxAge: -1,
|
||||
CORSMaxAge: -1,
|
||||
FrameOptions: "INVALID",
|
||||
}
|
||||
|
||||
err := config.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected validation error: %v", err)
|
||||
}
|
||||
|
||||
// Should fix invalid values
|
||||
if config.StrictTransportSecurityMaxAge != 0 {
|
||||
t.Error("Expected negative HSTS max age to be reset to 0")
|
||||
}
|
||||
|
||||
if config.CORSMaxAge != 0 {
|
||||
t.Error("Expected negative CORS max age to be reset to 0")
|
||||
}
|
||||
|
||||
if config.FrameOptions != "DENY" {
|
||||
t.Error("Expected invalid frame options to be reset to DENY")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSecurityHeadersApply(b *testing.B) {
|
||||
config := DefaultSecurityConfig()
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
rr := httptest.NewRecorder()
|
||||
middleware.Apply(rr, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
// Package singleton provides a centralized, thread-safe singleton management system
|
||||
// that consolidates all singleton patterns used throughout the application.
|
||||
// It ensures proper initialization, lifecycle management, and graceful shutdown.
|
||||
package singleton
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Registry is the centralized singleton registry that manages all singleton instances
|
||||
// in the application. It provides thread-safe initialization, access, and cleanup.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
instances map[string]*Instance
|
||||
groups map[string]*Group
|
||||
shutdown int32
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// Instance represents a singleton instance with lifecycle management
|
||||
type Instance struct {
|
||||
name string
|
||||
value interface{}
|
||||
initializer func() interface{}
|
||||
finalizer func(interface{})
|
||||
once sync.Once
|
||||
refCount int32
|
||||
}
|
||||
|
||||
// Group represents a group of related singletons
|
||||
type Group struct {
|
||||
name string
|
||||
instances map[string]*Instance
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
// globalRegistry is the singleton registry instance
|
||||
globalRegistry *Registry
|
||||
// registryOnce ensures single initialization
|
||||
registryOnce sync.Once
|
||||
)
|
||||
|
||||
// Get returns the global singleton registry
|
||||
func Get() *Registry {
|
||||
registryOnce.Do(func() {
|
||||
globalRegistry = &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
})
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
// Register registers a new singleton with its initializer and optional finalizer
|
||||
func (r *Registry) Register(name string, initializer func() interface{}, finalizer func(interface{})) error {
|
||||
if atomic.LoadInt32(&r.shutdown) == 1 {
|
||||
return fmt.Errorf("registry is shutting down")
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.instances[name]; exists {
|
||||
return fmt.Errorf("singleton %s already registered", name)
|
||||
}
|
||||
|
||||
r.instances[name] = &Instance{
|
||||
name: name,
|
||||
initializer: initializer,
|
||||
finalizer: finalizer,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstance retrieves or initializes a singleton instance
|
||||
func (r *Registry) GetInstance(name string) (interface{}, error) {
|
||||
if atomic.LoadInt32(&r.shutdown) == 1 {
|
||||
return nil, fmt.Errorf("registry is shutting down")
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
instance, exists := r.instances[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("singleton %s not registered", name)
|
||||
}
|
||||
|
||||
// Initialize the singleton if needed
|
||||
instance.once.Do(func() {
|
||||
if instance.initializer != nil {
|
||||
instance.value = instance.initializer()
|
||||
atomic.AddInt32(&instance.refCount, 1)
|
||||
}
|
||||
})
|
||||
|
||||
return instance.value, nil
|
||||
}
|
||||
|
||||
// MustGet retrieves a singleton instance, panicking if not found
|
||||
func (r *Registry) MustGet(name string) interface{} {
|
||||
val, err := r.GetInstance(name)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("singleton %s: %v", name, err))
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// RegisterGroup creates a new singleton group
|
||||
func (r *Registry) RegisterGroup(name string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.groups[name]; exists {
|
||||
return fmt.Errorf("group %s already exists", name)
|
||||
}
|
||||
|
||||
r.groups[name] = &Group{
|
||||
name: name,
|
||||
instances: make(map[string]*Instance),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddToGroup adds a singleton to a group
|
||||
func (r *Registry) AddToGroup(groupName, singletonName string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
group, groupExists := r.groups[groupName]
|
||||
if !groupExists {
|
||||
return fmt.Errorf("group %s does not exist", groupName)
|
||||
}
|
||||
|
||||
instance, instanceExists := r.instances[singletonName]
|
||||
if !instanceExists {
|
||||
return fmt.Errorf("singleton %s not registered", singletonName)
|
||||
}
|
||||
|
||||
group.mu.Lock()
|
||||
defer group.mu.Unlock()
|
||||
|
||||
group.instances[singletonName] = instance
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroup retrieves all singletons in a group
|
||||
func (r *Registry) GetGroup(name string) (map[string]interface{}, error) {
|
||||
r.mu.RLock()
|
||||
group, exists := r.groups[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("group %s does not exist", name)
|
||||
}
|
||||
|
||||
group.mu.RLock()
|
||||
defer group.mu.RUnlock()
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for name, instance := range group.instances {
|
||||
if instance.value != nil {
|
||||
result[name] = instance.value
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AddReference increments the reference count for a singleton
|
||||
func (r *Registry) AddReference(name string) error {
|
||||
r.mu.RLock()
|
||||
instance, exists := r.instances[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("singleton %s not registered", name)
|
||||
}
|
||||
|
||||
atomic.AddInt32(&instance.refCount, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseReference decrements the reference count for a singleton
|
||||
func (r *Registry) ReleaseReference(name string) error {
|
||||
r.mu.RLock()
|
||||
instance, exists := r.instances[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("singleton %s not registered", name)
|
||||
}
|
||||
|
||||
count := atomic.AddInt32(&instance.refCount, -1)
|
||||
if count == 0 && instance.finalizer != nil && instance.value != nil {
|
||||
// Run finalizer when last reference is released
|
||||
go instance.finalizer(instance.value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetReferenceCount returns the reference count for a singleton
|
||||
func (r *Registry) GetReferenceCount(name string) (int32, error) {
|
||||
r.mu.RLock()
|
||||
instance, exists := r.instances[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("singleton %s not registered", name)
|
||||
}
|
||||
|
||||
return atomic.LoadInt32(&instance.refCount), nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down all singletons
|
||||
func (r *Registry) Shutdown(ctx context.Context) error {
|
||||
if !atomic.CompareAndSwapInt32(&r.shutdown, 0, 1) {
|
||||
return fmt.Errorf("registry already shutting down")
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Create error channel for collecting shutdown errors
|
||||
errChan := make(chan error, len(r.instances))
|
||||
|
||||
// Run finalizers for all initialized singletons
|
||||
for name, instance := range r.instances {
|
||||
if instance.value != nil && instance.finalizer != nil {
|
||||
r.wg.Add(1)
|
||||
go func(n string, i *Instance) {
|
||||
defer r.wg.Done()
|
||||
|
||||
// Run finalizer with panic recovery
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("finalizer for %s panicked: %v", n, r)
|
||||
}
|
||||
}()
|
||||
i.finalizer(i.value)
|
||||
}()
|
||||
}(name, instance)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all finalizers to complete or timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
r.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// All finalizers completed
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("shutdown timeout: %w", ctx.Err())
|
||||
}
|
||||
|
||||
// Collect any errors
|
||||
close(errChan)
|
||||
var errs []error
|
||||
for err := range errChan {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all instances
|
||||
r.instances = make(map[string]*Instance)
|
||||
r.groups = make(map[string]*Group)
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("shutdown errors: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset resets the registry (mainly for testing)
|
||||
func (r *Registry) Reset() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.instances = make(map[string]*Instance)
|
||||
r.groups = make(map[string]*Group)
|
||||
atomic.StoreInt32(&r.shutdown, 0)
|
||||
}
|
||||
|
||||
// Stats returns statistics about the registry
|
||||
type Stats struct {
|
||||
TotalRegistered int
|
||||
TotalInitialized int
|
||||
TotalGroups int
|
||||
TotalReferences int32
|
||||
}
|
||||
|
||||
// GetStats returns current registry statistics
|
||||
func (r *Registry) GetStats() Stats {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
stats := Stats{
|
||||
TotalRegistered: len(r.instances),
|
||||
TotalGroups: len(r.groups),
|
||||
}
|
||||
|
||||
for _, instance := range r.instances {
|
||||
if instance.value != nil {
|
||||
stats.TotalInitialized++
|
||||
}
|
||||
stats.TotalReferences += atomic.LoadInt32(&instance.refCount)
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Builder provides a fluent interface for registering singletons
|
||||
type Builder struct {
|
||||
registry *Registry
|
||||
name string
|
||||
initializer func() interface{}
|
||||
finalizer func(interface{})
|
||||
group string
|
||||
}
|
||||
|
||||
// NewBuilder creates a new singleton builder
|
||||
func NewBuilder(name string) *Builder {
|
||||
return &Builder{
|
||||
registry: Get(),
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// WithInitializer sets the initializer function
|
||||
func (b *Builder) WithInitializer(init func() interface{}) *Builder {
|
||||
b.initializer = init
|
||||
return b
|
||||
}
|
||||
|
||||
// WithFinalizer sets the finalizer function
|
||||
func (b *Builder) WithFinalizer(final func(interface{})) *Builder {
|
||||
b.finalizer = final
|
||||
return b
|
||||
}
|
||||
|
||||
// InGroup adds the singleton to a group
|
||||
func (b *Builder) InGroup(group string) *Builder {
|
||||
b.group = group
|
||||
return b
|
||||
}
|
||||
|
||||
// Register registers the singleton with the configured options
|
||||
func (b *Builder) Register() error {
|
||||
if err := b.registry.Register(b.name, b.initializer, b.finalizer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.group != "" {
|
||||
// Ensure group exists
|
||||
if err := b.registry.RegisterGroup(b.group); err != nil {
|
||||
// Group might already exist, which is ok
|
||||
if !contains(err.Error(), "already exists") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return b.registry.AddToGroup(b.group, b.name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to check if string contains substring
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr))
|
||||
}
|
||||
|
||||
func containsHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,970 +0,0 @@
|
||||
package singleton
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGet_Singleton tests that Get() returns the same instance
|
||||
func TestGet_Singleton(t *testing.T) {
|
||||
registry1 := Get()
|
||||
registry2 := Get()
|
||||
|
||||
if registry1 != registry2 {
|
||||
t.Error("Get() should return the same instance (singleton)")
|
||||
}
|
||||
|
||||
if registry1 == nil {
|
||||
t.Error("Get() should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Register tests singleton registration
|
||||
func TestRegistry_Register(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
initializer := func() interface{} {
|
||||
return "test-value"
|
||||
}
|
||||
|
||||
finalizer := func(v interface{}) {
|
||||
// Mock finalizer
|
||||
}
|
||||
|
||||
// Test successful registration
|
||||
err := registry.Register("test-singleton", initializer, finalizer)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance was registered
|
||||
if len(registry.instances) != 1 {
|
||||
t.Error("Instance should be registered")
|
||||
}
|
||||
|
||||
instance := registry.instances["test-singleton"]
|
||||
if instance == nil {
|
||||
t.Error("Instance should not be nil")
|
||||
return
|
||||
}
|
||||
|
||||
if instance.name != "test-singleton" {
|
||||
t.Errorf("Instance name should be 'test-singleton', got '%s'", instance.name)
|
||||
}
|
||||
|
||||
if instance.initializer == nil {
|
||||
t.Error("Instance should have initializer")
|
||||
}
|
||||
|
||||
if instance.finalizer == nil {
|
||||
t.Error("Instance should have finalizer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Register_Duplicate tests duplicate registration
|
||||
func TestRegistry_Register_Duplicate(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
initializer := func() interface{} {
|
||||
return "test-value"
|
||||
}
|
||||
|
||||
// Register first time
|
||||
err := registry.Register("test-singleton", initializer, nil)
|
||||
if err != nil {
|
||||
t.Errorf("First registration should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Register again - should fail
|
||||
err = registry.Register("test-singleton", initializer, nil)
|
||||
if err == nil {
|
||||
t.Error("Duplicate registration should fail")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "already registered") {
|
||||
t.Errorf("Error should mention already registered, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Register_DuringShutdown tests registration during shutdown
|
||||
func TestRegistry_Register_DuringShutdown(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
shutdown: 1, // Already shutting down
|
||||
}
|
||||
|
||||
initializer := func() interface{} {
|
||||
return "test-value"
|
||||
}
|
||||
|
||||
err := registry.Register("test-singleton", initializer, nil)
|
||||
if err == nil {
|
||||
t.Error("Registration during shutdown should fail")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "shutting down") {
|
||||
t.Errorf("Error should mention shutting down, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_GetInstance tests singleton retrieval and initialization
|
||||
func TestRegistry_GetInstance(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
callCount := int32(0)
|
||||
testValue := "test-value"
|
||||
|
||||
initializer := func() interface{} {
|
||||
atomic.AddInt32(&callCount, 1)
|
||||
return testValue
|
||||
}
|
||||
|
||||
// Register singleton
|
||||
err := registry.Register("test-singleton", initializer, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// First get - should initialize
|
||||
value1, err := registry.GetInstance("test-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("GetInstance should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
if value1 != testValue {
|
||||
t.Errorf("Value should be '%s', got '%v'", testValue, value1)
|
||||
}
|
||||
|
||||
if atomic.LoadInt32(&callCount) != 1 {
|
||||
t.Errorf("Initializer should be called once, called %d times", callCount)
|
||||
}
|
||||
|
||||
// Second get - should return same instance without calling initializer
|
||||
value2, err := registry.GetInstance("test-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("GetInstance should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
if value2 != testValue {
|
||||
t.Errorf("Value should be '%s', got '%v'", testValue, value2)
|
||||
}
|
||||
|
||||
if atomic.LoadInt32(&callCount) != 1 {
|
||||
t.Errorf("Initializer should still be called only once, called %d times", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_GetInstance_NotRegistered tests getting unregistered singleton
|
||||
func TestRegistry_GetInstance_NotRegistered(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
value, err := registry.GetInstance("non-existent")
|
||||
if err == nil {
|
||||
t.Error("GetInstance of non-existent singleton should fail")
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
t.Error("Value should be nil for non-existent singleton")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "not registered") {
|
||||
t.Errorf("Error should mention not registered, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_GetInstance_DuringShutdown tests getting instance during shutdown
|
||||
func TestRegistry_GetInstance_DuringShutdown(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
shutdown: 1, // Already shutting down
|
||||
}
|
||||
|
||||
value, err := registry.GetInstance("test-singleton")
|
||||
if err == nil {
|
||||
t.Error("GetInstance during shutdown should fail")
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
t.Error("Value should be nil during shutdown")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "shutting down") {
|
||||
t.Errorf("Error should mention shutting down, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_MustGet tests MustGet method
|
||||
func TestRegistry_MustGet(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
testValue := "test-value"
|
||||
initializer := func() interface{} {
|
||||
return testValue
|
||||
}
|
||||
|
||||
// Register singleton
|
||||
err := registry.Register("test-singleton", initializer, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// MustGet should succeed
|
||||
value := registry.MustGet("test-singleton")
|
||||
if value != testValue {
|
||||
t.Errorf("Value should be '%s', got '%v'", testValue, value)
|
||||
}
|
||||
|
||||
// MustGet non-existent should panic
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGet of non-existent singleton should panic")
|
||||
}
|
||||
}()
|
||||
|
||||
registry.MustGet("non-existent")
|
||||
}
|
||||
|
||||
// TestRegistry_RegisterGroup tests group registration
|
||||
func TestRegistry_RegisterGroup(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
// Test successful group registration
|
||||
err := registry.RegisterGroup("test-group")
|
||||
if err != nil {
|
||||
t.Errorf("RegisterGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify group was registered
|
||||
if len(registry.groups) != 1 {
|
||||
t.Error("Group should be registered")
|
||||
}
|
||||
|
||||
group := registry.groups["test-group"]
|
||||
if group == nil {
|
||||
t.Error("Group should not be nil")
|
||||
return
|
||||
}
|
||||
|
||||
if group.name != "test-group" {
|
||||
t.Errorf("Group name should be 'test-group', got '%s'", group.name)
|
||||
}
|
||||
|
||||
// Test duplicate group registration
|
||||
err = registry.RegisterGroup("test-group")
|
||||
if err == nil {
|
||||
t.Error("Duplicate group registration should fail")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Errorf("Error should mention already exists, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_AddToGroup tests adding singletons to groups
|
||||
func TestRegistry_AddToGroup(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
// Register a singleton
|
||||
initializer := func() interface{} {
|
||||
return "test-value"
|
||||
}
|
||||
|
||||
err := registry.Register("test-singleton", initializer, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Register a group
|
||||
err = registry.RegisterGroup("test-group")
|
||||
if err != nil {
|
||||
t.Errorf("RegisterGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Add singleton to group
|
||||
err = registry.AddToGroup("test-group", "test-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("AddToGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify singleton is in group
|
||||
group := registry.groups["test-group"]
|
||||
if len(group.instances) != 1 {
|
||||
t.Error("Group should contain one instance")
|
||||
}
|
||||
|
||||
if group.instances["test-singleton"] == nil {
|
||||
t.Error("Singleton should be in group")
|
||||
}
|
||||
|
||||
// Test adding to non-existent group
|
||||
err = registry.AddToGroup("non-existent-group", "test-singleton")
|
||||
if err == nil {
|
||||
t.Error("Adding to non-existent group should fail")
|
||||
}
|
||||
|
||||
// Test adding non-existent singleton to group
|
||||
err = registry.AddToGroup("test-group", "non-existent-singleton")
|
||||
if err == nil {
|
||||
t.Error("Adding non-existent singleton should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_GetGroup tests retrieving group instances
|
||||
func TestRegistry_GetGroup(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
// Register singletons
|
||||
err := registry.Register("test-singleton-1", func() interface{} {
|
||||
return "value-1"
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
err = registry.Register("test-singleton-2", func() interface{} {
|
||||
return "value-2"
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Register group and add singletons
|
||||
err = registry.RegisterGroup("test-group")
|
||||
if err != nil {
|
||||
t.Errorf("RegisterGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
err = registry.AddToGroup("test-group", "test-singleton-1")
|
||||
if err != nil {
|
||||
t.Errorf("AddToGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
err = registry.AddToGroup("test-group", "test-singleton-2")
|
||||
if err != nil {
|
||||
t.Errorf("AddToGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Initialize singletons
|
||||
_, _ = registry.GetInstance("test-singleton-1")
|
||||
_, _ = registry.GetInstance("test-singleton-2")
|
||||
|
||||
// Get group
|
||||
groupInstances, err := registry.GetGroup("test-group")
|
||||
if err != nil {
|
||||
t.Errorf("GetGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
if len(groupInstances) != 2 {
|
||||
t.Errorf("Group should contain 2 instances, got %d", len(groupInstances))
|
||||
}
|
||||
|
||||
if groupInstances["test-singleton-1"] != "value-1" {
|
||||
t.Error("Group should contain correct instance values")
|
||||
}
|
||||
|
||||
if groupInstances["test-singleton-2"] != "value-2" {
|
||||
t.Error("Group should contain correct instance values")
|
||||
}
|
||||
|
||||
// Test getting non-existent group
|
||||
_, err = registry.GetGroup("non-existent-group")
|
||||
if err == nil {
|
||||
t.Error("Getting non-existent group should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_ReferenceCountingv tests reference counting
|
||||
func TestRegistry_ReferenceCountingv(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
finalizerCalled := int32(0)
|
||||
finalizer := func(v interface{}) {
|
||||
atomic.AddInt32(&finalizerCalled, 1)
|
||||
}
|
||||
|
||||
// Register singleton
|
||||
err := registry.Register("test-singleton", func() interface{} {
|
||||
return "test-value"
|
||||
}, finalizer)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Initialize singleton (this adds 1 reference)
|
||||
_, err = registry.GetInstance("test-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("GetInstance should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Check initial reference count
|
||||
count, err := registry.GetReferenceCount("test-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("GetReferenceCount should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Reference count should be 1, got %d", count)
|
||||
}
|
||||
|
||||
// Add reference
|
||||
err = registry.AddReference("test-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("AddReference should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
count, _ = registry.GetReferenceCount("test-singleton")
|
||||
if count != 2 {
|
||||
t.Errorf("Reference count should be 2, got %d", count)
|
||||
}
|
||||
|
||||
// Release reference
|
||||
err = registry.ReleaseReference("test-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("ReleaseReference should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
count, _ = registry.GetReferenceCount("test-singleton")
|
||||
if count != 1 {
|
||||
t.Errorf("Reference count should be 1, got %d", count)
|
||||
}
|
||||
|
||||
// Release last reference - should trigger finalizer
|
||||
err = registry.ReleaseReference("test-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("ReleaseReference should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
count, _ = registry.GetReferenceCount("test-singleton")
|
||||
if count != 0 {
|
||||
t.Errorf("Reference count should be 0, got %d", count)
|
||||
}
|
||||
|
||||
// Wait for finalizer to run (it runs in goroutine)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if atomic.LoadInt32(&finalizerCalled) != 1 {
|
||||
t.Errorf("Finalizer should be called once, called %d times", finalizerCalled)
|
||||
}
|
||||
|
||||
// Test reference operations on non-existent singleton
|
||||
err = registry.AddReference("non-existent")
|
||||
if err == nil {
|
||||
t.Error("AddReference on non-existent singleton should fail")
|
||||
}
|
||||
|
||||
err = registry.ReleaseReference("non-existent")
|
||||
if err == nil {
|
||||
t.Error("ReleaseReference on non-existent singleton should fail")
|
||||
}
|
||||
|
||||
_, err = registry.GetReferenceCount("non-existent")
|
||||
if err == nil {
|
||||
t.Error("GetReferenceCount on non-existent singleton should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Shutdown tests graceful shutdown
|
||||
func TestRegistry_Shutdown(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
finalizerCalled := int32(0)
|
||||
finalizer := func(v interface{}) {
|
||||
atomic.AddInt32(&finalizerCalled, 1)
|
||||
}
|
||||
|
||||
// Register and initialize singletons
|
||||
err := registry.Register("test-singleton-1", func() interface{} {
|
||||
return "value-1"
|
||||
}, finalizer)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
err = registry.Register("test-singleton-2", func() interface{} {
|
||||
return "value-2"
|
||||
}, finalizer)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Initialize singletons
|
||||
_, _ = registry.GetInstance("test-singleton-1")
|
||||
_, _ = registry.GetInstance("test-singleton-2")
|
||||
|
||||
// Shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = registry.Shutdown(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Shutdown should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify finalizers were called
|
||||
if atomic.LoadInt32(&finalizerCalled) != 2 {
|
||||
t.Errorf("Finalizers should be called 2 times, called %d times", finalizerCalled)
|
||||
}
|
||||
|
||||
// Verify registry is cleared
|
||||
if len(registry.instances) != 0 {
|
||||
t.Error("Instances should be cleared after shutdown")
|
||||
}
|
||||
|
||||
if len(registry.groups) != 0 {
|
||||
t.Error("Groups should be cleared after shutdown")
|
||||
}
|
||||
|
||||
// Verify shutdown flag is set
|
||||
if atomic.LoadInt32(®istry.shutdown) != 1 {
|
||||
t.Error("Shutdown flag should be set")
|
||||
}
|
||||
|
||||
// Test double shutdown
|
||||
err = registry.Shutdown(ctx)
|
||||
if err == nil {
|
||||
t.Error("Double shutdown should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Shutdown_Timeout tests shutdown timeout
|
||||
func TestRegistry_Shutdown_Timeout(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
// Register singleton with slow finalizer
|
||||
slowFinalizer := func(v interface{}) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
err := registry.Register("slow-singleton", func() interface{} {
|
||||
return "value"
|
||||
}, slowFinalizer)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Initialize singleton
|
||||
_, _ = registry.GetInstance("slow-singleton")
|
||||
|
||||
// Shutdown with short timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err = registry.Shutdown(ctx)
|
||||
if err == nil {
|
||||
t.Error("Shutdown should timeout")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "timeout") {
|
||||
t.Errorf("Error should mention timeout, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Shutdown_PanicRecovery tests panic recovery during shutdown
|
||||
func TestRegistry_Shutdown_PanicRecovery(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
// Register singleton with panicking finalizer
|
||||
panicFinalizer := func(v interface{}) {
|
||||
panic("finalizer panic")
|
||||
}
|
||||
|
||||
err := registry.Register("panic-singleton", func() interface{} {
|
||||
return "value"
|
||||
}, panicFinalizer)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Initialize singleton
|
||||
_, _ = registry.GetInstance("panic-singleton")
|
||||
|
||||
// Shutdown should handle panic
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = registry.Shutdown(ctx)
|
||||
if err == nil {
|
||||
t.Error("Shutdown should report finalizer panic")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "panicked") {
|
||||
t.Errorf("Error should mention panic, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Reset tests registry reset
|
||||
func TestRegistry_Reset(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
shutdown: 1,
|
||||
}
|
||||
|
||||
// Add some data
|
||||
registry.instances["test"] = &Instance{}
|
||||
registry.groups["test"] = &Group{}
|
||||
|
||||
// Reset
|
||||
registry.Reset()
|
||||
|
||||
// Verify everything is cleared
|
||||
if len(registry.instances) != 0 {
|
||||
t.Error("Instances should be cleared after reset")
|
||||
}
|
||||
|
||||
if len(registry.groups) != 0 {
|
||||
t.Error("Groups should be cleared after reset")
|
||||
}
|
||||
|
||||
if atomic.LoadInt32(®istry.shutdown) != 0 {
|
||||
t.Error("Shutdown flag should be cleared after reset")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_GetStats tests statistics
|
||||
func TestRegistry_GetStats(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
// Register singletons
|
||||
err := registry.Register("test-singleton-1", func() interface{} {
|
||||
return "value-1"
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
err = registry.Register("test-singleton-2", func() interface{} {
|
||||
return "value-2"
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Register group
|
||||
err = registry.RegisterGroup("test-group")
|
||||
if err != nil {
|
||||
t.Errorf("RegisterGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Initialize one singleton
|
||||
_, _ = registry.GetInstance("test-singleton-1")
|
||||
|
||||
// Add reference
|
||||
_ = registry.AddReference("test-singleton-1")
|
||||
|
||||
// Get stats
|
||||
stats := registry.GetStats()
|
||||
|
||||
if stats.TotalRegistered != 2 {
|
||||
t.Errorf("TotalRegistered should be 2, got %d", stats.TotalRegistered)
|
||||
}
|
||||
|
||||
if stats.TotalInitialized != 1 {
|
||||
t.Errorf("TotalInitialized should be 1, got %d", stats.TotalInitialized)
|
||||
}
|
||||
|
||||
if stats.TotalGroups != 1 {
|
||||
t.Errorf("TotalGroups should be 1, got %d", stats.TotalGroups)
|
||||
}
|
||||
|
||||
if stats.TotalReferences != 2 { // 1 from initialization + 1 from AddReference
|
||||
t.Errorf("TotalReferences should be 2, got %d", stats.TotalReferences)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilder tests the fluent builder interface
|
||||
func TestBuilder(t *testing.T) {
|
||||
// Reset global registry for clean test
|
||||
Get().Reset()
|
||||
|
||||
testValue := "builder-test-value"
|
||||
|
||||
initializer := func() interface{} {
|
||||
return testValue
|
||||
}
|
||||
|
||||
finalizer := func(v interface{}) {
|
||||
// Mock finalizer for builder test
|
||||
}
|
||||
|
||||
// Test builder
|
||||
err := NewBuilder("builder-singleton").
|
||||
WithInitializer(initializer).
|
||||
WithFinalizer(finalizer).
|
||||
InGroup("builder-group").
|
||||
Register()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Builder registration should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify singleton was registered
|
||||
value, err := Get().GetInstance("builder-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("GetInstance should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
if value != testValue {
|
||||
t.Errorf("Value should be '%s', got '%v'", testValue, value)
|
||||
}
|
||||
|
||||
// Verify group was created and singleton added
|
||||
groupInstances, err := Get().GetGroup("builder-group")
|
||||
if err != nil {
|
||||
t.Errorf("GetGroup should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
if len(groupInstances) != 1 {
|
||||
t.Errorf("Group should contain 1 instance, got %d", len(groupInstances))
|
||||
}
|
||||
|
||||
if groupInstances["builder-singleton"] != testValue {
|
||||
t.Error("Group should contain correct instance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilder_WithoutGroup tests builder without group
|
||||
func TestBuilder_WithoutGroup(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
builder := &Builder{
|
||||
registry: registry,
|
||||
name: "no-group-singleton",
|
||||
}
|
||||
|
||||
err := builder.WithInitializer(func() interface{} {
|
||||
return "value"
|
||||
}).Register()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Registration without group should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify singleton was registered
|
||||
if len(registry.instances) != 1 {
|
||||
t.Error("Singleton should be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContainsHelper tests the helper string contains function
|
||||
func TestContainsHelper(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
substr string
|
||||
expect bool
|
||||
}{
|
||||
{"hello world", "world", true},
|
||||
{"hello world", "hello", true},
|
||||
{"hello world", "lo wo", true},
|
||||
{"hello world", "xyz", false},
|
||||
{"hello", "hello world", false},
|
||||
{"", "test", false},
|
||||
{"test", "", true},
|
||||
{"", "", true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := contains(test.s, test.substr)
|
||||
if result != test.expect {
|
||||
t.Errorf("contains(%q, %q) = %v, want %v", test.s, test.substr, result, test.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_ConcurrentAccess tests concurrent access to registry
|
||||
func TestRegistry_ConcurrentAccess(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
callCount := int32(0)
|
||||
initializer := func() interface{} {
|
||||
atomic.AddInt32(&callCount, 1)
|
||||
return "concurrent-value"
|
||||
}
|
||||
|
||||
// Register singleton
|
||||
err := registry.Register("concurrent-singleton", initializer, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 50
|
||||
|
||||
// Concurrent access
|
||||
wg.Add(numGoroutines)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
value, err := registry.GetInstance("concurrent-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("GetInstance should succeed, got error: %v", err)
|
||||
return
|
||||
}
|
||||
if value != "concurrent-value" {
|
||||
t.Errorf("Value should be 'concurrent-value', got '%v'", value)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Initializer should be called only once despite concurrent access
|
||||
if atomic.LoadInt32(&callCount) != 1 {
|
||||
t.Errorf("Initializer should be called only once, called %d times", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_ConcurrentReferenceOperations tests concurrent reference operations
|
||||
func TestRegistry_ConcurrentReferenceOperations(t *testing.T) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
// Register singleton
|
||||
err := registry.Register("ref-singleton", func() interface{} {
|
||||
return "ref-value"
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Register should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Initialize singleton
|
||||
_, _ = registry.GetInstance("ref-singleton")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 20
|
||||
|
||||
// Concurrent reference operations
|
||||
wg.Add(numGoroutines * 2)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = registry.AddReference("ref-singleton")
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = registry.ReleaseReference("ref-singleton")
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Reference count should be consistent (initial 1 + net operations)
|
||||
count, err := registry.GetReferenceCount("ref-singleton")
|
||||
if err != nil {
|
||||
t.Errorf("GetReferenceCount should succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Count should be >= 0 due to balanced add/release operations
|
||||
if count < 0 {
|
||||
t.Errorf("Reference count should not be negative, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests for performance verification
|
||||
func BenchmarkRegistry_GetInstance(b *testing.B) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
registry.Register("benchmark-singleton", func() interface{} {
|
||||
return "benchmark-value"
|
||||
}, nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
registry.GetInstance("benchmark-singleton")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRegistry_ConcurrentGetInstance(b *testing.B) {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
registry.Register("concurrent-benchmark", func() interface{} {
|
||||
return "concurrent-value"
|
||||
}, nil)
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
registry.GetInstance("concurrent-benchmark")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkBuilder_Register(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
registry := &Registry{
|
||||
instances: make(map[string]*Instance),
|
||||
groups: make(map[string]*Group),
|
||||
}
|
||||
|
||||
builder := &Builder{
|
||||
registry: registry,
|
||||
name: fmt.Sprintf("benchmark-%d", i),
|
||||
}
|
||||
|
||||
builder.WithInitializer(func() interface{} {
|
||||
return "value"
|
||||
}).Register()
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
// Package testing provides unified mock implementations for tests
|
||||
package testing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UnifiedMockLogger provides a standard mock logger for all tests
|
||||
type UnifiedMockLogger struct {
|
||||
LoggedMessages []string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewUnifiedMockLogger() *UnifiedMockLogger {
|
||||
return &UnifiedMockLogger{
|
||||
LoggedMessages: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *UnifiedMockLogger) Debug(msg string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.LoggedMessages = append(l.LoggedMessages, fmt.Sprintf("DEBUG: %s", msg))
|
||||
}
|
||||
|
||||
func (l *UnifiedMockLogger) Debugf(format string, args ...interface{}) {
|
||||
l.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *UnifiedMockLogger) Info(msg string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.LoggedMessages = append(l.LoggedMessages, fmt.Sprintf("INFO: %s", msg))
|
||||
}
|
||||
|
||||
func (l *UnifiedMockLogger) Infof(format string, args ...interface{}) {
|
||||
l.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *UnifiedMockLogger) Error(msg string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.LoggedMessages = append(l.LoggedMessages, fmt.Sprintf("ERROR: %s", msg))
|
||||
}
|
||||
|
||||
func (l *UnifiedMockLogger) Errorf(format string, args ...interface{}) {
|
||||
l.Error(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *UnifiedMockLogger) GetMessages() []string {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
result := make([]string, len(l.LoggedMessages))
|
||||
copy(result, l.LoggedMessages)
|
||||
return result
|
||||
}
|
||||
|
||||
func (l *UnifiedMockLogger) Clear() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.LoggedMessages = l.LoggedMessages[:0]
|
||||
}
|
||||
|
||||
// UnifiedMockSession provides a standard mock session for all tests
|
||||
type UnifiedMockSession struct {
|
||||
authenticated bool
|
||||
idToken string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
email string
|
||||
csrf string
|
||||
nonce string
|
||||
codeVerifier string
|
||||
incomingPath string
|
||||
redirectCount int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewUnifiedMockSession() *UnifiedMockSession {
|
||||
return &UnifiedMockSession{}
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetAuthenticated() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.authenticated
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetAuthenticated(auth bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.authenticated = auth
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetIDToken() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.idToken
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetIDToken(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.idToken = token
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetAccessToken() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.accessToken
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetAccessToken(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.accessToken = token
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetRefreshToken() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.refreshToken
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetRefreshToken(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshToken = token
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetEmail() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.email
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetEmail(email string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.email = email
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetCSRF() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.csrf
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetCSRF(csrf string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.csrf = csrf
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetNonce() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.nonce
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetNonce(nonce string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nonce = nonce
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetCodeVerifier() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.codeVerifier
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetCodeVerifier(verifier string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.codeVerifier = verifier
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetIncomingPath() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.incomingPath
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) SetIncomingPath(path string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.incomingPath = path
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) GetRedirectCount() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.redirectCount
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) IncrementRedirectCount() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.redirectCount++
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) ResetRedirectCount() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.redirectCount = 0
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) Save(req *http.Request, rw http.ResponseWriter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) Clear(req *http.Request, rw http.ResponseWriter) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.authenticated = false
|
||||
s.idToken = ""
|
||||
s.accessToken = ""
|
||||
s.refreshToken = ""
|
||||
s.email = ""
|
||||
s.csrf = ""
|
||||
s.nonce = ""
|
||||
s.codeVerifier = ""
|
||||
s.incomingPath = ""
|
||||
s.redirectCount = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) MarkDirty() {}
|
||||
|
||||
func (s *UnifiedMockSession) IsDirty() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *UnifiedMockSession) ReturnToPoolSafely() {}
|
||||
|
||||
// UnifiedMockTokenVerifier provides a standard mock token verifier
|
||||
type UnifiedMockTokenVerifier struct {
|
||||
ShouldFail bool
|
||||
Error error
|
||||
}
|
||||
|
||||
func NewUnifiedMockTokenVerifier() *UnifiedMockTokenVerifier {
|
||||
return &UnifiedMockTokenVerifier{}
|
||||
}
|
||||
|
||||
func (v *UnifiedMockTokenVerifier) VerifyToken(token string) error {
|
||||
if v.ShouldFail {
|
||||
if v.Error != nil {
|
||||
return v.Error
|
||||
}
|
||||
return fmt.Errorf("mock verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnifiedMockTokenCache provides a standard mock token cache
|
||||
type UnifiedMockTokenCache struct {
|
||||
data map[string]map[string]interface{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewUnifiedMockTokenCache() *UnifiedMockTokenCache {
|
||||
return &UnifiedMockTokenCache{
|
||||
data: make(map[string]map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UnifiedMockTokenCache) Get(key string) (map[string]interface{}, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
value, exists := c.data[key]
|
||||
return value, exists
|
||||
}
|
||||
|
||||
func (c *UnifiedMockTokenCache) Set(key string, claims map[string]interface{}, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.data[key] = claims
|
||||
}
|
||||
|
||||
func (c *UnifiedMockTokenCache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.data, key)
|
||||
}
|
||||
|
||||
func (c *UnifiedMockTokenCache) SetMaxSize(size int) {}
|
||||
|
||||
func (c *UnifiedMockTokenCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.data)
|
||||
}
|
||||
|
||||
func (c *UnifiedMockTokenCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.data = make(map[string]map[string]interface{})
|
||||
}
|
||||
|
||||
func (c *UnifiedMockTokenCache) Cleanup() {}
|
||||
|
||||
func (c *UnifiedMockTokenCache) Close() {}
|
||||
|
||||
func (c *UnifiedMockTokenCache) GetStats() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"size": c.Size(),
|
||||
}
|
||||
}
|
||||
|
||||
// UnifiedMockHTTPClient provides a mock HTTP client for tests
|
||||
type UnifiedMockHTTPClient struct {
|
||||
Responses map[string]*http.Response
|
||||
Errors map[string]error
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewUnifiedMockHTTPClient() *UnifiedMockHTTPClient {
|
||||
return &UnifiedMockHTTPClient{
|
||||
Responses: make(map[string]*http.Response),
|
||||
Errors: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UnifiedMockHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
url := req.URL.String()
|
||||
if err, exists := c.Errors[url]; exists {
|
||||
return nil, err
|
||||
}
|
||||
if resp, exists := c.Responses[url]; exists {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Default response
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: http.NoBody,
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *UnifiedMockHTTPClient) SetResponse(url string, response *http.Response) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Responses[url] = response
|
||||
}
|
||||
|
||||
func (c *UnifiedMockHTTPClient) SetError(url string, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Errors[url] = err
|
||||
}
|
||||
|
||||
// TestSuite provides a unified test setup and teardown
|
||||
type TestSuite struct {
|
||||
Logger *UnifiedMockLogger
|
||||
Session *UnifiedMockSession
|
||||
TokenVerifier *UnifiedMockTokenVerifier
|
||||
TokenCache *UnifiedMockTokenCache
|
||||
HTTPClient *UnifiedMockHTTPClient
|
||||
}
|
||||
|
||||
func NewTestSuite() *TestSuite {
|
||||
return &TestSuite{
|
||||
Logger: NewUnifiedMockLogger(),
|
||||
Session: NewUnifiedMockSession(),
|
||||
TokenVerifier: NewUnifiedMockTokenVerifier(),
|
||||
TokenCache: NewUnifiedMockTokenCache(),
|
||||
HTTPClient: NewUnifiedMockHTTPClient(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TestSuite) Setup() {
|
||||
// Common test setup
|
||||
ts.Logger.Clear()
|
||||
_ = ts.Session.Clear(nil, nil) // Safe to ignore: test helper function
|
||||
ts.TokenCache.Clear()
|
||||
ts.TokenVerifier.ShouldFail = false
|
||||
ts.TokenVerifier.Error = nil
|
||||
}
|
||||
|
||||
func (ts *TestSuite) Teardown() {
|
||||
// Common test teardown
|
||||
ts.TokenCache.Close()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/testutil/fixtures"
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/testutil/mocks"
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/testutil/servers"
|
||||
)
|
||||
|
||||
// Re-export types for easier access from main package tests
|
||||
type (
|
||||
// Mocks
|
||||
JWKCacheMock = mocks.JWKCache
|
||||
TokenExchangerMock = mocks.TokenExchanger
|
||||
TokenVerifierMock = mocks.TokenVerifier
|
||||
JWTVerifierMock = mocks.JWTVerifier
|
||||
SessionManagerMock = mocks.SessionManager
|
||||
CacheMock = mocks.Cache
|
||||
TokenCacheMock = mocks.TokenCache
|
||||
BlacklistMock = mocks.Blacklist
|
||||
HTTPClientMock = mocks.HTTPClient
|
||||
RoundTripperMock = mocks.RoundTripper
|
||||
LoggerMock = mocks.Logger
|
||||
|
||||
// Mock types
|
||||
JWKSet = mocks.JWKSet
|
||||
JWK = mocks.JWK
|
||||
MockTokenResponse = mocks.TokenResponse
|
||||
MockSessionData = mocks.SessionData
|
||||
IntrospectionResp = mocks.IntrospectionResponse
|
||||
|
||||
// Fixtures
|
||||
TokenFixture = fixtures.TokenFixture
|
||||
|
||||
// Servers
|
||||
OIDCServer = servers.OIDCServer
|
||||
OIDCServerConfig = servers.OIDCServerConfig
|
||||
OIDCError = servers.OIDCError
|
||||
)
|
||||
|
||||
// NewJWKCacheMock creates a new JWK cache mock
|
||||
func NewJWKCacheMock() *mocks.JWKCache {
|
||||
return new(mocks.JWKCache)
|
||||
}
|
||||
|
||||
// NewTokenExchangerMock creates a new token exchanger mock
|
||||
func NewTokenExchangerMock() *mocks.TokenExchanger {
|
||||
return new(mocks.TokenExchanger)
|
||||
}
|
||||
|
||||
// NewTokenVerifierMock creates a new token verifier mock
|
||||
func NewTokenVerifierMock() *mocks.TokenVerifier {
|
||||
return new(mocks.TokenVerifier)
|
||||
}
|
||||
|
||||
// NewJWTVerifierMock creates a new JWT verifier mock
|
||||
func NewJWTVerifierMock() *mocks.JWTVerifier {
|
||||
return new(mocks.JWTVerifier)
|
||||
}
|
||||
|
||||
// NewSessionManagerMock creates a new session manager mock
|
||||
func NewSessionManagerMock() *mocks.SessionManager {
|
||||
return new(mocks.SessionManager)
|
||||
}
|
||||
|
||||
// NewCacheMock creates a new cache mock
|
||||
func NewCacheMock() *mocks.Cache {
|
||||
return new(mocks.Cache)
|
||||
}
|
||||
|
||||
// NewTokenCacheMock creates a new token cache mock
|
||||
func NewTokenCacheMock() *mocks.TokenCache {
|
||||
return new(mocks.TokenCache)
|
||||
}
|
||||
|
||||
// NewBlacklistMock creates a new blacklist mock
|
||||
func NewBlacklistMock() *mocks.Blacklist {
|
||||
return new(mocks.Blacklist)
|
||||
}
|
||||
|
||||
// NewHTTPClientMock creates a new HTTP client mock
|
||||
func NewHTTPClientMock() *mocks.HTTPClient {
|
||||
return new(mocks.HTTPClient)
|
||||
}
|
||||
|
||||
// NewRoundTripperMock creates a new round tripper mock
|
||||
func NewRoundTripperMock() *mocks.RoundTripper {
|
||||
return new(mocks.RoundTripper)
|
||||
}
|
||||
|
||||
// NewLoggerMock creates a new logger mock
|
||||
func NewLoggerMock() *mocks.Logger {
|
||||
return new(mocks.Logger)
|
||||
}
|
||||
|
||||
// NewTokenFixture creates a new token fixture
|
||||
func NewTokenFixture() (*fixtures.TokenFixture, error) {
|
||||
return fixtures.NewTokenFixture()
|
||||
}
|
||||
|
||||
// NewOIDCServer creates a new mock OIDC server
|
||||
func NewOIDCServer(config *servers.OIDCServerConfig) *servers.OIDCServer {
|
||||
return servers.NewOIDCServer(config)
|
||||
}
|
||||
|
||||
// DefaultServerConfig returns a default server configuration
|
||||
func DefaultServerConfig() *servers.OIDCServerConfig {
|
||||
return servers.DefaultConfig()
|
||||
}
|
||||
|
||||
// GoogleServerConfig returns a Google-like server configuration
|
||||
func GoogleServerConfig() *servers.OIDCServerConfig {
|
||||
return servers.GoogleConfig()
|
||||
}
|
||||
|
||||
// AzureServerConfig returns an Azure AD-like server configuration
|
||||
func AzureServerConfig() *servers.OIDCServerConfig {
|
||||
return servers.AzureConfig()
|
||||
}
|
||||
|
||||
// Auth0ServerConfig returns an Auth0-like server configuration
|
||||
func Auth0ServerConfig() *servers.OIDCServerConfig {
|
||||
return servers.Auth0Config()
|
||||
}
|
||||
|
||||
// KeycloakServerConfig returns a Keycloak-like server configuration
|
||||
func KeycloakServerConfig() *servers.OIDCServerConfig {
|
||||
return servers.KeycloakConfig()
|
||||
}
|
||||
|
||||
// SlowServerConfig returns a configuration with delays
|
||||
func SlowServerConfig(delay time.Duration) *servers.OIDCServerConfig {
|
||||
return servers.SlowServerConfig(delay)
|
||||
}
|
||||
|
||||
// RateLimitedServerConfig returns a rate-limited configuration
|
||||
func RateLimitedServerConfig(afterN int) *servers.OIDCServerConfig {
|
||||
return servers.RateLimitedConfig(afterN)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package fixtures
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenFixture provides JWT token generation for tests
|
||||
type TokenFixture struct {
|
||||
RSAPrivateKey *rsa.PrivateKey
|
||||
RSAPublicKey *rsa.PublicKey
|
||||
ECPrivateKey *ecdsa.PrivateKey
|
||||
ECPublicKey *ecdsa.PublicKey
|
||||
KeyID string
|
||||
Issuer string
|
||||
Audience string
|
||||
ClockSkew time.Duration
|
||||
}
|
||||
|
||||
// NewTokenFixture creates a new token fixture with generated keys
|
||||
func NewTokenFixture() (*TokenFixture, error) {
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenFixture{
|
||||
RSAPrivateKey: rsaKey,
|
||||
RSAPublicKey: &rsaKey.PublicKey,
|
||||
ECPrivateKey: ecKey,
|
||||
ECPublicKey: &ecKey.PublicKey,
|
||||
KeyID: "test-key-id",
|
||||
Issuer: "https://test-issuer.com",
|
||||
Audience: "test-client-id",
|
||||
ClockSkew: 2 * time.Minute,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultClaims returns standard JWT claims
|
||||
func (f *TokenFixture) DefaultClaims() map[string]interface{} {
|
||||
now := time.Now()
|
||||
return map[string]interface{}{
|
||||
"iss": f.Issuer,
|
||||
"aud": f.Audience,
|
||||
"sub": "test-subject",
|
||||
"email": "[email protected]",
|
||||
"exp": now.Add(1 * time.Hour).Unix(),
|
||||
"iat": now.Add(-f.ClockSkew).Unix(),
|
||||
"nbf": now.Add(-f.ClockSkew).Unix(),
|
||||
"nonce": "test-nonce",
|
||||
"jti": generateJTI(),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidToken creates a valid JWT token with optional claim overrides
|
||||
func (f *TokenFixture) ValidToken(claimOverrides map[string]interface{}) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
for k, v := range claimOverrides {
|
||||
claims[k] = v
|
||||
}
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// ExpiredToken creates an expired JWT token
|
||||
func (f *TokenFixture) ExpiredToken() (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
claims["exp"] = time.Now().Add(-1 * time.Hour).Unix()
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// NotYetValidToken creates a token that's not valid yet
|
||||
func (f *TokenFixture) NotYetValidToken() (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
claims["nbf"] = time.Now().Add(1 * time.Hour).Unix()
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// TokenWithSkew creates a token with a specific time offset
|
||||
func (f *TokenFixture) TokenWithSkew(skew time.Duration) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
claims["exp"] = time.Now().Add(skew).Unix()
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// TokenWithRoles creates a token with specific roles
|
||||
func (f *TokenFixture) TokenWithRoles(roles []string) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
claims["roles"] = roles
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// TokenWithGroups creates a token with specific groups
|
||||
func (f *TokenFixture) TokenWithGroups(groups []string) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
claims["groups"] = groups
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// TokenWithEmail creates a token with a specific email
|
||||
func (f *TokenFixture) TokenWithEmail(email string) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
claims["email"] = email
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// TokenWithAudience creates a token with a specific audience
|
||||
func (f *TokenFixture) TokenWithAudience(audience string) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
claims["aud"] = audience
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// TokenWithIssuer creates a token with a specific issuer
|
||||
func (f *TokenFixture) TokenWithIssuer(issuer string) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
claims["iss"] = issuer
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// TokenMissingClaim creates a token missing specified claims
|
||||
func (f *TokenFixture) TokenMissingClaim(missingClaims ...string) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
for _, claim := range missingClaims {
|
||||
delete(claims, claim)
|
||||
}
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// TokenWithCustomClaims creates a token with custom claims
|
||||
func (f *TokenFixture) TokenWithCustomClaims(customClaims map[string]interface{}) (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
for k, v := range customClaims {
|
||||
claims[k] = v
|
||||
}
|
||||
return f.createJWT(claims, "RS256", f.KeyID)
|
||||
}
|
||||
|
||||
// MalformedToken returns an invalid JWT string
|
||||
func (f *TokenFixture) MalformedToken() string {
|
||||
return "not.a.valid.jwt"
|
||||
}
|
||||
|
||||
// EmptyToken returns an empty string
|
||||
func (f *TokenFixture) EmptyToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// TokenWithWrongSignature creates a token signed with a different key
|
||||
func (f *TokenFixture) TokenWithWrongSignature() (string, error) {
|
||||
wrongKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := f.DefaultClaims()
|
||||
return createJWTWithKey(claims, "RS256", f.KeyID, wrongKey)
|
||||
}
|
||||
|
||||
// TokenWithWrongAlgorithm creates a token with mismatched algorithm
|
||||
func (f *TokenFixture) TokenWithWrongAlgorithm() (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
// Create token claiming RS256 but we'll return it as-is
|
||||
// This simulates algorithm confusion attacks
|
||||
return f.createJWT(claims, "none", f.KeyID)
|
||||
}
|
||||
|
||||
// ECToken creates a token signed with EC key
|
||||
func (f *TokenFixture) ECToken() (string, error) {
|
||||
claims := f.DefaultClaims()
|
||||
return f.createECJWT(claims, "ES256", f.KeyID)
|
||||
}
|
||||
|
||||
// GetJWKS returns a JWKS containing the test public key
|
||||
func (f *TokenFixture) GetJWKS() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"keys": []map[string]interface{}{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"kid": f.KeyID,
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": base64.RawURLEncoding.EncodeToString(f.RSAPublicKey.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(f.RSAPublicKey.E)))),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetJWKSBytes returns JWKS as JSON bytes
|
||||
func (f *TokenFixture) GetJWKSBytes() ([]byte, error) {
|
||||
return json.Marshal(f.GetJWKS())
|
||||
}
|
||||
|
||||
// createJWT creates a JWT with the fixture's RSA key
|
||||
func (f *TokenFixture) createJWT(claims map[string]interface{}, alg, kid string) (string, error) {
|
||||
return createJWTWithKey(claims, alg, kid, f.RSAPrivateKey)
|
||||
}
|
||||
|
||||
// createECJWT creates a JWT with the fixture's EC key
|
||||
func (f *TokenFixture) createECJWT(claims map[string]interface{}, alg, kid string) (string, error) {
|
||||
return createECJWTWithKey(claims, alg, kid, f.ECPrivateKey)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateJTI() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b) // #nosec G104 - test fixture, crypto strength not critical
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func bigIntToBytes(i *big.Int) []byte {
|
||||
return i.Bytes()
|
||||
}
|
||||
|
||||
func createJWTWithKey(claims map[string]interface{}, alg, kid string, key *rsa.PrivateKey) (string, error) {
|
||||
header := map[string]interface{}{
|
||||
"alg": alg,
|
||||
"typ": "JWT",
|
||||
"kid": kid,
|
||||
}
|
||||
|
||||
headerBytes, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claimsBytes, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerBytes)
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsBytes)
|
||||
|
||||
signingInput := headerB64 + "." + claimsB64
|
||||
|
||||
// For "none" algorithm, return without signature
|
||||
if alg == "none" {
|
||||
return signingInput + ".", nil
|
||||
}
|
||||
|
||||
// Sign with RSA-SHA256
|
||||
signature, err := signRS256([]byte(signingInput), key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
|
||||
return signingInput + "." + signatureB64, nil
|
||||
}
|
||||
|
||||
func createECJWTWithKey(claims map[string]interface{}, alg, kid string, key *ecdsa.PrivateKey) (string, error) {
|
||||
header := map[string]interface{}{
|
||||
"alg": alg,
|
||||
"typ": "JWT",
|
||||
"kid": kid,
|
||||
}
|
||||
|
||||
headerBytes, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claimsBytes, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerBytes)
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsBytes)
|
||||
|
||||
signingInput := headerB64 + "." + claimsB64
|
||||
|
||||
// Sign with ECDSA-SHA256
|
||||
signature, err := signES256([]byte(signingInput), key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
|
||||
return signingInput + "." + signatureB64, nil
|
||||
}
|
||||
|
||||
func signRS256(data []byte, key *rsa.PrivateKey) ([]byte, error) {
|
||||
h := hashSHA256(data)
|
||||
return rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h)
|
||||
}
|
||||
|
||||
func signES256(data []byte, key *ecdsa.PrivateKey) ([]byte, error) {
|
||||
h := hashSHA256(data)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, key, h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Encode r and s as fixed-size byte arrays
|
||||
curveBits := key.Curve.Params().BitSize
|
||||
keyBytes := curveBits / 8
|
||||
if curveBits%8 > 0 {
|
||||
keyBytes++
|
||||
}
|
||||
|
||||
rBytes := r.Bytes()
|
||||
sBytes := s.Bytes()
|
||||
|
||||
signature := make([]byte, 2*keyBytes)
|
||||
copy(signature[keyBytes-len(rBytes):keyBytes], rBytes)
|
||||
copy(signature[2*keyBytes-len(sBytes):], sBytes)
|
||||
|
||||
return signature, nil
|
||||
}
|
||||
|
||||
func hashSHA256(data []byte) []byte {
|
||||
h := sha256.Sum256(data)
|
||||
return h[:]
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package fixtures
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewTokenFixture(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, fixture.RSAPrivateKey)
|
||||
assert.NotNil(t, fixture.RSAPublicKey)
|
||||
assert.NotNil(t, fixture.ECPrivateKey)
|
||||
assert.NotNil(t, fixture.ECPublicKey)
|
||||
assert.NotEmpty(t, fixture.KeyID)
|
||||
assert.NotEmpty(t, fixture.Issuer)
|
||||
assert.NotEmpty(t, fixture.Audience)
|
||||
}
|
||||
|
||||
func TestDefaultClaims(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
claims := fixture.DefaultClaims()
|
||||
|
||||
assert.Equal(t, fixture.Issuer, claims["iss"])
|
||||
assert.Equal(t, fixture.Audience, claims["aud"])
|
||||
assert.NotEmpty(t, claims["sub"])
|
||||
assert.NotEmpty(t, claims["email"])
|
||||
assert.NotNil(t, claims["exp"])
|
||||
assert.NotNil(t, claims["iat"])
|
||||
assert.NotNil(t, claims["nbf"])
|
||||
assert.NotEmpty(t, claims["jti"])
|
||||
}
|
||||
|
||||
func TestValidToken(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("creates valid JWT structure", func(t *testing.T) {
|
||||
token, err := fixture.ValidToken(nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
// JWT has 3 parts
|
||||
parts := strings.Split(token, ".")
|
||||
assert.Len(t, parts, 3)
|
||||
})
|
||||
|
||||
t.Run("applies claim overrides", func(t *testing.T) {
|
||||
token, err := fixture.ValidToken(map[string]interface{}{
|
||||
"email": "[email protected]",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpiredToken(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.ExpiredToken()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
parts := strings.Split(token, ".")
|
||||
assert.Len(t, parts, 3)
|
||||
}
|
||||
|
||||
func TestNotYetValidToken(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.NotYetValidToken()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
|
||||
func TestTokenWithSkew(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("positive skew", func(t *testing.T) {
|
||||
token, err := fixture.TokenWithSkew(5 * time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
})
|
||||
|
||||
t.Run("negative skew", func(t *testing.T) {
|
||||
token, err := fixture.TokenWithSkew(-5 * time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenWithRoles(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.TokenWithRoles([]string{"admin", "user"})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
|
||||
func TestTokenWithGroups(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.TokenWithGroups([]string{"developers", "admins"})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
|
||||
func TestTokenWithEmail(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.TokenWithEmail("[email protected]")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
|
||||
func TestTokenWithAudience(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.TokenWithAudience("custom-audience")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
|
||||
func TestTokenWithIssuer(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.TokenWithIssuer("https://custom-issuer.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
|
||||
func TestTokenMissingClaim(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("missing single claim", func(t *testing.T) {
|
||||
token, err := fixture.TokenMissingClaim("email")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
})
|
||||
|
||||
t.Run("missing multiple claims", func(t *testing.T) {
|
||||
token, err := fixture.TokenMissingClaim("email", "sub", "nonce")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenWithCustomClaims(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.TokenWithCustomClaims(map[string]interface{}{
|
||||
"custom_claim": "custom_value",
|
||||
"another_claim": 123,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
|
||||
func TestMalformedToken(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token := fixture.MalformedToken()
|
||||
|
||||
assert.Equal(t, "not.a.valid.jwt", token)
|
||||
parts := strings.Split(token, ".")
|
||||
assert.Len(t, parts, 4) // 4 parts instead of 3
|
||||
}
|
||||
|
||||
func TestEmptyToken(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token := fixture.EmptyToken()
|
||||
|
||||
assert.Empty(t, token)
|
||||
}
|
||||
|
||||
func TestTokenWithWrongSignature(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := fixture.TokenWithWrongSignature()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
parts := strings.Split(token, ".")
|
||||
assert.Len(t, parts, 3)
|
||||
}
|
||||
|
||||
func TestGetJWKS(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
jwks := fixture.GetJWKS()
|
||||
|
||||
assert.Contains(t, jwks, "keys")
|
||||
keys, ok := jwks["keys"].([]map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, keys, 1)
|
||||
|
||||
key := keys[0]
|
||||
assert.Equal(t, "RSA", key["kty"])
|
||||
assert.Equal(t, fixture.KeyID, key["kid"])
|
||||
assert.NotEmpty(t, key["n"])
|
||||
assert.NotEmpty(t, key["e"])
|
||||
}
|
||||
|
||||
func TestGetJWKSBytes(t *testing.T) {
|
||||
fixture, err := NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
jwksBytes, err := fixture.GetJWKSBytes()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, jwksBytes)
|
||||
assert.Contains(t, string(jwksBytes), "keys")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// Cache is a testify mock for cache operations
|
||||
type Cache struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (m *Cache) Get(key string) (interface{}, bool) {
|
||||
args := m.Called(key)
|
||||
return args.Get(0), args.Bool(1)
|
||||
}
|
||||
|
||||
// Set stores a value in the cache
|
||||
func (m *Cache) Set(key string, value interface{}) {
|
||||
m.Called(key, value)
|
||||
}
|
||||
|
||||
// SetWithTTL stores a value with a specific TTL
|
||||
func (m *Cache) SetWithTTL(key string, value interface{}, ttl time.Duration) {
|
||||
m.Called(key, value, ttl)
|
||||
}
|
||||
|
||||
// Delete removes a value from the cache
|
||||
func (m *Cache) Delete(key string) {
|
||||
m.Called(key)
|
||||
}
|
||||
|
||||
// Has checks if a key exists in the cache
|
||||
func (m *Cache) Has(key string) bool {
|
||||
args := m.Called(key)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
// Clear removes all entries from the cache
|
||||
func (m *Cache) Clear() {
|
||||
m.Called()
|
||||
}
|
||||
|
||||
// Close closes the cache
|
||||
func (m *Cache) Close() {
|
||||
m.Called()
|
||||
}
|
||||
|
||||
// Size returns the number of items in the cache
|
||||
func (m *Cache) Size() int {
|
||||
args := m.Called()
|
||||
return args.Int(0)
|
||||
}
|
||||
|
||||
// TokenCache is a testify mock for token-specific cache operations
|
||||
type TokenCache struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Get retrieves a token from the cache
|
||||
func (m *TokenCache) Get(key string) (string, bool) {
|
||||
args := m.Called(key)
|
||||
return args.String(0), args.Bool(1)
|
||||
}
|
||||
|
||||
// Set stores a token in the cache
|
||||
func (m *TokenCache) Set(key string, token string, ttl time.Duration) {
|
||||
m.Called(key, token, ttl)
|
||||
}
|
||||
|
||||
// Delete removes a token from the cache
|
||||
func (m *TokenCache) Delete(key string) {
|
||||
m.Called(key)
|
||||
}
|
||||
|
||||
// Has checks if a token exists in the cache
|
||||
func (m *TokenCache) Has(key string) bool {
|
||||
args := m.Called(key)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
// Blacklist is a testify mock for token blacklist operations
|
||||
type Blacklist struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// IsBlacklisted checks if a token is blacklisted
|
||||
func (m *Blacklist) IsBlacklisted(jti string) bool {
|
||||
args := m.Called(jti)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
// Add adds a token to the blacklist
|
||||
func (m *Blacklist) Add(jti string, expiry time.Time) {
|
||||
m.Called(jti, expiry)
|
||||
}
|
||||
|
||||
// Remove removes a token from the blacklist
|
||||
func (m *Blacklist) Remove(jti string) {
|
||||
m.Called(jti)
|
||||
}
|
||||
|
||||
// Cleanup removes expired entries from the blacklist
|
||||
func (m *Blacklist) Cleanup() {
|
||||
m.Called()
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// JWKSet represents a JSON Web Key Set for testing
|
||||
type JWKSet struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
// JWK represents a JSON Web Key for testing
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
N string `json:"n,omitempty"`
|
||||
E string `json:"e,omitempty"`
|
||||
Crv string `json:"crv,omitempty"`
|
||||
X string `json:"x,omitempty"`
|
||||
Y string `json:"y,omitempty"`
|
||||
}
|
||||
|
||||
// TokenResponse represents an OAuth token response for testing
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// IntrospectionResponse represents a token introspection response
|
||||
type IntrospectionResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Exp int64 `json:"exp,omitempty"`
|
||||
Iat int64 `json:"iat,omitempty"`
|
||||
Nbf int64 `json:"nbf,omitempty"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Aud string `json:"aud,omitempty"`
|
||||
Iss string `json:"iss,omitempty"`
|
||||
Jti string `json:"jti,omitempty"`
|
||||
}
|
||||
|
||||
// JWKCache is a testify mock for JWK caching operations
|
||||
type JWKCache struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// GetJWKS retrieves a JWKS from the cache or fetches it
|
||||
func (m *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
||||
args := m.Called(ctx, jwksURL, httpClient)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*JWKSet), args.Error(1)
|
||||
}
|
||||
|
||||
// Close cleans up the cache
|
||||
func (m *JWKCache) Close() {
|
||||
m.Called()
|
||||
}
|
||||
|
||||
// Cleanup performs periodic cleanup
|
||||
func (m *JWKCache) Cleanup() {
|
||||
m.Called()
|
||||
}
|
||||
|
||||
// TokenExchanger is a testify mock for token exchange operations
|
||||
type TokenExchanger struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// ExchangeCodeForToken exchanges an authorization code for tokens
|
||||
func (m *TokenExchanger) ExchangeCodeForToken(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
|
||||
args := m.Called(ctx, grantType, codeOrToken, redirectURL, codeVerifier)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*TokenResponse), args.Error(1)
|
||||
}
|
||||
|
||||
// GetNewTokenWithRefreshToken refreshes an access token
|
||||
func (m *TokenExchanger) GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
|
||||
args := m.Called(refreshToken)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*TokenResponse), args.Error(1)
|
||||
}
|
||||
|
||||
// RevokeTokenWithProvider revokes a token
|
||||
func (m *TokenExchanger) RevokeTokenWithProvider(token, tokenType string) error {
|
||||
args := m.Called(token, tokenType)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// TokenVerifier is a testify mock for token verification
|
||||
type TokenVerifier struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// VerifyToken verifies a JWT token
|
||||
func (m *TokenVerifier) VerifyToken(token string) error {
|
||||
args := m.Called(token)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// JWTVerifier is a testify mock for JWT verification
|
||||
type JWTVerifier struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// VerifyJWT verifies a JWT and returns claims
|
||||
func (m *JWTVerifier) VerifyJWT(token string) (map[string]interface{}, error) {
|
||||
args := m.Called(token)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]interface{}), args.Error(1)
|
||||
}
|
||||
|
||||
// HTTPClient is a testify mock for HTTP client operations
|
||||
type HTTPClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Do executes an HTTP request
|
||||
func (m *HTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
args := m.Called(req)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*http.Response), args.Error(1)
|
||||
}
|
||||
|
||||
// RoundTripper is a testify mock for HTTP transport
|
||||
type RoundTripper struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// RoundTrip executes a single HTTP transaction
|
||||
func (m *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
args := m.Called(req)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*http.Response), args.Error(1)
|
||||
}
|
||||
|
||||
// Logger is a testify mock for logging operations
|
||||
type Logger struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (m *Logger) Debug(msg string) {
|
||||
m.Called(msg)
|
||||
}
|
||||
|
||||
// Debugf logs a formatted debug message
|
||||
func (m *Logger) Debugf(format string, args ...interface{}) {
|
||||
m.Called(format, args)
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func (m *Logger) Info(msg string) {
|
||||
m.Called(msg)
|
||||
}
|
||||
|
||||
// Infof logs a formatted info message
|
||||
func (m *Logger) Infof(format string, args ...interface{}) {
|
||||
m.Called(format, args)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (m *Logger) Error(msg string) {
|
||||
m.Called(msg)
|
||||
}
|
||||
|
||||
// Errorf logs a formatted error message
|
||||
func (m *Logger) Errorf(format string, args ...interface{}) {
|
||||
m.Called(format, args)
|
||||
}
|
||||
|
||||
// Warn logs a warning message
|
||||
func (m *Logger) Warn(msg string) {
|
||||
m.Called(msg)
|
||||
}
|
||||
|
||||
// Warnf logs a formatted warning message
|
||||
func (m *Logger) Warnf(format string, args ...interface{}) {
|
||||
m.Called(format, args)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestJWKCache(t *testing.T) {
|
||||
t.Run("GetJWKS returns configured response", func(t *testing.T) {
|
||||
m := new(JWKCache)
|
||||
expectedJWKS := &JWKSet{
|
||||
Keys: []JWK{{Kty: "RSA", Kid: "test-key"}},
|
||||
}
|
||||
|
||||
m.On("GetJWKS", mock.Anything, "https://example.com/jwks", mock.Anything).
|
||||
Return(expectedJWKS, nil)
|
||||
|
||||
result, err := m.GetJWKS(context.Background(), "https://example.com/jwks", nil)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedJWKS, result)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("GetJWKS returns error", func(t *testing.T) {
|
||||
m := new(JWKCache)
|
||||
expectedErr := errors.New("network error")
|
||||
|
||||
m.On("GetJWKS", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(nil, expectedErr)
|
||||
|
||||
result, err := m.GetJWKS(context.Background(), "https://example.com/jwks", nil)
|
||||
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Close is callable", func(t *testing.T) {
|
||||
m := new(JWKCache)
|
||||
m.On("Close").Return()
|
||||
|
||||
m.Close()
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenExchanger(t *testing.T) {
|
||||
t.Run("ExchangeCodeForToken success", func(t *testing.T) {
|
||||
m := new(TokenExchanger)
|
||||
expectedResp := &TokenResponse{
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
IDToken: "id-token",
|
||||
ExpiresIn: 3600,
|
||||
}
|
||||
|
||||
m.On("ExchangeCodeForToken", mock.Anything, "authorization_code", "test-code", "https://example.com/callback", "verifier").
|
||||
Return(expectedResp, nil)
|
||||
|
||||
result, err := m.ExchangeCodeForToken(context.Background(), "authorization_code", "test-code", "https://example.com/callback", "verifier")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedResp, result)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("RefreshToken success", func(t *testing.T) {
|
||||
m := new(TokenExchanger)
|
||||
expectedResp := &TokenResponse{
|
||||
AccessToken: "new-access-token",
|
||||
ExpiresIn: 3600,
|
||||
}
|
||||
|
||||
m.On("GetNewTokenWithRefreshToken", "refresh-token").
|
||||
Return(expectedResp, nil)
|
||||
|
||||
result, err := m.GetNewTokenWithRefreshToken("refresh-token")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedResp, result)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("RevokeToken success", func(t *testing.T) {
|
||||
m := new(TokenExchanger)
|
||||
m.On("RevokeTokenWithProvider", "token", "access_token").Return(nil)
|
||||
|
||||
err := m.RevokeTokenWithProvider("token", "access_token")
|
||||
|
||||
assert.NoError(t, err)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenVerifier(t *testing.T) {
|
||||
t.Run("VerifyToken success", func(t *testing.T) {
|
||||
m := new(TokenVerifier)
|
||||
m.On("VerifyToken", "valid-token").Return(nil)
|
||||
|
||||
err := m.VerifyToken("valid-token")
|
||||
|
||||
assert.NoError(t, err)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("VerifyToken failure", func(t *testing.T) {
|
||||
m := new(TokenVerifier)
|
||||
expectedErr := errors.New("token expired")
|
||||
m.On("VerifyToken", "expired-token").Return(expectedErr)
|
||||
|
||||
err := m.VerifyToken("expired-token")
|
||||
|
||||
assert.Equal(t, expectedErr, err)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionManager(t *testing.T) {
|
||||
t.Run("GetSession returns session", func(t *testing.T) {
|
||||
m := new(SessionManager)
|
||||
expectedSession := &SessionData{
|
||||
Email: "[email protected]",
|
||||
AccessToken: "access-token",
|
||||
}
|
||||
|
||||
m.On("GetSession", mock.AnythingOfType("*http.Request")).
|
||||
Return(expectedSession, nil)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
result, err := m.GetSession(req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedSession, result)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("SaveSession succeeds", func(t *testing.T) {
|
||||
m := new(SessionManager)
|
||||
session := &SessionData{Email: "[email protected]"}
|
||||
|
||||
m.On("SaveSession", mock.Anything, mock.Anything, session).Return(nil)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
err := m.SaveSession(req, nil, session)
|
||||
|
||||
assert.NoError(t, err)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DeleteSession succeeds", func(t *testing.T) {
|
||||
m := new(SessionManager)
|
||||
m.On("DeleteSession", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
err := m.DeleteSession(req, nil)
|
||||
|
||||
assert.NoError(t, err)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
t.Run("Get returns value", func(t *testing.T) {
|
||||
m := new(Cache)
|
||||
m.On("Get", "key").Return("value", true)
|
||||
|
||||
result, found := m.Get("key")
|
||||
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "value", result)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Get returns not found", func(t *testing.T) {
|
||||
m := new(Cache)
|
||||
m.On("Get", "missing").Return(nil, false)
|
||||
|
||||
result, found := m.Get("missing")
|
||||
|
||||
assert.False(t, found)
|
||||
assert.Nil(t, result)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("SetWithTTL is callable", func(t *testing.T) {
|
||||
m := new(Cache)
|
||||
m.On("SetWithTTL", "key", "value", 5*time.Minute).Return()
|
||||
|
||||
m.SetWithTTL("key", "value", 5*time.Minute)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Delete is callable", func(t *testing.T) {
|
||||
m := new(Cache)
|
||||
m.On("Delete", "key").Return()
|
||||
|
||||
m.Delete("key")
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPClient(t *testing.T) {
|
||||
t.Run("Do returns response", func(t *testing.T) {
|
||||
m := new(HTTPClient)
|
||||
expectedResp := &http.Response{StatusCode: 200}
|
||||
|
||||
m.On("Do", mock.AnythingOfType("*http.Request")).Return(expectedResp, nil)
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
result, err := m.Do(req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, result.StatusCode)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Do returns error", func(t *testing.T) {
|
||||
m := new(HTTPClient)
|
||||
expectedErr := errors.New("connection refused")
|
||||
|
||||
m.On("Do", mock.Anything).Return(nil, expectedErr)
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
result, err := m.Do(req)
|
||||
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogger(t *testing.T) {
|
||||
t.Run("Debug is callable", func(t *testing.T) {
|
||||
m := new(Logger)
|
||||
m.On("Debug", "test message").Return()
|
||||
|
||||
m.Debug("test message")
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Error is callable", func(t *testing.T) {
|
||||
m := new(Logger)
|
||||
m.On("Error", "error message").Return()
|
||||
|
||||
m.Error("error message")
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// SessionData represents session data for testing
|
||||
type SessionData struct {
|
||||
Email string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IDToken string
|
||||
Expiry int64
|
||||
Nonce string
|
||||
State string
|
||||
CodeVerifier string
|
||||
RedirectURL string
|
||||
Claims map[string]interface{}
|
||||
}
|
||||
|
||||
// SessionManager is a testify mock for session management
|
||||
type SessionManager struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// GetSession retrieves a session from the request
|
||||
func (m *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
|
||||
args := m.Called(r)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*SessionData), args.Error(1)
|
||||
}
|
||||
|
||||
// SaveSession saves a session to the response
|
||||
func (m *SessionManager) SaveSession(r *http.Request, w http.ResponseWriter, session *SessionData) error {
|
||||
args := m.Called(r, w, session)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// DeleteSession removes a session
|
||||
func (m *SessionManager) DeleteSession(r *http.Request, w http.ResponseWriter) error {
|
||||
args := m.Called(r, w)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// SetAccessToken sets the access token in the session
|
||||
func (m *SessionManager) SetAccessToken(session *SessionData, token string) error {
|
||||
args := m.Called(session, token)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// SetRefreshToken sets the refresh token in the session
|
||||
func (m *SessionManager) SetRefreshToken(session *SessionData, token string) error {
|
||||
args := m.Called(session, token)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// SetIDToken sets the ID token in the session
|
||||
func (m *SessionManager) SetIDToken(session *SessionData, token string) error {
|
||||
args := m.Called(session, token)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// GetAccessToken gets the access token from the session
|
||||
func (m *SessionManager) GetAccessToken(session *SessionData) string {
|
||||
args := m.Called(session)
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// GetRefreshToken gets the refresh token from the session
|
||||
func (m *SessionManager) GetRefreshToken(session *SessionData) string {
|
||||
args := m.Called(session)
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// GetIDToken gets the ID token from the session
|
||||
func (m *SessionManager) GetIDToken(session *SessionData) string {
|
||||
args := m.Called(session)
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// IsExpired checks if the session is expired
|
||||
func (m *SessionManager) IsExpired(session *SessionData) bool {
|
||||
args := m.Called(session)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
// CleanupOldCookies removes old/stale cookies
|
||||
func (m *SessionManager) CleanupOldCookies(r *http.Request, w http.ResponseWriter) {
|
||||
m.Called(r, w)
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package servers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/testutil/fixtures"
|
||||
)
|
||||
|
||||
// OIDCServerConfig configures the mock OIDC server behavior
|
||||
type OIDCServerConfig struct {
|
||||
// Identity
|
||||
Issuer string
|
||||
|
||||
// Discovery
|
||||
ScopesSupported []string
|
||||
ResponseTypesSupported []string
|
||||
GrantTypesSupported []string
|
||||
ClaimsSupported []string
|
||||
TokenEndpointAuthMethods []string
|
||||
|
||||
// Token fixture for signing
|
||||
TokenFixture *fixtures.TokenFixture
|
||||
|
||||
// Token endpoint behavior
|
||||
TokenResponse map[string]interface{}
|
||||
TokenError *OIDCError
|
||||
TokenDelay time.Duration
|
||||
RefreshResponse map[string]interface{}
|
||||
RefreshError *OIDCError
|
||||
|
||||
// JWKS behavior
|
||||
JWKSResponse map[string]interface{}
|
||||
JWKSError *OIDCError
|
||||
JWKSDelay time.Duration
|
||||
|
||||
// Introspection behavior
|
||||
IntrospectionResponse map[string]interface{}
|
||||
IntrospectionError *OIDCError
|
||||
|
||||
// Userinfo behavior
|
||||
UserinfoResponse map[string]interface{}
|
||||
UserinfoError *OIDCError
|
||||
|
||||
// Simulation flags
|
||||
SimulateTimeout bool
|
||||
TimeoutDuration time.Duration
|
||||
RateLimitAfter int
|
||||
FailAfterN int
|
||||
FailWithStatus int
|
||||
}
|
||||
|
||||
// OIDCError represents an OAuth error response
|
||||
type OIDCError struct {
|
||||
Error string `json:"error"`
|
||||
Description string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCServer is a configurable mock OIDC provider
|
||||
type OIDCServer struct {
|
||||
*httptest.Server
|
||||
Config *OIDCServerConfig
|
||||
RequestCount int32
|
||||
mu sync.Mutex
|
||||
requests []*http.Request
|
||||
}
|
||||
|
||||
// NewOIDCServer creates a new mock OIDC server
|
||||
func NewOIDCServer(config *OIDCServerConfig) *OIDCServer {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
server := &OIDCServer{
|
||||
Config: config,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", server.handleDiscovery)
|
||||
mux.HandleFunc("/token", server.handleToken)
|
||||
mux.HandleFunc("/jwks", server.handleJWKS)
|
||||
mux.HandleFunc("/authorize", server.handleAuthorize)
|
||||
mux.HandleFunc("/userinfo", server.handleUserinfo)
|
||||
mux.HandleFunc("/revoke", server.handleRevoke)
|
||||
mux.HandleFunc("/introspect", server.handleIntrospect)
|
||||
mux.HandleFunc("/logout", server.handleLogout)
|
||||
|
||||
server.Server = httptest.NewServer(mux)
|
||||
|
||||
// Update issuer to use actual server URL if not set
|
||||
if config.Issuer == "" {
|
||||
config.Issuer = server.URL
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// NewTLSServer creates a new mock OIDC server with TLS
|
||||
func NewTLSServer(config *OIDCServerConfig) *OIDCServer {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
server := &OIDCServer{
|
||||
Config: config,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", server.handleDiscovery)
|
||||
mux.HandleFunc("/token", server.handleToken)
|
||||
mux.HandleFunc("/jwks", server.handleJWKS)
|
||||
mux.HandleFunc("/authorize", server.handleAuthorize)
|
||||
mux.HandleFunc("/userinfo", server.handleUserinfo)
|
||||
mux.HandleFunc("/revoke", server.handleRevoke)
|
||||
mux.HandleFunc("/introspect", server.handleIntrospect)
|
||||
mux.HandleFunc("/logout", server.handleLogout)
|
||||
|
||||
server.Server = httptest.NewTLSServer(mux)
|
||||
|
||||
if config.Issuer == "" {
|
||||
config.Issuer = server.URL
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// GetRequestCount returns the number of requests received
|
||||
func (s *OIDCServer) GetRequestCount() int {
|
||||
return int(atomic.LoadInt32(&s.RequestCount))
|
||||
}
|
||||
|
||||
// GetRequests returns all recorded requests
|
||||
func (s *OIDCServer) GetRequests() []*http.Request {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.requests
|
||||
}
|
||||
|
||||
// Reset clears request tracking
|
||||
func (s *OIDCServer) Reset() {
|
||||
atomic.StoreInt32(&s.RequestCount, 0)
|
||||
s.mu.Lock()
|
||||
s.requests = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *OIDCServer) recordRequest(r *http.Request) {
|
||||
atomic.AddInt32(&s.RequestCount, 1)
|
||||
s.mu.Lock()
|
||||
s.requests = append(s.requests, r)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *OIDCServer) shouldFail() bool {
|
||||
count := int(atomic.LoadInt32(&s.RequestCount))
|
||||
if s.Config.FailAfterN > 0 && count > s.Config.FailAfterN {
|
||||
return true
|
||||
}
|
||||
if s.Config.RateLimitAfter > 0 && count > s.Config.RateLimitAfter {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *OIDCServer) handleDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordRequest(r)
|
||||
|
||||
if s.Config.SimulateTimeout {
|
||||
time.Sleep(s.Config.TimeoutDuration)
|
||||
return
|
||||
}
|
||||
|
||||
discovery := map[string]interface{}{
|
||||
"issuer": s.Config.Issuer,
|
||||
"authorization_endpoint": s.Config.Issuer + "/authorize",
|
||||
"token_endpoint": s.Config.Issuer + "/token",
|
||||
"userinfo_endpoint": s.Config.Issuer + "/userinfo",
|
||||
"jwks_uri": s.Config.Issuer + "/jwks",
|
||||
"revocation_endpoint": s.Config.Issuer + "/revoke",
|
||||
"introspection_endpoint": s.Config.Issuer + "/introspect",
|
||||
"end_session_endpoint": s.Config.Issuer + "/logout",
|
||||
"scopes_supported": s.Config.ScopesSupported,
|
||||
"response_types_supported": s.Config.ResponseTypesSupported,
|
||||
"grant_types_supported": s.Config.GrantTypesSupported,
|
||||
"claims_supported": s.Config.ClaimsSupported,
|
||||
"token_endpoint_auth_methods_supported": s.Config.TokenEndpointAuthMethods,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(discovery) // #nosec G104 - test server, error handling not critical
|
||||
}
|
||||
|
||||
func (s *OIDCServer) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordRequest(r)
|
||||
|
||||
if s.Config.SimulateTimeout {
|
||||
time.Sleep(s.Config.TimeoutDuration)
|
||||
return
|
||||
}
|
||||
|
||||
if s.Config.TokenDelay > 0 {
|
||||
time.Sleep(s.Config.TokenDelay)
|
||||
}
|
||||
|
||||
if s.shouldFail() {
|
||||
status := http.StatusTooManyRequests
|
||||
if s.Config.FailWithStatus > 0 {
|
||||
status = s.Config.FailWithStatus
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(OIDCError{Error: "rate_limited"}) // #nosec G104
|
||||
return
|
||||
}
|
||||
|
||||
if s.Config.TokenError != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(s.Config.TokenError) // #nosec G104
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.ParseForm() // #nosec G104
|
||||
grantType := r.FormValue("grant_type")
|
||||
|
||||
var response map[string]interface{}
|
||||
|
||||
if grantType == "refresh_token" {
|
||||
if s.Config.RefreshError != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(s.Config.RefreshError) // #nosec G104
|
||||
return
|
||||
}
|
||||
response = s.Config.RefreshResponse
|
||||
} else {
|
||||
response = s.Config.TokenResponse
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
response = s.defaultTokenResponse()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response) // #nosec G104
|
||||
}
|
||||
|
||||
func (s *OIDCServer) handleJWKS(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordRequest(r)
|
||||
|
||||
if s.Config.SimulateTimeout {
|
||||
time.Sleep(s.Config.TimeoutDuration)
|
||||
return
|
||||
}
|
||||
|
||||
if s.Config.JWKSDelay > 0 {
|
||||
time.Sleep(s.Config.JWKSDelay)
|
||||
}
|
||||
|
||||
if s.Config.JWKSError != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(s.Config.JWKSError) // #nosec G104
|
||||
return
|
||||
}
|
||||
|
||||
response := s.Config.JWKSResponse
|
||||
if response == nil && s.Config.TokenFixture != nil {
|
||||
response = s.Config.TokenFixture.GetJWKS()
|
||||
}
|
||||
if response == nil {
|
||||
response = map[string]interface{}{"keys": []interface{}{}}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response) // #nosec G104
|
||||
}
|
||||
|
||||
func (s *OIDCServer) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordRequest(r)
|
||||
|
||||
// In real flow, this would redirect with code
|
||||
// For testing, we return a simple page
|
||||
state := r.URL.Query().Get("state")
|
||||
redirectURI := r.URL.Query().Get("redirect_uri")
|
||||
|
||||
// Validate redirect URI to prevent open redirect vulnerability
|
||||
if !isValidRedirectURI(redirectURI, s.URL) {
|
||||
http.Error(w, "invalid redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s?code=test-auth-code&state=%s", redirectURI, state)
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *OIDCServer) handleUserinfo(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordRequest(r)
|
||||
|
||||
if s.Config.UserinfoError != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(s.Config.UserinfoError) // #nosec G104
|
||||
return
|
||||
}
|
||||
|
||||
response := s.Config.UserinfoResponse
|
||||
if response == nil {
|
||||
response = map[string]interface{}{
|
||||
"sub": "test-subject",
|
||||
"email": "[email protected]",
|
||||
"name": "Test User",
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response) // #nosec G104
|
||||
}
|
||||
|
||||
func (s *OIDCServer) handleRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordRequest(r)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *OIDCServer) handleIntrospect(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordRequest(r)
|
||||
|
||||
if s.Config.IntrospectionError != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(s.Config.IntrospectionError) // #nosec G104
|
||||
return
|
||||
}
|
||||
|
||||
response := s.Config.IntrospectionResponse
|
||||
if response == nil {
|
||||
response = map[string]interface{}{
|
||||
"active": true,
|
||||
"sub": "test-subject",
|
||||
"client_id": "test-client",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response) // #nosec G104
|
||||
}
|
||||
|
||||
func (s *OIDCServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordRequest(r)
|
||||
|
||||
postLogoutRedirect := r.URL.Query().Get("post_logout_redirect_uri")
|
||||
if postLogoutRedirect != "" {
|
||||
// Validate post-logout redirect URI to prevent open redirect vulnerability
|
||||
if !isValidRedirectURI(postLogoutRedirect, s.URL) {
|
||||
http.Error(w, "invalid post_logout_redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, postLogoutRedirect, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("Logged out")) // #nosec G104
|
||||
}
|
||||
|
||||
func (s *OIDCServer) defaultTokenResponse() map[string]interface{} {
|
||||
var idToken string
|
||||
if s.Config.TokenFixture != nil {
|
||||
idToken, _ = s.Config.TokenFixture.ValidToken(nil)
|
||||
} else {
|
||||
idToken = "mock-id-token"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"id_token": idToken,
|
||||
}
|
||||
}
|
||||
|
||||
// isValidRedirectURI validates that a redirect URI is safe to use.
|
||||
// It ensures the URI is either:
|
||||
// 1. A relative path (no scheme or host)
|
||||
// 2. Points to the same host as the test server (localhost)
|
||||
// 3. Points to a common test domain (127.0.0.1, localhost)
|
||||
// This prevents open redirect vulnerabilities in the test server.
|
||||
func isValidRedirectURI(redirectURI, serverURL string) bool {
|
||||
if redirectURI == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow relative paths (no scheme means relative)
|
||||
if parsed.Scheme == "" && parsed.Host == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse the server URL to get its host
|
||||
serverParsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow same host as test server
|
||||
if parsed.Host == serverParsed.Host {
|
||||
return true
|
||||
}
|
||||
|
||||
// Allow common localhost variations used in tests
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
allowedHosts := []string{
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"[::1]",
|
||||
"example.com", // Common test domain
|
||||
"myapp.com", // Common test domain
|
||||
"test.example.com",
|
||||
}
|
||||
|
||||
for _, allowed := range allowedHosts {
|
||||
if host == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default server configuration
|
||||
func DefaultConfig() *OIDCServerConfig {
|
||||
return &OIDCServerConfig{
|
||||
ScopesSupported: []string{"openid", "profile", "email", "offline_access"},
|
||||
ResponseTypesSupported: []string{"code", "token", "id_token"},
|
||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||
ClaimsSupported: []string{"sub", "email", "name", "groups", "roles"},
|
||||
TokenEndpointAuthMethods: []string{"client_secret_basic", "client_secret_post"},
|
||||
TimeoutDuration: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// GoogleConfig returns a Google-like server configuration
|
||||
func GoogleConfig() *OIDCServerConfig {
|
||||
config := DefaultConfig()
|
||||
config.Issuer = "https://accounts.google.com"
|
||||
config.ScopesSupported = []string{"openid", "profile", "email"}
|
||||
// Google doesn't support offline_access, uses access_type=offline instead
|
||||
return config
|
||||
}
|
||||
|
||||
// AzureConfig returns an Azure AD-like server configuration
|
||||
func AzureConfig() *OIDCServerConfig {
|
||||
config := DefaultConfig()
|
||||
config.Issuer = "https://login.microsoftonline.com/common/v2.0"
|
||||
config.ScopesSupported = []string{"openid", "profile", "email", "offline_access"}
|
||||
return config
|
||||
}
|
||||
|
||||
// Auth0Config returns an Auth0-like server configuration
|
||||
func Auth0Config() *OIDCServerConfig {
|
||||
config := DefaultConfig()
|
||||
config.ScopesSupported = []string{"openid", "profile", "email", "offline_access"}
|
||||
return config
|
||||
}
|
||||
|
||||
// KeycloakConfig returns a Keycloak-like server configuration
|
||||
func KeycloakConfig() *OIDCServerConfig {
|
||||
config := DefaultConfig()
|
||||
config.ScopesSupported = []string{"openid", "profile", "email", "offline_access", "roles", "groups"}
|
||||
return config
|
||||
}
|
||||
|
||||
// SlowServerConfig returns a configuration that simulates slow responses
|
||||
func SlowServerConfig(delay time.Duration) *OIDCServerConfig {
|
||||
config := DefaultConfig()
|
||||
config.TokenDelay = delay
|
||||
config.JWKSDelay = delay
|
||||
return config
|
||||
}
|
||||
|
||||
// RateLimitedConfig returns a configuration that rate limits after N requests
|
||||
func RateLimitedConfig(afterN int) *OIDCServerConfig {
|
||||
config := DefaultConfig()
|
||||
config.RateLimitAfter = afterN
|
||||
return config
|
||||
}
|
||||
|
||||
// FailingConfig returns a configuration that fails after N requests
|
||||
func FailingConfig(afterN int, status int) *OIDCServerConfig {
|
||||
config := DefaultConfig()
|
||||
config.FailAfterN = afterN
|
||||
config.FailWithStatus = status
|
||||
return config
|
||||
}
|
||||
|
||||
// TimeoutConfig returns a configuration that simulates timeouts
|
||||
func TimeoutConfig(duration time.Duration) *OIDCServerConfig {
|
||||
config := DefaultConfig()
|
||||
config.SimulateTimeout = true
|
||||
config.TimeoutDuration = duration
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package servers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/testutil/fixtures"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewOIDCServer(t *testing.T) {
|
||||
t.Run("creates server with default config", func(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
assert.NotNil(t, server)
|
||||
assert.NotEmpty(t, server.URL)
|
||||
})
|
||||
|
||||
t.Run("creates server with custom config", func(t *testing.T) {
|
||||
config := &OIDCServerConfig{
|
||||
Issuer: "https://custom-issuer.com",
|
||||
ScopesSupported: []string{"openid", "custom"},
|
||||
}
|
||||
server := NewOIDCServer(config)
|
||||
defer server.Close()
|
||||
|
||||
assert.NotNil(t, server)
|
||||
assert.Equal(t, "https://custom-issuer.com", server.Config.Issuer)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscoveryEndpoint(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/.well-known/openid-configuration")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var discovery map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&discovery)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, server.URL, discovery["issuer"])
|
||||
assert.Contains(t, discovery["token_endpoint"], "/token")
|
||||
assert.Contains(t, discovery["jwks_uri"], "/jwks")
|
||||
assert.Contains(t, discovery["authorization_endpoint"], "/authorize")
|
||||
}
|
||||
|
||||
func TestTokenEndpoint(t *testing.T) {
|
||||
t.Run("returns default token response", func(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"test-code"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&tokenResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, tokenResp["access_token"])
|
||||
assert.NotEmpty(t, tokenResp["refresh_token"])
|
||||
})
|
||||
|
||||
t.Run("returns configured error", func(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.TokenError = &OIDCError{
|
||||
Error: "invalid_grant",
|
||||
Description: "The authorization code is invalid",
|
||||
}
|
||||
server := NewOIDCServer(config)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"test-code"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var errResp OIDCError
|
||||
err = json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "invalid_grant", errResp.Error)
|
||||
})
|
||||
|
||||
t.Run("handles refresh token grant", func(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.RefreshResponse = map[string]interface{}{
|
||||
"access_token": "new-access-token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
server := NewOIDCServer(config)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {"test-refresh-token"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&tokenResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "new-access-token", tokenResp["access_token"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWKSEndpoint(t *testing.T) {
|
||||
t.Run("returns empty JWKS without fixture", func(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/jwks")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var jwks map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&jwks)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, jwks, "keys")
|
||||
})
|
||||
|
||||
t.Run("returns JWKS from fixture", func(t *testing.T) {
|
||||
fixture, err := fixtures.NewTokenFixture()
|
||||
require.NoError(t, err)
|
||||
|
||||
config := DefaultConfig()
|
||||
config.TokenFixture = fixture
|
||||
server := NewOIDCServer(config)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/jwks")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var jwks map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&jwks)
|
||||
require.NoError(t, err)
|
||||
|
||||
keys, ok := jwks["keys"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, keys, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserinfoEndpoint(t *testing.T) {
|
||||
t.Run("returns default userinfo", func(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/userinfo")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var userinfo map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&userinfo)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, userinfo["sub"])
|
||||
assert.NotEmpty(t, userinfo["email"])
|
||||
})
|
||||
|
||||
t.Run("returns configured userinfo", func(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.UserinfoResponse = map[string]interface{}{
|
||||
"sub": "custom-sub",
|
||||
"email": "[email protected]",
|
||||
"name": "Custom User",
|
||||
}
|
||||
server := NewOIDCServer(config)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/userinfo")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var userinfo map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&userinfo)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "[email protected]", userinfo["email"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntrospectionEndpoint(t *testing.T) {
|
||||
t.Run("returns active token", func(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.PostForm(server.URL+"/introspect", map[string][]string{
|
||||
"token": {"test-token"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var introspection map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&introspection)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, true, introspection["active"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestRevocationEndpoint(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.PostForm(server.URL+"/revoke", map[string][]string{
|
||||
"token": {"test-token"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestLogoutEndpoint(t *testing.T) {
|
||||
t.Run("returns OK without redirect", func(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(server.URL + "/logout")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("redirects with post_logout_redirect_uri", func(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(server.URL + "/logout?post_logout_redirect_uri=https://example.com/logged-out")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||
assert.Equal(t, "https://example.com/logged-out", resp.Header.Get("Location"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequestTracking(t *testing.T) {
|
||||
server := NewOIDCServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
assert.Equal(t, 0, server.GetRequestCount())
|
||||
|
||||
http.Get(server.URL + "/.well-known/openid-configuration")
|
||||
assert.Equal(t, 1, server.GetRequestCount())
|
||||
|
||||
http.Get(server.URL + "/jwks")
|
||||
assert.Equal(t, 2, server.GetRequestCount())
|
||||
|
||||
requests := server.GetRequests()
|
||||
assert.Len(t, requests, 2)
|
||||
|
||||
server.Reset()
|
||||
assert.Equal(t, 0, server.GetRequestCount())
|
||||
assert.Len(t, server.GetRequests(), 0)
|
||||
}
|
||||
|
||||
func TestRateLimiting(t *testing.T) {
|
||||
config := RateLimitedConfig(2)
|
||||
server := NewOIDCServer(config)
|
||||
defer server.Close()
|
||||
|
||||
// First 2 requests should succeed
|
||||
for i := 0; i < 2; i++ {
|
||||
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"test-code"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Third request should be rate limited
|
||||
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"test-code"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestSlowServer(t *testing.T) {
|
||||
config := SlowServerConfig(100 * time.Millisecond)
|
||||
server := NewOIDCServer(config)
|
||||
defer server.Close()
|
||||
|
||||
start := time.Now()
|
||||
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"test-code"},
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
|
||||
assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(100))
|
||||
}
|
||||
|
||||
func TestProviderConfigs(t *testing.T) {
|
||||
t.Run("GoogleConfig", func(t *testing.T) {
|
||||
config := GoogleConfig()
|
||||
assert.Equal(t, "https://accounts.google.com", config.Issuer)
|
||||
assert.NotContains(t, config.ScopesSupported, "offline_access")
|
||||
})
|
||||
|
||||
t.Run("AzureConfig", func(t *testing.T) {
|
||||
config := AzureConfig()
|
||||
assert.Contains(t, config.Issuer, "microsoftonline.com")
|
||||
assert.Contains(t, config.ScopesSupported, "offline_access")
|
||||
})
|
||||
|
||||
t.Run("Auth0Config", func(t *testing.T) {
|
||||
config := Auth0Config()
|
||||
assert.Contains(t, config.ScopesSupported, "offline_access")
|
||||
})
|
||||
|
||||
t.Run("KeycloakConfig", func(t *testing.T) {
|
||||
config := KeycloakConfig()
|
||||
assert.Contains(t, config.ScopesSupported, "roles")
|
||||
assert.Contains(t, config.ScopesSupported, "groups")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeoutConfig(t *testing.T) {
|
||||
config := TimeoutConfig(50 * time.Millisecond)
|
||||
server := NewOIDCServer(config)
|
||||
defer server.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 100 * time.Millisecond,
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Get(server.URL + "/.well-known/openid-configuration")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Either timeout or empty response
|
||||
if err == nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
// With timeout simulation, response body may be empty
|
||||
assert.True(t, len(body) == 0 || elapsed >= 50*time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/testutil/fixtures"
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/testutil/mocks"
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/testutil/servers"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// OIDCSuite is a base test suite for OIDC-related tests
|
||||
type OIDCSuite struct {
|
||||
suite.Suite
|
||||
|
||||
// Common fixtures
|
||||
TokenFixture *fixtures.TokenFixture
|
||||
|
||||
// Mock OIDC server
|
||||
OIDCServer *servers.OIDCServer
|
||||
|
||||
// Mocks
|
||||
JWKCacheMock *mocks.JWKCache
|
||||
TokenExchangerMock *mocks.TokenExchanger
|
||||
SessionManagerMock *mocks.SessionManager
|
||||
CacheMock *mocks.Cache
|
||||
LoggerMock *mocks.Logger
|
||||
}
|
||||
|
||||
// SetupSuite runs once before all tests in the suite
|
||||
func (s *OIDCSuite) SetupSuite() {
|
||||
var err error
|
||||
s.TokenFixture, err = fixtures.NewTokenFixture()
|
||||
s.Require().NoError(err, "Failed to create token fixture")
|
||||
}
|
||||
|
||||
// SetupTest runs before each test
|
||||
func (s *OIDCSuite) SetupTest() {
|
||||
// Create fresh mocks for each test
|
||||
s.JWKCacheMock = new(mocks.JWKCache)
|
||||
s.TokenExchangerMock = new(mocks.TokenExchanger)
|
||||
s.SessionManagerMock = new(mocks.SessionManager)
|
||||
s.CacheMock = new(mocks.Cache)
|
||||
s.LoggerMock = new(mocks.Logger)
|
||||
|
||||
// Create OIDC server with token fixture
|
||||
config := servers.DefaultConfig()
|
||||
config.TokenFixture = s.TokenFixture
|
||||
s.OIDCServer = servers.NewOIDCServer(config)
|
||||
}
|
||||
|
||||
// TearDownTest runs after each test
|
||||
func (s *OIDCSuite) TearDownTest() {
|
||||
if s.OIDCServer != nil {
|
||||
s.OIDCServer.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TearDownSuite runs once after all tests in the suite
|
||||
func (s *OIDCSuite) TearDownSuite() {
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
// NewRequest creates a new HTTP request for testing
|
||||
func (s *OIDCSuite) NewRequest(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
return req
|
||||
}
|
||||
|
||||
// NewRequestWithCookie creates a request with a session cookie
|
||||
func (s *OIDCSuite) NewRequestWithCookie(method, path, cookieName, cookieValue string) *http.Request {
|
||||
req := s.NewRequest(method, path)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: cookieValue,
|
||||
})
|
||||
return req
|
||||
}
|
||||
|
||||
// NewRecorder creates a new response recorder
|
||||
func (s *OIDCSuite) NewRecorder() *httptest.ResponseRecorder {
|
||||
return httptest.NewRecorder()
|
||||
}
|
||||
|
||||
// AssertMocksCalled verifies all mock expectations were met
|
||||
func (s *OIDCSuite) AssertMocksCalled() {
|
||||
s.JWKCacheMock.AssertExpectations(s.T())
|
||||
s.TokenExchangerMock.AssertExpectations(s.T())
|
||||
s.SessionManagerMock.AssertExpectations(s.T())
|
||||
s.CacheMock.AssertExpectations(s.T())
|
||||
s.LoggerMock.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
// ValidToken returns a valid JWT token
|
||||
func (s *OIDCSuite) ValidToken() string {
|
||||
token, err := s.TokenFixture.ValidToken(nil)
|
||||
s.Require().NoError(err)
|
||||
return token
|
||||
}
|
||||
|
||||
// ExpiredToken returns an expired JWT token
|
||||
func (s *OIDCSuite) ExpiredToken() string {
|
||||
token, err := s.TokenFixture.ExpiredToken()
|
||||
s.Require().NoError(err)
|
||||
return token
|
||||
}
|
||||
|
||||
// TokenWithClaims returns a token with custom claims
|
||||
func (s *OIDCSuite) TokenWithClaims(claims map[string]interface{}) string {
|
||||
token, err := s.TokenFixture.ValidToken(claims)
|
||||
s.Require().NoError(err)
|
||||
return token
|
||||
}
|
||||
|
||||
// RunSuite runs a test suite
|
||||
func RunSuite(t *testing.T, s suite.TestingSuite) {
|
||||
suite.Run(t, s)
|
||||
}
|
||||
|
||||
// MinimalSuite is a lightweight test suite without OIDC server
|
||||
type MinimalSuite struct {
|
||||
suite.Suite
|
||||
|
||||
// Mocks only
|
||||
JWKCacheMock *mocks.JWKCache
|
||||
TokenExchangerMock *mocks.TokenExchanger
|
||||
CacheMock *mocks.Cache
|
||||
}
|
||||
|
||||
// SetupTest runs before each test
|
||||
func (s *MinimalSuite) SetupTest() {
|
||||
s.JWKCacheMock = new(mocks.JWKCache)
|
||||
s.TokenExchangerMock = new(mocks.TokenExchanger)
|
||||
s.CacheMock = new(mocks.Cache)
|
||||
}
|
||||
|
||||
// AssertMocksCalled verifies all mock expectations were met
|
||||
func (s *MinimalSuite) AssertMocksCalled() {
|
||||
s.JWKCacheMock.AssertExpectations(s.T())
|
||||
s.TokenExchangerMock.AssertExpectations(s.T())
|
||||
s.CacheMock.AssertExpectations(s.T())
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
// Package token provides token management functionality for OIDC authentication.
|
||||
package token
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenCache manages cached verified tokens
|
||||
type TokenCache struct {
|
||||
cache CacheInterface
|
||||
blacklist CacheInterface
|
||||
logger LoggerInterface
|
||||
metrics MetricsInterface
|
||||
cleanupTicker *time.Ticker
|
||||
cleanupStop chan bool
|
||||
mu sync.RWMutex
|
||||
maxTTL time.Duration
|
||||
}
|
||||
|
||||
// NewTokenCache creates a new token cache manager
|
||||
func NewTokenCache(cache, blacklist CacheInterface, logger LoggerInterface, metrics MetricsInterface, maxTTL time.Duration) *TokenCache {
|
||||
return &TokenCache{
|
||||
cache: cache,
|
||||
blacklist: blacklist,
|
||||
logger: logger,
|
||||
metrics: metrics,
|
||||
maxTTL: maxTTL,
|
||||
cleanupStop: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// CacheToken stores a verified token with its claims in cache
|
||||
func (tc *TokenCache) CacheToken(token string, claims map[string]interface{}) {
|
||||
if token == "" || len(claims) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
// Add timestamp for TTL management
|
||||
claimsWithMeta := make(map[string]interface{})
|
||||
for k, v := range claims {
|
||||
claimsWithMeta[k] = v
|
||||
}
|
||||
claimsWithMeta["_cached_at"] = time.Now().Unix()
|
||||
|
||||
tc.cache.Set(token, claimsWithMeta)
|
||||
tc.logger.Logf("Cached verified token (claims count: %d)", len(claims))
|
||||
}
|
||||
|
||||
// GetCachedToken retrieves a token's claims from cache if present and valid
|
||||
func (tc *TokenCache) GetCachedToken(token string) (map[string]interface{}, bool) {
|
||||
if token == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
|
||||
claims, exists := tc.cache.Get(token)
|
||||
if !exists || len(claims) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if token is blacklisted
|
||||
if tc.isBlacklisted(token, claims) {
|
||||
tc.cache.Delete(token)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check cache TTL
|
||||
if cachedAt, ok := claims["_cached_at"].(int64); ok {
|
||||
if time.Since(time.Unix(cachedAt, 0)) > tc.maxTTL {
|
||||
tc.cache.Delete(token)
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// Check token expiry from claims
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
if time.Now().Unix() > int64(exp) {
|
||||
tc.cache.Delete(token)
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
tc.logger.Logf("Token found in cache (valid)")
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// InvalidateToken removes a token from cache and adds it to blacklist
|
||||
func (tc *TokenCache) InvalidateToken(token string) {
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
// Remove from cache
|
||||
tc.cache.Delete(token)
|
||||
|
||||
// Add to blacklist
|
||||
if tc.blacklist != nil {
|
||||
tc.blacklist.Set(token, map[string]interface{}{
|
||||
"invalidated_at": time.Now().Unix(),
|
||||
"reason": "manual_invalidation",
|
||||
})
|
||||
|
||||
// Also blacklist JTI if present
|
||||
if claims, exists := tc.cache.Get(token); exists {
|
||||
if jti, ok := claims["jti"].(string); ok && jti != "" {
|
||||
tc.blacklist.Set(jti, map[string]interface{}{
|
||||
"invalidated_at": time.Now().Unix(),
|
||||
"reason": "jti_invalidation",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tc.logger.Logf("Token invalidated and blacklisted")
|
||||
}
|
||||
|
||||
// StartCleanup starts the background cleanup process for expired tokens
|
||||
func (tc *TokenCache) StartCleanup(interval time.Duration) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
if tc.cleanupTicker != nil {
|
||||
return // Already running
|
||||
}
|
||||
|
||||
// Create fresh stop channel for this cleanup session
|
||||
tc.cleanupStop = make(chan bool, 1)
|
||||
tc.cleanupTicker = time.NewTicker(interval)
|
||||
tickerChan := tc.cleanupTicker.C // Capture channel before goroutine starts
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-tickerChan:
|
||||
tc.cleanupExpiredTokens()
|
||||
case <-tc.cleanupStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tc.logger.Logf("Started token cache cleanup (interval: %v)", interval)
|
||||
}
|
||||
|
||||
// StopCleanup stops the background cleanup process
|
||||
func (tc *TokenCache) StopCleanup() {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
if tc.cleanupTicker != nil {
|
||||
tc.cleanupTicker.Stop()
|
||||
select {
|
||||
case tc.cleanupStop <- true: // Signal stop
|
||||
default: // Channel might be full or goroutine already stopped
|
||||
}
|
||||
tc.cleanupTicker = nil
|
||||
tc.logger.Logf("Stopped token cache cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpiredTokens removes expired tokens from cache
|
||||
func (tc *TokenCache) cleanupExpiredTokens() {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
// This would need to iterate through cache entries
|
||||
// Since we're using an interface, we'd need to add a method to get all keys
|
||||
// For now, this is a placeholder that would be implemented based on the actual cache implementation
|
||||
tc.logger.Logf("Running token cache cleanup")
|
||||
}
|
||||
|
||||
// isBlacklisted checks if a token or its JTI is blacklisted
|
||||
func (tc *TokenCache) isBlacklisted(token string, claims map[string]interface{}) bool {
|
||||
if tc.blacklist == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check token itself
|
||||
if blacklisted, exists := tc.blacklist.Get(token); exists && blacklisted != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check JTI
|
||||
if jti, ok := claims["jti"].(string); ok && jti != "" {
|
||||
if blacklisted, exists := tc.blacklist.Get(jti); exists && blacklisted != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TokenBlacklist manages blacklisted tokens
|
||||
type TokenBlacklist struct {
|
||||
blacklist CacheInterface
|
||||
logger LoggerInterface
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTokenBlacklist creates a new token blacklist manager
|
||||
func NewTokenBlacklist(blacklist CacheInterface, logger LoggerInterface) *TokenBlacklist {
|
||||
return &TokenBlacklist{
|
||||
blacklist: blacklist,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a token to the blacklist
|
||||
func (tb *TokenBlacklist) Add(token string, reason string) {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
|
||||
tb.blacklist.Set(token, map[string]interface{}{
|
||||
"blacklisted_at": time.Now().Unix(),
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
tb.logger.Logf("Token added to blacklist (reason: %s)", reason)
|
||||
}
|
||||
|
||||
// AddJTI adds a JTI to the blacklist for replay detection
|
||||
func (tb *TokenBlacklist) AddJTI(jti string) {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
|
||||
tb.blacklist.Set(jti, map[string]interface{}{
|
||||
"blacklisted_at": time.Now().Unix(),
|
||||
"reason": "jti_replay_detection",
|
||||
})
|
||||
|
||||
tb.logger.Logf("JTI added to blacklist for replay detection")
|
||||
}
|
||||
|
||||
// IsBlacklisted checks if a token is blacklisted
|
||||
func (tb *TokenBlacklist) IsBlacklisted(token string) bool {
|
||||
tb.mu.RLock()
|
||||
defer tb.mu.RUnlock()
|
||||
|
||||
if blacklisted, exists := tb.blacklist.Get(token); exists && blacklisted != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsJTIBlacklisted checks if a JTI is blacklisted
|
||||
func (tb *TokenBlacklist) IsJTIBlacklisted(jti string) bool {
|
||||
tb.mu.RLock()
|
||||
defer tb.mu.RUnlock()
|
||||
|
||||
if blacklisted, exists := tb.blacklist.Get(jti); exists && blacklisted != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TokenRevocationManager handles token revocation with providers
|
||||
type TokenRevocationManager struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
revocationURL string
|
||||
httpClient *http.Client
|
||||
logger LoggerInterface
|
||||
blacklist *TokenBlacklist
|
||||
}
|
||||
|
||||
// NewTokenRevocationManager creates a new revocation manager
|
||||
func NewTokenRevocationManager(clientID, clientSecret, revocationURL string, httpClient *http.Client, logger LoggerInterface, blacklist *TokenBlacklist) *TokenRevocationManager {
|
||||
return &TokenRevocationManager{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
revocationURL: revocationURL,
|
||||
httpClient: httpClient,
|
||||
logger: logger,
|
||||
blacklist: blacklist,
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeToken revokes a token locally and optionally with the provider
|
||||
func (trm *TokenRevocationManager) RevokeToken(token string, tokenType string, withProvider bool) error {
|
||||
// Add to local blacklist immediately
|
||||
trm.blacklist.Add(token, fmt.Sprintf("revoked_%s", tokenType))
|
||||
|
||||
// Parse token to get JTI
|
||||
if jwt, err := parseJWT(token); err == nil {
|
||||
if jti, ok := jwt.Claims["jti"].(string); ok && jti != "" {
|
||||
trm.blacklist.AddJTI(jti)
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke with provider if requested
|
||||
if withProvider && trm.revocationURL != "" {
|
||||
return trm.revokeWithProvider(token, tokenType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeWithProvider sends revocation request to the OIDC provider
|
||||
func (trm *TokenRevocationManager) revokeWithProvider(token, tokenType string) error {
|
||||
// Implementation would send HTTP request to revocation endpoint
|
||||
// This is simplified for module structure
|
||||
trm.logger.Logf("Revoking %s with provider", tokenType)
|
||||
return nil
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock implementations
|
||||
type mockCache struct {
|
||||
data map[string]map[string]interface{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newMockCache() *mockCache {
|
||||
return &mockCache{
|
||||
data: make(map[string]map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockCache) Get(key string) (map[string]interface{}, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
val, exists := m.data[key]
|
||||
return val, exists
|
||||
}
|
||||
|
||||
func (m *mockCache) Set(key string, value map[string]interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = value
|
||||
}
|
||||
|
||||
func (m *mockCache) Delete(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, key)
|
||||
}
|
||||
|
||||
type mockLogger struct{}
|
||||
|
||||
func (m *mockLogger) Logf(format string, args ...interface{}) {}
|
||||
func (m *mockLogger) ErrorLogf(format string, args ...interface{}) {}
|
||||
|
||||
type mockMetrics struct{}
|
||||
|
||||
func (m *mockMetrics) RecordTokenRefresh() {}
|
||||
func (m *mockMetrics) RecordTokenRefreshError() {}
|
||||
|
||||
// TokenCache tests
|
||||
func TestNewTokenCache(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
logger := &mockLogger{}
|
||||
metrics := &mockMetrics{}
|
||||
|
||||
tokenCache := NewTokenCache(cache, blacklist, logger, metrics, 5*time.Minute)
|
||||
|
||||
if tokenCache == nil {
|
||||
t.Fatal("Expected NewTokenCache to return non-nil")
|
||||
}
|
||||
|
||||
if tokenCache.cache == nil {
|
||||
t.Error("Expected cache to be set")
|
||||
}
|
||||
|
||||
if tokenCache.maxTTL != 5*time.Minute {
|
||||
t.Error("Expected maxTTL to be 5 minutes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_CacheToken(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
logger := &mockLogger{}
|
||||
metrics := &mockMetrics{}
|
||||
tokenCache := NewTokenCache(cache, blacklist, logger, metrics, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("test-token", claims)
|
||||
|
||||
// Verify it was cached with metadata
|
||||
stored, exists := cache.Get("test-token")
|
||||
if !exists {
|
||||
t.Error("Expected token to be cached")
|
||||
}
|
||||
|
||||
if stored["sub"] != "user123" {
|
||||
t.Error("Expected sub claim to be preserved")
|
||||
}
|
||||
|
||||
if _, ok := stored["_cached_at"]; !ok {
|
||||
t.Error("Expected _cached_at metadata to be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_CacheToken_EmptyToken(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{"sub": "user"}
|
||||
|
||||
// Should not cache empty token
|
||||
tokenCache.CacheToken("", claims)
|
||||
|
||||
if len(cache.data) != 0 {
|
||||
t.Error("Expected empty token not to be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_CacheToken_EmptyClaims(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
// Should not cache with empty claims
|
||||
tokenCache.CacheToken("test-token", map[string]interface{}{})
|
||||
|
||||
if len(cache.data) != 0 {
|
||||
t.Error("Expected token with empty claims not to be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_GetCachedToken(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, blacklist, &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("test-token", claims)
|
||||
|
||||
// Retrieve token
|
||||
retrieved, exists := tokenCache.GetCachedToken("test-token")
|
||||
if !exists {
|
||||
t.Error("Expected cached token to be found")
|
||||
}
|
||||
|
||||
if retrieved["sub"] != "user123" {
|
||||
t.Error("Expected sub claim to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_GetCachedToken_Expired(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
// Add expired token
|
||||
expiredClaims := map[string]interface{}{
|
||||
"sub": "user",
|
||||
"exp": float64(time.Now().Add(-1 * time.Hour).Unix()),
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("expired-token", expiredClaims)
|
||||
|
||||
// Should not return expired token
|
||||
_, exists := tokenCache.GetCachedToken("expired-token")
|
||||
if exists {
|
||||
t.Error("Expected expired token not to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_GetCachedToken_ExceedsMaxTTL(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 1*time.Millisecond)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user",
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
"_cached_at": time.Now().Add(-10 * time.Minute).Unix(),
|
||||
}
|
||||
|
||||
cache.Set("old-token", claims)
|
||||
|
||||
// Should not return token that exceeds maxTTL
|
||||
_, exists := tokenCache.GetCachedToken("old-token")
|
||||
if exists {
|
||||
t.Error("Expected token exceeding maxTTL not to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_GetCachedToken_Blacklisted(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, blacklist, &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user",
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("token", claims)
|
||||
|
||||
// Blacklist the token
|
||||
blacklist.Set("token", map[string]interface{}{"reason": "test"})
|
||||
|
||||
// Should not return blacklisted token
|
||||
_, exists := tokenCache.GetCachedToken("token")
|
||||
if exists {
|
||||
t.Error("Expected blacklisted token not to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_InvalidateToken(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, blacklist, &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user",
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("token", claims)
|
||||
|
||||
// Invalidate
|
||||
tokenCache.InvalidateToken("token")
|
||||
|
||||
// Should be removed from cache
|
||||
_, exists := cache.Get("token")
|
||||
if exists {
|
||||
t.Error("Expected token to be removed from cache")
|
||||
}
|
||||
|
||||
// Should be in blacklist
|
||||
_, blacklisted := blacklist.Get("token")
|
||||
if !blacklisted {
|
||||
t.Error("Expected token to be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_StartStopCleanup(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
// Start cleanup
|
||||
tokenCache.StartCleanup(100 * time.Millisecond)
|
||||
|
||||
// Verify ticker is set
|
||||
if tokenCache.cleanupTicker == nil {
|
||||
t.Error("Expected cleanup ticker to be started")
|
||||
}
|
||||
|
||||
// Stop cleanup
|
||||
tokenCache.StopCleanup()
|
||||
|
||||
// Wait briefly for cleanup to stop
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Ticker should be nil after stop
|
||||
if tokenCache.cleanupTicker != nil {
|
||||
t.Error("Expected cleanup ticker to be stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_StartCleanup_AlreadyRunning(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
// Start cleanup
|
||||
tokenCache.StartCleanup(100 * time.Millisecond)
|
||||
ticker1 := tokenCache.cleanupTicker
|
||||
|
||||
// Start again (should not create new ticker)
|
||||
tokenCache.StartCleanup(100 * time.Millisecond)
|
||||
ticker2 := tokenCache.cleanupTicker
|
||||
|
||||
if ticker1 != ticker2 {
|
||||
t.Error("Expected same ticker when starting cleanup while already running")
|
||||
}
|
||||
|
||||
tokenCache.StopCleanup()
|
||||
}
|
||||
|
||||
// TokenBlacklist tests
|
||||
func TestNewTokenBlacklist(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
logger := &mockLogger{}
|
||||
|
||||
tb := NewTokenBlacklist(blacklist, logger)
|
||||
|
||||
if tb == nil {
|
||||
t.Fatal("Expected NewTokenBlacklist to return non-nil")
|
||||
}
|
||||
|
||||
if tb.blacklist == nil {
|
||||
t.Error("Expected blacklist to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_Add(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
tb.Add("test-token", "test_reason")
|
||||
|
||||
// Verify token was blacklisted
|
||||
data, exists := blacklist.Get("test-token")
|
||||
if !exists {
|
||||
t.Error("Expected token to be blacklisted")
|
||||
}
|
||||
|
||||
if data["reason"] != "test_reason" {
|
||||
t.Error("Expected reason to be stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_AddJTI(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
tb.AddJTI("jti-123")
|
||||
|
||||
// Verify JTI was blacklisted
|
||||
data, exists := blacklist.Get("jti-123")
|
||||
if !exists {
|
||||
t.Error("Expected JTI to be blacklisted")
|
||||
}
|
||||
|
||||
if data["reason"] != "jti_replay_detection" {
|
||||
t.Error("Expected replay detection reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_IsBlacklisted(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
tb.Add("blacklisted-token", "test")
|
||||
|
||||
if !tb.IsBlacklisted("blacklisted-token") {
|
||||
t.Error("Expected token to be blacklisted")
|
||||
}
|
||||
|
||||
if tb.IsBlacklisted("not-blacklisted") {
|
||||
t.Error("Expected token not to be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_IsJTIBlacklisted(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
tb.AddJTI("jti-123")
|
||||
|
||||
if !tb.IsJTIBlacklisted("jti-123") {
|
||||
t.Error("Expected JTI to be blacklisted")
|
||||
}
|
||||
|
||||
if tb.IsJTIBlacklisted("jti-456") {
|
||||
t.Error("Expected JTI not to be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
// TokenRevocationManager tests
|
||||
func TestNewTokenRevocationManager(t *testing.T) {
|
||||
blacklist := NewTokenBlacklist(newMockCache(), &mockLogger{})
|
||||
httpClient := &http.Client{}
|
||||
|
||||
trm := NewTokenRevocationManager("client-id", "secret", "https://revoke.url", httpClient, &mockLogger{}, blacklist)
|
||||
|
||||
if trm == nil {
|
||||
t.Fatal("Expected NewTokenRevocationManager to return non-nil")
|
||||
}
|
||||
|
||||
if trm.clientID != "client-id" {
|
||||
t.Error("Expected clientID to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRevocationManager_RevokeToken(t *testing.T) {
|
||||
blacklist := NewTokenBlacklist(newMockCache(), &mockLogger{})
|
||||
trm := NewTokenRevocationManager("client-id", "secret", "https://revoke.url", &http.Client{}, &mockLogger{}, blacklist)
|
||||
|
||||
err := trm.RevokeToken("test-token", "access_token", false)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Token should be in blacklist
|
||||
if !blacklist.IsBlacklisted("test-token") {
|
||||
t.Error("Expected token to be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
// Race condition tests
|
||||
func TestTokenCache_ConcurrentAccess(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
iterations := 100
|
||||
|
||||
// Concurrent cache operations
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
claims := map[string]interface{}{
|
||||
"sub": idx,
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
token := string(rune('A' + idx%26))
|
||||
tokenCache.CacheToken(token, claims)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent retrieve operations
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
token := string(rune('A' + idx%26))
|
||||
_, _ = tokenCache.GetCachedToken(token)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent invalidations
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
token := string(rune('A' + idx%26))
|
||||
tokenCache.InvalidateToken(token)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_ConcurrentAccess(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent adds
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
tb.Add(string(rune('A'+idx%26)), "test")
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent checks
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_ = tb.IsBlacklisted(string(rune('A' + idx%26)))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestTokenCache_CleanupWithConcurrentOperations(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stopFlag := atomic.Bool{}
|
||||
|
||||
// Start cleanup
|
||||
tokenCache.StartCleanup(50 * time.Millisecond)
|
||||
|
||||
// Goroutine adding tokens
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; !stopFlag.Load() && i < 50; i++ {
|
||||
claims := map[string]interface{}{
|
||||
"sub": i,
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
tokenCache.CacheToken(string(rune('A'+i%26)), claims)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
// Goroutine invalidating tokens
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; !stopFlag.Load() && i < 30; i++ {
|
||||
tokenCache.InvalidateToken(string(rune('A' + i%26)))
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
// Let it run for a bit
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
stopFlag.Store(true)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Stop cleanup
|
||||
tokenCache.StopCleanup()
|
||||
|
||||
// Should not have panicked
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// Package token provides token management functionality for OIDC authentication.
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Introspector handles token introspection operations
|
||||
type Introspector struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
introspectionURL string
|
||||
httpClient *http.Client
|
||||
logger LoggerInterface
|
||||
groupsClaimPath []string
|
||||
rolesClaimPath []string
|
||||
extractClaimsRegex string
|
||||
}
|
||||
|
||||
// NewIntrospector creates a new token introspector
|
||||
func NewIntrospector(clientID, clientSecret, introspectionURL string, httpClient *http.Client, logger LoggerInterface, groupsClaimPath, rolesClaimPath []string, extractClaimsRegex string) *Introspector {
|
||||
return &Introspector{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
introspectionURL: introspectionURL,
|
||||
httpClient: httpClient,
|
||||
logger: logger,
|
||||
groupsClaimPath: groupsClaimPath,
|
||||
rolesClaimPath: rolesClaimPath,
|
||||
extractClaimsRegex: extractClaimsRegex,
|
||||
}
|
||||
}
|
||||
|
||||
// IntrospectToken performs token introspection with the OIDC provider
|
||||
func (i *Introspector) IntrospectToken(token string, tokenTypeHint string) (*IntrospectionResponse, error) {
|
||||
if i.introspectionURL == "" {
|
||||
return nil, fmt.Errorf("introspection endpoint not configured")
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("token", token)
|
||||
if tokenTypeHint != "" {
|
||||
data.Set("token_type_hint", tokenTypeHint)
|
||||
}
|
||||
data.Set("client_id", i.clientID)
|
||||
data.Set("client_secret", i.clientSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, i.introspectionURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create introspection request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := i.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("introspection request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read introspection response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("introspection failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var introspectResp IntrospectionResponse
|
||||
if err := json.Unmarshal(body, &introspectResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse introspection response: %w", err)
|
||||
}
|
||||
|
||||
// Parse any extra fields
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(body, &raw); err == nil {
|
||||
introspectResp.Extra = make(map[string]interface{})
|
||||
for k, v := range raw {
|
||||
switch k {
|
||||
case "active", "scope", "client_id", "username", "token_type",
|
||||
"exp", "iat", "nbf", "sub", "aud", "iss", "jti":
|
||||
// Skip standard fields
|
||||
default:
|
||||
introspectResp.Extra[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &introspectResp, nil
|
||||
}
|
||||
|
||||
// ExtractGroupsAndRoles extracts groups and roles from an ID token
|
||||
func (i *Introspector) ExtractGroupsAndRoles(idToken string) ([]string, []string, error) {
|
||||
jwt, err := parseJWT(idToken)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse ID token: %w", err)
|
||||
}
|
||||
|
||||
groups := i.extractClaimValues(jwt.Claims, i.groupsClaimPath)
|
||||
roles := i.extractClaimValues(jwt.Claims, i.rolesClaimPath)
|
||||
|
||||
i.logger.Logf("Extracted %d groups and %d roles from ID token", len(groups), len(roles))
|
||||
return groups, roles, nil
|
||||
}
|
||||
|
||||
// DetectTokenType analyzes a token and determines its type
|
||||
func (i *Introspector) DetectTokenType(token string) (string, error) {
|
||||
jwt, err := parseJWT(token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
// Check for ID token characteristics
|
||||
if aud, ok := jwt.Claims["aud"]; ok {
|
||||
switch v := aud.(type) {
|
||||
case string:
|
||||
if v == i.clientID {
|
||||
return "id_token", nil
|
||||
}
|
||||
case []interface{}:
|
||||
for _, a := range v {
|
||||
if str, ok := a.(string); ok && str == i.clientID {
|
||||
return "id_token", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for access token characteristics
|
||||
if scope, ok := jwt.Claims["scope"]; ok {
|
||||
if _, isString := scope.(string); isString {
|
||||
return "access_token", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check token_use claim (AWS Cognito specific)
|
||||
if tokenUse, ok := jwt.Claims["token_use"]; ok {
|
||||
if use, isString := tokenUse.(string); isString {
|
||||
switch use {
|
||||
case "id":
|
||||
return "id_token", nil
|
||||
case "access":
|
||||
return "access_token", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check typ header
|
||||
if typ, ok := jwt.Header["typ"]; ok {
|
||||
if typStr, isString := typ.(string); isString {
|
||||
switch strings.ToLower(typStr) {
|
||||
case "jwt", "at+jwt":
|
||||
return "access_token", nil
|
||||
case "id+jwt":
|
||||
return "id_token", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown", nil
|
||||
}
|
||||
|
||||
// extractClaimValues extracts claim values from JWT claims using a path
|
||||
func (i *Introspector) extractClaimValues(claims map[string]interface{}, claimPath []string) []string {
|
||||
if len(claimPath) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []string
|
||||
current := claims
|
||||
|
||||
for idx, key := range claimPath {
|
||||
if idx == len(claimPath)-1 {
|
||||
// Last key - extract the values
|
||||
if val, exists := current[key]; exists {
|
||||
result = i.extractStringSlice(val)
|
||||
}
|
||||
} else {
|
||||
// Navigate deeper
|
||||
if next, ok := current[key].(map[string]interface{}); ok {
|
||||
current = next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractStringSlice converts various types to string slice
|
||||
func (i *Introspector) extractStringSlice(val interface{}) []string {
|
||||
switch v := val.(type) {
|
||||
case []interface{}:
|
||||
var result []string
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
case []string:
|
||||
return v
|
||||
case string:
|
||||
if v != "" {
|
||||
// Handle comma-separated or space-separated values
|
||||
if strings.Contains(v, ",") {
|
||||
return strings.Split(v, ",")
|
||||
}
|
||||
return []string{v}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseJWT parses a JWT token without verification
|
||||
func parseJWT(token string) (*JWT, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
header, err := decodeSegment(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode header: %w", err)
|
||||
}
|
||||
|
||||
claims, err := decodeSegment(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode claims: %w", err)
|
||||
}
|
||||
|
||||
return &JWT{
|
||||
Header: header,
|
||||
Claims: claims,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeSegment decodes a base64url encoded JWT segment
|
||||
func decodeSegment(seg string) (map[string]interface{}, error) {
|
||||
// Add padding if necessary
|
||||
if l := len(seg) % 4; l > 0 {
|
||||
seg += strings.Repeat("=", 4-l)
|
||||
}
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(seg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode segment: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal segment: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Introspector tests
|
||||
func TestNewIntrospector(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/introspect",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
[]string{"groups"},
|
||||
[]string{"roles"},
|
||||
"",
|
||||
)
|
||||
|
||||
if introspector == nil {
|
||||
t.Fatal("Expected NewIntrospector to return non-nil")
|
||||
}
|
||||
|
||||
if introspector.clientID != "client-id" {
|
||||
t.Error("Expected clientID to be set")
|
||||
}
|
||||
|
||||
if introspector.clientSecret != "client-secret" {
|
||||
t.Error("Expected clientSecret to be set")
|
||||
}
|
||||
|
||||
if introspector.introspectionURL != "https://provider.example.com/introspect" {
|
||||
t.Error("Expected introspectionURL to be set")
|
||||
}
|
||||
|
||||
if len(introspector.groupsClaimPath) != 1 || introspector.groupsClaimPath[0] != "groups" {
|
||||
t.Error("Expected groupsClaimPath to be set")
|
||||
}
|
||||
|
||||
if len(introspector.rolesClaimPath) != 1 || introspector.rolesClaimPath[0] != "roles" {
|
||||
t.Error("Expected rolesClaimPath to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_NoEndpoint(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"", // No introspection endpoint
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
_, err := introspector.IntrospectToken("token", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error when introspection endpoint not configured")
|
||||
}
|
||||
|
||||
if err.Error() != "introspection endpoint not configured" {
|
||||
t.Errorf("Expected configuration error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_Success(t *testing.T) {
|
||||
// Create a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify parameters
|
||||
if r.FormValue("token") != "test-token" {
|
||||
t.Error("Expected token parameter")
|
||||
}
|
||||
|
||||
if r.FormValue("token_type_hint") != "access_token" {
|
||||
t.Error("Expected token_type_hint parameter")
|
||||
}
|
||||
|
||||
if r.FormValue("client_id") != "test-client" {
|
||||
t.Error("Expected client_id parameter")
|
||||
}
|
||||
|
||||
// Return valid introspection response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"active": true,
|
||||
"scope": "openid profile email",
|
||||
"client_id": "test-client",
|
||||
"username": "testuser",
|
||||
"token_type": "Bearer",
|
||||
"exp": 1234567890,
|
||||
"iat": 1234567800,
|
||||
"sub": "user123",
|
||||
"aud": "test-audience",
|
||||
"iss": "https://issuer.example.com",
|
||||
"custom_claim": "custom_value"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"test-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
resp, err := introspector.IntrospectToken("test-token", "access_token")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if !resp.Active {
|
||||
t.Error("Expected token to be active")
|
||||
}
|
||||
|
||||
if resp.Scope != "openid profile email" {
|
||||
t.Errorf("Expected scope 'openid profile email', got '%s'", resp.Scope)
|
||||
}
|
||||
|
||||
if resp.ClientID != "test-client" {
|
||||
t.Errorf("Expected client_id 'test-client', got '%s'", resp.ClientID)
|
||||
}
|
||||
|
||||
if resp.Username != "testuser" {
|
||||
t.Errorf("Expected username 'testuser', got '%s'", resp.Username)
|
||||
}
|
||||
|
||||
if resp.TokenType != "Bearer" {
|
||||
t.Errorf("Expected token_type 'Bearer', got '%s'", resp.TokenType)
|
||||
}
|
||||
|
||||
// Check extra fields
|
||||
if resp.Extra == nil {
|
||||
t.Fatal("Expected Extra map to be populated")
|
||||
}
|
||||
|
||||
if val, ok := resp.Extra["custom_claim"]; !ok || val != "custom_value" {
|
||||
t.Error("Expected custom_claim in Extra fields")
|
||||
}
|
||||
|
||||
// Standard fields should not be in Extra
|
||||
if _, ok := resp.Extra["active"]; ok {
|
||||
t.Error("Standard field 'active' should not be in Extra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_HTTPError(t *testing.T) {
|
||||
// Create a test server that returns an error
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"invalid_token"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
_, err := introspector.IntrospectToken("bad-token", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for HTTP 401 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_InvalidJSON(t *testing.T) {
|
||||
// Create a test server that returns invalid JSON
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{invalid json`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
_, err := introspector.IntrospectToken("token", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_NoTokenTypeHint(t *testing.T) {
|
||||
// Test that token_type_hint is optional
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify token_type_hint is not set when empty
|
||||
if r.FormValue("token_type_hint") != "" {
|
||||
t.Error("Expected no token_type_hint when not provided")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"active":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
_, err := introspector.IntrospectToken("token", "") // Empty token type hint
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_DetectTokenType_IDToken_AudienceString(t *testing.T) {
|
||||
_ = NewIntrospector(
|
||||
"test-client",
|
||||
"client-secret",
|
||||
"https://introspect.example.com",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
// Mock JWT with audience matching client ID
|
||||
// Note: parseJWT is a package-level function that we can't easily mock,
|
||||
// so this test validates the logic assuming parseJWT works
|
||||
// We'll test the DetectTokenType method indirectly
|
||||
|
||||
// This test would require mocking parseJWT which is complex
|
||||
// Skip for now or implement when parseJWT is mockable
|
||||
t.Skip("Requires parseJWT mocking - tested indirectly through integration")
|
||||
}
|
||||
|
||||
func TestIntrospector_DetectTokenType_AccessToken_Scope(t *testing.T) {
|
||||
// Similar to above - requires parseJWT mocking
|
||||
t.Skip("Requires parseJWT mocking - tested indirectly through integration")
|
||||
}
|
||||
|
||||
func TestIntrospector_ExtractGroupsAndRoles(t *testing.T) {
|
||||
// Requires parseJWT mocking
|
||||
t.Skip("Requires parseJWT mocking - tested indirectly through integration")
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// Package token provides token management functionality for OIDC authentication.
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Refresher handles token refresh operations
|
||||
type Refresher struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
tokenURL string
|
||||
httpClient *http.Client
|
||||
logger LoggerInterface
|
||||
metrics MetricsInterface
|
||||
sessionManager SessionManagerInterface
|
||||
tokenCache CacheInterface
|
||||
verifier TokenVerifier
|
||||
}
|
||||
|
||||
// NewRefresher creates a new token refresher
|
||||
func NewRefresher(clientID, clientSecret, tokenURL string, httpClient *http.Client, logger LoggerInterface, metrics MetricsInterface, sessionManager SessionManagerInterface, tokenCache CacheInterface, verifier TokenVerifier) *Refresher {
|
||||
return &Refresher{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
tokenURL: tokenURL,
|
||||
httpClient: httpClient,
|
||||
logger: logger,
|
||||
metrics: metrics,
|
||||
sessionManager: sessionManager,
|
||||
tokenCache: tokenCache,
|
||||
verifier: verifier,
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshToken attempts to refresh expired tokens using the refresh token.
|
||||
// Returns true if refresh was successful or not needed, false if refresh failed and session should be terminated.
|
||||
func (r *Refresher) RefreshToken(rw http.ResponseWriter, req *http.Request, session SessionDataInterface) bool {
|
||||
if session == nil {
|
||||
r.logger.ErrorLogf("RefreshToken: Session is nil")
|
||||
return false
|
||||
}
|
||||
|
||||
refreshToken := session.GetRefreshToken()
|
||||
if refreshToken == "" {
|
||||
r.logger.Logf("No refresh token available, cannot refresh")
|
||||
return false
|
||||
}
|
||||
|
||||
r.logger.Logf("Attempting to refresh expired tokens")
|
||||
tokenResp, err := r.GetNewTokenWithRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
r.logger.ErrorLogf("Failed to refresh tokens: %v", err)
|
||||
r.metrics.RecordTokenRefreshError()
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse expiry from expires_in
|
||||
var idTokenExpiry, accessTokenExpiry time.Time
|
||||
if tokenResp.ExpiresIn > 0 {
|
||||
expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
idTokenExpiry = expiry
|
||||
accessTokenExpiry = expiry
|
||||
}
|
||||
|
||||
// Update session with new tokens
|
||||
if tokenResp.IDToken != "" && tokenResp.AccessToken != "" {
|
||||
session.SetTokens(
|
||||
tokenResp.IDToken,
|
||||
tokenResp.AccessToken,
|
||||
tokenResp.RefreshToken,
|
||||
idTokenExpiry,
|
||||
accessTokenExpiry,
|
||||
)
|
||||
} else if tokenResp.IDToken != "" {
|
||||
session.SetIDToken(tokenResp.IDToken, idTokenExpiry)
|
||||
if tokenResp.RefreshToken != "" {
|
||||
session.SetRefreshToken(tokenResp.RefreshToken)
|
||||
}
|
||||
} else if tokenResp.AccessToken != "" {
|
||||
session.SetAccessToken(tokenResp.AccessToken, accessTokenExpiry)
|
||||
if tokenResp.RefreshToken != "" {
|
||||
session.SetRefreshToken(tokenResp.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear old tokens from cache
|
||||
if oldIDToken := session.GetIDToken(); oldIDToken != "" {
|
||||
r.tokenCache.Delete(oldIDToken)
|
||||
}
|
||||
if oldAccessToken := session.GetAccessToken(); oldAccessToken != "" {
|
||||
r.tokenCache.Delete(oldAccessToken)
|
||||
}
|
||||
|
||||
// Verify and cache new tokens
|
||||
if tokenResp.IDToken != "" {
|
||||
if err := r.verifier.VerifyToken(tokenResp.IDToken); err != nil {
|
||||
r.logger.ErrorLogf("Failed to verify refreshed ID token: %v", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if tokenResp.AccessToken != "" {
|
||||
if err := r.verifier.VerifyToken(tokenResp.AccessToken); err != nil {
|
||||
r.logger.ErrorLogf("Failed to verify refreshed access token: %v", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated session
|
||||
if err := session.SaveToCache(); err != nil {
|
||||
r.logger.ErrorLogf("Failed to save refreshed session: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
r.metrics.RecordTokenRefresh()
|
||||
r.logger.Logf("Successfully refreshed tokens")
|
||||
return true
|
||||
}
|
||||
|
||||
// GetNewTokenWithRefreshToken exchanges a refresh token for new tokens
|
||||
func (r *Refresher) GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
|
||||
return r.exchangeToken("refresh_token", refreshToken, "", "")
|
||||
}
|
||||
|
||||
// exchangeToken performs the actual token exchange with the provider
|
||||
func (r *Refresher) exchangeToken(grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
|
||||
data := url.Values{}
|
||||
data.Set("client_id", r.clientID)
|
||||
data.Set("client_secret", r.clientSecret)
|
||||
data.Set("grant_type", grantType)
|
||||
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
data.Set("code", codeOrToken)
|
||||
if redirectURL != "" {
|
||||
data.Set("redirect_uri", redirectURL)
|
||||
}
|
||||
if codeVerifier != "" {
|
||||
data.Set("code_verifier", codeVerifier)
|
||||
}
|
||||
case "refresh_token":
|
||||
data.Set("refresh_token", codeOrToken)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported grant type: %s", grantType)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, r.tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock implementations for refresher tests
|
||||
type mockSessionManager struct{}
|
||||
|
||||
func (m *mockSessionManager) GetSession(sessionID string) (SessionDataInterface, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) SaveSession(session SessionDataInterface) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockSessionData struct {
|
||||
idToken string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
idExpiry time.Time
|
||||
accessExpiry time.Time
|
||||
saveErr error
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetIDToken() string {
|
||||
return m.idToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetAccessToken() string {
|
||||
return m.accessToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetRefreshToken() string {
|
||||
return m.refreshToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetIDTokenExpiry() time.Time {
|
||||
return m.idExpiry
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetAccessTokenExpiry() time.Time {
|
||||
return m.accessExpiry
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetTokens(idToken, accessToken, refreshToken string, idExp, accessExp time.Time) {
|
||||
m.idToken = idToken
|
||||
m.accessToken = accessToken
|
||||
m.refreshToken = refreshToken
|
||||
m.idExpiry = idExp
|
||||
m.accessExpiry = accessExp
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetIDToken(token string, expiry time.Time) {
|
||||
m.idToken = token
|
||||
m.idExpiry = expiry
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetAccessToken(token string, expiry time.Time) {
|
||||
m.accessToken = token
|
||||
m.accessExpiry = expiry
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetRefreshToken(token string) {
|
||||
m.refreshToken = token
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SaveToCache() error {
|
||||
return m.saveErr
|
||||
}
|
||||
|
||||
type mockTokenVerifier struct {
|
||||
shouldFail bool
|
||||
}
|
||||
|
||||
func (m *mockTokenVerifier) VerifyToken(token string) error {
|
||||
if m.shouldFail {
|
||||
return fmt.Errorf("token verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refresher tests
|
||||
func TestNewRefresher(t *testing.T) {
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/token",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
if refresher == nil {
|
||||
t.Fatal("Expected NewRefresher to return non-nil")
|
||||
}
|
||||
|
||||
if refresher.clientID != "client-id" {
|
||||
t.Error("Expected clientID to be set")
|
||||
}
|
||||
|
||||
if refresher.clientSecret != "client-secret" {
|
||||
t.Error("Expected clientSecret to be set")
|
||||
}
|
||||
|
||||
if refresher.tokenURL != "https://provider.example.com/token" {
|
||||
t.Error("Expected tokenURL to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_RefreshToken_NilSession(t *testing.T) {
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/token",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
result := refresher.RefreshToken(nil, nil, nil)
|
||||
if result {
|
||||
t.Error("Expected RefreshToken to return false for nil session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_RefreshToken_NoRefreshToken(t *testing.T) {
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/token",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
session := &mockSessionData{
|
||||
refreshToken: "", // No refresh token
|
||||
}
|
||||
|
||||
result := refresher.RefreshToken(nil, nil, session)
|
||||
if result {
|
||||
t.Error("Expected RefreshToken to return false when no refresh token available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_UnsupportedGrantType(t *testing.T) {
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/token",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
_, err := refresher.exchangeToken("unsupported_grant", "token", "", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported grant type")
|
||||
}
|
||||
|
||||
if err.Error() != "unsupported grant type: unsupported_grant" {
|
||||
t.Errorf("Expected unsupported grant type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_RefreshToken_RequestCreation(t *testing.T) {
|
||||
// Test with valid refresh_token grant type but invalid URL to test request creation
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"://invalid-url", // Invalid URL
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
_, err := refresher.exchangeToken("refresh_token", "refresh-token-value", "", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_AuthorizationCode_WithPKCE(t *testing.T) {
|
||||
// Create a test server that verifies the request
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify PKCE parameters are included
|
||||
if r.FormValue("code_verifier") != "test-verifier" {
|
||||
t.Error("Expected code_verifier to be included")
|
||||
}
|
||||
|
||||
if r.FormValue("code") != "auth-code" {
|
||||
t.Error("Expected authorization code to be included")
|
||||
}
|
||||
|
||||
if r.FormValue("grant_type") != "authorization_code" {
|
||||
t.Error("Expected grant_type to be authorization_code")
|
||||
}
|
||||
|
||||
// Return valid token response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-access","id_token":"test-id","expires_in":3600}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
resp, err := refresher.exchangeToken("authorization_code", "auth-code", "https://callback.example.com", "test-verifier")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if resp.AccessToken != "test-access" {
|
||||
t.Errorf("Expected access token 'test-access', got '%s'", resp.AccessToken)
|
||||
}
|
||||
|
||||
if resp.IDToken != "test-id" {
|
||||
t.Errorf("Expected ID token 'test-id', got '%s'", resp.IDToken)
|
||||
}
|
||||
|
||||
if resp.ExpiresIn != 3600 {
|
||||
t.Errorf("Expected expires_in 3600, got %d", resp.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_HTTPError(t *testing.T) {
|
||||
// Create a test server that returns an error
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"invalid_grant"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
_, err := refresher.exchangeToken("refresh_token", "bad-token", "", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for HTTP 401 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_InvalidJSON(t *testing.T) {
|
||||
// Create a test server that returns invalid JSON
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{invalid json`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
_, err := refresher.exchangeToken("refresh_token", "token", "", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_GetNewTokenWithRefreshToken(t *testing.T) {
|
||||
// Create a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
resp, err := refresher.GetNewTokenWithRefreshToken("old-refresh")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if resp.AccessToken != "new-access" {
|
||||
t.Error("Expected new access token")
|
||||
}
|
||||
|
||||
if resp.RefreshToken != "new-refresh" {
|
||||
t.Error("Expected new refresh token")
|
||||
}
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Helper function to create a simple JWT token for testing
|
||||
func createTestJWT(header, claims map[string]interface{}) string {
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
|
||||
// Use fake signature
|
||||
return headerB64 + "." + claimsB64 + ".fake-signature"
|
||||
}
|
||||
|
||||
// parseJWT Tests
|
||||
func TestParseJWT_Valid(t *testing.T) {
|
||||
header := map[string]interface{}{"alg": "RS256", "typ": "JWT"}
|
||||
claims := map[string]interface{}{"sub": "user123", "aud": "client-id"}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
jwt, err := parseJWT(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if jwt == nil {
|
||||
t.Fatal("Expected non-nil JWT")
|
||||
}
|
||||
|
||||
if jwt.Header["alg"] != "RS256" {
|
||||
t.Error("Expected alg to be RS256")
|
||||
}
|
||||
|
||||
if jwt.Claims["sub"] != "user123" {
|
||||
t.Error("Expected sub to be user123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWT_InvalidFormat(t *testing.T) {
|
||||
// Token with wrong number of parts
|
||||
_, err := parseJWT("invalid.token")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid token format")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "expected 3 parts") {
|
||||
t.Errorf("Expected error about parts, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWT_InvalidBase64(t *testing.T) {
|
||||
// Token with invalid base64
|
||||
_, err := parseJWT("!@#$%^.invalid.base64")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid base64")
|
||||
}
|
||||
}
|
||||
|
||||
// decodeSegment Tests
|
||||
func TestDecodeSegment_Valid(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"field1": "value1",
|
||||
"field2": 123,
|
||||
}
|
||||
jsonData, _ := json.Marshal(data)
|
||||
encoded := base64.RawURLEncoding.EncodeToString(jsonData)
|
||||
|
||||
result, err := decodeSegment(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if result["field1"] != "value1" {
|
||||
t.Error("Expected field1 to be value1")
|
||||
}
|
||||
|
||||
if result["field2"].(float64) != 123 {
|
||||
t.Error("Expected field2 to be 123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSegment_WithPadding(t *testing.T) {
|
||||
// Create data that needs padding
|
||||
data := map[string]interface{}{"test": "value"}
|
||||
jsonData, _ := json.Marshal(data)
|
||||
// Use standard encoding to get padded version
|
||||
encoded := base64.URLEncoding.EncodeToString(jsonData)
|
||||
// Remove padding to test the function adds it back
|
||||
encoded = strings.TrimRight(encoded, "=")
|
||||
|
||||
result, err := decodeSegment(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error with unpadded segment, got: %v", err)
|
||||
}
|
||||
|
||||
if result["test"] != "value" {
|
||||
t.Error("Expected test to be value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSegment_InvalidBase64(t *testing.T) {
|
||||
_, err := decodeSegment("!@#$%^&*()")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSegment_InvalidJSON(t *testing.T) {
|
||||
// Valid base64 but invalid JSON
|
||||
invalid := base64.RawURLEncoding.EncodeToString([]byte("{invalid json"))
|
||||
_, err := decodeSegment(invalid)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// DetectTokenType Tests
|
||||
func TestDetectTokenType_IDToken_StringAudience(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"https://introspect.example.com",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"aud": "test-client", // Matches clientID
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "id_token" {
|
||||
t.Errorf("Expected 'id_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_IDToken_ArrayAudience(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"aud": []interface{}{"test-client", "other-client"},
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "id_token" {
|
||||
t.Errorf("Expected 'id_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_AccessToken_Scope(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"scope": "openid profile email",
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "access_token" {
|
||||
t.Errorf("Expected 'access_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_IDToken_TokenUse(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"token_use": "id",
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "id_token" {
|
||||
t.Errorf("Expected 'id_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_AccessToken_TokenUse(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"token_use": "access",
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "access_token" {
|
||||
t.Errorf("Expected 'access_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_AccessToken_TypHeader(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256", "typ": "at+jwt"}
|
||||
claims := map[string]interface{}{"sub": "user123"}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "access_token" {
|
||||
t.Errorf("Expected 'access_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_Unknown(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{"sub": "user123"}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "unknown" {
|
||||
t.Errorf("Expected 'unknown', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractGroupsAndRoles Tests
|
||||
func TestExtractGroupsAndRoles_SimpleArrays(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
[]string{"groups"},
|
||||
[]string{"roles"},
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"groups": []interface{}{"group1", "group2", "group3"},
|
||||
"roles": []interface{}{"role1", "role2"},
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
groups, roles, err := introspector.ExtractGroupsAndRoles(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if len(groups) != 3 {
|
||||
t.Errorf("Expected 3 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if len(roles) != 2 {
|
||||
t.Errorf("Expected 2 roles, got %d", len(roles))
|
||||
}
|
||||
|
||||
if groups[0] != "group1" {
|
||||
t.Errorf("Expected first group to be 'group1', got '%s'", groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsAndRoles_NestedClaims(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
[]string{"resource_access", "account", "roles"},
|
||||
[]string{"realm_access", "roles"},
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"resource_access": map[string]interface{}{
|
||||
"account": map[string]interface{}{
|
||||
"roles": []interface{}{"manage-account", "view-profile"},
|
||||
},
|
||||
},
|
||||
"realm_access": map[string]interface{}{
|
||||
"roles": []interface{}{"admin", "user"},
|
||||
},
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
groups, roles, err := introspector.ExtractGroupsAndRoles(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Errorf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if len(roles) != 2 {
|
||||
t.Errorf("Expected 2 roles, got %d", len(roles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsAndRoles_InvalidToken(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
[]string{"groups"},
|
||||
[]string{"roles"},
|
||||
"",
|
||||
)
|
||||
|
||||
_, _, err := introspector.ExtractGroupsAndRoles("invalid.token")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
// extractStringSlice Tests (indirect via Introspector)
|
||||
func TestExtractStringSlice_StringArray(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
val := []interface{}{"value1", "value2", "value3"}
|
||||
result := introspector.extractStringSlice(val)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 values, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0] != "value1" {
|
||||
t.Errorf("Expected 'value1', got '%s'", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_StringSlice(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
val := []string{"a", "b", "c"}
|
||||
result := introspector.extractStringSlice(val)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 values, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_SingleString(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
result := introspector.extractStringSlice("single-value")
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Errorf("Expected 1 value, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0] != "single-value" {
|
||||
t.Errorf("Expected 'single-value', got '%s'", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_CommaSeparated(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
result := introspector.extractStringSlice("value1,value2,value3")
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 values, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0] != "value1" {
|
||||
t.Errorf("Expected 'value1', got '%s'", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_EmptyString(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
result := introspector.extractStringSlice("")
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for empty string, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_InvalidType(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
result := introspector.extractStringSlice(12345)
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for invalid type, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// extractClaimValues Tests (indirect via Introspector)
|
||||
func TestExtractClaimValues_SimplePath(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"roles": []interface{}{"admin", "user"},
|
||||
}
|
||||
|
||||
result := introspector.extractClaimValues(claims, []string{"roles"})
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 values, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimValues_NestedPath(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"resource": map[string]interface{}{
|
||||
"account": map[string]interface{}{
|
||||
"roles": []interface{}{"role1", "role2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := introspector.extractClaimValues(claims, []string{"resource", "account", "roles"})
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 values, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimValues_EmptyPath(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
claims := map[string]interface{}{"roles": []interface{}{"admin"}}
|
||||
|
||||
result := introspector.extractClaimValues(claims, []string{})
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for empty path, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimValues_PathNotFound(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
claims := map[string]interface{}{"other": "value"}
|
||||
|
||||
result := introspector.extractClaimValues(claims, []string{"roles"})
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("Expected 0 values for missing path, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// TokenRevocationManager revokeWithProvider test
|
||||
func TestTokenRevocationManager_RevokeWithProvider(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
cache := newMockCache()
|
||||
blacklist := NewTokenBlacklist(cache, logger)
|
||||
trm := NewTokenRevocationManager(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/revoke",
|
||||
nil, // http client
|
||||
logger,
|
||||
blacklist,
|
||||
)
|
||||
|
||||
// This function is a simplified placeholder that just logs
|
||||
err := trm.revokeWithProvider("test-token", "access_token")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
// Just verify it doesn't panic - mockLogger doesn't track logs
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenResponse represents the response from a token endpoint.
|
||||
// It contains the tokens and additional metadata returned by the OIDC provider.
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// JWT represents a parsed JSON Web Token.
|
||||
// It contains the decoded header and claims from the token.
|
||||
type JWT struct {
|
||||
Header map[string]interface{}
|
||||
Claims map[string]interface{}
|
||||
}
|
||||
|
||||
// JWK represents a JSON Web Key used for token verification.
|
||||
// It contains the cryptographic key material and metadata.
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use"`
|
||||
Kid string `json:"kid"`
|
||||
Alg string `json:"alg"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
X5c []string `json:"x5c,omitempty"`
|
||||
}
|
||||
|
||||
// JWKS represents a JSON Web Key Set.
|
||||
// It contains multiple public keys that can be used for token verification.
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
// TokenVerifier interface for verifying tokens
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(token string) error
|
||||
}
|
||||
|
||||
// TokenExchanger interface for exchanging tokens
|
||||
type TokenExchanger interface {
|
||||
GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error)
|
||||
ExchangeCodeForToken(ctx interface{}, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error)
|
||||
}
|
||||
|
||||
// ClaimsExtractor function type for extracting claims from tokens
|
||||
type ClaimsExtractor func(token string) (map[string]interface{}, error)
|
||||
|
||||
// CacheInterface defines cache operations for storing token data
|
||||
type CacheInterface interface {
|
||||
Get(key string) (map[string]interface{}, bool)
|
||||
Set(key string, value map[string]interface{})
|
||||
Delete(key string)
|
||||
}
|
||||
|
||||
// TokenCacheInterface defines methods for token caching operations
|
||||
type TokenCacheInterface interface {
|
||||
CacheToken(token string, claims map[string]interface{})
|
||||
GetCachedToken(token string) (map[string]interface{}, bool)
|
||||
InvalidateToken(token string)
|
||||
StartCleanup(interval time.Duration)
|
||||
StopCleanup()
|
||||
}
|
||||
|
||||
// LoggerInterface defines logging methods
|
||||
type LoggerInterface interface {
|
||||
Logf(format string, args ...interface{})
|
||||
ErrorLogf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// MetricsInterface defines metrics tracking methods
|
||||
type MetricsInterface interface {
|
||||
RecordTokenRefresh()
|
||||
RecordTokenRefreshError()
|
||||
}
|
||||
|
||||
// SessionManagerInterface defines session management methods
|
||||
type SessionManagerInterface interface {
|
||||
GetSession(sessionID string) (SessionDataInterface, error)
|
||||
SaveSession(session SessionDataInterface) error
|
||||
}
|
||||
|
||||
// SessionDataInterface defines minimal session interface needed by refresher
|
||||
type SessionDataInterface interface {
|
||||
GetRefreshToken() string
|
||||
GetIDToken() string
|
||||
GetAccessToken() string
|
||||
GetIDTokenExpiry() time.Time
|
||||
GetAccessTokenExpiry() time.Time
|
||||
SetIDToken(token string, expiry time.Time)
|
||||
SetAccessToken(token string, expiry time.Time)
|
||||
SetRefreshToken(token string)
|
||||
SetTokens(idToken, accessToken, refreshToken string, idExpiry, accessExpiry time.Time)
|
||||
SaveToCache() error
|
||||
}
|
||||
|
||||
// IntrospectorInterface defines methods for token introspection
|
||||
type IntrospectorInterface interface {
|
||||
IntrospectToken(token string, tokenTypeHint string) (*IntrospectionResponse, error)
|
||||
ExtractGroupsAndRoles(idToken string) ([]string, []string, error)
|
||||
DetectTokenType(token string) (string, error)
|
||||
}
|
||||
|
||||
// IntrospectionResponse represents the response from token introspection
|
||||
type IntrospectionResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Exp int64 `json:"exp,omitempty"`
|
||||
Iat int64 `json:"iat,omitempty"`
|
||||
Nbf int64 `json:"nbf,omitempty"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Aud interface{} `json:"aud,omitempty"`
|
||||
Iss string `json:"iss,omitempty"`
|
||||
Jti string `json:"jti,omitempty"`
|
||||
Extra map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
// RefresherInterface defines methods for token refresh operations
|
||||
type RefresherInterface interface {
|
||||
RefreshToken(rw http.ResponseWriter, req *http.Request, session SessionDataInterface) bool
|
||||
GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error)
|
||||
}
|
||||
|
||||
// RevokeTokenEntry represents a token revocation request
|
||||
type RevokeTokenEntry struct {
|
||||
Token string
|
||||
TokenType string
|
||||
RevokedAt time.Time
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidatorConfig contains configuration for the token validator
|
||||
type ValidatorConfig struct {
|
||||
ClientID string
|
||||
Audience string
|
||||
IssuerURL string
|
||||
JwksURL string
|
||||
TokenCache TokenCacheInterface
|
||||
TokenBlacklist CacheInterface
|
||||
TokenTypeCache CacheInterface
|
||||
JwkCache interface{}
|
||||
HTTPClient *http.Client
|
||||
Limiter interface{}
|
||||
ExtractClaimsFunc ClaimsExtractor
|
||||
TokenVerifier TokenVerifier
|
||||
DisableReplayDetection bool
|
||||
SuppressDiagnosticLogs bool
|
||||
MetadataMu interface{} // sync.RWMutex
|
||||
Logger interface{}
|
||||
}
|
||||
|
||||
// Constants for token validation
|
||||
const (
|
||||
DefaultBlacklistDuration = 24 * time.Hour
|
||||
TokenCacheDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Token type constants
|
||||
const (
|
||||
TokenTypeAccess = "ACCESS_TOKEN"
|
||||
TokenTypeID = "ID_TOKEN"
|
||||
TokenTypeRefresh = "REFRESH_TOKEN"
|
||||
TokenTypeUnknown = "UNKNOWN"
|
||||
)
|
||||
|
||||
// Provider constants
|
||||
const (
|
||||
ProviderGoogle = "google"
|
||||
ProviderAzure = "azure"
|
||||
ProviderOkta = "okta"
|
||||
ProviderAuth0 = "auth0"
|
||||
)
|
||||
@@ -1,355 +0,0 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Validator handles token validation operations
|
||||
type Validator struct {
|
||||
clientID string
|
||||
audience string
|
||||
issuerURL string
|
||||
jwksURL string
|
||||
tokenCache TokenCacheInterface
|
||||
tokenBlacklist CacheInterface
|
||||
tokenTypeCache CacheInterface
|
||||
jwkCache interface{} // JWK cache interface
|
||||
httpClient *http.Client
|
||||
limiter interface{} // Rate limiter interface
|
||||
extractClaimsFunc ClaimsExtractor
|
||||
tokenVerifier TokenVerifier
|
||||
disableReplayDetection bool
|
||||
suppressDiagnosticLogs bool
|
||||
metadataMu *sync.RWMutex
|
||||
logger interface{} // Logger interface
|
||||
}
|
||||
|
||||
// NewValidator creates a new token validator
|
||||
func NewValidator(config ValidatorConfig) *Validator {
|
||||
var metadataMu *sync.RWMutex
|
||||
if config.MetadataMu != nil {
|
||||
if mu, ok := config.MetadataMu.(*sync.RWMutex); ok {
|
||||
metadataMu = mu
|
||||
}
|
||||
}
|
||||
|
||||
return &Validator{
|
||||
clientID: config.ClientID,
|
||||
audience: config.Audience,
|
||||
issuerURL: config.IssuerURL,
|
||||
jwksURL: config.JwksURL,
|
||||
tokenCache: config.TokenCache,
|
||||
tokenBlacklist: config.TokenBlacklist,
|
||||
tokenTypeCache: config.TokenTypeCache,
|
||||
jwkCache: config.JwkCache,
|
||||
httpClient: config.HTTPClient,
|
||||
limiter: config.Limiter,
|
||||
extractClaimsFunc: config.ExtractClaimsFunc,
|
||||
tokenVerifier: config.TokenVerifier,
|
||||
disableReplayDetection: config.DisableReplayDetection,
|
||||
suppressDiagnosticLogs: config.SuppressDiagnosticLogs,
|
||||
metadataMu: metadataMu,
|
||||
logger: config.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyToken verifies the validity of an ID token or access token.
|
||||
// It performs comprehensive validation including format checks, blacklist verification,
|
||||
// signature validation using JWKs, and standard claims validation.
|
||||
func (v *Validator) VerifyToken(token string) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("invalid JWT format: token is empty")
|
||||
}
|
||||
|
||||
if strings.Count(token, ".") != 2 {
|
||||
return fmt.Errorf("invalid JWT format: expected JWT with 3 parts, got %d parts", strings.Count(token, ".")+1)
|
||||
}
|
||||
|
||||
if len(token) < 10 {
|
||||
return fmt.Errorf("token too short to be valid JWT")
|
||||
}
|
||||
|
||||
// Check raw token blacklist
|
||||
if v.tokenBlacklist != nil {
|
||||
if blacklisted, exists := v.tokenBlacklist.Get(token); exists && blacklisted != nil {
|
||||
return fmt.Errorf("token is blacklisted (raw string) in cache")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JWT for further validation
|
||||
parsedJWT, parseErr := v.parseJWT(token)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("failed to parse JWT for blacklist check: %w", parseErr)
|
||||
}
|
||||
|
||||
tokenType := v.determineTokenType(parsedJWT)
|
||||
|
||||
// Check token cache FIRST - if token is already verified and cached, return immediately
|
||||
// This prevents false positives when multiple goroutines validate the same token concurrently
|
||||
if claims, exists := v.tokenCache.GetCachedToken(token); exists && len(claims) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check JTI blacklist for replay detection
|
||||
if err := v.checkJTIBlacklist(parsedJWT, token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rate limiting check
|
||||
if !v.checkRateLimit() {
|
||||
return fmt.Errorf("rate limit exceeded")
|
||||
}
|
||||
|
||||
// Verify signature and claims
|
||||
if err := v.VerifyJWTSignatureAndClaims(parsedJWT, token); err != nil {
|
||||
if !strings.Contains(err.Error(), "token has expired") {
|
||||
v.logErrorf("%s token verification failed: %v", tokenType, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache verified token
|
||||
v.cacheVerifiedToken(token, parsedJWT.Claims)
|
||||
|
||||
// Add JTI to blacklist for replay prevention
|
||||
v.addJTIToBlacklist(parsedJWT)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyJWTSignatureAndClaims verifies JWT signature using provider's public keys and validates standard claims
|
||||
func (v *Validator) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
|
||||
v.logDebugf("Verifying JWT signature and claims")
|
||||
|
||||
// Get JWKS URL
|
||||
v.metadataMu.RLock()
|
||||
jwksURL := v.jwksURL
|
||||
v.metadataMu.RUnlock()
|
||||
|
||||
// Get JWKS from cache
|
||||
jwks, err := v.getJWKS(context.Background(), jwksURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get JWKS: %w", err)
|
||||
}
|
||||
|
||||
// Extract key ID and algorithm from token header
|
||||
kid, ok := jwt.Header["kid"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing key ID in token header")
|
||||
}
|
||||
|
||||
alg, ok := jwt.Header["alg"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing algorithm in token header")
|
||||
}
|
||||
|
||||
// Find matching key in JWKS
|
||||
matchingKey := v.findMatchingKey(jwks, kid)
|
||||
if matchingKey == nil {
|
||||
return fmt.Errorf("no matching public key found for kid: %s", kid)
|
||||
}
|
||||
|
||||
// Convert JWK to PEM and verify signature
|
||||
if err := v.verifyTokenSignature(token, matchingKey, alg); err != nil {
|
||||
return fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Detect token type and validate claims
|
||||
isIDToken := v.detectTokenType(jwt, token)
|
||||
expectedAudience := v.audience
|
||||
if isIDToken {
|
||||
expectedAudience = v.clientID
|
||||
}
|
||||
|
||||
// Verify standard claims
|
||||
v.metadataMu.RLock()
|
||||
issuerURL := v.issuerURL
|
||||
v.metadataMu.RUnlock()
|
||||
|
||||
if err := v.verifyStandardClaims(jwt, issuerURL, expectedAudience); err != nil {
|
||||
return fmt.Errorf("standard claim verification failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectTokenType efficiently detects whether a token is an ID token or access token
|
||||
func (v *Validator) detectTokenType(jwt *JWT, token string) bool {
|
||||
// Use first 32 chars of token as cache key
|
||||
cacheKey := token
|
||||
if len(token) > 32 {
|
||||
cacheKey = token[:32]
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if v.tokenTypeCache != nil {
|
||||
if cachedData, found := v.tokenTypeCache.Get(cacheKey); found {
|
||||
if isIDToken, ok := cachedData["is_id_token"].(bool); ok {
|
||||
return isIDToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for ID token indicators
|
||||
isIDToken := false
|
||||
|
||||
// 1. Check 'nonce' claim (definitive for ID tokens)
|
||||
if nonce, ok := jwt.Claims["nonce"]; ok {
|
||||
if _, ok := nonce.(string); ok {
|
||||
v.cacheTokenType(cacheKey, true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check 'typ' header for "at+jwt" (definitive for access tokens)
|
||||
if typ, ok := jwt.Header["typ"].(string); ok && typ == "at+jwt" {
|
||||
v.cacheTokenType(cacheKey, false)
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Check 'token_use' claim
|
||||
if tokenUse, ok := jwt.Claims["token_use"].(string); ok {
|
||||
switch tokenUse {
|
||||
case "id":
|
||||
v.cacheTokenType(cacheKey, true)
|
||||
return true
|
||||
case "access":
|
||||
v.cacheTokenType(cacheKey, false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check 'scope' claim (indicator for access tokens)
|
||||
if scope, ok := jwt.Claims["scope"]; ok {
|
||||
if _, ok := scope.(string); ok {
|
||||
v.cacheTokenType(cacheKey, false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check audience matching
|
||||
if aud, ok := jwt.Claims["aud"]; ok {
|
||||
if audStr, ok := aud.(string); ok && audStr == v.clientID {
|
||||
isIDToken = true
|
||||
} else if audArr, ok := aud.([]interface{}); ok && len(audArr) == 1 {
|
||||
for _, val := range audArr {
|
||||
if str, ok := val.(string); ok && str == v.clientID {
|
||||
isIDToken = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
v.cacheTokenType(cacheKey, isIDToken)
|
||||
return isIDToken
|
||||
}
|
||||
|
||||
// Helper methods (stubs for interface compatibility)
|
||||
|
||||
func (v *Validator) parseJWT(token string) (*JWT, error) {
|
||||
// This would call the actual JWT parsing function
|
||||
// For now, returning a stub
|
||||
return nil, fmt.Errorf("parseJWT not implemented")
|
||||
}
|
||||
|
||||
func (v *Validator) determineTokenType(jwt *JWT) string {
|
||||
if v.detectTokenType(jwt, "") {
|
||||
return TokenTypeID
|
||||
}
|
||||
return TokenTypeAccess
|
||||
}
|
||||
|
||||
func (v *Validator) checkJTIBlacklist(jwt *JWT, token string) error {
|
||||
if v.disableReplayDetection {
|
||||
return nil
|
||||
}
|
||||
|
||||
if jti, ok := jwt.Claims["jti"].(string); ok && jti != "" {
|
||||
// Skip for test tokens
|
||||
if !strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") {
|
||||
if v.tokenBlacklist != nil {
|
||||
if blacklisted, exists := v.tokenBlacklist.Get(jti); exists && blacklisted != nil {
|
||||
return fmt.Errorf("token replay detected (jti: %s) in cache", jti)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Validator) checkRateLimit() bool {
|
||||
// Interface method call would go here
|
||||
return true
|
||||
}
|
||||
|
||||
func (v *Validator) cacheVerifiedToken(token string, claims map[string]interface{}) {
|
||||
v.tokenCache.CacheToken(token, claims)
|
||||
}
|
||||
|
||||
func (v *Validator) addJTIToBlacklist(jwt *JWT) {
|
||||
if v.disableReplayDetection {
|
||||
return
|
||||
}
|
||||
|
||||
jti, ok := jwt.Claims["jti"].(string)
|
||||
if !ok || jti == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if v.tokenBlacklist != nil {
|
||||
v.tokenBlacklist.Set(jti, map[string]interface{}{
|
||||
"blacklisted_at": time.Now().Unix(),
|
||||
"reason": "jti_replay_prevention",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) cacheTokenType(cacheKey string, isIDToken bool) {
|
||||
if v.tokenTypeCache != nil {
|
||||
v.tokenTypeCache.Set(cacheKey, map[string]interface{}{
|
||||
"is_id_token": isIDToken,
|
||||
"cached_at": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) getJWKS(ctx context.Context, jwksURL string) (*JWKS, error) {
|
||||
// Interface method call would go here
|
||||
return nil, fmt.Errorf("getJWKS not implemented")
|
||||
}
|
||||
|
||||
func (v *Validator) findMatchingKey(jwks *JWKS, kid string) *JWK {
|
||||
if jwks == nil {
|
||||
return nil
|
||||
}
|
||||
for _, key := range jwks.Keys {
|
||||
if key.Kid == kid {
|
||||
return &key
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Validator) verifyTokenSignature(token string, key *JWK, alg string) error {
|
||||
// Interface method call would go here
|
||||
return fmt.Errorf("verifyTokenSignature not implemented")
|
||||
}
|
||||
|
||||
func (v *Validator) verifyStandardClaims(jwt *JWT, issuer, audience string) error {
|
||||
// Interface method call would go here
|
||||
return fmt.Errorf("verifyStandardClaims not implemented")
|
||||
}
|
||||
|
||||
func (v *Validator) logDebugf(format string, args ...interface{}) {
|
||||
// Logger interface call would go here
|
||||
}
|
||||
|
||||
func (v *Validator) logErrorf(format string, args ...interface{}) {
|
||||
// Logger interface call would go here
|
||||
}
|
||||
@@ -1,684 +0,0 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock implementations for validator tests
|
||||
type mockTokenCache struct {
|
||||
data map[string]map[string]interface{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newMockTokenCache() *mockTokenCache {
|
||||
return &mockTokenCache{
|
||||
data: make(map[string]map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockTokenCache) CacheToken(token string, claims map[string]interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[token] = claims
|
||||
}
|
||||
|
||||
func (m *mockTokenCache) GetCachedToken(token string) (map[string]interface{}, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
claims, exists := m.data[token]
|
||||
return claims, exists
|
||||
}
|
||||
|
||||
func (m *mockTokenCache) InvalidateToken(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, token)
|
||||
}
|
||||
|
||||
func (m *mockTokenCache) StartCleanup(interval time.Duration) {
|
||||
// No-op for tests
|
||||
}
|
||||
|
||||
func (m *mockTokenCache) StopCleanup() {
|
||||
// No-op for tests
|
||||
}
|
||||
|
||||
// Validator tests
|
||||
func TestNewValidator(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
Audience: "test-audience",
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
JwksURL: "https://issuer.example.com/jwks",
|
||||
TokenCache: newMockTokenCache(),
|
||||
TokenBlacklist: newMockCache(),
|
||||
TokenTypeCache: newMockCache(),
|
||||
HTTPClient: &http.Client{},
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
if validator == nil {
|
||||
t.Fatal("Expected NewValidator to return non-nil")
|
||||
}
|
||||
|
||||
if validator.clientID != "test-client" {
|
||||
t.Error("Expected clientID to be set")
|
||||
}
|
||||
|
||||
if validator.audience != "test-audience" {
|
||||
t.Error("Expected audience to be set")
|
||||
}
|
||||
|
||||
if validator.issuerURL != "https://issuer.example.com" {
|
||||
t.Error("Expected issuerURL to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidator_NilMetadataMu(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
// MetadataMu is nil
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
if validator.metadataMu != nil {
|
||||
t.Error("Expected metadataMu to be nil when not provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_VerifyToken_EmptyToken(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenCache: newMockTokenCache(),
|
||||
TokenBlacklist: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
err := validator.VerifyToken("")
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty token")
|
||||
}
|
||||
|
||||
if err.Error() != "invalid JWT format: token is empty" {
|
||||
t.Errorf("Expected empty token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_VerifyToken_InvalidFormat(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenCache: newMockTokenCache(),
|
||||
TokenBlacklist: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
// Token with only 2 parts (missing 3rd part)
|
||||
err := validator.VerifyToken("header.payload")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid token format")
|
||||
}
|
||||
|
||||
// Token with too many parts
|
||||
err = validator.VerifyToken("part1.part2.part3.part4")
|
||||
if err == nil {
|
||||
t.Error("Expected error for token with too many parts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_VerifyToken_TooShort(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenCache: newMockTokenCache(),
|
||||
TokenBlacklist: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
err := validator.VerifyToken("ab.cd.ef")
|
||||
if err == nil {
|
||||
t.Error("Expected error for too short token")
|
||||
}
|
||||
|
||||
if err.Error() != "token too short to be valid JWT" {
|
||||
t.Errorf("Expected too short error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_DetermineTokenType(t *testing.T) {
|
||||
// Test ID token
|
||||
configID := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
validatorID := NewValidator(configID)
|
||||
|
||||
jwtID := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"nonce": "test-nonce",
|
||||
},
|
||||
}
|
||||
|
||||
tokenType := validatorID.determineTokenType(jwtID)
|
||||
if tokenType != TokenTypeID {
|
||||
t.Errorf("Expected ID token type, got: %s", tokenType)
|
||||
}
|
||||
|
||||
// Test access token with separate validator to avoid cache interference
|
||||
configAccess := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
validatorAccess := NewValidator(configAccess)
|
||||
|
||||
jwtAccess := &JWT{
|
||||
Header: map[string]interface{}{
|
||||
"typ": "at+jwt",
|
||||
},
|
||||
Claims: map[string]interface{}{},
|
||||
}
|
||||
|
||||
tokenType = validatorAccess.determineTokenType(jwtAccess)
|
||||
if tokenType != TokenTypeAccess {
|
||||
t.Errorf("Expected access token type, got: %s", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_DetectTokenType_Nonce(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"nonce": "test-nonce-123",
|
||||
},
|
||||
}
|
||||
|
||||
isIDToken := validator.detectTokenType(jwt, "test-token")
|
||||
if !isIDToken {
|
||||
t.Error("Expected nonce to indicate ID token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_DetectTokenType_AtJwt(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwt := &JWT{
|
||||
Header: map[string]interface{}{
|
||||
"typ": "at+jwt",
|
||||
},
|
||||
Claims: map[string]interface{}{},
|
||||
}
|
||||
|
||||
isIDToken := validator.detectTokenType(jwt, "test-token")
|
||||
if isIDToken {
|
||||
t.Error("Expected at+jwt type to indicate access token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_DetectTokenType_TokenUse(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
// ID token
|
||||
jwtID := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"token_use": "id",
|
||||
},
|
||||
}
|
||||
|
||||
if !validator.detectTokenType(jwtID, "test-token-id") {
|
||||
t.Error("Expected token_use=id to indicate ID token")
|
||||
}
|
||||
|
||||
// Access token
|
||||
jwtAccess := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"token_use": "access",
|
||||
},
|
||||
}
|
||||
|
||||
if validator.detectTokenType(jwtAccess, "test-token-access") {
|
||||
t.Error("Expected token_use=access to indicate access token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_DetectTokenType_Scope(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
}
|
||||
|
||||
isIDToken := validator.detectTokenType(jwt, "test-token")
|
||||
if isIDToken {
|
||||
t.Error("Expected scope claim to indicate access token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_DetectTokenType_AudienceMatching(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client-id",
|
||||
TokenTypeCache: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
// Single audience matching client ID
|
||||
jwtSingleAud := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"aud": "test-client-id",
|
||||
},
|
||||
}
|
||||
|
||||
if !validator.detectTokenType(jwtSingleAud, "test-token-1") {
|
||||
t.Error("Expected matching audience to indicate ID token")
|
||||
}
|
||||
|
||||
// Array audience with matching client ID
|
||||
jwtArrayAud := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"aud": []interface{}{"test-client-id"},
|
||||
},
|
||||
}
|
||||
|
||||
if !validator.detectTokenType(jwtArrayAud, "test-token-2") {
|
||||
t.Error("Expected matching audience array to indicate ID token")
|
||||
}
|
||||
|
||||
// Non-matching audience
|
||||
jwtNoMatch := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"aud": "different-audience",
|
||||
},
|
||||
}
|
||||
|
||||
if validator.detectTokenType(jwtNoMatch, "test-token-3") {
|
||||
t.Error("Expected non-matching audience to indicate access token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_DetectTokenType_Caching(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: cache,
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test"
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"nonce": "test",
|
||||
},
|
||||
}
|
||||
|
||||
// First call - should cache
|
||||
isIDToken := validator.detectTokenType(jwt, token)
|
||||
if !isIDToken {
|
||||
t.Error("Expected ID token")
|
||||
}
|
||||
|
||||
// Verify cache was populated
|
||||
cacheKey := token[:32]
|
||||
cached, exists := cache.Get(cacheKey)
|
||||
if !exists {
|
||||
t.Error("Expected token type to be cached")
|
||||
}
|
||||
|
||||
if isID, ok := cached["is_id_token"].(bool); !ok || !isID {
|
||||
t.Error("Expected cached value to be true for ID token")
|
||||
}
|
||||
|
||||
// Modify JWT but use cached value
|
||||
jwt.Claims = map[string]interface{}{
|
||||
"scope": "openid", // Would indicate access token
|
||||
}
|
||||
|
||||
// Should still return cached ID token result
|
||||
isIDToken = validator.detectTokenType(jwt, token)
|
||||
if !isIDToken {
|
||||
t.Error("Expected cached ID token result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_CheckJTIBlacklist_Disabled(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
DisableReplayDetection: true,
|
||||
TokenBlacklist: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"jti": "blacklisted-jti",
|
||||
},
|
||||
}
|
||||
|
||||
// Should not check blacklist when disabled
|
||||
err := validator.checkJTIBlacklist(jwt, "test-token")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when replay detection disabled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_CheckJTIBlacklist_NoJTI(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenBlacklist: newMockCache(),
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
// No JTI claim
|
||||
},
|
||||
}
|
||||
|
||||
err := validator.checkJTIBlacklist(jwt, "test-token")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when JTI missing, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_AddJTIToBlacklist(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenBlacklist: blacklist,
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"jti": "test-jti-123",
|
||||
},
|
||||
}
|
||||
|
||||
validator.addJTIToBlacklist(jwt)
|
||||
|
||||
// Verify JTI was blacklisted
|
||||
data, exists := blacklist.Get("test-jti-123")
|
||||
if !exists {
|
||||
t.Error("Expected JTI to be blacklisted")
|
||||
}
|
||||
|
||||
if reason, ok := data["reason"].(string); !ok || reason != "jti_replay_prevention" {
|
||||
t.Error("Expected JTI blacklist reason to be jti_replay_prevention")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_AddJTIToBlacklist_Disabled(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
DisableReplayDetection: true,
|
||||
TokenBlacklist: blacklist,
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"jti": "test-jti",
|
||||
},
|
||||
}
|
||||
|
||||
validator.addJTIToBlacklist(jwt)
|
||||
|
||||
// Should not blacklist when disabled
|
||||
_, exists := blacklist.Get("test-jti")
|
||||
if exists {
|
||||
t.Error("Expected JTI not to be blacklisted when replay detection disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_AddJTIToBlacklist_NoJTI(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenBlacklist: blacklist,
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
// No JTI
|
||||
},
|
||||
}
|
||||
|
||||
validator.addJTIToBlacklist(jwt)
|
||||
|
||||
// Should handle gracefully
|
||||
if len(blacklist.data) != 0 {
|
||||
t.Error("Expected no entries in blacklist when JTI missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_CacheTokenType(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: cache,
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
validator.cacheTokenType("cache-key-123", true)
|
||||
|
||||
data, exists := cache.Get("cache-key-123")
|
||||
if !exists {
|
||||
t.Error("Expected token type to be cached")
|
||||
}
|
||||
|
||||
if isID, ok := data["is_id_token"].(bool); !ok || !isID {
|
||||
t.Error("Expected is_id_token to be true")
|
||||
}
|
||||
|
||||
if _, ok := data["cached_at"].(int64); !ok {
|
||||
t.Error("Expected cached_at timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_CacheVerifiedToken(t *testing.T) {
|
||||
tokenCache := newMockTokenCache()
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenCache: tokenCache,
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"exp": time.Now().Add(1 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
validator.cacheVerifiedToken("test-token", claims)
|
||||
|
||||
cached, exists := tokenCache.GetCachedToken("test-token")
|
||||
if !exists {
|
||||
t.Error("Expected token to be cached")
|
||||
}
|
||||
|
||||
if cached["sub"] != "user123" {
|
||||
t.Error("Expected cached claims to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_CheckRateLimit(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
// Default implementation returns true
|
||||
if !validator.checkRateLimit() {
|
||||
t.Error("Expected checkRateLimit to return true by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_FindMatchingKey(t *testing.T) {
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
jwks := &JWKS{
|
||||
Keys: []JWK{
|
||||
{Kid: "key-1", Kty: "RSA"},
|
||||
{Kid: "key-2", Kty: "RSA"},
|
||||
{Kid: "key-3", Kty: "RSA"},
|
||||
},
|
||||
}
|
||||
|
||||
key := validator.findMatchingKey(jwks, "key-2")
|
||||
if key == nil {
|
||||
t.Fatal("Expected to find matching key")
|
||||
}
|
||||
|
||||
if key.Kid != "key-2" {
|
||||
t.Errorf("Expected kid 'key-2', got '%s'", key.Kid)
|
||||
}
|
||||
|
||||
// Test non-existent key
|
||||
key = validator.findMatchingKey(jwks, "key-999")
|
||||
if key != nil {
|
||||
t.Error("Expected nil for non-existent key")
|
||||
}
|
||||
|
||||
// Test nil JWKS
|
||||
key = validator.findMatchingKey(nil, "key-1")
|
||||
if key != nil {
|
||||
t.Error("Expected nil for nil JWKS")
|
||||
}
|
||||
}
|
||||
|
||||
// Race condition tests
|
||||
func TestValidator_ConcurrentTokenTypeDetection(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenTypeCache: cache,
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test-concurrent"
|
||||
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"nonce": "test",
|
||||
},
|
||||
}
|
||||
|
||||
// Concurrent token type detection
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = validator.detectTokenType(jwt, token)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should have cached the result
|
||||
cacheKey := token[:32]
|
||||
if _, exists := cache.Get(cacheKey); !exists {
|
||||
t.Error("Expected token type to be cached after concurrent access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_ConcurrentJTIBlacklisting(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
config := ValidatorConfig{
|
||||
ClientID: "test-client",
|
||||
TokenBlacklist: blacklist,
|
||||
MetadataMu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
validator := NewValidator(config)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent JTI blacklisting
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
jwt := &JWT{
|
||||
Claims: map[string]interface{}{
|
||||
"jti": string(rune('A' + idx%26)),
|
||||
},
|
||||
}
|
||||
validator.addJTIToBlacklist(jwt)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should have multiple JTIs blacklisted
|
||||
if len(blacklist.data) == 0 {
|
||||
t.Error("Expected JTIs to be blacklisted")
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,6 @@ type LoggerInterface interface {
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RECOVERY LOGGER WRAPPER
|
||||
// ============================================================================
|
||||
|
||||
// recoveryLoggerWrapper wraps a logger to match recovery.Logger interface
|
||||
type recoveryLoggerWrapper struct {
|
||||
logger LoggerInterface
|
||||
@@ -47,10 +43,6 @@ func (lw *recoveryLoggerWrapper) DebugLogf(format string, args ...interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLEANUP LOGGER WRAPPER
|
||||
// ============================================================================
|
||||
|
||||
// cleanupLoggerWrapper wraps a logger to match cleanup.Logger interface
|
||||
type cleanupLoggerWrapper struct {
|
||||
logger LoggerInterface
|
||||
@@ -82,10 +74,6 @@ func (lw *cleanupLoggerWrapper) DebugLogf(format string, args ...interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION LOGGER WRAPPER
|
||||
// ============================================================================
|
||||
|
||||
// Note: Session logger wrapper is not included here because session.Logger
|
||||
// has a different interface (Debug/Info/Warn/Error instead of Logf/ErrorLogf/DebugLogf).
|
||||
// Each package should implement its own session logger adapter as needed.
|
||||
|
||||
+53
-2
@@ -2,6 +2,8 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -119,7 +121,56 @@ func KeysFromMap(m map[string]struct{}) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// BuildFullURL constructs a URL from scheme, host, and path components
|
||||
// DetermineScheme determines the URL scheme for building redirect URLs.
|
||||
// Priority order (highest to lowest):
|
||||
// 1. forceHTTPS parameter - explicit security requirement
|
||||
// 2. X-Forwarded-Proto header - proxy/load balancer information
|
||||
// 3. TLS connection state - direct HTTPS connection
|
||||
// 4. Default to http
|
||||
//
|
||||
// The forceHTTPS parameter ensures redirect URIs use HTTPS even when behind
|
||||
// proxies/load balancers that may overwrite X-Forwarded-Proto header
|
||||
// (e.g., AWS ALB terminating TLS).
|
||||
func DetermineScheme(req *http.Request, forceHTTPS bool) string {
|
||||
// Honor forceHTTPS configuration as highest priority
|
||||
if forceHTTPS {
|
||||
return "https"
|
||||
}
|
||||
|
||||
// Check X-Forwarded-Proto header for proxy scenarios
|
||||
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
|
||||
// Check if connection has TLS
|
||||
if req.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
|
||||
// Default to http
|
||||
return "http"
|
||||
}
|
||||
|
||||
// DetermineHost determines the host for building redirect URLs.
|
||||
// It checks X-Forwarded-Host header first (for proxy scenarios),
|
||||
// then falls back to req.Host.
|
||||
func DetermineHost(req *http.Request) string {
|
||||
if host := req.Header.Get("X-Forwarded-Host"); host != "" {
|
||||
return host
|
||||
}
|
||||
return req.Host
|
||||
}
|
||||
|
||||
// BuildFullURL constructs a URL from scheme, host, and path components.
|
||||
// It handles absolute URLs (returning them as-is) and ensures paths have leading slashes.
|
||||
func BuildFullURL(scheme, host, path string) string {
|
||||
return scheme + "://" + host + path
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s%s", scheme, host, path)
|
||||
}
|
||||
|
||||
@@ -380,9 +380,9 @@ func TestUtilsPackageComplete(t *testing.T) {
|
||||
t.Errorf("Expected empty slice, got %v", emptyMapKeys)
|
||||
}
|
||||
|
||||
// Test BuildFullURL with empty values
|
||||
// Test BuildFullURL with empty values (adds leading / to empty path)
|
||||
emptyURL := BuildFullURL("", "", "")
|
||||
expected := "://"
|
||||
expected := ":///"
|
||||
if emptyURL != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, emptyURL)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user