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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 117 additions & 16 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
)


Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
17 changes: 16 additions & 1 deletion backend/app/schemas.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
Loading