Skip to content

Commit b79a42e

Browse files
authored
Merge pull request #10 from chrisw-dev/copilot/implement-error-scenarios
Add configurable error scenarios for /authorize endpoint
2 parents 0b26d7f + ee044b2 commit b79a42e

File tree

8 files changed

+417
-4
lines changed

8 files changed

+417
-4
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ require (
99
golang.org/x/oauth2 v0.28.0
1010
)
1111

12-
require github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
12+
require github.com/golang-jwt/jwt/v5 v5.3.0

internal/handlers/authorize.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@ func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2929
return
3030
}
3131

32+
// Check if there's an error scenario configured for the authorize endpoint
33+
if errorScenario, exists := h.Store.GetErrorScenario("authorize"); exists {
34+
// Redirect to the provided redirect URI with error parameters
35+
redirectURL, err := url.Parse(redirectURI)
36+
if err != nil {
37+
http.Error(w, "Invalid redirect URI", http.StatusBadRequest)
38+
return
39+
}
40+
41+
query := redirectURL.Query()
42+
query.Set("error", errorScenario.ErrorCode)
43+
if errorScenario.Description != "" {
44+
query.Set("error_description", errorScenario.Description)
45+
}
46+
if state != "" {
47+
query.Set("state", state)
48+
}
49+
redirectURL.RawQuery = query.Encode()
50+
51+
log.Printf("Returning error redirect for authorize endpoint: error=%s, description=%s", errorScenario.ErrorCode, errorScenario.Description)
52+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
53+
return
54+
}
55+
3256
// Generate authorization code
3357
authCode := uuid.New().String()
3458
expiration := time.Now().Add(10 * time.Minute)

internal/handlers/authorize_test.go

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88

99
"github.com/chrisw-dev/golang-mock-oauth2-server/internal/store"
10+
"github.com/chrisw-dev/golang-mock-oauth2-server/internal/types"
1011
)
1112

1213
func TestAuthorizeHandler_ServeHTTP(t *testing.T) {
@@ -74,3 +75,336 @@ func TestAuthorizeHandler_ServeHTTP(t *testing.T) {
7475
})
7576
}
7677
}
78+
79+
func TestAuthorizeHandler_ErrorScenarios(t *testing.T) {
80+
tests := []struct {
81+
name string
82+
errorScenario types.ErrorScenario
83+
queryParams url.Values
84+
expectedStatus int
85+
expectedError string
86+
expectedErrorDesc string
87+
shouldHaveState bool
88+
}{
89+
{
90+
name: "access_denied error",
91+
errorScenario: types.ErrorScenario{
92+
Enabled: true,
93+
Endpoint: "authorize",
94+
ErrorCode: "access_denied",
95+
Description: "User denied access",
96+
},
97+
queryParams: url.Values{
98+
"client_id": {"test-client"},
99+
"redirect_uri": {"http://localhost/callback"},
100+
"scope": {"openid"},
101+
"response_type": {"code"},
102+
"state": {"test-state"},
103+
},
104+
expectedStatus: http.StatusFound,
105+
expectedError: "access_denied",
106+
expectedErrorDesc: "User denied access",
107+
shouldHaveState: true,
108+
},
109+
{
110+
name: "invalid_request error",
111+
errorScenario: types.ErrorScenario{
112+
Enabled: true,
113+
Endpoint: "authorize",
114+
ErrorCode: "invalid_request",
115+
Description: "Missing client_id parameter",
116+
},
117+
queryParams: url.Values{
118+
"client_id": {"test-client"},
119+
"redirect_uri": {"http://localhost/callback"},
120+
"scope": {"openid"},
121+
"response_type": {"code"},
122+
},
123+
expectedStatus: http.StatusFound,
124+
expectedError: "invalid_request",
125+
expectedErrorDesc: "Missing client_id parameter",
126+
shouldHaveState: false,
127+
},
128+
{
129+
name: "unauthorized_client error",
130+
errorScenario: types.ErrorScenario{
131+
Enabled: true,
132+
Endpoint: "authorize",
133+
ErrorCode: "unauthorized_client",
134+
Description: "Client not authorized",
135+
},
136+
queryParams: url.Values{
137+
"client_id": {"test-client"},
138+
"redirect_uri": {"http://localhost/callback"},
139+
"scope": {"openid"},
140+
"response_type": {"code"},
141+
"state": {"test-state-123"},
142+
},
143+
expectedStatus: http.StatusFound,
144+
expectedError: "unauthorized_client",
145+
expectedErrorDesc: "Client not authorized",
146+
shouldHaveState: true,
147+
},
148+
{
149+
name: "unsupported_response_type error",
150+
errorScenario: types.ErrorScenario{
151+
Enabled: true,
152+
Endpoint: "authorize",
153+
ErrorCode: "unsupported_response_type",
154+
Description: "Response type not supported",
155+
},
156+
queryParams: url.Values{
157+
"client_id": {"test-client"},
158+
"redirect_uri": {"http://localhost/callback"},
159+
"scope": {"openid"},
160+
"response_type": {"code"},
161+
},
162+
expectedStatus: http.StatusFound,
163+
expectedError: "unsupported_response_type",
164+
expectedErrorDesc: "Response type not supported",
165+
shouldHaveState: false,
166+
},
167+
{
168+
name: "invalid_scope error",
169+
errorScenario: types.ErrorScenario{
170+
Enabled: true,
171+
Endpoint: "authorize",
172+
ErrorCode: "invalid_scope",
173+
Description: "Scope 'admin' is not available",
174+
},
175+
queryParams: url.Values{
176+
"client_id": {"test-client"},
177+
"redirect_uri": {"http://localhost/callback"},
178+
"scope": {"openid admin"},
179+
"response_type": {"code"},
180+
"state": {"test-state"},
181+
},
182+
expectedStatus: http.StatusFound,
183+
expectedError: "invalid_scope",
184+
expectedErrorDesc: "Scope 'admin' is not available",
185+
shouldHaveState: true,
186+
},
187+
{
188+
name: "server_error",
189+
errorScenario: types.ErrorScenario{
190+
Enabled: true,
191+
Endpoint: "authorize",
192+
ErrorCode: "server_error",
193+
Description: "Internal server error",
194+
},
195+
queryParams: url.Values{
196+
"client_id": {"test-client"},
197+
"redirect_uri": {"http://localhost/callback"},
198+
"scope": {"openid"},
199+
"response_type": {"code"},
200+
},
201+
expectedStatus: http.StatusFound,
202+
expectedError: "server_error",
203+
expectedErrorDesc: "Internal server error",
204+
shouldHaveState: false,
205+
},
206+
{
207+
name: "temporarily_unavailable error",
208+
errorScenario: types.ErrorScenario{
209+
Enabled: true,
210+
Endpoint: "authorize",
211+
ErrorCode: "temporarily_unavailable",
212+
Description: "Server is under maintenance",
213+
},
214+
queryParams: url.Values{
215+
"client_id": {"test-client"},
216+
"redirect_uri": {"http://localhost/callback"},
217+
"scope": {"openid"},
218+
"response_type": {"code"},
219+
"state": {"test-state"},
220+
},
221+
expectedStatus: http.StatusFound,
222+
expectedError: "temporarily_unavailable",
223+
expectedErrorDesc: "Server is under maintenance",
224+
shouldHaveState: true,
225+
},
226+
{
227+
name: "error scenario disabled - should succeed normally",
228+
errorScenario: types.ErrorScenario{
229+
Enabled: false, // Disabled
230+
Endpoint: "authorize",
231+
ErrorCode: "access_denied",
232+
Description: "This should not appear",
233+
},
234+
queryParams: url.Values{
235+
"client_id": {"test-client"},
236+
"redirect_uri": {"http://localhost/callback"},
237+
"scope": {"openid"},
238+
"response_type": {"code"},
239+
"state": {"test-state"},
240+
},
241+
expectedStatus: http.StatusFound,
242+
expectedError: "", // No error expected
243+
expectedErrorDesc: "",
244+
shouldHaveState: true,
245+
},
246+
}
247+
248+
for _, tt := range tests {
249+
t.Run(tt.name, func(t *testing.T) {
250+
// Create a fresh store for each test
251+
testStore := store.NewMemoryStore()
252+
handler := &AuthorizeHandler{Store: testStore}
253+
254+
// Configure the error scenario
255+
testStore.StoreErrorScenario(tt.errorScenario)
256+
257+
// Make the request
258+
req := httptest.NewRequest(http.MethodGet, "/authorize?"+tt.queryParams.Encode(), nil)
259+
resp := httptest.NewRecorder()
260+
261+
handler.ServeHTTP(resp, req)
262+
263+
// Check status code
264+
if resp.Code != tt.expectedStatus {
265+
t.Errorf("expected status %d, got %d", tt.expectedStatus, resp.Code)
266+
}
267+
268+
// Check redirect location
269+
location := resp.Header().Get("Location")
270+
if location == "" {
271+
t.Fatalf("expected redirect location, got empty")
272+
}
273+
274+
// Parse the redirect URL
275+
redirectURL, err := url.Parse(location)
276+
if err != nil {
277+
t.Fatalf("failed to parse redirect URL: %v", err)
278+
}
279+
280+
// Check error parameter
281+
if tt.expectedError != "" {
282+
errorParam := redirectURL.Query().Get("error")
283+
if errorParam != tt.expectedError {
284+
t.Errorf("expected error %q, got %q", tt.expectedError, errorParam)
285+
}
286+
287+
// Check error_description parameter
288+
errorDesc := redirectURL.Query().Get("error_description")
289+
if errorDesc != tt.expectedErrorDesc {
290+
t.Errorf("expected error_description %q, got %q", tt.expectedErrorDesc, errorDesc)
291+
}
292+
} else {
293+
// When error scenario is disabled, should get authorization code
294+
code := redirectURL.Query().Get("code")
295+
if code == "" {
296+
t.Errorf("expected authorization code when error scenario is disabled, got none")
297+
}
298+
errorParam := redirectURL.Query().Get("error")
299+
if errorParam != "" {
300+
t.Errorf("expected no error when scenario is disabled, got %q", errorParam)
301+
}
302+
}
303+
304+
// Check state parameter if expected
305+
if tt.shouldHaveState {
306+
stateParam := redirectURL.Query().Get("state")
307+
expectedState := tt.queryParams.Get("state")
308+
if stateParam != expectedState {
309+
t.Errorf("expected state %q, got %q", expectedState, stateParam)
310+
}
311+
}
312+
})
313+
}
314+
}
315+
316+
func TestAuthorizeHandler_ErrorScenarioForDifferentEndpoint(t *testing.T) {
317+
// Test that error scenario for "token" endpoint doesn't affect "authorize" endpoint
318+
testStore := store.NewMemoryStore()
319+
handler := &AuthorizeHandler{Store: testStore}
320+
321+
// Configure error scenario for "token" endpoint (not "authorize")
322+
testStore.StoreErrorScenario(types.ErrorScenario{
323+
Enabled: true,
324+
Endpoint: "token", // Different endpoint
325+
ErrorCode: "invalid_grant",
326+
Description: "This should not affect authorize",
327+
})
328+
329+
queryParams := url.Values{
330+
"client_id": {"test-client"},
331+
"redirect_uri": {"http://localhost/callback"},
332+
"scope": {"openid"},
333+
"response_type": {"code"},
334+
"state": {"test-state"},
335+
}
336+
337+
req := httptest.NewRequest(http.MethodGet, "/authorize?"+queryParams.Encode(), nil)
338+
resp := httptest.NewRecorder()
339+
340+
handler.ServeHTTP(resp, req)
341+
342+
// Should succeed normally (no error)
343+
if resp.Code != http.StatusFound {
344+
t.Errorf("expected status %d, got %d", http.StatusFound, resp.Code)
345+
}
346+
347+
location := resp.Header().Get("Location")
348+
redirectURL, err := url.Parse(location)
349+
if err != nil {
350+
t.Fatalf("failed to parse redirect URL: %v", err)
351+
}
352+
353+
// Should have authorization code, not error
354+
code := redirectURL.Query().Get("code")
355+
if code == "" {
356+
t.Errorf("expected authorization code, got none")
357+
}
358+
359+
errorParam := redirectURL.Query().Get("error")
360+
if errorParam != "" {
361+
t.Errorf("expected no error, got %q", errorParam)
362+
}
363+
}
364+
365+
func TestAuthorizeHandler_ErrorWithoutDescription(t *testing.T) {
366+
testStore := store.NewMemoryStore()
367+
handler := &AuthorizeHandler{Store: testStore}
368+
369+
// Configure error scenario without description
370+
testStore.StoreErrorScenario(types.ErrorScenario{
371+
Enabled: true,
372+
Endpoint: "authorize",
373+
ErrorCode: "access_denied",
374+
Description: "", // No description
375+
})
376+
377+
queryParams := url.Values{
378+
"client_id": {"test-client"},
379+
"redirect_uri": {"http://localhost/callback"},
380+
"scope": {"openid"},
381+
"response_type": {"code"},
382+
"state": {"test-state"},
383+
}
384+
385+
req := httptest.NewRequest(http.MethodGet, "/authorize?"+queryParams.Encode(), nil)
386+
resp := httptest.NewRecorder()
387+
388+
handler.ServeHTTP(resp, req)
389+
390+
if resp.Code != http.StatusFound {
391+
t.Errorf("expected status %d, got %d", http.StatusFound, resp.Code)
392+
}
393+
394+
location := resp.Header().Get("Location")
395+
redirectURL, err := url.Parse(location)
396+
if err != nil {
397+
t.Fatalf("failed to parse redirect URL: %v", err)
398+
}
399+
400+
// Should have error but no error_description
401+
errorParam := redirectURL.Query().Get("error")
402+
if errorParam != "access_denied" {
403+
t.Errorf("expected error 'access_denied', got %q", errorParam)
404+
}
405+
406+
errorDesc := redirectURL.Query().Get("error_description")
407+
if errorDesc != "" {
408+
t.Errorf("expected no error_description, got %q", errorDesc)
409+
}
410+
}

0 commit comments

Comments
 (0)