diff --git a/README.md b/README.md
index 6e87e34..205f4e0 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,13 @@ The browser calls the frontend's same-origin `/api/backend` proxy. The proxy
forwards only the supported CalorieApp endpoints to the configured backend and
keeps mobile authentication sessions first-party.
+Xaman sign-in opens in a separate tab while the original CalorieApp tab waits
+for a short-lived, one-time browser handoff. If Android returns from Xaman in a
+different default browser, the callback browser receives its normal session and
+the original tab securely claims a separate session for the same user. Only
+hashes of the handoff proof are stored, the proof is never sent through
+WordPress/Xaman URLs, and it cannot be claimed by a third browser after use.
+
Create frontend/.env.local from the template before running the frontend.
Frontend default local URL: http://localhost:3000
diff --git a/backend/app/main.py b/backend/app/main.py
index c724454..8e678a5 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -29,17 +29,24 @@
FoodSearchResponse,
IdentityCallbackRequest,
IdentityClaimsResponse,
+ IdentityLoginStatusRequest,
+ IdentityLoginStatusResponse,
IdentityStateValidationRequest,
IdentityStateValidationResponse,
IdentityStartResponse,
LogoutResponse,
)
from .services.identity import (
+ claim_origin_login_handoff,
cleanup_pending_login_states,
+ complete_origin_login_handoff,
consume_pending_login_state,
+ create_origin_login_handoff,
create_pending_login_state,
+ fail_origin_login_handoff,
get_or_create_user_from_external_identity,
validate_pending_login_state,
+ validate_origin_login_handoff,
)
from .services.open_food_facts import search_food_products
@@ -523,6 +530,18 @@ def _create_auth_session(
raise RuntimeError("Unable to allocate unique session token")
+def _set_auth_session_cookie(response: Response, session_token: str) -> None:
+ response.set_cookie(
+ key=SESSION_COOKIE_NAME,
+ value=session_token,
+ httponly=True,
+ secure=_SESSION_COOKIE_SECURE,
+ samesite=_SESSION_COOKIE_SAMESITE,
+ path="/",
+ max_age=SESSION_ABSOLUTE_LIFETIME_SECONDS,
+ )
+
+
def _resolve_auth_session(
session: Session,
session_token: str,
@@ -672,6 +691,11 @@ def identity_login_start(session: DbSession) -> IdentityStartResponse:
state_lifetime_seconds=_LOGIN_STATE_LIFETIME_SECONDS,
post_login_redirect=_CALORIEAPP_POST_LOGIN_REDIRECT,
)
+ browser_handoff_token, _ = create_origin_login_handoff(
+ session=session,
+ state=state,
+ lifetime_seconds=_LOGIN_STATE_LIFETIME_SECONDS,
+ )
wordpress_signin_url = _build_wordpress_signin_url(state)
logger.info("Login flow started (expires_at=%s)", pending.expires_at)
@@ -680,6 +704,7 @@ def identity_login_start(session: DbSession) -> IdentityStartResponse:
state=state,
expires_at=pending.expires_at,
wordpress_signin_url=wordpress_signin_url,
+ browser_handoff_token=browser_handoff_token,
)
@@ -753,14 +778,21 @@ def identity_callback(
raise HTTPException(status_code=400, detail="Login state already consumed")
raise HTTPException(status_code=400, detail="Unknown login state")
- claims = _exchange_code_for_claims(code=code, state=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,
- external_subject=claims.external_subject,
- xrpl_address=claims.xrpl_address,
- )
+ user, created = get_or_create_user_from_external_identity(
+ session=session,
+ provider=_IDENTITY_PROVIDER,
+ external_subject=claims.external_subject,
+ xrpl_address=claims.xrpl_address,
+ )
+ except Exception:
+ fail_origin_login_handoff(session, state)
+ raise
+
+ if not complete_origin_login_handoff(session, state, user.id):
+ logger.warning("Origin browser handoff could not be completed")
_cleanup_auth_sessions(session)
replaced_session: Optional[AuthSessionDB] = None
@@ -774,15 +806,7 @@ def identity_callback(
replaced_session=replaced_session,
)
- response.set_cookie(
- key=SESSION_COOKIE_NAME,
- value=session_token,
- httponly=True,
- secure=_SESSION_COOKIE_SECURE,
- samesite=_SESSION_COOKIE_SAMESITE,
- path="/",
- max_age=SESSION_ABSOLUTE_LIFETIME_SECONDS,
- )
+ _set_auth_session_cookie(response, session_token)
logger.info("Identity callback succeeded (created=%s)", created)
@@ -793,6 +817,83 @@ def identity_callback(
)
+@app.post("/api/identity/login/status", response_model=IdentityLoginStatusResponse)
+def identity_login_status(
+ payload: IdentityLoginStatusRequest,
+ session: DbSession,
+ request: Request,
+ response: Response,
+) -> IdentityLoginStatusResponse:
+ """Let only the browser that started login claim the completed identity."""
+ state = payload.state.strip()
+ handoff_token = payload.browser_handoff_token.strip()
+ if not _is_valid_state_format(state) or not _is_valid_state_format(handoff_token):
+ raise HTTPException(status_code=400, detail="Invalid login status proof")
+
+ cleanup_pending_login_states(session)
+ valid, status, existing = validate_origin_login_handoff(
+ session,
+ state,
+ handoff_token,
+ )
+ if not valid:
+ if status == "expired":
+ raise HTTPException(status_code=410, detail="Login handoff expired")
+ raise HTTPException(status_code=404, detail="Login handoff not found")
+
+ if status == "pending":
+ return IdentityLoginStatusResponse(status="pending")
+ if status == "failed":
+ return IdentityLoginStatusResponse(status="failed")
+
+ if status == "claimed" and existing is not None and existing.calorieapp_user_id:
+ existing_token = request.cookies.get(SESSION_COOKIE_NAME)
+ if existing_token:
+ existing_user, _, reason = _resolve_auth_session(session, existing_token)
+ if reason == "ok" and existing_user is not None and existing_user.id == existing.calorieapp_user_id:
+ return IdentityLoginStatusResponse(
+ status="authenticated",
+ redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT,
+ )
+ raise HTTPException(status_code=409, detail="Login handoff already claimed")
+
+ claimed, claim_status, handoff = claim_origin_login_handoff(
+ session,
+ state,
+ handoff_token,
+ )
+ if not claimed or handoff is None or not handoff.calorieapp_user_id:
+ if claim_status == "pending":
+ return IdentityLoginStatusResponse(status="pending")
+ if claim_status == "failed":
+ return IdentityLoginStatusResponse(status="failed")
+ raise HTTPException(status_code=409, detail="Login handoff already claimed")
+
+ _cleanup_auth_sessions(session)
+ replaced_session: Optional[AuthSessionDB] = None
+ existing_token = request.cookies.get(SESSION_COOKIE_NAME)
+ if existing_token:
+ existing_user, current_session, reason = _resolve_auth_session(session, existing_token)
+ if reason == "ok" and existing_user is not None and existing_user.id == handoff.calorieapp_user_id:
+ return IdentityLoginStatusResponse(
+ status="authenticated",
+ redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT,
+ )
+ replaced_session = current_session
+
+ session_token, _ = _create_auth_session(
+ session=session,
+ user_id=handoff.calorieapp_user_id,
+ replaced_session=replaced_session,
+ )
+ _set_auth_session_cookie(response, session_token)
+
+ return IdentityLoginStatusResponse(
+ status="authenticated",
+ redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT,
+ )
+
+
@app.get("/api/identity/me", response_model=CurrentUserResponse)
def identity_me(
current_user: CurrentUser,
diff --git a/backend/app/models.py b/backend/app/models.py
index 5afc47a..515e40e 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -96,6 +96,27 @@ class PendingLoginStateDB(SQLModel, table=True):
post_login_redirect: Optional[str] = Field(default=None, max_length=255)
+class OriginLoginHandoffDB(SQLModel, table=True):
+ """One-time proof that lets the browser which started login claim a session."""
+
+ __tablename__ = "originloginhandoff"
+
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
+ state_hash: str = Field(max_length=64, unique=True, index=True)
+ handoff_token_hash: str = Field(max_length=64, index=True)
+ status: str = Field(default="pending", max_length=20, index=True)
+ calorieapp_user_id: Optional[str] = Field(
+ default=None,
+ foreign_key="calorieappuser.id",
+ index=True,
+ )
+ created_at: datetime = Field(default_factory=utc_now)
+ expires_at: datetime = Field(index=True)
+ completed_at: Optional[datetime] = Field(default=None)
+ claimed_at: Optional[datetime] = Field(default=None)
+ failure_code: Optional[str] = Field(default=None, max_length=40)
+
+
class AuthSessionDB(SQLModel, table=True):
"""Opaque server-side authentication session."""
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index a9f71b0..224a219 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -1,5 +1,5 @@
from datetime import UTC, datetime
-from typing import Optional
+from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -87,6 +87,7 @@ class IdentityStartResponse(BaseModel):
state: str
expires_at: datetime
wordpress_signin_url: str
+ browser_handoff_token: str
@field_validator("expires_at", mode="after")
@classmethod
@@ -114,6 +115,20 @@ class IdentityCallbackResponse(BaseModel):
redirect_to: str
+class IdentityLoginStatusRequest(BaseModel):
+ """Proof presented only by the browser that started the login."""
+
+ state: str = Field(..., min_length=32, max_length=255)
+ browser_handoff_token: str = Field(..., min_length=32, max_length=255)
+
+
+class IdentityLoginStatusResponse(BaseModel):
+ """Progress or completion result for the original browser tab."""
+
+ status: Literal["pending", "failed", "authenticated"]
+ redirect_to: Optional[str] = None
+
+
class IdentityExchangeRequest(BaseModel):
"""Request from CalorieApp frontend to exchange code server-to-server."""
diff --git a/backend/app/services/identity.py b/backend/app/services/identity.py
index 14f5adf..a0ff8b7 100644
--- a/backend/app/services/identity.py
+++ b/backend/app/services/identity.py
@@ -23,6 +23,7 @@
AuthorizationCodeDB,
CalorieAppUserDB,
ExternalIdentityDB,
+ OriginLoginHandoffDB,
PendingLoginStateDB,
utc_now,
)
@@ -34,6 +35,7 @@
AUTH_CODE_LENGTH = 32 # bytes
AUTH_CODE_LIFETIME_SECONDS = 60 # 60 second lifetime
LOGIN_STATE_LENGTH = 48
+ORIGIN_HANDOFF_TOKEN_LENGTH = 48
LOGIN_STATE_LIFETIME_SECONDS = int(os.getenv("LOGIN_STATE_LIFETIME_SECONDS", "300"))
WORDPRESS_BRIDGE_SECRET = os.getenv("WORDPRESS_BRIDGE_SECRET", "")
@@ -69,6 +71,16 @@ def hash_login_state(state: str) -> str:
return hashlib.sha256(state.encode("utf-8")).hexdigest()
+def generate_origin_handoff_token() -> str:
+ """Generate the proof retained only by the browser that started login."""
+ return token_urlsafe(ORIGIN_HANDOFF_TOKEN_LENGTH)
+
+
+def hash_origin_handoff_token(token: str) -> str:
+ """Hash an origin-browser handoff token before persistent storage."""
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
def create_pending_login_state(
session: Session,
state_lifetime_seconds: int,
@@ -102,6 +114,141 @@ def create_pending_login_state(
raise RuntimeError("Unable to allocate unique login state")
+def create_origin_login_handoff(
+ session: Session,
+ state: str,
+ lifetime_seconds: int,
+) -> tuple[str, OriginLoginHandoffDB]:
+ """Create a browser-bound, one-time handoff for the login origin tab."""
+ created_at = utc_now()
+ expires_at = created_at + timedelta(seconds=lifetime_seconds)
+
+ for _ in range(3):
+ token = generate_origin_handoff_token()
+ token_hash = hash_origin_handoff_token(token)
+ existing = session.exec(
+ select(OriginLoginHandoffDB).where(
+ OriginLoginHandoffDB.handoff_token_hash == token_hash
+ )
+ ).first()
+ if existing:
+ continue
+
+ row = OriginLoginHandoffDB(
+ state_hash=hash_login_state(state),
+ handoff_token_hash=token_hash,
+ status="pending",
+ created_at=created_at,
+ expires_at=expires_at,
+ )
+ session.add(row)
+ session.commit()
+ session.refresh(row)
+ return token, row
+
+ raise RuntimeError("Unable to allocate unique origin login handoff")
+
+
+def validate_origin_login_handoff(
+ session: Session,
+ state: str,
+ handoff_token: str,
+) -> tuple[bool, str, Optional[OriginLoginHandoffDB]]:
+ """Validate state + origin proof without exposing which value was wrong."""
+ row = session.exec(
+ select(OriginLoginHandoffDB).where(
+ OriginLoginHandoffDB.state_hash == hash_login_state(state)
+ )
+ ).first()
+
+ if row is None:
+ return False, "unknown", None
+
+ supplied_hash = hash_origin_handoff_token(handoff_token)
+ if not compare_digest(row.handoff_token_hash, supplied_hash):
+ return False, "unknown", None
+
+ expires_at = (
+ row.expires_at.replace(tzinfo=UTC)
+ if row.expires_at.tzinfo is None
+ else row.expires_at.astimezone(UTC)
+ )
+ if expires_at < datetime.now(UTC):
+ return False, "expired", row
+
+ return True, row.status, row
+
+
+def complete_origin_login_handoff(
+ session: Session,
+ state: str,
+ calorieapp_user_id: str,
+) -> bool:
+ """Mark the origin handoff ready after the verified callback resolves a user."""
+ now = utc_now()
+ updated = session.exec(
+ update(OriginLoginHandoffDB)
+ .where(OriginLoginHandoffDB.state_hash == hash_login_state(state))
+ .where(OriginLoginHandoffDB.status == "pending")
+ .where(OriginLoginHandoffDB.expires_at >= now)
+ .values(
+ status="completed",
+ calorieapp_user_id=calorieapp_user_id,
+ completed_at=now,
+ )
+ )
+ session.commit()
+ return updated.rowcount == 1
+
+
+def fail_origin_login_handoff(
+ session: Session,
+ state: str,
+ failure_code: str = "callback_failed",
+) -> None:
+ """Let the origin tab stop waiting when callback processing cannot finish."""
+ session.exec(
+ update(OriginLoginHandoffDB)
+ .where(OriginLoginHandoffDB.state_hash == hash_login_state(state))
+ .where(OriginLoginHandoffDB.status == "pending")
+ .values(status="failed", failure_code=failure_code[:40])
+ )
+ session.commit()
+
+
+def claim_origin_login_handoff(
+ session: Session,
+ state: str,
+ handoff_token: str,
+) -> tuple[bool, str, Optional[OriginLoginHandoffDB]]:
+ """Atomically claim a completed origin handoff once."""
+ valid, status, row = validate_origin_login_handoff(session, state, handoff_token)
+ if not valid or row is None:
+ return False, status, row
+ if status != "completed":
+ return False, status, row
+ if not row.calorieapp_user_id:
+ return False, "failed", row
+
+ now = utc_now()
+ updated = session.exec(
+ update(OriginLoginHandoffDB)
+ .where(OriginLoginHandoffDB.id == row.id)
+ .where(OriginLoginHandoffDB.status == "completed")
+ .where(OriginLoginHandoffDB.claimed_at.is_(None))
+ .where(OriginLoginHandoffDB.expires_at >= now)
+ .values(status="claimed", claimed_at=now)
+ )
+ session.commit()
+
+ if updated.rowcount != 1:
+ refreshed = session.get(OriginLoginHandoffDB, row.id)
+ return False, "claimed", refreshed
+
+ session.refresh(row)
+ return True, "claimed", row
+
+
def validate_pending_login_state(
session: Session,
state: str,
@@ -162,6 +309,9 @@ def cleanup_pending_login_states(session: Session) -> None:
session.exec(
delete(PendingLoginStateDB).where(PendingLoginStateDB.expires_at < now)
)
+ session.exec(
+ delete(OriginLoginHandoffDB).where(OriginLoginHandoffDB.expires_at < now)
+ )
session.commit()
diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py
index 6b2e3cb..72f8487 100644
--- a/backend/tests/test_identity.py
+++ b/backend/tests/test_identity.py
@@ -15,20 +15,27 @@
AuthorizationCodeDB,
CalorieAppUserDB,
ExternalIdentityDB,
+ OriginLoginHandoffDB,
PendingLoginStateDB,
SQLModel,
)
from app.services.identity import (
+ claim_origin_login_handoff,
cleanup_pending_login_states,
+ complete_origin_login_handoff,
consume_pending_login_state,
+ create_origin_login_handoff,
create_pending_login_state,
create_authorization_code,
+ fail_origin_login_handoff,
hash_login_state,
+ hash_origin_handoff_token,
generate_authorization_code,
generate_login_state,
generate_login_session_id,
get_or_create_user_from_external_identity,
get_user_by_id,
+ validate_origin_login_handoff,
validate_pending_login_state,
hash_authorization_code,
validate_and_consume_authorization_code,
@@ -494,3 +501,117 @@ def test_state_persists_across_sessions(self):
assert is_valid
assert reason == "ok"
engine.dispose()
+
+
+class TestOriginLoginHandoff:
+ def test_handoff_proof_is_random_and_only_persisted_as_a_hash(
+ self,
+ test_session: Session,
+ ):
+ state, _ = create_pending_login_state(test_session, state_lifetime_seconds=300)
+ token, row = create_origin_login_handoff(
+ test_session,
+ state,
+ lifetime_seconds=300,
+ )
+
+ assert len(token) >= 32
+ assert row.state_hash == hash_login_state(state)
+ assert row.state_hash != state
+ assert row.handoff_token_hash == hash_origin_handoff_token(token)
+ assert row.handoff_token_hash != token
+
+ valid, status, persisted = validate_origin_login_handoff(
+ test_session,
+ state,
+ token,
+ )
+ assert valid
+ assert status == "pending"
+ assert persisted is not None
+
+ def test_wrong_handoff_proof_is_rejected_without_revealing_which_value_failed(
+ self,
+ test_session: Session,
+ ):
+ state, _ = create_pending_login_state(test_session, state_lifetime_seconds=300)
+ create_origin_login_handoff(test_session, state, lifetime_seconds=300)
+
+ valid, status, row = validate_origin_login_handoff(
+ test_session,
+ state,
+ "wrong-proof-value-that-is-still-long-enough-for-the-test",
+ )
+
+ assert not valid
+ assert status == "unknown"
+ assert row is None
+
+ def test_completed_handoff_can_only_be_claimed_once(self, test_session: Session):
+ state, _ = create_pending_login_state(test_session, state_lifetime_seconds=300)
+ token, _ = create_origin_login_handoff(
+ test_session,
+ state,
+ lifetime_seconds=300,
+ )
+ user = CalorieAppUserDB(status="active")
+ test_session.add(user)
+ test_session.commit()
+ test_session.refresh(user)
+
+ assert complete_origin_login_handoff(test_session, state, user.id)
+
+ first_ok, first_status, first_row = claim_origin_login_handoff(
+ test_session,
+ state,
+ token,
+ )
+ second_ok, second_status, second_row = claim_origin_login_handoff(
+ test_session,
+ state,
+ token,
+ )
+
+ assert first_ok
+ assert first_status == "claimed"
+ assert first_row is not None
+ assert first_row.calorieapp_user_id == user.id
+ assert not second_ok
+ assert second_status == "claimed"
+ assert second_row is not None
+
+ def test_failed_handoff_stops_waiting_browser(self, test_session: Session):
+ state, _ = create_pending_login_state(test_session, state_lifetime_seconds=300)
+ token, _ = create_origin_login_handoff(
+ test_session,
+ state,
+ lifetime_seconds=300,
+ )
+
+ fail_origin_login_handoff(test_session, state, "bridge_failed")
+ valid, status, row = validate_origin_login_handoff(
+ test_session,
+ state,
+ token,
+ )
+
+ assert valid
+ assert status == "failed"
+ assert row is not None
+ assert row.failure_code == "bridge_failed"
+
+ def test_cleanup_removes_expired_handoff(self, test_session: Session):
+ state, _ = create_pending_login_state(test_session, state_lifetime_seconds=300)
+ _, row = create_origin_login_handoff(
+ test_session,
+ state,
+ lifetime_seconds=300,
+ )
+ row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
+ row_id = row.id
+ test_session.add(row)
+ test_session.commit()
+
+ cleanup_pending_login_states(test_session)
+
+ assert test_session.get(OriginLoginHandoffDB, row_id) is None
diff --git a/backend/tests/test_identity_endpoints.py b/backend/tests/test_identity_endpoints.py
index 3fdf53f..dc45219 100644
--- a/backend/tests/test_identity_endpoints.py
+++ b/backend/tests/test_identity_endpoints.py
@@ -15,9 +15,17 @@
import app.database as db_module
import app.main as main_module
from app.main import app
-from app.models import AuthSessionDB, CalorieAppUserDB, ExternalIdentityDB, FoodLogDB, PendingLoginStateDB, SQLModel
+from app.models import (
+ AuthSessionDB,
+ CalorieAppUserDB,
+ ExternalIdentityDB,
+ FoodLogDB,
+ OriginLoginHandoffDB,
+ PendingLoginStateDB,
+ SQLModel,
+)
from app.schemas import IdentityClaimsResponse
-from app.services.identity import hash_login_state
+from app.services.identity import hash_login_state, hash_origin_handoff_token
from sqlmodel import Session, create_engine, select
from sqlmodel.pool import StaticPool
@@ -302,10 +310,13 @@ def test_login_start(self, client: TestClient):
assert "state" in data
assert "expires_at" in data
assert "wordpress_signin_url" in data
+ assert "browser_handoff_token" in data
assert data["wordpress_signin_url"].startswith("https://calorietoken.net/?xl-signin&redirect=")
assert "%2F%3Fcalorieapp_authorize%3D1%26state%3D" in data["wordpress_signin_url"]
assert "state%3D" in data["wordpress_signin_url"]
assert len(data["state"]) >= 32
+ assert len(data["browser_handoff_token"]) >= 32
+ assert data["browser_handoff_token"] not in data["wordpress_signin_url"]
assert response.headers["cache-control"] == "no-store"
assert response.headers["pragma"] == "no-cache"
assert data["expires_at"].endswith("Z")
@@ -724,7 +735,9 @@ def test_bridge_state_validate_rejects_when_secret_not_configured(self, client:
assert response.status_code == 500
def test_login_state_is_persisted_and_not_stored_plaintext(self, client: TestClient):
- state = client.post("/api/identity/login/start").json()["state"]
+ start = client.post("/api/identity/login/start").json()
+ state = start["state"]
+ handoff_token = start["browser_handoff_token"]
with Session(db_module.engine) as session:
row = session.exec(
@@ -735,6 +748,17 @@ def test_login_state_is_persisted_and_not_stored_plaintext(self, client: TestCli
assert row.state_hash == hash_login_state(state)
assert row.state_hash != state
+ with Session(db_module.engine) as session:
+ handoff = session.exec(
+ select(OriginLoginHandoffDB).where(
+ OriginLoginHandoffDB.state_hash == hash_login_state(state)
+ )
+ ).first()
+
+ assert handoff is not None
+ assert handoff.handoff_token_hash == hash_origin_handoff_token(handoff_token)
+ assert handoff.handoff_token_hash != handoff_token
+
class TestFoodLogAuthentication:
"""Test that food log endpoints require authentication."""
@@ -1115,7 +1139,9 @@ def test_callback_can_finish_in_a_different_browser(self, monkeypatch: pytest.Mo
)
with TestClient(app) as starting_browser, TestClient(app) as return_browser:
- state = starting_browser.post("/api/identity/login/start").json()["state"]
+ start = starting_browser.post("/api/identity/login/start").json()
+ state = start["state"]
+ handoff_token = start["browser_handoff_token"]
callback = return_browser.post(
"/api/identity/callback",
@@ -1126,6 +1152,126 @@ def test_callback_can_finish_in_a_different_browser(self, monkeypatch: pytest.Mo
assert return_browser.get("/api/identity/me").status_code == 200
assert starting_browser.get("/api/identity/me").status_code == 401
+ handoff = starting_browser.post(
+ "/api/identity/login/status",
+ json={
+ "state": state,
+ "browser_handoff_token": handoff_token,
+ },
+ )
+ assert handoff.status_code == 200
+ assert handoff.json()["status"] == "authenticated"
+
+ starting_me = starting_browser.get("/api/identity/me")
+ return_me = return_browser.get("/api/identity/me")
+ assert starting_me.status_code == 200
+ assert return_me.status_code == 200
+ assert starting_me.json()["user_id"] == return_me.json()["user_id"]
+
+ def test_origin_handoff_is_pending_before_callback(self, client: TestClient):
+ start = client.post("/api/identity/login/start").json()
+
+ status = client.post(
+ "/api/identity/login/status",
+ json={
+ "state": start["state"],
+ "browser_handoff_token": start["browser_handoff_token"],
+ },
+ )
+
+ assert status.status_code == 200
+ assert status.json() == {"status": "pending", "redirect_to": None}
+ assert SESSION_COOKIE_NAME not in status.cookies
+
+ def test_origin_handoff_rejects_wrong_browser_proof(self, client: TestClient):
+ start = client.post("/api/identity/login/start").json()
+
+ status = client.post(
+ "/api/identity/login/status",
+ json={
+ "state": start["state"],
+ "browser_handoff_token": "wrong-proof-value-that-is-long-enough-0123456789",
+ },
+ )
+
+ assert status.status_code == 404
+ assert SESSION_COOKIE_NAME not in status.cookies
+
+ def test_origin_handoff_is_single_use_across_browsers(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ):
+ _set_session_cookie_security_config(
+ monkeypatch,
+ secure=False,
+ environment="local",
+ )
+ monkeypatch.setattr(
+ main_module,
+ "_exchange_code_for_claims",
+ lambda code, state: self._stub_claims(),
+ )
+
+ with (
+ TestClient(app) as starting_browser,
+ TestClient(app) as return_browser,
+ TestClient(app) as replay_browser,
+ ):
+ start = starting_browser.post("/api/identity/login/start").json()
+ callback = return_browser.post(
+ "/api/identity/callback",
+ json={"code": "bridge-code", "state": start["state"]},
+ )
+ assert callback.status_code == 200
+
+ proof = {
+ "state": start["state"],
+ "browser_handoff_token": start["browser_handoff_token"],
+ }
+ first_claim = starting_browser.post(
+ "/api/identity/login/status",
+ json=proof,
+ )
+ replay_claim = replay_browser.post(
+ "/api/identity/login/status",
+ json=proof,
+ )
+
+ assert first_claim.status_code == 200
+ assert first_claim.json()["status"] == "authenticated"
+ assert replay_claim.status_code == 409
+ assert replay_browser.get("/api/identity/me").status_code == 401
+
+ def test_failed_callback_marks_origin_handoff_failed(
+ 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",
+ )
+
+ monkeypatch.setattr(main_module, "_exchange_code_for_claims", _raise_exchange_failure)
+ start = client.post("/api/identity/login/start").json()
+
+ callback = client.post(
+ "/api/identity/callback",
+ json={"code": "bridge-code", "state": start["state"]},
+ )
+ status = client.post(
+ "/api/identity/login/status",
+ json={
+ "state": start["state"],
+ "browser_handoff_token": start["browser_handoff_token"],
+ },
+ )
+
+ assert callback.status_code == 502
+ assert status.status_code == 200
+ assert status.json()["status"] == "failed"
+
def test_concurrent_callback_state_use_allows_only_one_success(self, monkeypatch: pytest.MonkeyPatch):
from concurrent.futures import ThreadPoolExecutor
diff --git a/frontend/app/api/backend/[...path]/route.ts b/frontend/app/api/backend/[...path]/route.ts
index b2955ac..038e465 100644
--- a/frontend/app/api/backend/[...path]/route.ts
+++ b/frontend/app/api/backend/[...path]/route.ts
@@ -12,7 +12,7 @@ const ROUTE_METHODS: Array<{ pattern: RegExp; methods: Set }> = [
{ pattern: /^logs$/, methods: new Set(["GET", "DELETE"]) },
{ pattern: /^logs\/[^/]+$/, methods: new Set(["DELETE"]) },
{
- pattern: /^api\/identity\/(login\/start|callback|logout)$/,
+ pattern: /^api\/identity\/(login\/(start|status)|callback|logout)$/,
methods: new Set(["POST"]),
},
{ pattern: /^api\/identity\/me$/, methods: new Set(["GET"]) },
diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx
index 51c55b8..f233bfb 100644
--- a/frontend/app/auth/callback/page.tsx
+++ b/frontend/app/auth/callback/page.tsx
@@ -113,7 +113,14 @@ function AuthCallbackContent() {
const payload = await submitCallbackOnce(code, state);
if (!cancelled) {
- router.replace(safeLocalRedirect(payload.redirect_to));
+ window.sessionStorage.setItem(
+ "calorieapp-login-return",
+ "default-browser"
+ );
+ const redirectTo = safeLocalRedirect(payload.redirect_to);
+ router.replace(
+ `/auth/complete?next=${encodeURIComponent(redirectTo)}`
+ );
}
} catch (requestError) {
if (!cancelled) {
diff --git a/frontend/app/auth/complete/page.tsx b/frontend/app/auth/complete/page.tsx
new file mode 100644
index 0000000..3f8b14d
--- /dev/null
+++ b/frontend/app/auth/complete/page.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { Suspense, useMemo } from "react";
+
+function safeLocalRedirect(value: string | null): string {
+ if (
+ !value ||
+ !value.startsWith("/") ||
+ value.startsWith("//") ||
+ value.includes("\\")
+ ) {
+ return "/";
+ }
+
+ try {
+ const base = new URL("https://calorieapp.invalid");
+ const target = new URL(value, base);
+ if (target.origin !== base.origin) {
+ return "/";
+ }
+ return `${target.pathname}${target.search}${target.hash}`;
+ } catch {
+ return "/";
+ }
+}
+
+function LoginCompleteContent() {
+ const params = useSearchParams();
+ const next = useMemo(
+ () => safeLocalRedirect(params.get("next")),
+ [params]
+ );
+
+ return (
+
+
+
+ ✓
+
+
+ Sign-in completed
+
+
+ Your original CalorieApp tab is signing in automatically. You can
+ close this tab and return there, even if Xaman opened this page in
+ your phone's default browser.
+
+
+ You are also signed in in this browser, so continuing here is safe.
+
+
+ Continue in this browser
+
+
+
+ );
+}
+
+export default function LoginCompletePage() {
+ return (
+
+
+ Completing sign-in...
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/app/auth/launching/page.tsx b/frontend/app/auth/launching/page.tsx
new file mode 100644
index 0000000..6300739
--- /dev/null
+++ b/frontend/app/auth/launching/page.tsx
@@ -0,0 +1,20 @@
+export default function XamanLaunchingPage() {
+ return (
+
+
+
+ Preparing Xaman sign-in
+
+
+ Keep your original CalorieApp tab open. After approval, Xaman may
+ return in your default browser; the original tab will finish signing
+ in automatically.
+
+
+
+
+ );
+}
diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx
index 2072cef..40ca2d5 100644
--- a/frontend/components/XamanLoginPanel.tsx
+++ b/frontend/components/XamanLoginPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { announceAuthState } from "@/components/authEvents";
import {
backendRequest,
@@ -17,16 +17,114 @@ type LoginStartResponse = {
state: string;
expires_at: string;
wordpress_signin_url: string;
+ browser_handoff_token: string;
+};
+
+type LoginStatusResponse = {
+ status: "pending" | "failed" | "authenticated";
+ redirect_to?: string | null;
};
const BACKEND_BASE_URL = "/api/backend";
+const LOGIN_STATUS_POLL_INTERVAL_MS = 5_000;
+const LOGIN_STATUS_FALLBACK_LIFETIME_MS = 5 * 60_000;
+const LOGIN_STATUS_RATE_LIMIT_DELAY_MS = 15_000;
+const LOGIN_STATUS_MAX_RETRY_AFTER_MS = 60_000;
+
+function delay(milliseconds: number, signal: AbortSignal) {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(signal.reason ?? new Error("Login cancelled"));
+ return;
+ }
+
+ const onAbort = () => {
+ window.clearTimeout(timeoutId);
+ reject(signal.reason ?? new Error("Login cancelled"));
+ };
+ const timeoutId = window.setTimeout(() => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ }, milliseconds);
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+}
+
+async function waitForOriginLogin(
+ state: string,
+ browserHandoffToken: string,
+ expiresAt: string,
+ signal: AbortSignal
+) {
+ const parsedExpiry = Date.parse(expiresAt);
+ const deadline = Number.isFinite(parsedExpiry)
+ ? parsedExpiry
+ : Date.now() + LOGIN_STATUS_FALLBACK_LIFETIME_MS;
+
+ while (Date.now() < deadline) {
+ await delay(LOGIN_STATUS_POLL_INTERVAL_MS, signal);
+
+ let response: Response;
+ try {
+ response = await backendRequest(
+ `${BACKEND_BASE_URL}/api/identity/login/status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ state,
+ browser_handoff_token: browserHandoffToken,
+ }),
+ signal,
+ }
+ );
+ } catch {
+ if (signal.aborted) {
+ throw signal.reason ?? new Error("Login cancelled");
+ }
+ continue;
+ }
+
+ if (response.status === 429) {
+ const retryAfterSeconds = Number(
+ response.headers.get("retry-after")?.trim()
+ );
+ const retryDelay = Number.isFinite(retryAfterSeconds)
+ ? Math.min(
+ Math.max(0, retryAfterSeconds * 1_000),
+ LOGIN_STATUS_MAX_RETRY_AFTER_MS
+ )
+ : LOGIN_STATUS_RATE_LIMIT_DELAY_MS;
+ await delay(retryDelay, signal);
+ continue;
+ }
+ if ([502, 503, 504].includes(response.status)) {
+ continue;
+ }
+ if (!response.ok) {
+ throw new Error(`Login status failed with ${response.status}`);
+ }
+
+ const payload = (await response.json()) as LoginStatusResponse;
+ if (payload.status === "authenticated") {
+ return;
+ }
+ if (payload.status === "failed") {
+ throw new Error("Xaman callback failed");
+ }
+ }
+
+ throw new Error("Login handoff expired");
+}
export function XamanLoginPanel() {
const [isLoading, setIsLoading] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [error, setError] = useState(null);
const [loginStatus, setLoginStatus] = useState(null);
+ const [successNotice, setSuccessNotice] = useState(null);
const [currentUser, setCurrentUser] = useState(null);
+ const loginAbortController = useRef(null);
const refreshCurrentUser = useCallback(async () => {
try {
@@ -49,21 +147,42 @@ export function XamanLoginPanel() {
useEffect(() => {
refreshCurrentUser();
+
+ if (window.sessionStorage.getItem("calorieapp-login-return")) {
+ window.sessionStorage.removeItem("calorieapp-login-return");
+ setSuccessNotice(
+ "Sign-in completed in your default browser. You can continue safely in this tab."
+ );
+ }
+
+ return () => {
+ loginAbortController.current?.abort();
+ };
}, [refreshCurrentUser]);
async function handleLogin() {
+ const loginWindow = window.open(
+ "/auth/launching",
+ "calorieapp-xaman-login"
+ );
+ const controller = new AbortController();
+ loginAbortController.current?.abort();
+ loginAbortController.current = controller;
+
setError(null);
+ setSuccessNotice(null);
setIsLoading(true);
setLoginStatus(
- "Connecting securely. After inactivity, the free demo can take up to 3 minutes to start. Please keep this page open."
+ "Preparing a separate Xaman sign-in tab. Keep this CalorieApp tab open; it will finish automatically."
);
try {
- await waitForBackendReady(BACKEND_BASE_URL);
+ await waitForBackendReady(BACKEND_BASE_URL, controller.signal);
setLoginStatus("Service ready. Opening Xaman...");
const response = await backendRequest(`${BACKEND_BASE_URL}/api/identity/login/start`, {
method: "POST",
+ signal: controller.signal,
});
if (!response.ok) {
@@ -71,16 +190,57 @@ export function XamanLoginPanel() {
}
const data = (await response.json()) as LoginStartResponse;
- if (!data.wordpress_signin_url) {
- throw new Error("Missing signin URL");
+ if (!data.wordpress_signin_url || !data.browser_handoff_token) {
+ throw new Error("Missing signin handoff data");
+ }
+
+ if (!loginWindow || loginWindow.closed) {
+ window.location.assign(data.wordpress_signin_url);
+ return;
}
- window.location.assign(data.wordpress_signin_url);
+ loginWindow.opener = null;
+ loginWindow.location.replace(data.wordpress_signin_url);
+ setLoginStatus(
+ "Approve the request in Xaman. It may return in your default browser; this original tab will sign in automatically."
+ );
+
+ await waitForOriginLogin(
+ data.state,
+ data.browser_handoff_token,
+ data.expires_at,
+ controller.signal
+ );
+
+ try {
+ loginWindow.close();
+ } catch {
+ // A browser may keep the external Xaman tab open. The login still succeeded.
+ }
+
+ await refreshCurrentUser();
+ announceAuthState(true);
+ setSuccessNotice(
+ "Sign-in completed. You can continue in this original CalorieApp tab."
+ );
+ setLoginStatus(null);
+ setIsLoading(false);
} catch (requestError) {
+ if (controller.signal.aborted) {
+ return;
+ }
+
+ try {
+ if (loginWindow && !loginWindow.closed) {
+ loginWindow.close();
+ }
+ } catch {
+ // The external sign-in window may no longer be script-controllable.
+ }
setError(
backendUnavailableMessage(
requestError,
- "Unable to start Xaman login right now. Please try again."
+ "Sign-in could not be confirmed in this tab. You can continue in the browser Xaman opened, or try again."
)
);
setLoginStatus(null);
@@ -131,9 +291,9 @@ export function XamanLoginPanel() {
Sign in securely to save, review, and manage your personal food log.
- On your phone, the Xaman app opens outside this browser. After approval,
- your phone may return in its default browser; sign-in completes securely
- in that browser.
+ A separate sign-in tab opens. Xaman may return in your phone's default
+ browser, while this original CalorieApp tab remains available and signs
+ in automatically.
{currentUser ? (
@@ -176,6 +336,16 @@ export function XamanLoginPanel() {
{error}
)}
+
+ {successNotice && (
+
+ {successNotice}
+
+ )}
);
}