Release 0.7.5 (#70)

* Resolve issue with opaque tokens not being parsed correctly

* Increase test coverage

* Further improvements to test coverage and code quality

* Add new providers.

* fixup! Add new providers.

* Cleanup.

* fixup! Cleanup.

* fixup! fixup! Cleanup.

* fixup! fixup! fixup! Cleanup.

* fixup! fixup! fixup! fixup! Cleanup.

* Memory management optimisation

24 bytes per Put < 256-4096 bytes per buffer allocation avoided (10-170x difference)

* Pooling cleanup.
This commit is contained in:
2025-10-01 12:13:10 +01:00
committed by GitHub
parent 3bbc6a1608
commit c3f23cb99b
93 changed files with 26767 additions and 4230 deletions
+224
View File
@@ -0,0 +1,224 @@
// 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 cancelled 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
}
+588
View File
@@ -0,0 +1,588 @@
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 cancelled",
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)
}
}
+247
View File
@@ -0,0 +1,247 @@
// 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)
} 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)
}
}
// 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
}
}
}
+587
View File
@@ -0,0 +1,587 @@
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)
}
}