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
42 changes: 34 additions & 8 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from sqlmodel import Session, select

from .database import get_session, init_db
from .locales import resolve_locale
from .models import AuthSessionDB, BridgeAuthNonceDB, CalorieAppUserDB, FoodLogDB
from .schemas import (
CurrentUserResponse,
Expand All @@ -31,6 +32,7 @@
IdentityClaimsResponse,
IdentityLoginStatusRequest,
IdentityLoginStatusResponse,
IdentityStartRequest,
IdentityStateValidationRequest,
IdentityStateValidationResponse,
IdentityStartResponse,
Expand All @@ -44,6 +46,7 @@
create_origin_login_handoff,
create_pending_login_state,
fail_origin_login_handoff,
get_pending_login_locale,
get_or_create_user_from_external_identity,
restore_pending_login_state_after_transient_failure,
validate_pending_login_state,
Expand Down Expand Up @@ -619,10 +622,11 @@ def _is_valid_state_format(state: str) -> bool:
return all(ch.isalnum() or ch in "-_.~" for ch in state)


def _build_wordpress_signin_url(state: str) -> str:
def _build_wordpress_signin_url(state: str, locale: str) -> str:
parsed = urlsplit(_WORDPRESS_BRIDGE_AUTHORIZE_URL)
query_items = parse_qsl(parsed.query, keep_blank_values=True)
query_items.append(("state", state))
query_items.append(("locale", locale))
bridge_authorize_url = urlunsplit(
(
parsed.scheme,
Expand Down Expand Up @@ -679,25 +683,36 @@ def health() -> dict[str, str]:


@app.post("/api/identity/login/start", response_model=IdentityStartResponse)
def identity_login_start(session: DbSession) -> IdentityStartResponse:
def identity_login_start(
request: Request,
session: DbSession,
payload: Optional[IdentityStartRequest] = None,
) -> IdentityStartResponse:
"""
Start the login flow.

Creates a high-entropy state, stores a pending login transaction,
and returns the fixed WordPress XUMM signin URL that targets the bridge.
"""
requested_locale = (
payload.locale
if payload is not None and payload.locale
else request.headers.get("accept-language")
)
locale = resolve_locale(requested_locale)
cleanup_pending_login_states(session)
state, pending = create_pending_login_state(
session=session,
state_lifetime_seconds=_LOGIN_STATE_LIFETIME_SECONDS,
post_login_redirect=_CALORIEAPP_POST_LOGIN_REDIRECT,
locale=locale,
)
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)
wordpress_signin_url = _build_wordpress_signin_url(state, locale)

logger.info("Login flow started (expires_at=%s)", pending.expires_at)

Expand All @@ -706,6 +721,7 @@ def identity_login_start(session: DbSession) -> IdentityStartResponse:
expires_at=pending.expires_at,
wordpress_signin_url=wordpress_signin_url,
browser_handoff_token=browser_handoff_token,
locale=locale,
)


Expand Down Expand Up @@ -747,7 +763,11 @@ def identity_validate_pending_state(
raise HTTPException(status_code=400, detail="Login state already consumed")
raise HTTPException(status_code=400, detail="Unknown login state")

return IdentityStateValidationResponse(valid=True, expires_at=pending.expires_at)
return IdentityStateValidationResponse(
valid=True,
expires_at=pending.expires_at,
locale=get_pending_login_locale(session, payload.state),
)


@app.post("/api/identity/callback", response_model=IdentityCallbackResponse)
Expand All @@ -771,6 +791,7 @@ def identity_callback(
raise HTTPException(status_code=400, detail="code and state are required")

cleanup_pending_login_states(session)
locale = get_pending_login_locale(session, state)
consumed, reason = consume_pending_login_state(session, state)
if not consumed:
if reason == "expired":
Expand Down Expand Up @@ -827,6 +848,7 @@ def identity_callback(
user_id=user.id,
created=created,
redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT,
locale=locale,
)


Expand All @@ -844,6 +866,7 @@ def identity_login_status(
raise HTTPException(status_code=400, detail="Invalid login status proof")

cleanup_pending_login_states(session)
locale = get_pending_login_locale(session, state)
valid, status, existing = validate_origin_login_handoff(
session,
state,
Expand All @@ -855,9 +878,9 @@ def identity_login_status(
raise HTTPException(status_code=404, detail="Login handoff not found")

if status == "pending":
return IdentityLoginStatusResponse(status="pending")
return IdentityLoginStatusResponse(status="pending", locale=locale)
if status == "failed":
return IdentityLoginStatusResponse(status="failed")
return IdentityLoginStatusResponse(status="failed", locale=locale)

if status == "claimed" and existing is not None and existing.calorieapp_user_id:
existing_token = request.cookies.get(SESSION_COOKIE_NAME)
Expand All @@ -867,6 +890,7 @@ def identity_login_status(
return IdentityLoginStatusResponse(
status="authenticated",
redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT,
locale=locale,
)
raise HTTPException(status_code=409, detail="Login handoff already claimed")

Expand All @@ -877,9 +901,9 @@ def identity_login_status(
)
if not claimed or handoff is None or not handoff.calorieapp_user_id:
if claim_status == "pending":
return IdentityLoginStatusResponse(status="pending")
return IdentityLoginStatusResponse(status="pending", locale=locale)
if claim_status == "failed":
return IdentityLoginStatusResponse(status="failed")
return IdentityLoginStatusResponse(status="failed", locale=locale)
raise HTTPException(status_code=409, detail="Login handoff already claimed")

_cleanup_auth_sessions(session)
Expand All @@ -891,6 +915,7 @@ def identity_login_status(
return IdentityLoginStatusResponse(
status="authenticated",
redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT,
locale=locale,
)
replaced_session = current_session

Expand All @@ -904,6 +929,7 @@ def identity_login_status(
return IdentityLoginStatusResponse(
status="authenticated",
redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT,
locale=locale,
)


Expand Down
12 changes: 12 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ class PendingLoginStateDB(SQLModel, table=True):
post_login_redirect: Optional[str] = Field(default=None, max_length=255)


class PendingLoginLocaleDB(SQLModel, table=True):
"""Ephemeral locale context bound to a hashed login state."""

__tablename__ = "pendingloginlocale"

id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
state_hash: str = Field(max_length=64, unique=True, index=True)
locale: str = Field(default="en", max_length=16)
created_at: datetime = Field(default_factory=utc_now)
expires_at: datetime = Field(index=True)


class OriginLoginHandoffDB(SQLModel, table=True):
"""One-time proof that lets the browser which started login claim a session."""

Expand Down
12 changes: 12 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,22 @@ class FoodSearchResponse(BaseModel):
# =========================================================================


class IdentityStartRequest(BaseModel):
"""Optional browser locale context for a new login transaction."""

model_config = ConfigDict(str_strip_whitespace=True)

locale: Optional[str] = Field(default=None, max_length=64)


class IdentityStartResponse(BaseModel):
"""Response when starting the login flow."""

state: str
expires_at: datetime
wordpress_signin_url: str
browser_handoff_token: str
locale: str

@field_validator("expires_at", mode="after")
@classmethod
Expand All @@ -113,6 +122,7 @@ class IdentityCallbackResponse(BaseModel):
user_id: str
created: bool
redirect_to: str
locale: str


class IdentityLoginStatusRequest(BaseModel):
Expand All @@ -127,6 +137,7 @@ class IdentityLoginStatusResponse(BaseModel):

status: Literal["pending", "failed", "authenticated"]
redirect_to: Optional[str] = None
locale: str


class IdentityExchangeRequest(BaseModel):
Expand All @@ -148,6 +159,7 @@ class IdentityStateValidationResponse(BaseModel):

valid: bool
expires_at: datetime
locale: str

@field_validator("expires_at", mode="after")
@classmethod
Expand Down
25 changes: 24 additions & 1 deletion backend/app/services/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
CalorieAppUserDB,
ExternalIdentityDB,
OriginLoginHandoffDB,
PendingLoginLocaleDB,
PendingLoginStateDB,
utc_now,
)
Expand Down Expand Up @@ -85,6 +86,7 @@ def create_pending_login_state(
session: Session,
state_lifetime_seconds: int,
post_login_redirect: Optional[str] = None,
locale: str = "en",
) -> tuple[str, PendingLoginStateDB]:
"""Create a persistent pending login state transaction and return plaintext state."""
created_at = utc_now()
Expand All @@ -106,14 +108,32 @@ def create_pending_login_state(
expires_at=expires_at,
post_login_redirect=post_login_redirect,
)
session.add(row)
locale_row = PendingLoginLocaleDB(
state_hash=state_hash,
locale=locale,
created_at=created_at,
expires_at=expires_at,
)
session.add_all([row, locale_row])
session.commit()
session.refresh(row)
return state, row

raise RuntimeError("Unable to allocate unique login state")


def get_pending_login_locale(session: Session, state: str) -> str:
"""Return state-bound locale context with compatibility fallback to English."""
row = session.exec(
select(PendingLoginLocaleDB).where(
PendingLoginLocaleDB.state_hash == hash_login_state(state)
)
).first()
if row is None:
return "en"
return row.locale


def create_origin_login_handoff(
session: Session,
state: str,
Expand Down Expand Up @@ -329,6 +349,9 @@ def cleanup_pending_login_states(session: Session) -> None:
session.exec(
delete(OriginLoginHandoffDB).where(OriginLoginHandoffDB.expires_at < now)
)
session.exec(
delete(PendingLoginLocaleDB).where(PendingLoginLocaleDB.expires_at < now)
)
session.commit()


Expand Down
29 changes: 29 additions & 0 deletions backend/tests/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CalorieAppUserDB,
ExternalIdentityDB,
OriginLoginHandoffDB,
PendingLoginLocaleDB,
PendingLoginStateDB,
SQLModel,
)
Expand All @@ -34,6 +35,7 @@
generate_login_state,
generate_login_session_id,
get_or_create_user_from_external_identity,
get_pending_login_locale,
get_user_by_id,
validate_origin_login_handoff,
validate_pending_login_state,
Expand Down Expand Up @@ -402,6 +404,24 @@ def test_state_is_persisted_and_hashed(self, test_session: Session):
assert row.state_hash == hash_login_state(state)
assert row.state_hash != state

def test_locale_context_is_bound_to_hashed_state(self, test_session: Session):
state, _ = create_pending_login_state(
test_session,
state_lifetime_seconds=300,
locale="nl",
)

locale_row = test_session.exec(
select(PendingLoginLocaleDB).where(
PendingLoginLocaleDB.state_hash == hash_login_state(state)
)
).first()

assert locale_row is not None
assert locale_row.state_hash != state
assert locale_row.locale == "nl"
assert get_pending_login_locale(test_session, state) == "nl"

def test_state_validation_accepts_valid_pending_state(self, test_session: Session):
state, _ = create_pending_login_state(test_session, state_lifetime_seconds=300)
is_valid, reason, _ = validate_pending_login_state(test_session, state)
Expand Down Expand Up @@ -469,7 +489,14 @@ def test_cleanup_removes_expired_and_keeps_valid(self, test_session: Session):
valid_state, _ = create_pending_login_state(test_session, state_lifetime_seconds=300)
expired_state, expired_row = create_pending_login_state(test_session, state_lifetime_seconds=300)
expired_row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
expired_locale = test_session.exec(
select(PendingLoginLocaleDB).where(
PendingLoginLocaleDB.state_hash == hash_login_state(expired_state)
)
).one()
expired_locale.expires_at = datetime.now(UTC) - timedelta(seconds=1)
test_session.add(expired_row)
test_session.add(expired_locale)
test_session.commit()

cleanup_pending_login_states(test_session)
Expand All @@ -483,6 +510,8 @@ def test_cleanup_removes_expired_and_keeps_valid(self, test_session: Session):

assert valid_exists is not None
assert expired_exists is None
assert get_pending_login_locale(test_session, valid_state) == "en"
assert get_pending_login_locale(test_session, expired_state) == "en"

def test_state_persists_across_sessions(self):
engine = create_engine(
Expand Down
Loading