diff --git a/internal/handlers/authorize.go b/internal/handlers/authorize.go index a255e03..23fcea1 100644 --- a/internal/handlers/authorize.go +++ b/internal/handlers/authorize.go @@ -29,7 +29,15 @@ func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Check if there's an error scenario configured for the authorize endpoint + // Check if there's an error scenario configured for the authorize endpoint. + // If an error scenario is configured and enabled for this endpoint, we return + // an OAuth2 error response by redirecting to the redirect_uri with error parameters + // instead of proceeding with the normal authorization code flow. + // + // The error response follows the OAuth2 specification (RFC 6749 Section 4.1.2.1): + // - error: OAuth2 error code (e.g., "access_denied", "invalid_scope") + // - error_description: Human-readable error description (optional) + // - state: The state parameter from the original request (if provided) if errorScenario, exists := h.Store.GetErrorScenario("authorize"); exists { // Redirect to the provided redirect URI with error parameters redirectURL, err := url.Parse(redirectURI) diff --git a/internal/handlers/config.go b/internal/handlers/config.go index 59fb74c..a28be82 100644 --- a/internal/handlers/config.go +++ b/internal/handlers/config.go @@ -33,6 +33,24 @@ type ConfigRequest struct { } // ErrorScenario defines an error condition to simulate +// +// When an error scenario is configured for an endpoint, that endpoint will return +// an OAuth2 error response instead of proceeding with the normal authentication flow. +// +// The Enabled field uses a pointer to bool (*bool) to distinguish between three states: +// - nil (field not provided in JSON): defaults to true - error scenario is enabled +// - true (explicitly set): error scenario is enabled +// - false (explicitly set): error scenario is disabled +// +// This allows clients to: +// 1. Enable an error by just providing endpoint and error fields +// 2. Explicitly enable with "enabled": true +// 3. Disable a previously configured error with "enabled": false +// +// Example usage: +// Enable error (implicit): {"endpoint": "authorize", "error": "access_denied"} +// Enable error (explicit): {"endpoint": "authorize", "error": "access_denied", "enabled": true} +// Disable error: {"endpoint": "authorize", "enabled": false} type ErrorScenario struct { Enabled *bool `json:"enabled,omitempty"` // Whether the error scenario is enabled (defaults to true if not specified) Endpoint string `json:"endpoint"` // Which endpoint should return an error (authorize, token, userinfo) @@ -85,6 +103,10 @@ func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Store error scenario if provided if config.ErrorScenario != nil { h.storeErrorScenario(*config.ErrorScenario) + log.Printf("Configured error scenario: endpoint=%s, error=%s, enabled=%v", + config.ErrorScenario.Endpoint, + config.ErrorScenario.Error, + config.ErrorScenario.Enabled) } // Return success response @@ -108,6 +130,18 @@ func (h *ConfigHandler) storeTokenConfig(tokenConfig map[string]interface{}) { } // storeErrorScenario saves error scenario configuration to the store +// +// This function handles the defaulting logic for the Enabled field: +// - If scenario.Enabled is nil (not provided in JSON), it defaults to true +// - If scenario.Enabled is not nil, it uses the explicit value (true or false) +// +// The function also determines the appropriate HTTP status code based on the +// OAuth2 error code and creates a types.ErrorScenario that is stored in the store. +// +// Note: The store only maintains ONE error scenario at a time. If multiple error +// scenarios are configured for different endpoints, the last one configured will +// overwrite any previous configurations. This is by design for simplicity in a +// mock server context. func (h *ConfigHandler) storeErrorScenario(scenario ErrorScenario) { // Default enabled to true when an error scenario is being configured // If Enabled is nil (not provided), default to true @@ -126,6 +160,9 @@ func (h *ConfigHandler) storeErrorScenario(scenario ErrorScenario) { Description: scenario.ErrorDescription, } + log.Printf("Storing error scenario: endpoint=%s, error=%s, enabled=%t, status_code=%d", + storeScenario.Endpoint, storeScenario.ErrorCode, storeScenario.Enabled, storeScenario.StatusCode) + // Store the error scenario in the store h.store.StoreErrorScenario(storeScenario) } diff --git a/test/integration/oauth_flow_test.go b/test/integration/oauth_flow_test.go index 7b14daf..b354361 100644 --- a/test/integration/oauth_flow_test.go +++ b/test/integration/oauth_flow_test.go @@ -494,3 +494,354 @@ func TestUserInfoEndpointErrorScenario(t *testing.T) { } }) } + +// TestErrorScenarioEdgeCases tests various edge cases for error scenario configuration +// to ensure robust behavior in all situations +func TestErrorScenarioEdgeCases(t *testing.T) { + mockServer := server.NewServer(":0") + ts := httptest.NewServer(mockServer.Handler) + defer ts.Close() + + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + queryParams := url.Values{ + "client_id": {"test-client"}, + "redirect_uri": {"http://localhost/callback"}, + "scope": {"openid"}, + "response_type": {"code"}, + "state": {"test-state"}, + } + + t.Run("Error scenario without enabled field defaults to true", func(t *testing.T) { + // Configure error without specifying enabled field + configReq := map[string]interface{}{ + "error_scenario": map[string]interface{}{ + "endpoint": "authorize", + "error": "invalid_scope", + "error_description": "Scope not allowed", + }, + } + + reqBody, _ := json.Marshal(configReq) + resp, err := http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post config: %v", err) + } + resp.Body.Close() + + // Verify authorization fails with error + authURL := ts.URL + "/authorize?" + queryParams.Encode() + authResp, err := client.Get(authURL) + if err != nil { + t.Fatalf("Failed to call authorize: %v", err) + } + defer authResp.Body.Close() + + location := authResp.Header.Get("Location") + if location == "" { + t.Fatalf("No Location header in response") + } + redirectURL, err := url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL: %v", err) + } + + if redirectURL.Query().Get("error") != "invalid_scope" { + t.Errorf("Expected error 'invalid_scope', got %q", redirectURL.Query().Get("error")) + } + }) + + t.Run("Switching between different error codes for same endpoint", func(t *testing.T) { + // First error + configReq := map[string]interface{}{ + "error_scenario": map[string]interface{}{ + "endpoint": "authorize", + "error": "access_denied", + }, + } + reqBody, err := json.Marshal(configReq) + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + _, err = http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post config: %v", err) + } + + // Verify first error + authResp, err := client.Get(ts.URL + "/authorize?" + queryParams.Encode()) + if err != nil { + t.Fatalf("Failed to call authorize: %v", err) + } + location := authResp.Header.Get("Location") + authResp.Body.Close() + if location == "" { + t.Fatalf("No Location header in response") + } + redirectURL, err := url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL: %v", err) + } + if redirectURL.Query().Get("error") != "access_denied" { + t.Errorf("Expected first error 'access_denied', got %q", redirectURL.Query().Get("error")) + } + + // Switch to different error + configReq["error_scenario"] = map[string]interface{}{ + "endpoint": "authorize", + "error": "server_error", + } + reqBody, err = json.Marshal(configReq) + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + _, err = http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post second config: %v", err) + } + + // Verify second error + authResp, err = client.Get(ts.URL + "/authorize?" + queryParams.Encode()) + if err != nil { + t.Fatalf("Failed to call authorize: %v", err) + } + location = authResp.Header.Get("Location") + authResp.Body.Close() + if location == "" { + t.Fatalf("No Location header in second response") + } + redirectURL, err = url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse second redirect URL: %v", err) + } + if redirectURL.Query().Get("error") != "server_error" { + t.Errorf("Expected second error 'server_error', got %q", redirectURL.Query().Get("error")) + } + }) + + t.Run("Explicitly setting enabled to true works", func(t *testing.T) { + configReq := map[string]interface{}{ + "error_scenario": map[string]interface{}{ + "enabled": true, + "endpoint": "authorize", + "error": "temporarily_unavailable", + }, + } + + reqBody, err := json.Marshal(configReq) + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + _, err = http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post config: %v", err) + } + + authResp, err := client.Get(ts.URL + "/authorize?" + queryParams.Encode()) + if err != nil { + t.Fatalf("Failed to call authorize: %v", err) + } + location := authResp.Header.Get("Location") + authResp.Body.Close() + if location == "" { + t.Fatalf("No Location header in response") + } + redirectURL, err := url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL: %v", err) + } + + if redirectURL.Query().Get("error") != "temporarily_unavailable" { + t.Errorf("Expected error 'temporarily_unavailable', got %q", redirectURL.Query().Get("error")) + } + }) + + t.Run("Re-enabling after disabling works", func(t *testing.T) { + // Enable error + configReq := map[string]interface{}{ + "error_scenario": map[string]interface{}{ + "endpoint": "authorize", + "error": "unauthorized_client", + }, + } + reqBody, err := json.Marshal(configReq) + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + _, err = http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post config: %v", err) + } + + // Verify error is active + authResp, err := client.Get(ts.URL + "/authorize?" + queryParams.Encode()) + if err != nil { + t.Fatalf("Failed to call authorize: %v", err) + } + location := authResp.Header.Get("Location") + authResp.Body.Close() + if location == "" { + t.Fatalf("No Location header in response") + } + redirectURL, err := url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL: %v", err) + } + if redirectURL.Query().Get("error") == "" { + t.Error("Expected error to be active") + } + + // Disable error + configReq["error_scenario"] = map[string]interface{}{ + "enabled": false, + "endpoint": "authorize", + } + reqBody, err = json.Marshal(configReq) + if err != nil { + t.Fatalf("Failed to marshal disable config: %v", err) + } + _, err = http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post disable config: %v", err) + } + + // Verify auth works now + authResp, err = client.Get(ts.URL + "/authorize?" + queryParams.Encode()) + if err != nil { + t.Fatalf("Failed to call authorize after disable: %v", err) + } + location = authResp.Header.Get("Location") + authResp.Body.Close() + if location == "" { + t.Fatalf("No Location header after disable") + } + redirectURL, err = url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL after disable: %v", err) + } + if redirectURL.Query().Get("code") == "" { + t.Error("Expected auth code after disabling error") + } + + // Re-enable with new error + configReq["error_scenario"] = map[string]interface{}{ + "endpoint": "authorize", + "error": "invalid_request", + } + reqBody, err = json.Marshal(configReq) + if err != nil { + t.Fatalf("Failed to marshal re-enable config: %v", err) + } + _, err = http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post re-enable config: %v", err) + } + + // Verify new error is active + authResp, err = client.Get(ts.URL + "/authorize?" + queryParams.Encode()) + if err != nil { + t.Fatalf("Failed to call authorize after re-enable: %v", err) + } + location = authResp.Header.Get("Location") + authResp.Body.Close() + if location == "" { + t.Fatalf("No Location header after re-enable") + } + redirectURL, err = url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL after re-enable: %v", err) + } + if redirectURL.Query().Get("error") != "invalid_request" { + t.Errorf("Expected re-enabled error 'invalid_request', got %q", redirectURL.Query().Get("error")) + } + }) + + t.Run("Error description is optional", func(t *testing.T) { + // Configure error without description + configReq := map[string]interface{}{ + "error_scenario": map[string]interface{}{ + "endpoint": "authorize", + "error": "access_denied", + }, + } + + reqBody, err := json.Marshal(configReq) + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + _, err = http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post config: %v", err) + } + + authResp, err := client.Get(ts.URL + "/authorize?" + queryParams.Encode()) + if err != nil { + t.Fatalf("Failed to call authorize: %v", err) + } + location := authResp.Header.Get("Location") + authResp.Body.Close() + if location == "" { + t.Fatalf("No Location header in response") + } + redirectURL, err := url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL: %v", err) + } + + // Should have error but no description + if redirectURL.Query().Get("error") != "access_denied" { + t.Errorf("Expected error 'access_denied'") + } + if redirectURL.Query().Get("error_description") != "" { + t.Errorf("Expected no error_description, got %q", redirectURL.Query().Get("error_description")) + } + }) + + t.Run("State parameter is preserved in error response", func(t *testing.T) { + configReq := map[string]interface{}{ + "error_scenario": map[string]interface{}{ + "endpoint": "authorize", + "error": "access_denied", + }, + } + + reqBody, err := json.Marshal(configReq) + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + _, err = http.Post(ts.URL+"/config", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to post config: %v", err) + } + + paramsWithState := url.Values{ + "client_id": {"test-client"}, + "redirect_uri": {"http://localhost/callback"}, + "scope": {"openid"}, + "response_type": {"code"}, + "state": {"my-unique-state-12345"}, + } + + authResp, err := client.Get(ts.URL + "/authorize?" + paramsWithState.Encode()) + if err != nil { + t.Fatalf("Failed to call authorize: %v", err) + } + location := authResp.Header.Get("Location") + authResp.Body.Close() + if location == "" { + t.Fatalf("No Location header in response") + } + redirectURL, err := url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL: %v", err) + } + + if redirectURL.Query().Get("state") != "my-unique-state-12345" { + t.Errorf("Expected state 'my-unique-state-12345', got %q", redirectURL.Query().Get("state")) + } + }) +}