|
1 | 1 | package api |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "crypto/sha256" |
| 5 | + "encoding/base64" |
4 | 6 | "fmt" |
5 | 7 | "net/http" |
6 | 8 | "net/http/httptest" |
@@ -408,3 +410,55 @@ func (ts *ExternalTestSuite) TestOAuthState_InvalidFormat() { |
408 | 410 | // Should redirect to site URL with error since state is invalid |
409 | 411 | ts.Require().Equal(http.StatusSeeOther, w.Code) |
410 | 412 | } |
| 413 | + |
| 414 | +// TestPKCEFlowStateReuseRejected verifies that a PKCE flow state cannot be reused |
| 415 | +// after the OAuth callback has been completed |
| 416 | +func (ts *ExternalTestSuite) TestPKCEFlowStateReuseRejected() { |
| 417 | + code := "authcode" |
| 418 | + server := setupGenericOAuthServer(ts, code) |
| 419 | + defer server.Close() |
| 420 | + |
| 421 | + codeVerifier := "testtesttesttesttesttesttesttesttesttesttesttesttesttest" |
| 422 | + hashedCodeVerifier := sha256.Sum256([]byte(codeVerifier)) |
| 423 | + codeChallenge := base64.RawURLEncoding.EncodeToString(hashedCodeVerifier[:]) |
| 424 | + |
| 425 | + // Step 1: Initiate PKCE authorization flow and extract the state parameter |
| 426 | + w := performPKCEAuthorizationRequest(ts, "github", codeChallenge, "s256") |
| 427 | + ts.Require().Equal(http.StatusFound, w.Code) |
| 428 | + u, err := url.Parse(w.Header().Get("Location")) |
| 429 | + ts.Require().NoError(err) |
| 430 | + state := u.Query().Get("state") |
| 431 | + ts.Require().NotEmpty(state) |
| 432 | + |
| 433 | + // Step 2: First callback completes successfully (sets UserID on the flow state) |
| 434 | + callbackURL, err := url.Parse("http://localhost/callback") |
| 435 | + ts.Require().NoError(err) |
| 436 | + v := callbackURL.Query() |
| 437 | + v.Set("code", code) |
| 438 | + v.Set("state", state) |
| 439 | + callbackURL.RawQuery = v.Encode() |
| 440 | + |
| 441 | + req := httptest.NewRequest(http.MethodGet, callbackURL.String(), nil) |
| 442 | + w = httptest.NewRecorder() |
| 443 | + ts.API.handler.ServeHTTP(w, req) |
| 444 | + ts.Require().Equal(http.StatusFound, w.Code) |
| 445 | + |
| 446 | + firstRedirect, err := url.Parse(w.Header().Get("Location")) |
| 447 | + ts.Require().NoError(err) |
| 448 | + firstQuery, err := url.ParseQuery(firstRedirect.RawQuery) |
| 449 | + ts.Require().NoError(err) |
| 450 | + ts.Require().NotEmpty(firstQuery.Get("code"), "first callback should return an auth code") |
| 451 | + |
| 452 | + // Step 3: Second callback with the same state must be rejected |
| 453 | + req = httptest.NewRequest(http.MethodGet, callbackURL.String(), nil) |
| 454 | + w = httptest.NewRecorder() |
| 455 | + ts.API.handler.ServeHTTP(w, req) |
| 456 | + |
| 457 | + // The callback redirects errors to the redirect URL with error parameters |
| 458 | + redirectURL, err := url.Parse(w.Header().Get("Location")) |
| 459 | + ts.Require().NoError(err) |
| 460 | + errorQuery, err := url.ParseQuery(redirectURL.RawQuery) |
| 461 | + ts.Require().NoError(err) |
| 462 | + ts.Contains(errorQuery.Get("error_description"), "already been used", |
| 463 | + "second callback with same state should be rejected as already used") |
| 464 | +} |
0 commit comments