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} +

+ )} ); }