Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,18 @@ def _exchange_code_for_claims(code: str, state: str) -> IdentityClaimsResponse:
logger.warning("WordPress bridge exchange failed (%s)", type(exc).__name__)
raise HTTPException(status_code=502, detail="WordPress bridge exchange failed") from exc

if response.status_code in {429, 502, 503, 504}:
# A temporary WordPress/edge response must not consume the browser's
# login permanently. Keep it distinguishable from a rejected code so
# the callback can restore the pending state and the UI can recover.
logger.warning("WordPress bridge temporarily unavailable (status=%s)", response.status_code)
retry_after = response.headers.get("Retry-After")
raise HTTPException(
status_code=response.status_code,
detail="WordPress bridge temporarily unavailable",
headers={"Retry-After": retry_after} if retry_after else None,
)

if response.status_code != 200:
logger.warning("WordPress bridge rejected code exchange (status=%s)", response.status_code)
raise HTTPException(status_code=400, detail="Authorization code exchange rejected")
Expand Down Expand Up @@ -973,7 +985,7 @@ def identity_callback(
try:
claims = _exchange_code_for_claims(code=code, state=state)
except HTTPException as exc:
if exc.status_code in {502, 503, 504}:
if exc.status_code in {429, 502, 503, 504}:
restored = restore_pending_login_state_after_transient_failure(session, state)
if not restored:
fail_origin_login_handoff(session, state)
Expand Down
82 changes: 82 additions & 0 deletions backend/tests/test_identity_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from secrets import token_urlsafe

import pytest
import httpx
from fastapi.testclient import TestClient
from sqlalchemy.pool import NullPool
from sqlalchemy.exc import SQLAlchemyError
Expand Down Expand Up @@ -1654,6 +1655,87 @@ def flaky_exchange(code: str, state: str):
assert replay.status_code == 400
assert "already consumed" in replay.json()["detail"]

@pytest.mark.parametrize("upstream_status", [429, 502, 503, 504])
def test_transient_wordpress_http_response_allows_same_login_to_finish(
self,
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
upstream_status: int,
):
"""Exercise the real exchange adapter, not a pre-classified exception."""
monkeypatch.setattr(main_module, "_WORDPRESS_BRIDGE_SECRET", "synthetic-test-secret")
monkeypatch.setattr(main_module, "_SESSION_COOKIE_SECURE", False)
exchange_calls = []

def wordpress_response(url, *, json, headers, timeout):
exchange_calls.append(dict(json))
if len(exchange_calls) == 1:
return httpx.Response(upstream_status, headers={"Retry-After": "45"})
return httpx.Response(200, json=self._stub_claims().model_dump(mode="json"))

monkeypatch.setattr(main_module.httpx, "post", wordpress_response)
start = client.post("/api/identity/login/start").json()
callback_payload = {"code": "synthetic-bridge-code", "state": start["state"]}

failed = client.post("/api/identity/callback", json=callback_payload)
assert failed.status_code == upstream_status
assert failed.headers["retry-after"] == "45"
assert SESSION_COOKIE_NAME not in failed.cookies
assert client.get("/api/identity/me").status_code == 401

pending = client.post(
"/api/identity/login/status",
json={
"state": start["state"],
"browser_handoff_token": start["browser_handoff_token"],
},
)
assert pending.status_code == 200
assert pending.json()["status"] == "pending"

retried = client.post("/api/identity/callback", json=callback_payload)
assert retried.status_code == 200
assert SESSION_COOKIE_NAME in retried.cookies
assert client.get("/api/identity/me").status_code == 200
assert exchange_calls == [callback_payload, callback_payload]

replay = client.post("/api/identity/callback", json=callback_payload)
assert replay.status_code == 400
assert "already consumed" in replay.json()["detail"]
assert len(exchange_calls) == 2

@pytest.mark.parametrize("upstream_status", [400, 401, 403])
def test_permanent_wordpress_http_rejection_still_fails_closed(
self,
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
upstream_status: int,
):
monkeypatch.setattr(main_module, "_WORDPRESS_BRIDGE_SECRET", "synthetic-test-secret")
monkeypatch.setattr(
main_module.httpx,
"post",
lambda *args, **kwargs: httpx.Response(upstream_status),
)
start = client.post("/api/identity/login/start").json()
callback_payload = {"code": "rejected-code", "state": start["state"]}

failed = client.post("/api/identity/callback", json=callback_payload)
assert failed.status_code == 400
assert SESSION_COOKIE_NAME not in failed.cookies
pending = client.post(
"/api/identity/login/status",
json={
"state": start["state"],
"browser_handoff_token": start["browser_handoff_token"],
},
)
assert pending.json()["status"] == "failed"
assert client.get("/api/identity/me").status_code == 401
replay = client.post("/api/identity/callback", json=callback_payload)
assert replay.status_code == 400
assert "already consumed" in replay.json()["detail"]

def test_state_substitution_fails(self, client: TestClient, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(main_module, "_exchange_code_for_claims", lambda code, state: self._stub_claims())

Expand Down