TestHTTP_Token_CodeReplay drives the code-reuse path: exchange once (success), then re-present the same code (rejection + the originally issued tokens get revoked per RFC 6749 §10.5).
(t *testing.T)
| 302 | // once (success), then re-present the same code (rejection + the |
| 303 | // originally issued tokens get revoked per RFC 6749 §10.5). |
| 304 | func TestHTTP_Token_CodeReplay(t *testing.T) { |
| 305 | server, provider, pool, clientID, userID := setupOAuthAPI(t) |
| 306 | redirectURI := "http://localhost:8765/callback" |
| 307 | verifier, challenge := newPKCE(t) |
| 308 | code := mintAuthCode(t, provider, clientID, userID, redirectURI, challenge) |
| 309 | |
| 310 | form := url.Values{} |
| 311 | form.Set("grant_type", "authorization_code") |
| 312 | form.Set("code", code) |
| 313 | form.Set("client_id", clientID) |
| 314 | form.Set("redirect_uri", redirectURI) |
| 315 | form.Set("code_verifier", verifier) |
| 316 | |
| 317 | // First exchange wins. |
| 318 | resp, err := http.Post(server.URL+"/oauth2/token", |
| 319 | "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) |
| 320 | if err != nil { |
| 321 | t.Fatal(err) |
| 322 | } |
| 323 | if resp.StatusCode != http.StatusOK { |
| 324 | t.Fatalf("first exchange: status = %d, want 200", resp.StatusCode) |
| 325 | } |
| 326 | var first struct { |
| 327 | AccessToken string `json:"access_token"` |
| 328 | } |
| 329 | json.NewDecoder(resp.Body).Decode(&first) |
| 330 | resp.Body.Close() |
| 331 | |
| 332 | // Replay: same code, same verifier. Must fail. |
| 333 | resp2, err := http.Post(server.URL+"/oauth2/token", |
| 334 | "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) |
| 335 | if err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | defer resp2.Body.Close() |
| 339 | if resp2.StatusCode != http.StatusBadRequest { |
| 340 | t.Fatalf("replay: status = %d, want 400 invalid_grant", resp2.StatusCode) |
| 341 | } |
| 342 | |
| 343 | // And the tokens from the first exchange must be revoked (the |
| 344 | // §10.5 reuse defense fosite drives via RevokeAccessToken + |
| 345 | // RevokeRefreshToken). Both halves must fire — checking only the |
| 346 | // access side would miss a regression that broke the refresh |
| 347 | // cascade. |
| 348 | var accessRevoked bool |
| 349 | if err := pool.QueryRow(context.Background(), ` |
| 350 | SELECT revoked_at IS NOT NULL FROM oauth_access_tokens |
| 351 | WHERE request_id IN ( |
| 352 | SELECT request_id FROM oauth_auth_codes WHERE active = FALSE |
| 353 | ) |
| 354 | LIMIT 1 |
| 355 | `).Scan(&accessRevoked); err != nil { |
| 356 | t.Fatalf("query access revoked status: %v", err) |
| 357 | } |
| 358 | if !accessRevoked { |
| 359 | t.Error("expected the originally-issued access token to be revoked after code replay") |
| 360 | } |
| 361 |
nothing calls this directly
no test coverage detected