From 201c24ff266a7abc0ce7830973c635c15d34b8cf Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:30:38 +0000 Subject: [PATCH 1/6] Keep Xaman callback state retry-safe Validate first, exchange with WordPress, then atomically consume state before creating the local session. --- backend/app/main.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 8e678a5..7827e27 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -761,7 +761,8 @@ def identity_callback( Browser callback contract remains code + state only. Backend validates pending state, exchanges code with WordPress bridge, - resolves/creates CalorieApp user identity, and issues CalorieApp session cookie. + consumes state only after a successful exchange, resolves/creates CalorieApp + user identity, and issues the CalorieApp session cookie. """ code = payload.code.strip() state = payload.state.strip() @@ -770,6 +771,18 @@ def identity_callback( raise HTTPException(status_code=400, detail="code and state are required") cleanup_pending_login_states(session) + is_valid, reason, pending = validate_pending_login_state(session, state) + if not is_valid or pending is None: + if reason == "expired": + raise HTTPException(status_code=400, detail="Login state expired") + if reason == "consumed": + raise HTTPException(status_code=400, detail="Login state already consumed") + raise HTTPException(status_code=400, detail="Unknown login state") + + # A transient WordPress/Xaman bridge failure must not burn the pending + # login state. Consume it only after a successful exchange. + claims = _exchange_code_for_claims(code=code, state=state) + consumed, reason = consume_pending_login_state(session, state) if not consumed: if reason == "expired": @@ -779,8 +792,6 @@ def identity_callback( raise HTTPException(status_code=400, detail="Unknown login state") try: - claims = _exchange_code_for_claims(code=code, state=state) - user, created = get_or_create_user_from_external_identity( session=session, provider=_IDENTITY_PROVIDER, From 673e86632429d95308c4ec218f62f333746abd05 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:31:49 +0000 Subject: [PATCH 2/6] Cover retry-safe Xaman callback behavior Replace the obsolete consume-on-failure expectation with pending, retry-success and replay-rejection coverage. --- backend/tests/test_identity_endpoints.py | 44 ++++++++++++++++++------ 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/backend/tests/test_identity_endpoints.py b/backend/tests/test_identity_endpoints.py index dc45219..6c5aeb9 100644 --- a/backend/tests/test_identity_endpoints.py +++ b/backend/tests/test_identity_endpoints.py @@ -1242,7 +1242,7 @@ def test_origin_handoff_is_single_use_across_browsers( assert replay_claim.status_code == 409 assert replay_browser.get("/api/identity/me").status_code == 401 - def test_failed_callback_marks_origin_handoff_failed( + def test_transient_bridge_failure_keeps_origin_handoff_pending( self, client: TestClient, monkeypatch: pytest.MonkeyPatch, @@ -1270,7 +1270,7 @@ def _raise_exchange_failure(code: str, state: str): assert callback.status_code == 502 assert status.status_code == 200 - assert status.json()["status"] == "failed" + assert status.json()["status"] == "pending" def test_concurrent_callback_state_use_allows_only_one_success(self, monkeypatch: pytest.MonkeyPatch): from concurrent.futures import ThreadPoolExecutor @@ -1293,20 +1293,44 @@ def call_callback(code_value: str) -> int: assert sorted(statuses) == [200, 400] - def test_bridge_exchange_failure_consumes_state_and_retry_fails(self, client: TestClient, monkeypatch: pytest.MonkeyPatch): - def _raise_exchange_failure(code: str, state: str): - raise main_module.HTTPException(status_code=502, detail="WordPress bridge exchange failed") + def test_bridge_exchange_failure_keeps_state_retryable_then_blocks_replay( + self, + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + ): + calls = {"count": 0} + + def flaky_exchange(code: str, state: str): + calls["count"] += 1 + if calls["count"] == 1: + raise main_module.HTTPException( + status_code=502, + detail="WordPress bridge exchange failed", + ) + return self._stub_claims() - monkeypatch.setattr(main_module, "_exchange_code_for_claims", _raise_exchange_failure) + monkeypatch.setattr(main_module, "_exchange_code_for_claims", flaky_exchange) state = client.post("/api/identity/login/start").json()["state"] - failed = client.post("/api/identity/callback", json={"code": "bridge-code", "state": state}) + failed = client.post( + "/api/identity/callback", + json={"code": "bridge-code", "state": state}, + ) assert failed.status_code == 502 - retried = client.post("/api/identity/callback", json={"code": "bridge-code", "state": state}) - assert retried.status_code == 400 - assert "already consumed" in retried.json()["detail"] + retried = client.post( + "/api/identity/callback", + json={"code": "bridge-code", "state": state}, + ) + assert retried.status_code == 200 + + replay = client.post( + "/api/identity/callback", + json={"code": "bridge-code", "state": state}, + ) + 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()) From db3b3b93fb05682708ef25bbf929d6ae94c685a6 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:39:38 +0000 Subject: [PATCH 3/6] Reserve callback state before bridge exchange Preserve single-use concurrency and restore the state only after retryable 502-504 bridge failures. --- backend/app/main.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 7827e27..9c153c4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -45,6 +45,7 @@ create_pending_login_state, fail_origin_login_handoff, get_or_create_user_from_external_identity, + restore_pending_login_state_after_transient_failure, validate_pending_login_state, validate_origin_login_handoff, ) @@ -760,9 +761,9 @@ def identity_callback( Browser callback contract: code + state only. Browser callback contract remains code + state only. - Backend validates pending state, exchanges code with WordPress bridge, - consumes state only after a successful exchange, resolves/creates CalorieApp - user identity, and issues the CalorieApp session cookie. + Backend atomically reserves the pending state, exchanges code with the + WordPress bridge, restores the state only for transient bridge failures, + resolves/creates CalorieApp user identity, and issues the session cookie. """ code = payload.code.strip() state = payload.state.strip() @@ -771,18 +772,6 @@ def identity_callback( raise HTTPException(status_code=400, detail="code and state are required") cleanup_pending_login_states(session) - is_valid, reason, pending = validate_pending_login_state(session, state) - if not is_valid or pending is None: - if reason == "expired": - raise HTTPException(status_code=400, detail="Login state expired") - if reason == "consumed": - raise HTTPException(status_code=400, detail="Login state already consumed") - raise HTTPException(status_code=400, detail="Unknown login state") - - # A transient WordPress/Xaman bridge failure must not burn the pending - # login state. Consume it only after a successful exchange. - claims = _exchange_code_for_claims(code=code, state=state) - consumed, reason = consume_pending_login_state(session, state) if not consumed: if reason == "expired": @@ -791,6 +780,20 @@ def identity_callback( raise HTTPException(status_code=400, detail="Login state already consumed") raise HTTPException(status_code=400, detail="Unknown login state") + try: + claims = _exchange_code_for_claims(code=code, state=state) + except HTTPException as exc: + if exc.status_code in {502, 503, 504}: + restored = restore_pending_login_state_after_transient_failure(session, state) + if not restored: + fail_origin_login_handoff(session, state) + raise + fail_origin_login_handoff(session, state) + raise + except Exception: + fail_origin_login_handoff(session, state) + raise + try: user, created = get_or_create_user_from_external_identity( session=session, From 12d6a691a9c0b0c9db34bfdafe50adeecdcbc8fb Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:40:20 +0000 Subject: [PATCH 4/6] Restore consumed state after transient bridge failure Atomically reopen an unexpired consumed login state only for the retry path. --- backend/app/services/identity.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/app/services/identity.py b/backend/app/services/identity.py index a0ff8b7..64b5e13 100644 --- a/backend/app/services/identity.py +++ b/backend/app/services/identity.py @@ -303,6 +303,23 @@ def consume_pending_login_state( return False, reason +def restore_pending_login_state_after_transient_failure( + session: Session, + state: str, +) -> bool: + """Restore a consumed, unexpired state after a retryable bridge failure.""" + now = utc_now() + updated = session.exec( + update(PendingLoginStateDB) + .where(PendingLoginStateDB.state_hash == hash_login_state(state)) + .where(PendingLoginStateDB.status == "consumed") + .where(PendingLoginStateDB.expires_at >= now) + .values(status="pending", consumed_at=None) + ) + session.commit() + return updated.rowcount == 1 + + def cleanup_pending_login_states(session: Session) -> None: """Delete expired pending login states opportunistically.""" now = utc_now() From dd85ce2ba588c743e15014655d010c13e15ba06a Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:41:13 +0000 Subject: [PATCH 5/6] Cover transient and permanent callback failures Require pending retry for 502 failures, failed handoff for permanent rejection, successful retry and blocked replay. --- backend/tests/test_identity_endpoints.py | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/backend/tests/test_identity_endpoints.py b/backend/tests/test_identity_endpoints.py index 6c5aeb9..8bf15dd 100644 --- a/backend/tests/test_identity_endpoints.py +++ b/backend/tests/test_identity_endpoints.py @@ -1272,6 +1272,42 @@ def _raise_exchange_failure(code: str, state: str): assert status.status_code == 200 assert status.json()["status"] == "pending" + def test_non_retryable_bridge_failure_marks_origin_handoff_failed( + self, + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + ): + def reject_exchange(code: str, state: str): + raise main_module.HTTPException( + status_code=400, + detail="Authorization code exchange rejected", + ) + + monkeypatch.setattr(main_module, "_exchange_code_for_claims", reject_exchange) + start = client.post("/api/identity/login/start").json() + + callback = client.post( + "/api/identity/callback", + json={"code": "rejected-code", "state": start["state"]}, + ) + status = client.post( + "/api/identity/login/status", + json={ + "state": start["state"], + "browser_handoff_token": start["browser_handoff_token"], + }, + ) + replay = client.post( + "/api/identity/callback", + json={"code": "rejected-code", "state": start["state"]}, + ) + + assert callback.status_code == 400 + assert status.status_code == 200 + assert status.json()["status"] == "failed" + assert replay.status_code == 400 + assert "already consumed" in replay.json()["detail"] + def test_concurrent_callback_state_use_allows_only_one_success(self, monkeypatch: pytest.MonkeyPatch): from concurrent.futures import ThreadPoolExecutor From 76f87bcddbaca027bfc9f242032f41b8ea6eff6f Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:42:08 +0000 Subject: [PATCH 6/6] Remove duplicate callback contract wording Removed redundant line from browser callback contract documentation. --- backend/app/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index 9c153c4..b2ad56d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -760,7 +760,6 @@ def identity_callback( """ Browser callback contract: code + state only. - Browser callback contract remains code + state only. Backend atomically reserves the pending state, exchanges code with the WordPress bridge, restores the state only for transient bridge failures, resolves/creates CalorieApp user identity, and issues the session cookie.