mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-13 04:07:37 +00:00
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:
@@ -0,0 +1,599 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test mocks
|
||||
type mockLogger struct {
|
||||
debugMessages []string
|
||||
errorMessages []string
|
||||
}
|
||||
|
||||
func (l *mockLogger) Debugf(format string, args ...interface{}) {
|
||||
l.debugMessages = append(l.debugMessages, format)
|
||||
}
|
||||
|
||||
func (l *mockLogger) Errorf(format string, args ...interface{}) {
|
||||
l.errorMessages = append(l.errorMessages, format)
|
||||
}
|
||||
|
||||
type mockSessionData struct {
|
||||
authenticated bool
|
||||
email string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
idToken string
|
||||
csrf string
|
||||
nonce string
|
||||
codeVerifier string
|
||||
incomingPath string
|
||||
redirectCount int
|
||||
saveError error
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func (s *mockSessionData) GetRedirectCount() int { return s.redirectCount }
|
||||
func (s *mockSessionData) ResetRedirectCount() { s.redirectCount = 0 }
|
||||
func (s *mockSessionData) IncrementRedirectCount() { s.redirectCount++ }
|
||||
func (s *mockSessionData) SetAuthenticated(auth bool) { s.authenticated = auth }
|
||||
func (s *mockSessionData) SetEmail(email string) { s.email = email }
|
||||
func (s *mockSessionData) SetAccessToken(token string) { s.accessToken = token }
|
||||
func (s *mockSessionData) SetRefreshToken(token string) { s.refreshToken = token }
|
||||
func (s *mockSessionData) SetIDToken(token string) { s.idToken = token }
|
||||
func (s *mockSessionData) SetNonce(nonce string) { s.nonce = nonce }
|
||||
func (s *mockSessionData) SetCodeVerifier(verifier string) { s.codeVerifier = verifier }
|
||||
func (s *mockSessionData) SetCSRF(csrf string) { s.csrf = csrf }
|
||||
func (s *mockSessionData) SetIncomingPath(path string) { s.incomingPath = path }
|
||||
func (s *mockSessionData) MarkDirty() { s.dirty = true }
|
||||
|
||||
func (s *mockSessionData) Save(req *http.Request, rw http.ResponseWriter) error {
|
||||
return s.saveError
|
||||
}
|
||||
|
||||
// TestAuthHandler_NewAuthHandler tests the constructor
|
||||
func TestAuthHandler_NewAuthHandler(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
isGoogleProv := func() bool { return false }
|
||||
isAzureProv := func() bool { return true }
|
||||
scopes := []string{"openid", "profile", "email"}
|
||||
|
||||
handler := NewAuthHandler(logger, true, isGoogleProv, isAzureProv,
|
||||
"test-client-id", "https://example.com/auth", "https://example.com",
|
||||
scopes, false)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("Expected handler to be created, got nil")
|
||||
}
|
||||
|
||||
if handler.logger != logger {
|
||||
t.Error("Logger not set correctly")
|
||||
}
|
||||
|
||||
if !handler.enablePKCE {
|
||||
t.Error("PKCE should be enabled")
|
||||
}
|
||||
|
||||
if handler.clientID != "test-client-id" {
|
||||
t.Errorf("Expected clientID 'test-client-id', got '%s'", handler.clientID)
|
||||
}
|
||||
|
||||
if handler.authURL != "https://example.com/auth" {
|
||||
t.Errorf("Expected authURL 'https://example.com/auth', got '%s'", handler.authURL)
|
||||
}
|
||||
|
||||
if handler.issuerURL != "https://example.com" {
|
||||
t.Errorf("Expected issuerURL 'https://example.com', got '%s'", handler.issuerURL)
|
||||
}
|
||||
|
||||
if len(handler.scopes) != 3 {
|
||||
t.Errorf("Expected 3 scopes, got %d", len(handler.scopes))
|
||||
}
|
||||
|
||||
if handler.overrideScopes {
|
||||
t.Error("overrideScopes should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_InitiateAuthentication_MaxRedirects tests redirect limit enforcement
|
||||
func TestAuthHandler_InitiateAuthentication_MaxRedirects(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
session := &mockSessionData{redirectCount: 5} // At the limit
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
generateNonce := func() (string, error) { return "test-nonce", nil }
|
||||
generateCodeVerifier := func() (string, error) { return "", nil }
|
||||
deriveCodeChallenge := func() (string, error) { return "", nil }
|
||||
|
||||
handler.InitiateAuthentication(rw, req, session, "https://example.com/callback",
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
|
||||
if rw.Code != http.StatusLoopDetected {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusLoopDetected, rw.Code)
|
||||
}
|
||||
|
||||
body := rw.Body.String()
|
||||
if !strings.Contains(body, "Too many redirects") {
|
||||
t.Errorf("Expected 'Too many redirects' in response body, got '%s'", body)
|
||||
}
|
||||
|
||||
if session.redirectCount != 0 {
|
||||
t.Errorf("Expected redirect count to be reset, got %d", session.redirectCount)
|
||||
}
|
||||
|
||||
if len(logger.errorMessages) == 0 {
|
||||
t.Error("Expected error to be logged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_InitiateAuthentication_NonceGenerationError tests nonce generation failure
|
||||
func TestAuthHandler_InitiateAuthentication_NonceGenerationError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
session := &mockSessionData{}
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
generateNonce := func() (string, error) { return "", &testError{"nonce generation failed"} }
|
||||
generateCodeVerifier := func() (string, error) { return "", nil }
|
||||
deriveCodeChallenge := func() (string, error) { return "", nil }
|
||||
|
||||
handler.InitiateAuthentication(rw, req, session, "https://example.com/callback",
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
body := rw.Body.String()
|
||||
if !strings.Contains(body, "Failed to generate nonce") {
|
||||
t.Errorf("Expected 'Failed to generate nonce' in response body, got '%s'", body)
|
||||
}
|
||||
|
||||
if len(logger.errorMessages) == 0 {
|
||||
t.Error("Expected error to be logged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_InitiateAuthentication_PKCECodeVerifierError tests PKCE code verifier generation failure
|
||||
func TestAuthHandler_InitiateAuthentication_PKCECodeVerifierError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
session := &mockSessionData{}
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
generateNonce := func() (string, error) { return "test-nonce", nil }
|
||||
generateCodeVerifier := func() (string, error) { return "", &testError{"code verifier generation failed"} }
|
||||
deriveCodeChallenge := func() (string, error) { return "", nil }
|
||||
|
||||
handler.InitiateAuthentication(rw, req, session, "https://example.com/callback",
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
body := rw.Body.String()
|
||||
if !strings.Contains(body, "Failed to generate code verifier") {
|
||||
t.Errorf("Expected 'Failed to generate code verifier' in response body, got '%s'", body)
|
||||
}
|
||||
|
||||
if len(logger.errorMessages) == 0 {
|
||||
t.Error("Expected error to be logged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_InitiateAuthentication_PKCECodeChallengeError tests PKCE code challenge derivation failure
|
||||
func TestAuthHandler_InitiateAuthentication_PKCECodeChallengeError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
session := &mockSessionData{}
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
generateNonce := func() (string, error) { return "test-nonce", nil }
|
||||
generateCodeVerifier := func() (string, error) { return "test-verifier", nil }
|
||||
deriveCodeChallenge := func() (string, error) { return "", &testError{"code challenge derivation failed"} }
|
||||
|
||||
handler.InitiateAuthentication(rw, req, session, "https://example.com/callback",
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
body := rw.Body.String()
|
||||
if !strings.Contains(body, "Failed to generate code challenge") {
|
||||
t.Errorf("Expected 'Failed to generate code challenge' in response body, got '%s'", body)
|
||||
}
|
||||
|
||||
if len(logger.errorMessages) == 0 {
|
||||
t.Error("Expected error to be logged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_InitiateAuthentication_SessionSaveError tests session save failure
|
||||
func TestAuthHandler_InitiateAuthentication_SessionSaveError(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
session := &mockSessionData{saveError: &testError{"save failed"}}
|
||||
req := httptest.NewRequest("GET", "/test?param=value", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
generateNonce := func() (string, error) { return "test-nonce", nil }
|
||||
generateCodeVerifier := func() (string, error) { return "", nil }
|
||||
deriveCodeChallenge := func() (string, error) { return "", nil }
|
||||
|
||||
handler.InitiateAuthentication(rw, req, session, "https://example.com/callback",
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
|
||||
if rw.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
body := rw.Body.String()
|
||||
if !strings.Contains(body, "Failed to save session") {
|
||||
t.Errorf("Expected 'Failed to save session' in response body, got '%s'", body)
|
||||
}
|
||||
|
||||
if len(logger.errorMessages) == 0 {
|
||||
t.Error("Expected error to be logged")
|
||||
}
|
||||
|
||||
// Verify session was prepared correctly before the save failure
|
||||
if session.incomingPath != "/test?param=value" {
|
||||
t.Errorf("Expected incoming path '/test?param=value', got '%s'", session.incomingPath)
|
||||
}
|
||||
|
||||
if session.nonce != "test-nonce" {
|
||||
t.Errorf("Expected nonce 'test-nonce', got '%s'", session.nonce)
|
||||
}
|
||||
|
||||
if session.redirectCount != 1 {
|
||||
t.Errorf("Expected redirect count 1, got %d", session.redirectCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_InitiateAuthentication_Success tests successful authentication initiation
|
||||
func TestAuthHandler_InitiateAuthentication_Success(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{"openid", "email"}, false)
|
||||
|
||||
session := &mockSessionData{}
|
||||
req := httptest.NewRequest("GET", "/protected/resource", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
generateNonce := func() (string, error) { return "generated-nonce", nil }
|
||||
generateCodeVerifier := func() (string, error) { return "generated-verifier", nil }
|
||||
deriveCodeChallenge := func() (string, error) { return "generated-challenge", nil }
|
||||
|
||||
handler.InitiateAuthentication(rw, req, session, "https://example.com/callback",
|
||||
generateNonce, generateCodeVerifier, deriveCodeChallenge)
|
||||
|
||||
// Should redirect
|
||||
if rw.Code != http.StatusFound {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusFound, rw.Code)
|
||||
}
|
||||
|
||||
location := rw.Header().Get("Location")
|
||||
if location == "" {
|
||||
t.Error("Expected Location header to be set")
|
||||
}
|
||||
|
||||
// Parse the redirect URL to verify parameters
|
||||
parsedURL, err := url.Parse(location)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse redirect URL: %v", err)
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
|
||||
// Verify required parameters
|
||||
if query.Get("client_id") != "test-client" {
|
||||
t.Errorf("Expected client_id 'test-client', got '%s'", query.Get("client_id"))
|
||||
}
|
||||
|
||||
if query.Get("response_type") != "code" {
|
||||
t.Errorf("Expected response_type 'code', got '%s'", query.Get("response_type"))
|
||||
}
|
||||
|
||||
if query.Get("redirect_uri") != "https://example.com/callback" {
|
||||
t.Errorf("Expected redirect_uri 'https://example.com/callback', got '%s'", query.Get("redirect_uri"))
|
||||
}
|
||||
|
||||
if query.Get("nonce") != "generated-nonce" {
|
||||
t.Errorf("Expected nonce 'generated-nonce', got '%s'", query.Get("nonce"))
|
||||
}
|
||||
|
||||
// Verify PKCE parameters
|
||||
if query.Get("code_challenge") != "generated-challenge" {
|
||||
t.Errorf("Expected code_challenge 'generated-challenge', got '%s'", query.Get("code_challenge"))
|
||||
}
|
||||
|
||||
if query.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("Expected code_challenge_method 'S256', got '%s'", query.Get("code_challenge_method"))
|
||||
}
|
||||
|
||||
// Verify scope
|
||||
scope := query.Get("scope")
|
||||
if !strings.Contains(scope, "openid") || !strings.Contains(scope, "email") {
|
||||
t.Errorf("Expected scope to contain 'openid' and 'email', got '%s'", scope)
|
||||
}
|
||||
|
||||
// Verify session was updated correctly
|
||||
if !session.dirty {
|
||||
t.Error("Expected session to be marked dirty")
|
||||
}
|
||||
|
||||
if session.incomingPath != "/protected/resource" {
|
||||
t.Errorf("Expected incoming path '/protected/resource', got '%s'", session.incomingPath)
|
||||
}
|
||||
|
||||
if session.nonce != "generated-nonce" {
|
||||
t.Errorf("Expected session nonce 'generated-nonce', got '%s'", session.nonce)
|
||||
}
|
||||
|
||||
if session.codeVerifier != "generated-verifier" {
|
||||
t.Errorf("Expected session code verifier 'generated-verifier', got '%s'", session.codeVerifier)
|
||||
}
|
||||
|
||||
// Verify session data was cleared
|
||||
if session.authenticated {
|
||||
t.Error("Expected session to not be authenticated")
|
||||
}
|
||||
|
||||
if session.email != "" {
|
||||
t.Errorf("Expected email to be cleared, got '%s'", session.email)
|
||||
}
|
||||
|
||||
if session.accessToken != "" {
|
||||
t.Errorf("Expected access token to be cleared, got '%s'", session.accessToken)
|
||||
}
|
||||
|
||||
if session.idToken != "" {
|
||||
t.Errorf("Expected ID token to be cleared, got '%s'", session.idToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_BuildAuthURL_GoogleProvider tests Google-specific URL building
|
||||
func TestAuthHandler_BuildAuthURL_GoogleProvider(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return true }, func() bool { return false },
|
||||
"google-client", "https://accounts.google.com/oauth2/auth", "https://accounts.google.com",
|
||||
[]string{"openid", "profile", "email"}, false)
|
||||
|
||||
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
|
||||
// Google-specific parameters
|
||||
if query.Get("access_type") != "offline" {
|
||||
t.Errorf("Expected access_type 'offline' for Google, got '%s'", query.Get("access_type"))
|
||||
}
|
||||
|
||||
if query.Get("prompt") != "consent" {
|
||||
t.Errorf("Expected prompt 'consent' for Google, got '%s'", query.Get("prompt"))
|
||||
}
|
||||
|
||||
// Standard parameters should still be present
|
||||
if query.Get("client_id") != "google-client" {
|
||||
t.Errorf("Expected client_id 'google-client', got '%s'", query.Get("client_id"))
|
||||
}
|
||||
|
||||
if query.Get("state") != "test-state" {
|
||||
t.Errorf("Expected state 'test-state', got '%s'", query.Get("state"))
|
||||
}
|
||||
|
||||
if query.Get("nonce") != "test-nonce" {
|
||||
t.Errorf("Expected nonce 'test-nonce', got '%s'", query.Get("nonce"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_BuildAuthURL_AzureProvider tests Azure-specific URL building
|
||||
func TestAuthHandler_BuildAuthURL_AzureProvider(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return true },
|
||||
"azure-client", "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
|
||||
"https://login.microsoftonline.com/tenant/v2.0",
|
||||
[]string{"openid", "profile", "email"}, false)
|
||||
|
||||
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
|
||||
// Azure-specific parameters
|
||||
if query.Get("response_mode") != "query" {
|
||||
t.Errorf("Expected response_mode 'query' for Azure, got '%s'", query.Get("response_mode"))
|
||||
}
|
||||
|
||||
// Azure should add offline_access scope automatically
|
||||
scope := query.Get("scope")
|
||||
if !strings.Contains(scope, "offline_access") {
|
||||
t.Errorf("Expected scope to contain 'offline_access' for Azure, got '%s'", scope)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_BuildAuthURL_PKCEEnabled tests PKCE parameter inclusion
|
||||
func TestAuthHandler_BuildAuthURL_PKCEEnabled(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
|
||||
"pkce-client", "https://example.com/auth", "https://example.com",
|
||||
[]string{"openid"}, false)
|
||||
|
||||
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "test-challenge")
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
|
||||
if query.Get("code_challenge") != "test-challenge" {
|
||||
t.Errorf("Expected code_challenge 'test-challenge', got '%s'", query.Get("code_challenge"))
|
||||
}
|
||||
|
||||
if query.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("Expected code_challenge_method 'S256', got '%s'", query.Get("code_challenge_method"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_BuildAuthURL_PKCEDisabled tests when PKCE is disabled
|
||||
func TestAuthHandler_BuildAuthURL_PKCEDisabled(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"no-pkce-client", "https://example.com/auth", "https://example.com",
|
||||
[]string{"openid"}, false)
|
||||
|
||||
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "test-challenge")
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
|
||||
// PKCE parameters should not be included
|
||||
if query.Get("code_challenge") != "" {
|
||||
t.Errorf("Expected no code_challenge when PKCE disabled, got '%s'", query.Get("code_challenge"))
|
||||
}
|
||||
|
||||
if query.Get("code_challenge_method") != "" {
|
||||
t.Errorf("Expected no code_challenge_method when PKCE disabled, got '%s'", query.Get("code_challenge_method"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_BuildAuthURL_ScopeHandling tests various scope configurations
|
||||
func TestAuthHandler_BuildAuthURL_ScopeHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scopes []string
|
||||
overrideScopes bool
|
||||
isAzure bool
|
||||
expectedScopes []string
|
||||
}{
|
||||
{
|
||||
name: "Basic scopes",
|
||||
scopes: []string{"openid", "profile", "email"},
|
||||
overrideScopes: false,
|
||||
isAzure: false,
|
||||
expectedScopes: []string{"openid", "profile", "email", "offline_access"},
|
||||
},
|
||||
{
|
||||
name: "Azure with offline_access already present",
|
||||
scopes: []string{"openid", "profile", "offline_access"},
|
||||
overrideScopes: false,
|
||||
isAzure: true,
|
||||
expectedScopes: []string{"openid", "profile", "offline_access"},
|
||||
},
|
||||
{
|
||||
name: "Azure auto-add offline_access",
|
||||
scopes: []string{"openid", "profile"},
|
||||
overrideScopes: false,
|
||||
isAzure: true,
|
||||
expectedScopes: []string{"openid", "profile", "offline_access"},
|
||||
},
|
||||
{
|
||||
name: "Override scopes with empty array",
|
||||
scopes: []string{},
|
||||
overrideScopes: true,
|
||||
isAzure: true,
|
||||
expectedScopes: []string{"offline_access"},
|
||||
},
|
||||
{
|
||||
name: "Override scopes prevents auto-add",
|
||||
scopes: []string{"openid", "custom_scope"},
|
||||
overrideScopes: true,
|
||||
isAzure: true,
|
||||
expectedScopes: []string{"openid", "custom_scope"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return tt.isAzure },
|
||||
"test-client", "https://example.com/auth", "https://example.com",
|
||||
tt.scopes, tt.overrideScopes)
|
||||
|
||||
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||
}
|
||||
|
||||
actualScope := parsedURL.Query().Get("scope")
|
||||
actualScopes := strings.Split(actualScope, " ")
|
||||
|
||||
// Check each expected scope is present
|
||||
for _, expectedScope := range tt.expectedScopes {
|
||||
found := false
|
||||
for _, actualScope := range actualScopes {
|
||||
if actualScope == expectedScope {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected scope '%s' not found in '%s'", expectedScope, actualScope)
|
||||
}
|
||||
}
|
||||
|
||||
// Check no unexpected scopes are present
|
||||
for _, actualScope := range actualScopes {
|
||||
if actualScope == "" {
|
||||
continue // Skip empty strings from split
|
||||
}
|
||||
found := false
|
||||
for _, expectedScope := range tt.expectedScopes {
|
||||
if actualScope == expectedScope {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Unexpected scope '%s' found in '%s'", actualScope, parsedURL.Query().Get("scope"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test helper type for errors
|
||||
type testError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *testError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthHandler_validateURL tests URL validation functionality
|
||||
func TestAuthHandler_validateURL(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid HTTPS URL",
|
||||
url: "https://example.com/auth",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid HTTP URL",
|
||||
url: "http://example.com/auth",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Empty URL",
|
||||
url: "",
|
||||
wantErr: true,
|
||||
errMsg: "empty URL",
|
||||
},
|
||||
{
|
||||
name: "Invalid URL format",
|
||||
url: "not-a-url",
|
||||
wantErr: true,
|
||||
errMsg: "disallowed URL scheme",
|
||||
},
|
||||
{
|
||||
name: "Disallowed scheme - javascript",
|
||||
url: "javascript:alert('xss')",
|
||||
wantErr: true,
|
||||
errMsg: "disallowed URL scheme",
|
||||
},
|
||||
{
|
||||
name: "Disallowed scheme - data",
|
||||
url: "data:text/html,<script>alert('xss')</script>",
|
||||
wantErr: true,
|
||||
errMsg: "disallowed URL scheme",
|
||||
},
|
||||
{
|
||||
name: "Disallowed scheme - file",
|
||||
url: "file:///etc/passwd",
|
||||
wantErr: true,
|
||||
errMsg: "disallowed URL scheme",
|
||||
},
|
||||
{
|
||||
name: "Disallowed scheme - ftp",
|
||||
url: "ftp://example.com/file",
|
||||
wantErr: true,
|
||||
errMsg: "disallowed URL scheme",
|
||||
},
|
||||
{
|
||||
name: "Missing host",
|
||||
url: "https:///path",
|
||||
wantErr: true,
|
||||
errMsg: "missing host",
|
||||
},
|
||||
{
|
||||
name: "Path traversal attempt",
|
||||
url: "https://example.com/../../../etc/passwd",
|
||||
wantErr: true,
|
||||
errMsg: "path traversal detected",
|
||||
},
|
||||
{
|
||||
name: "Path traversal in middle",
|
||||
url: "https://example.com/path/../sensitive/file",
|
||||
wantErr: true,
|
||||
errMsg: "path traversal detected",
|
||||
},
|
||||
{
|
||||
name: "Localhost attempt",
|
||||
url: "https://localhost/auth",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1 attempt",
|
||||
url: "https://127.0.0.1/auth",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "IPv6 localhost attempt",
|
||||
url: "https://[::1]/auth",
|
||||
wantErr: true,
|
||||
errMsg: "invalid host:port format",
|
||||
},
|
||||
{
|
||||
name: "0.0.0.0 attempt",
|
||||
url: "https://0.0.0.0/auth",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "Private IP - 192.168.x.x",
|
||||
url: "https://192.168.1.1/auth",
|
||||
wantErr: true,
|
||||
errMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Private IP - 10.x.x.x",
|
||||
url: "https://10.0.0.1/auth",
|
||||
wantErr: true,
|
||||
errMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Private IP - 172.16.x.x",
|
||||
url: "https://172.16.0.1/auth",
|
||||
wantErr: true,
|
||||
errMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Link-local IP",
|
||||
url: "https://169.254.1.1/auth",
|
||||
wantErr: true,
|
||||
errMsg: "link-local IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Multicast IP",
|
||||
url: "https://224.0.0.1/auth",
|
||||
wantErr: true,
|
||||
errMsg: "multicast IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Valid public IP",
|
||||
url: "https://8.8.8.8/auth",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid domain with port",
|
||||
url: "https://example.com:8443/auth",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "localhost with case variation",
|
||||
url: "https://LOCALHOST/auth",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "Invalid host:port format",
|
||||
url: "https://example.com:notanumber/auth",
|
||||
wantErr: true,
|
||||
errMsg: "invalid URL format",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := handler.validateURL(tt.url)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("validateURL() expected error but got none")
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("validateURL() error = %v, expected error containing %v", err, tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateURL() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_validateHost tests host validation specifically
|
||||
func TestAuthHandler_validateHost(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid hostname",
|
||||
host: "example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid hostname with subdomain",
|
||||
host: "api.example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid hostname with port",
|
||||
host: "example.com:8080",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Empty host",
|
||||
host: "",
|
||||
wantErr: true,
|
||||
errMsg: "empty host",
|
||||
},
|
||||
{
|
||||
name: "localhost",
|
||||
host: "localhost",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "LOCALHOST (case insensitive)",
|
||||
host: "LOCALHOST",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "localhost with port",
|
||||
host: "localhost:8080",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1",
|
||||
host: "127.0.0.1",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1 with port",
|
||||
host: "127.0.0.1:8080",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "IPv6 localhost",
|
||||
host: "::1",
|
||||
wantErr: true,
|
||||
errMsg: "invalid host:port format",
|
||||
},
|
||||
{
|
||||
name: "0.0.0.0",
|
||||
host: "0.0.0.0",
|
||||
wantErr: true,
|
||||
errMsg: "localhost access not allowed",
|
||||
},
|
||||
{
|
||||
name: "Private IP 192.168.1.1",
|
||||
host: "192.168.1.1",
|
||||
wantErr: true,
|
||||
errMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Private IP 10.0.0.1",
|
||||
host: "10.0.0.1",
|
||||
wantErr: true,
|
||||
errMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Private IP 172.16.0.1",
|
||||
host: "172.16.0.1",
|
||||
wantErr: true,
|
||||
errMsg: "private IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Public IP 8.8.8.8",
|
||||
host: "8.8.8.8",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Link-local IP",
|
||||
host: "169.254.1.1",
|
||||
wantErr: true,
|
||||
errMsg: "link-local IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Multicast IP",
|
||||
host: "224.0.0.1",
|
||||
wantErr: true,
|
||||
errMsg: "multicast IP not allowed",
|
||||
},
|
||||
{
|
||||
name: "Invalid host:port format",
|
||||
host: "example.com::",
|
||||
wantErr: true,
|
||||
errMsg: "invalid host:port format",
|
||||
},
|
||||
{
|
||||
name: "Valid international domain",
|
||||
host: "example.org",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid ccTLD",
|
||||
host: "example.co.uk",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := handler.validateHost(tt.host)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("validateHost() expected error but got none")
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("validateHost() error = %v, expected error containing %v", err, tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateHost() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_buildURLWithParams tests URL building with parameters
|
||||
func TestAuthHandler_buildURLWithParams(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
params url.Values
|
||||
expected string
|
||||
expectEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "Absolute HTTPS URL",
|
||||
baseURL: "https://provider.com/auth",
|
||||
params: url.Values{
|
||||
"client_id": []string{"test-client"},
|
||||
"response_type": []string{"code"},
|
||||
},
|
||||
expected: "https://provider.com/auth?client_id=test-client&response_type=code",
|
||||
},
|
||||
{
|
||||
name: "Absolute HTTP URL",
|
||||
baseURL: "http://provider.com/auth",
|
||||
params: url.Values{
|
||||
"state": []string{"test-state"},
|
||||
},
|
||||
expected: "http://provider.com/auth?state=test-state",
|
||||
},
|
||||
{
|
||||
name: "Relative URL resolved against issuer",
|
||||
baseURL: "/oauth2/authorize",
|
||||
params: url.Values{
|
||||
"scope": []string{"openid"},
|
||||
},
|
||||
expected: "https://example.com/oauth2/authorize?scope=openid",
|
||||
},
|
||||
{
|
||||
name: "Root relative URL",
|
||||
baseURL: "/auth",
|
||||
params: url.Values{
|
||||
"nonce": []string{"test-nonce"},
|
||||
},
|
||||
expected: "https://example.com/auth?nonce=test-nonce",
|
||||
},
|
||||
{
|
||||
name: "Invalid absolute URL",
|
||||
baseURL: "https://localhost/auth",
|
||||
params: url.Values{},
|
||||
expectEmpty: true, // Should return empty string due to validation failure
|
||||
},
|
||||
{
|
||||
name: "Invalid relative URL when resolved",
|
||||
baseURL: "/auth",
|
||||
params: url.Values{},
|
||||
expected: "", // Should be empty because issuer validation would be tested separately
|
||||
expectEmpty: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := handler.buildURLWithParams(tt.baseURL, tt.params)
|
||||
|
||||
if tt.expectEmpty {
|
||||
if result != "" {
|
||||
t.Errorf("buildURLWithParams() expected empty string, got %v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For relative URLs, we expect them to be resolved against the issuer URL
|
||||
if !strings.HasPrefix(tt.baseURL, "http") {
|
||||
// Verify it starts with the issuer URL
|
||||
if !strings.HasPrefix(result, handler.issuerURL) {
|
||||
t.Errorf("buildURLWithParams() relative URL not resolved against issuer URL. Got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the result to verify parameters
|
||||
parsedURL, err := url.Parse(result)
|
||||
if err != nil {
|
||||
t.Fatalf("buildURLWithParams() produced invalid URL: %v", err)
|
||||
}
|
||||
|
||||
// Verify all expected parameters are present
|
||||
resultParams := parsedURL.Query()
|
||||
for key, expectedValues := range tt.params {
|
||||
actualValues := resultParams[key]
|
||||
if len(actualValues) != len(expectedValues) {
|
||||
t.Errorf("Parameter %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
|
||||
continue
|
||||
}
|
||||
for i, expectedValue := range expectedValues {
|
||||
if actualValues[i] != expectedValue {
|
||||
t.Errorf("Parameter %s[%d]: expected %v, got %v", key, i, expectedValue, actualValues[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_buildURLWithParams_ParameterEncoding tests proper parameter encoding
|
||||
func TestAuthHandler_buildURLWithParams_ParameterEncoding(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
// Test special characters that need encoding
|
||||
params := url.Values{
|
||||
"redirect_uri": []string{"https://example.com/callback?test=value&other=data"},
|
||||
"state": []string{"state with spaces and & special chars"},
|
||||
"scope": []string{"openid profile email"},
|
||||
"special": []string{"value+with+plus&ersand=equals"},
|
||||
}
|
||||
|
||||
result := handler.buildURLWithParams("https://provider.com/auth", params)
|
||||
|
||||
parsedURL, err := url.Parse(result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse result URL: %v", err)
|
||||
}
|
||||
|
||||
// Verify parameters are correctly encoded/decoded
|
||||
resultParams := parsedURL.Query()
|
||||
|
||||
expectedParams := map[string]string{
|
||||
"redirect_uri": "https://example.com/callback?test=value&other=data",
|
||||
"state": "state with spaces and & special chars",
|
||||
"scope": "openid profile email",
|
||||
"special": "value+with+plus&ersand=equals",
|
||||
}
|
||||
|
||||
for key, expectedValue := range expectedParams {
|
||||
actualValue := resultParams.Get(key)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Parameter %s: expected %v, got %v", key, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHandler_validateParsedURL tests validateParsedURL method
|
||||
func TestAuthHandler_validateParsedURL(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
|
||||
"test-client", "https://example.com/auth", "https://example.com", []string{}, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid HTTPS URL",
|
||||
url: "https://example.com/path",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid HTTP URL with warning",
|
||||
url: "http://example.com/path",
|
||||
wantErr: false, // Should not error but should log warning
|
||||
},
|
||||
{
|
||||
name: "Invalid scheme",
|
||||
url: "javascript:alert('xss')",
|
||||
wantErr: true,
|
||||
errMsg: "disallowed URL scheme",
|
||||
},
|
||||
{
|
||||
name: "Missing host",
|
||||
url: "https:///path",
|
||||
wantErr: true,
|
||||
errMsg: "missing host",
|
||||
},
|
||||
{
|
||||
name: "Path traversal",
|
||||
url: "https://example.com/path/../../../etc",
|
||||
wantErr: true,
|
||||
errMsg: "path traversal detected",
|
||||
},
|
||||
{
|
||||
name: "Invalid host (private IP)",
|
||||
url: "https://192.168.1.1/path",
|
||||
wantErr: true,
|
||||
errMsg: "invalid host",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsedURL, err := url.Parse(tt.url)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse test URL: %v", err)
|
||||
}
|
||||
|
||||
err = handler.validateParsedURL(parsedURL)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("validateParsedURL() expected error but got none")
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("validateParsedURL() error = %v, expected error containing %v", err, tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateParsedURL() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
// Check for HTTP warning in debug logs
|
||||
if parsedURL.Scheme == "http" && len(logger.debugMessages) > 0 {
|
||||
found := false
|
||||
for _, msg := range logger.debugMessages {
|
||||
if strings.Contains(msg, "Warning: Using HTTP scheme") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected HTTP scheme warning in debug logs")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user