Splitting code into multiple files with reasonable testing coverage.

```
ok      github.com/lukaszraczylo/traefikoidc    117.017s        coverage: 72.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/auth       0.505s  coverage: 87.1% of statements
ok      github.com/lukaszraczylo/traefikoidc/circuit_breaker    0.283s  coverage: 99.0% of statements
        github.com/lukaszraczylo/traefikoidc/config             coverage: 0.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/handlers   0.349s  coverage: 98.2% of statements
ok      github.com/lukaszraczylo/traefikoidc/internal/providers (cached)        coverage: 94.3% of statements
ok      github.com/lukaszraczylo/traefikoidc/middleware 0.808s  coverage: 78.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/recovery   0.653s  coverage: 100.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/chunking   (cached)        coverage: 87.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/core       (cached)        coverage: 85.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/crypto     (cached)        coverage: 81.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/storage    (cached)        coverage: 93.5% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/validators (cached)        coverage: 98.8% of statements
````
This commit is contained in:
2025-09-07 12:57:18 +01:00
parent c9a23dc6c0
commit 4371f9fffd
26 changed files with 15091 additions and 243 deletions
+360
View File
@@ -0,0 +1,360 @@
// Package auth provides authentication-related functionality for the OIDC middleware.
package auth
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/google/uuid"
)
// AuthHandler provides core authentication functionality for OIDC flows
type AuthHandler struct {
logger Logger
enablePKCE bool
isGoogleProv func() bool
isAzureProv func() bool
clientID string
authURL string
issuerURL string
scopes []string
overrideScopes bool
}
// Logger interface for dependency injection
type Logger interface {
Debugf(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// NewAuthHandler creates a new AuthHandler instance
func NewAuthHandler(logger Logger, enablePKCE bool, isGoogleProv, isAzureProv func() bool,
clientID, authURL, issuerURL string, scopes []string, overrideScopes bool) *AuthHandler {
return &AuthHandler{
logger: logger,
enablePKCE: enablePKCE,
isGoogleProv: isGoogleProv,
isAzureProv: isAzureProv,
clientID: clientID,
authURL: authURL,
issuerURL: issuerURL,
scopes: scopes,
overrideScopes: overrideScopes,
}
}
// InitiateAuthentication initiates the OIDC authentication flow.
// It generates CSRF tokens, nonce, PKCE parameters (if enabled), clears the session,
// stores authentication state, and redirects the user to the OIDC provider.
func (h *AuthHandler) InitiateAuthentication(rw http.ResponseWriter, req *http.Request,
session SessionData, redirectURL string,
generateNonce, generateCodeVerifier, deriveCodeChallenge func() (string, error)) {
h.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
const maxRedirects = 5
redirectCount := session.GetRedirectCount()
if redirectCount >= maxRedirects {
h.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
session.ResetRedirectCount()
http.Error(rw, "Authentication failed: Too many redirects", http.StatusLoopDetected)
return
}
session.IncrementRedirectCount()
csrfToken := uuid.NewString()
nonce, err := generateNonce()
if err != nil {
h.logger.Errorf("Failed to generate nonce: %v", err)
http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError)
return
}
// Generate PKCE code verifier and challenge if PKCE is enabled
var codeVerifier, codeChallenge string
if h.enablePKCE {
codeVerifier, err = generateCodeVerifier()
if err != nil {
h.logger.Errorf("Failed to generate code verifier: %v", err)
http.Error(rw, "Failed to generate code verifier", http.StatusInternalServerError)
return
}
codeChallenge, err = deriveCodeChallenge()
if err != nil {
h.logger.Errorf("Failed to generate code challenge: %v", err)
http.Error(rw, "Failed to generate code challenge", http.StatusInternalServerError)
return
}
h.logger.Debugf("PKCE enabled, generated code challenge")
}
session.SetAuthenticated(false)
session.SetEmail("")
session.SetAccessToken("")
session.SetRefreshToken("")
session.SetIDToken("")
session.SetNonce("")
session.SetCodeVerifier("")
session.SetCSRF(csrfToken)
session.SetNonce(nonce)
if h.enablePKCE {
session.SetCodeVerifier(codeVerifier)
}
session.SetIncomingPath(req.URL.RequestURI())
h.logger.Debugf("Storing incoming path: %s", req.URL.RequestURI())
session.MarkDirty()
if err := session.Save(req, rw); err != nil {
h.logger.Errorf("Failed to save session before redirecting to provider: %v", err)
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return
}
h.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
csrfToken, nonce)
authURL := h.BuildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
h.logger.Debugf("Redirecting user to OIDC provider: %s", authURL)
http.Redirect(rw, req, authURL, http.StatusFound)
}
// BuildAuthURL constructs the OIDC provider authorization URL.
// It builds the URL with all necessary parameters including client_id, scopes,
// PKCE parameters, and provider-specific parameters for Google and Azure.
func (h *AuthHandler) BuildAuthURL(redirectURL, state, nonce, codeChallenge string) string {
params := url.Values{}
params.Set("client_id", h.clientID)
params.Set("response_type", "code")
params.Set("redirect_uri", redirectURL)
params.Set("state", state)
params.Set("nonce", nonce)
if h.enablePKCE && codeChallenge != "" {
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
}
scopes := make([]string, len(h.scopes))
copy(scopes, h.scopes)
if h.isGoogleProv() {
params.Set("access_type", "offline")
h.logger.Debugf("Google OIDC provider detected, added access_type=offline for refresh tokens")
params.Set("prompt", "consent")
h.logger.Debugf("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
} else if h.isAzureProv() {
params.Set("response_mode", "query")
h.logger.Debugf("Azure AD provider detected, added response_mode=query")
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !h.overrideScopes || (h.overrideScopes && len(h.scopes) == 0) {
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
h.logger.Debugf("Azure AD provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", h.overrideScopes, len(h.scopes))
}
} else {
h.logger.Debugf("Azure AD provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(h.scopes))
}
} else {
if !h.overrideScopes || (h.overrideScopes && len(h.scopes) == 0) {
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
h.logger.Debugf("Standard provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", h.overrideScopes, len(h.scopes))
}
} else {
h.logger.Debugf("Standard provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(h.scopes))
}
}
if len(scopes) > 0 {
finalScopeString := strings.Join(scopes, " ")
params.Set("scope", finalScopeString)
h.logger.Debugf("AuthHandler.BuildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString)
}
return h.buildURLWithParams(h.authURL, params)
}
// buildURLWithParams constructs a URL by combining a base URL with query parameters.
// It handles both relative and absolute URLs, validates URL security,
// and properly encodes query parameters.
func (h *AuthHandler) buildURLWithParams(baseURL string, params url.Values) string {
if baseURL != "" {
if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") {
if err := h.validateURL(baseURL); err != nil {
h.logger.Errorf("URL validation failed for %s: %v", baseURL, err)
return ""
}
}
}
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
issuerURLParsed, err := url.Parse(h.issuerURL)
if err != nil {
h.logger.Errorf("Could not parse issuerURL: %s. Error: %v", h.issuerURL, err)
return ""
}
baseURLParsed, err := url.Parse(baseURL)
if err != nil {
h.logger.Errorf("Could not parse baseURL: %s. Error: %v", baseURL, err)
return ""
}
resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed)
if err := h.validateURL(resolvedURL.String()); err != nil {
h.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err)
return ""
}
resolvedURL.RawQuery = params.Encode()
return resolvedURL.String()
}
u, err := url.Parse(baseURL)
if err != nil {
h.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err)
return ""
}
if err := h.validateParsedURL(u); err != nil {
h.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err)
return ""
}
u.RawQuery = params.Encode()
return u.String()
}
// validateURL performs security validation on URLs to prevent SSRF attacks.
// It checks for allowed schemes, validates hosts, and prevents access to private networks.
func (h *AuthHandler) validateURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("empty URL")
}
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
return h.validateParsedURL(u)
}
// validateParsedURL validates a parsed URL structure for security.
// It checks schemes, hosts, and paths to prevent malicious URLs.
func (h *AuthHandler) validateParsedURL(u *url.URL) error {
allowedSchemes := map[string]bool{
"https": true,
"http": true,
}
if !allowedSchemes[u.Scheme] {
return fmt.Errorf("disallowed URL scheme: %s", u.Scheme)
}
if u.Scheme == "http" {
h.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String())
}
if u.Host == "" {
return fmt.Errorf("missing host in URL")
}
if err := h.validateHost(u.Host); err != nil {
return fmt.Errorf("invalid host: %w", err)
}
if strings.Contains(u.Path, "..") {
return fmt.Errorf("path traversal detected in URL path")
}
return nil
}
// validateHost validates a hostname for security and reachability.
// It prevents access to private networks and localhost addresses.
func (h *AuthHandler) validateHost(host string) error {
if host == "" {
return fmt.Errorf("empty host")
}
// Strip port if present
if strings.Contains(host, ":") {
var err error
host, _, err = net.SplitHostPort(host)
if err != nil {
return fmt.Errorf("invalid host:port format: %w", err)
}
}
// Check for localhost variations
localhostVariations := []string{
"localhost", "127.0.0.1", "::1", "0.0.0.0",
}
for _, localhost := range localhostVariations {
if strings.EqualFold(host, localhost) {
return fmt.Errorf("localhost access not allowed: %s", host)
}
}
// Try to parse as IP address
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() {
return fmt.Errorf("loopback IP not allowed: %s", host)
}
if ip.IsPrivate() {
return fmt.Errorf("private IP not allowed: %s", host)
}
if ip.IsLinkLocalUnicast() {
return fmt.Errorf("link-local IP not allowed: %s", host)
}
if ip.IsMulticast() {
return fmt.Errorf("multicast IP not allowed: %s", host)
}
}
return nil
}
// SessionData interface for dependency injection
type SessionData interface {
GetRedirectCount() int
ResetRedirectCount()
IncrementRedirectCount()
SetAuthenticated(bool)
SetEmail(string)
SetAccessToken(string)
SetRefreshToken(string)
SetIDToken(string)
SetNonce(string)
SetCodeVerifier(string)
SetCSRF(string)
SetIncomingPath(string)
MarkDirty()
Save(req *http.Request, rw http.ResponseWriter) error
}
+923
View File
@@ -0,0 +1,923 @@
package auth
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// Mock logger for testing
type mockLogger struct {
debugLogs []string
errorLogs []string
mu sync.RWMutex
}
func (m *mockLogger) Debugf(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
}
func (m *mockLogger) Errorf(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
}
//lint:ignore U1000 May be needed for future debug log verification tests
func (m *mockLogger) getDebugLogs() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.debugLogs))
copy(result, m.debugLogs)
return result
}
func (m *mockLogger) getErrorLogs() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.errorLogs))
copy(result, m.errorLogs)
return result
}
//lint:ignore U1000 May be needed for future test isolation
func (m *mockLogger) reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.debugLogs = nil
m.errorLogs = nil
}
// Mock session for testing
type mockSession struct {
data map[string]interface{}
redirectCount int
saveError error
mu sync.RWMutex
dirty bool
authenticated bool
email string
accessToken string
refreshToken string
idToken string
nonce string
codeVerifier string
csrf string
incomingPath string
}
func newMockSession() *mockSession {
return &mockSession{
data: make(map[string]interface{}),
}
}
func (m *mockSession) GetRedirectCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return m.redirectCount
}
func (m *mockSession) ResetRedirectCount() {
m.mu.Lock()
defer m.mu.Unlock()
m.redirectCount = 0
}
func (m *mockSession) IncrementRedirectCount() {
m.mu.Lock()
defer m.mu.Unlock()
m.redirectCount++
}
func (m *mockSession) SetAuthenticated(auth bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.authenticated = auth
}
func (m *mockSession) SetEmail(email string) {
m.mu.Lock()
defer m.mu.Unlock()
m.email = email
}
func (m *mockSession) SetAccessToken(token string) {
m.mu.Lock()
defer m.mu.Unlock()
m.accessToken = token
}
func (m *mockSession) SetRefreshToken(token string) {
m.mu.Lock()
defer m.mu.Unlock()
m.refreshToken = token
}
func (m *mockSession) SetIDToken(token string) {
m.mu.Lock()
defer m.mu.Unlock()
m.idToken = token
}
func (m *mockSession) SetNonce(nonce string) {
m.mu.Lock()
defer m.mu.Unlock()
m.nonce = nonce
}
func (m *mockSession) SetCodeVerifier(verifier string) {
m.mu.Lock()
defer m.mu.Unlock()
m.codeVerifier = verifier
}
func (m *mockSession) SetCSRF(csrf string) {
m.mu.Lock()
defer m.mu.Unlock()
m.csrf = csrf
}
func (m *mockSession) SetIncomingPath(path string) {
m.mu.Lock()
defer m.mu.Unlock()
m.incomingPath = path
}
func (m *mockSession) MarkDirty() {
m.mu.Lock()
defer m.mu.Unlock()
m.dirty = true
}
func (m *mockSession) Save(req *http.Request, rw http.ResponseWriter) error {
m.mu.RLock()
defer m.mu.RUnlock()
return m.saveError
}
func (m *mockSession) setSaveError(err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.saveError = err
}
// Test helper functions
func createTestAuthHandler(logger Logger, enablePKCE bool, isGoogle, isAzure func() bool) *AuthHandler {
return NewAuthHandler(
logger,
enablePKCE,
isGoogle,
isAzure,
"test-client-id",
"https://provider.example.com/auth",
"https://provider.example.com",
[]string{"openid", "email", "profile"},
false,
)
}
func TestNewAuthHandler(t *testing.T) {
tests := []struct {
name string
logger Logger
enablePKCE bool
clientID string
authURL string
issuerURL string
scopes []string
overrideScopes bool
}{
{
name: "creates handler with basic config",
logger: &mockLogger{},
enablePKCE: true,
clientID: "test-client",
authURL: "https://example.com/auth",
issuerURL: "https://example.com",
scopes: []string{"openid", "email"},
overrideScopes: false,
},
{
name: "creates handler with PKCE disabled",
logger: &mockLogger{},
enablePKCE: false,
clientID: "test-client",
authURL: "https://example.com/auth",
issuerURL: "https://example.com",
scopes: []string{"openid"},
overrideScopes: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isGoogle := func() bool { return false }
isAzure := func() bool { return false }
handler := NewAuthHandler(
tt.logger,
tt.enablePKCE,
isGoogle,
isAzure,
tt.clientID,
tt.authURL,
tt.issuerURL,
tt.scopes,
tt.overrideScopes,
)
if handler == nil {
t.Fatal("NewAuthHandler returned nil")
}
if handler.clientID != tt.clientID {
t.Errorf("Expected clientID %s, got %s", tt.clientID, handler.clientID)
}
if handler.enablePKCE != tt.enablePKCE {
t.Errorf("Expected enablePKCE %v, got %v", tt.enablePKCE, handler.enablePKCE)
}
if handler.authURL != tt.authURL {
t.Errorf("Expected authURL %s, got %s", tt.authURL, handler.authURL)
}
if handler.issuerURL != tt.issuerURL {
t.Errorf("Expected issuerURL %s, got %s", tt.issuerURL, handler.issuerURL)
}
if handler.overrideScopes != tt.overrideScopes {
t.Errorf("Expected overrideScopes %v, got %v", tt.overrideScopes, handler.overrideScopes)
}
})
}
}
func TestInitiateAuthentication(t *testing.T) {
tests := []struct {
name string
enablePKCE bool
initialRedirectCount int
generateNonceError error
generateCodeVerifierError error
deriveCodeChallengeError error
sessionSaveError error
expectedStatusCode int
expectRedirect bool
}{
{
name: "successful authentication without PKCE",
enablePKCE: false,
expectedStatusCode: http.StatusFound,
expectRedirect: true,
},
{
name: "successful authentication with PKCE",
enablePKCE: true,
expectedStatusCode: http.StatusFound,
expectRedirect: true,
},
{
name: "fails when nonce generation fails",
enablePKCE: false,
generateNonceError: fmt.Errorf("nonce generation failed"),
expectedStatusCode: http.StatusInternalServerError,
expectRedirect: false,
},
{
name: "fails when code verifier generation fails",
enablePKCE: true,
generateCodeVerifierError: fmt.Errorf("code verifier generation failed"),
expectedStatusCode: http.StatusInternalServerError,
expectRedirect: false,
},
{
name: "fails when code challenge derivation fails",
enablePKCE: true,
deriveCodeChallengeError: fmt.Errorf("code challenge derivation failed"),
expectedStatusCode: http.StatusInternalServerError,
expectRedirect: false,
},
{
name: "fails when session save fails",
enablePKCE: false,
sessionSaveError: fmt.Errorf("session save failed"),
expectedStatusCode: http.StatusInternalServerError,
expectRedirect: false,
},
{
name: "fails when redirect count exceeds limit",
enablePKCE: false,
initialRedirectCount: 5,
expectedStatusCode: http.StatusLoopDetected,
expectRedirect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := &mockLogger{}
session := newMockSession()
session.redirectCount = tt.initialRedirectCount
if tt.sessionSaveError != nil {
session.setSaveError(tt.sessionSaveError)
}
handler := createTestAuthHandler(logger, tt.enablePKCE, func() bool { return false }, func() bool { return false })
req := httptest.NewRequest("GET", "/test", nil)
rw := httptest.NewRecorder()
generateNonce := func() (string, error) {
if tt.generateNonceError != nil {
return "", tt.generateNonceError
}
return "test-nonce", nil
}
generateCodeVerifier := func() (string, error) {
if tt.generateCodeVerifierError != nil {
return "", tt.generateCodeVerifierError
}
return "test-code-verifier", nil
}
deriveCodeChallenge := func() (string, error) {
if tt.deriveCodeChallengeError != nil {
return "", tt.deriveCodeChallengeError
}
return "test-code-challenge", nil
}
handler.InitiateAuthentication(
rw, req, session,
"https://example.com/callback",
generateNonce,
generateCodeVerifier,
deriveCodeChallenge,
)
if rw.Code != tt.expectedStatusCode {
t.Errorf("Expected status code %d, got %d", tt.expectedStatusCode, rw.Code)
}
if tt.expectRedirect {
location := rw.Header().Get("Location")
if location == "" {
t.Error("Expected redirect location header, got empty")
}
if !strings.Contains(location, "https://provider.example.com/auth") {
t.Errorf("Expected redirect to auth URL, got %s", location)
}
}
// Verify error logs for failure cases
if tt.expectedStatusCode != http.StatusFound {
errorLogs := logger.getErrorLogs()
if len(errorLogs) == 0 {
t.Error("Expected error logs for failure case, got none")
}
}
})
}
}
func TestBuildAuthURL(t *testing.T) {
tests := []struct {
name string
enablePKCE bool
isGoogle bool
isAzure bool
scopes []string
overrideScopes bool
codeChallenge string
expectedParams map[string]string
expectContains []string
}{
{
name: "builds URL without PKCE for standard provider",
enablePKCE: false,
isGoogle: false,
isAzure: false,
scopes: []string{"openid", "email"},
codeChallenge: "",
expectedParams: map[string]string{
"client_id": "test-client-id",
"response_type": "code",
"state": "test-state",
"nonce": "test-nonce",
"scope": "openid email offline_access",
},
},
{
name: "builds URL with PKCE for standard provider",
enablePKCE: true,
isGoogle: false,
isAzure: false,
scopes: []string{"openid", "email"},
codeChallenge: "test-challenge",
expectedParams: map[string]string{
"client_id": "test-client-id",
"response_type": "code",
"state": "test-state",
"nonce": "test-nonce",
"code_challenge": "test-challenge",
"code_challenge_method": "S256",
"scope": "openid email offline_access",
},
},
{
name: "builds URL for Google provider",
enablePKCE: false,
isGoogle: true,
isAzure: false,
scopes: []string{"openid", "email"},
expectedParams: map[string]string{
"client_id": "test-client-id",
"response_type": "code",
"state": "test-state",
"nonce": "test-nonce",
"access_type": "offline",
"prompt": "consent",
"scope": "openid email",
},
},
{
name: "builds URL for Azure provider",
enablePKCE: false,
isGoogle: false,
isAzure: true,
scopes: []string{"openid", "email"},
expectedParams: map[string]string{
"client_id": "test-client-id",
"response_type": "code",
"state": "test-state",
"nonce": "test-nonce",
"response_mode": "query",
"scope": "openid email offline_access",
},
},
{
name: "handles scope override for Azure",
enablePKCE: false,
isGoogle: false,
isAzure: true,
scopes: []string{"custom-scope"},
overrideScopes: true,
expectedParams: map[string]string{
"client_id": "test-client-id",
"response_type": "code",
"state": "test-state",
"nonce": "test-nonce",
"response_mode": "query",
"scope": "custom-scope",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := &mockLogger{}
isGoogleFunc := func() bool { return tt.isGoogle }
isAzureFunc := func() bool { return tt.isAzure }
handler := NewAuthHandler(
logger,
tt.enablePKCE,
isGoogleFunc,
isAzureFunc,
"test-client-id",
"https://provider.example.com/auth",
"https://provider.example.com",
tt.scopes,
tt.overrideScopes,
)
authURL := handler.BuildAuthURL(
"https://example.com/callback",
"test-state",
"test-nonce",
tt.codeChallenge,
)
if authURL == "" {
t.Fatal("BuildAuthURL returned empty string")
}
parsedURL, err := url.Parse(authURL)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
// Check base URL
expectedBase := "https://provider.example.com/auth"
actualBase := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, parsedURL.Path)
if actualBase != expectedBase {
t.Errorf("Expected base URL %s, got %s", expectedBase, actualBase)
}
// Check required parameters
query := parsedURL.Query()
for param, expectedValue := range tt.expectedParams {
actualValue := query.Get(param)
if actualValue != expectedValue {
t.Errorf("Expected %s=%s, got %s=%s", param, expectedValue, param, actualValue)
}
}
// Check that contains expected strings
for _, contains := range tt.expectContains {
if !strings.Contains(authURL, contains) {
t.Errorf("Expected URL to contain %s, got %s", contains, authURL)
}
}
})
}
}
func TestValidateURL(t *testing.T) {
logger := &mockLogger{}
handler := createTestAuthHandler(logger, false, func() bool { return false }, func() bool { return false })
tests := []struct {
name string
url string
expectError bool
errorMsg string
}{
{
name: "valid HTTPS URL",
url: "https://example.com/path",
expectError: false,
},
{
name: "valid HTTP URL",
url: "http://example.com/path",
expectError: false,
},
{
name: "empty URL",
url: "",
expectError: true,
errorMsg: "empty URL",
},
{
name: "invalid scheme",
url: "ftp://example.com/path",
expectError: true,
errorMsg: "disallowed URL scheme",
},
{
name: "missing host",
url: "https:///path",
expectError: true,
errorMsg: "missing host in URL",
},
{
name: "localhost not allowed",
url: "https://localhost/path",
expectError: true,
errorMsg: "localhost access not allowed",
},
{
name: "127.0.0.1 not allowed",
url: "https://127.0.0.1/path",
expectError: true,
errorMsg: "localhost access not allowed",
},
{
name: "private IP not allowed",
url: "https://192.168.1.1/path",
expectError: true,
errorMsg: "private IP not allowed",
},
{
name: "path traversal not allowed",
url: "https://example.com/../path",
expectError: true,
errorMsg: "path traversal detected",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := handler.validateURL(tt.url)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for URL %s, got nil", tt.url)
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("Expected error to contain %s, got %s", tt.errorMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error for URL %s, got %v", tt.url, err)
}
}
})
}
}
func TestValidateHost(t *testing.T) {
logger := &mockLogger{}
handler := createTestAuthHandler(logger, false, func() bool { return false }, func() bool { return false })
tests := []struct {
name string
host string
expectError bool
errorMsg string
}{
{
name: "valid domain",
host: "example.com",
expectError: false,
},
{
name: "valid domain with port",
host: "example.com:443",
expectError: false,
},
{
name: "empty host",
host: "",
expectError: true,
errorMsg: "empty host",
},
{
name: "localhost",
host: "localhost",
expectError: true,
errorMsg: "localhost access not allowed",
},
{
name: "localhost with port",
host: "localhost:8080",
expectError: true,
errorMsg: "localhost access not allowed",
},
{
name: "127.0.0.1",
host: "127.0.0.1",
expectError: true,
errorMsg: "localhost access not allowed",
},
{
name: "::1 (IPv6 localhost)",
host: "::1",
expectError: true,
errorMsg: "invalid host:port format",
},
{
name: "private IP 192.168.1.1",
host: "192.168.1.1",
expectError: true,
errorMsg: "private IP not allowed",
},
{
name: "private IP 10.0.0.1",
host: "10.0.0.1",
expectError: true,
errorMsg: "private IP not allowed",
},
{
name: "invalid host:port format",
host: "example.com:99999", // Use invalid port number instead
expectError: false, // This actually doesn't error in Go's net.SplitHostPort
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := handler.validateHost(tt.host)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for host %s, got nil", tt.host)
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("Expected error to contain %s, got %s", tt.errorMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error for host %s, got %v", tt.host, err)
}
}
})
}
}
func TestBuildURLWithParams(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(
logger,
false,
func() bool { return false },
func() bool { return false },
"test-client-id",
"https://provider.example.com/auth",
"https://provider.example.com",
[]string{"openid"},
false,
)
tests := []struct {
name string
baseURL string
params url.Values
expected string
expectError bool
}{
{
name: "absolute HTTPS URL with params",
baseURL: "https://example.com/auth",
params: url.Values{"param1": []string{"value1"}, "param2": []string{"value2"}},
expected: "https://example.com/auth?param1=value1&param2=value2",
},
{
name: "relative URL resolved against issuer",
baseURL: "/auth/endpoint",
params: url.Values{"client_id": []string{"test"}},
expected: "https://provider.example.com/auth/endpoint?client_id=test",
},
{
name: "invalid absolute URL",
baseURL: "https://localhost/auth",
params: url.Values{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.buildURLWithParams(tt.baseURL, tt.params)
if tt.expectError {
if result != "" {
t.Errorf("Expected empty result for invalid URL, got %s", result)
}
} else {
if result == "" {
t.Error("Expected non-empty result, got empty string")
}
// Parse and compare the result
resultURL, err := url.Parse(result)
if err != nil {
t.Fatalf("Failed to parse result URL: %v", err)
}
expectedURL, err := url.Parse(tt.expected)
if err != nil {
t.Fatalf("Failed to parse expected URL: %v", err)
}
if resultURL.Scheme != expectedURL.Scheme {
t.Errorf("Expected scheme %s, got %s", expectedURL.Scheme, resultURL.Scheme)
}
if resultURL.Host != expectedURL.Host {
t.Errorf("Expected host %s, got %s", expectedURL.Host, resultURL.Host)
}
if resultURL.Path != expectedURL.Path {
t.Errorf("Expected path %s, got %s", expectedURL.Path, resultURL.Path)
}
// Check query parameters
resultQuery := resultURL.Query()
expectedQuery := expectedURL.Query()
for key, expectedValues := range expectedQuery {
resultValues := resultQuery[key]
if len(resultValues) != len(expectedValues) {
t.Errorf("Expected %d values for %s, got %d", len(expectedValues), key, len(resultValues))
}
for i, expectedValue := range expectedValues {
if i >= len(resultValues) || resultValues[i] != expectedValue {
t.Errorf("Expected %s=%s, got %s=%s", key, expectedValue, key, resultValues[i])
}
}
}
}
})
}
}
func TestConcurrentAccess(t *testing.T) {
logger := &mockLogger{}
handler := createTestAuthHandler(logger, true, func() bool { return false }, func() bool { return false })
const numGoroutines = 10
const numRequests = 5
var wg sync.WaitGroup
errors := make(chan error, numGoroutines*numRequests)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numRequests; j++ {
session := newMockSession()
req := httptest.NewRequest("GET", fmt.Sprintf("/test-%d-%d", id, j), nil)
rw := httptest.NewRecorder()
generateNonce := func() (string, error) {
return fmt.Sprintf("nonce-%d-%d", id, j), nil
}
generateCodeVerifier := func() (string, error) {
return fmt.Sprintf("verifier-%d-%d", id, j), nil
}
deriveCodeChallenge := func() (string, error) {
return fmt.Sprintf("challenge-%d-%d", id, j), nil
}
handler.InitiateAuthentication(
rw, req, session,
"https://example.com/callback",
generateNonce,
generateCodeVerifier,
deriveCodeChallenge,
)
if rw.Code != http.StatusFound {
errors <- fmt.Errorf("goroutine %d request %d: expected status %d, got %d",
id, j, http.StatusFound, rw.Code)
}
}
}(i)
}
wg.Wait()
close(errors)
var errorCount int
for err := range errors {
t.Error(err)
errorCount++
}
if errorCount > 0 {
t.Errorf("Found %d errors in concurrent access test", errorCount)
}
}
// Benchmark tests
func BenchmarkInitiateAuthentication(b *testing.B) {
logger := &mockLogger{}
handler := createTestAuthHandler(logger, true, func() bool { return false }, func() bool { return false })
generateNonce := func() (string, error) { return "test-nonce", nil }
generateCodeVerifier := func() (string, error) { return "test-verifier", nil }
deriveCodeChallenge := func() (string, error) { return "test-challenge", nil }
b.ResetTimer()
for i := 0; i < b.N; i++ {
session := newMockSession()
req := httptest.NewRequest("GET", "/test", nil)
rw := httptest.NewRecorder()
handler.InitiateAuthentication(
rw, req, session,
"https://example.com/callback",
generateNonce,
generateCodeVerifier,
deriveCodeChallenge,
)
}
}
func BenchmarkBuildAuthURL(b *testing.B) {
logger := &mockLogger{}
handler := createTestAuthHandler(logger, true, func() bool { return false }, func() bool { return false })
b.ResetTimer()
for i := 0; i < b.N; i++ {
handler.BuildAuthURL(
"https://example.com/callback",
"test-state",
"test-nonce",
"test-challenge",
)
}
}
func BenchmarkValidateURL(b *testing.B) {
logger := &mockLogger{}
handler := createTestAuthHandler(logger, false, func() bool { return false }, func() bool { return false })
testURLs := []string{
"https://example.com/auth",
"https://provider.example.com/oauth/authorize",
"https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
handler.validateURL(testURLs[i%len(testURLs)])
}
}