From 6c5466c86e22ee21c41060288c540b533a868e06 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:27:27 +0200 Subject: [PATCH 1/3] Bind locale context across identity login --- backend/app/main.py | 42 +++++-- backend/app/models.py | 12 ++ backend/app/schemas.py | 12 ++ backend/app/services/identity.py | 25 +++- backend/tests/test_identity.py | 29 +++++ backend/tests/test_identity_endpoints.py | 85 ++++++++++++- contracts/identity-bridge/v1/README.md | 7 ++ .../identity-bridge/v1/login-test-matrix.json | 42 +++++++ frontend/components/XamanLoginPanel.tsx | 113 +++++++++++++++--- .../tests/calorieapp_embed_readiness.test.mjs | 47 +++++++- tools/tests/test_identity_contracts.py | 34 ++++++ tools/tests/xaman_login_start_retry.test.mjs | 24 +++- .../calorieapp-identity-bridge/README.md | 6 + .../calorieapp-identity-bridge/TESTING.md | 3 + .../assets/calorieapp-embed.js | 35 +++++- ...eapp-identity-bridge-browser-authorize.php | 5 +- ...ieapp-identity-bridge-integrated-login.php | 32 ++++- .../class-calorieapp-identity-bridge-rest.php | 33 ++++- .../tests/test-identity-bridge-rest.php | 18 ++- .../tests/test-integrated-login.php | 22 +++- 20 files changed, 578 insertions(+), 48 deletions(-) create mode 100644 contracts/identity-bridge/v1/login-test-matrix.json diff --git a/backend/app/main.py b/backend/app/main.py index b2ad56d..90cd623 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, @@ -31,6 +32,7 @@ IdentityClaimsResponse, IdentityLoginStatusRequest, IdentityLoginStatusResponse, + IdentityStartRequest, IdentityStateValidationRequest, IdentityStateValidationResponse, IdentityStartResponse, @@ -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, @@ -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, @@ -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) @@ -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, ) @@ -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) @@ -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": @@ -827,6 +848,7 @@ def identity_callback( user_id=user.id, created=created, redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT, + locale=locale, ) @@ -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, @@ -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) @@ -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") @@ -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) @@ -891,6 +915,7 @@ def identity_login_status( return IdentityLoginStatusResponse( status="authenticated", redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT, + locale=locale, ) replaced_session = current_session @@ -904,6 +929,7 @@ def identity_login_status( return IdentityLoginStatusResponse( status="authenticated", redirect_to=_CALORIEAPP_POST_LOGIN_REDIRECT, + locale=locale, ) diff --git a/backend/app/models.py b/backend/app/models.py index 515e40e..2ffa1fb 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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.""" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 224a219..a51ef66 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -81,6 +81,14 @@ 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.""" @@ -88,6 +96,7 @@ class IdentityStartResponse(BaseModel): expires_at: datetime wordpress_signin_url: str browser_handoff_token: str + locale: str @field_validator("expires_at", mode="after") @classmethod @@ -113,6 +122,7 @@ class IdentityCallbackResponse(BaseModel): user_id: str created: bool redirect_to: str + locale: str class IdentityLoginStatusRequest(BaseModel): @@ -127,6 +137,7 @@ class IdentityLoginStatusResponse(BaseModel): status: Literal["pending", "failed", "authenticated"] redirect_to: Optional[str] = None + locale: str class IdentityExchangeRequest(BaseModel): @@ -148,6 +159,7 @@ class IdentityStateValidationResponse(BaseModel): valid: bool expires_at: datetime + locale: str @field_validator("expires_at", mode="after") @classmethod diff --git a/backend/app/services/identity.py b/backend/app/services/identity.py index 64b5e13..7555141 100644 --- a/backend/app/services/identity.py +++ b/backend/app/services/identity.py @@ -24,6 +24,7 @@ CalorieAppUserDB, ExternalIdentityDB, OriginLoginHandoffDB, + PendingLoginLocaleDB, PendingLoginStateDB, utc_now, ) @@ -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() @@ -106,7 +108,13 @@ 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 @@ -114,6 +122,18 @@ def create_pending_login_state( 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, @@ -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() diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py index 72f8487..71e0821 100644 --- a/backend/tests/test_identity.py +++ b/backend/tests/test_identity.py @@ -16,6 +16,7 @@ CalorieAppUserDB, ExternalIdentityDB, OriginLoginHandoffDB, + PendingLoginLocaleDB, PendingLoginStateDB, SQLModel, ) @@ -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, @@ -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) @@ -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) @@ -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( diff --git a/backend/tests/test_identity_endpoints.py b/backend/tests/test_identity_endpoints.py index 8bf15dd..66055a0 100644 --- a/backend/tests/test_identity_endpoints.py +++ b/backend/tests/test_identity_endpoints.py @@ -311,9 +311,11 @@ def test_login_start(self, client: TestClient): assert "expires_at" in data assert "wordpress_signin_url" in data assert "browser_handoff_token" in data + assert data["locale"] == "en" 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 "%26locale%3Den" 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"] @@ -321,6 +323,41 @@ def test_login_start(self, client: TestClient): assert response.headers["pragma"] == "no-cache" assert data["expires_at"].endswith("Z") + @pytest.mark.parametrize( + "locale", + ["en", "zh-Hans", "hi", "es", "ar", "fr", "bn", "pt", "id", "ur", "nl"], + ) + def test_login_start_accepts_every_contract_locale( + self, + client: TestClient, + locale: str, + ): + response = client.post("/api/identity/login/start", json={"locale": locale}) + + assert response.status_code == 200 + assert response.json()["locale"] == locale + + def test_login_start_resolves_alias_and_accept_language(self, client: TestClient): + explicit = client.post("/api/identity/login/start", json={"locale": "nl-NL"}) + negotiated = client.post( + "/api/identity/login/start", + headers={"accept-language": "fr-CA,fr;q=0.9,en;q=0.8"}, + ) + + assert explicit.json()["locale"] == "nl" + assert negotiated.json()["locale"] == "fr" + + @pytest.mark.parametrize("requested", ["unknown", "zh-Hant"]) + def test_login_start_falls_back_to_english_for_unsupported_locale( + self, + client: TestClient, + requested: str, + ): + response = client.post("/api/identity/login/start", json={"locale": requested}) + + assert response.status_code == 200 + assert response.json()["locale"] == "en" + def test_login_start_generates_unique_high_entropy_states(self, client: TestClient): first = client.post("/api/identity/login/start").json()["state"] second = client.post("/api/identity/login/start").json()["state"] @@ -483,6 +520,7 @@ def test_bridge_canonicalization_independent_interoperability(self, client: Test assert response.status_code == 200 assert response.json()["valid"] is True + assert response.json()["locale"] == "en" def test_bridge_canonicalization_distinguishes_logically_different_values(self): p1 = _canonical_bridge_payload_for_test( @@ -517,6 +555,51 @@ def test_bridge_state_validate_accepts_valid_hmac(self, client: TestClient, monk ) assert response.status_code == 200 assert response.json()["valid"] is True + assert response.json()["locale"] == "en" + + def test_locale_remains_state_bound_through_callback_and_status( + self, + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + ): + monkeypatch.setattr(main_module, "_WORDPRESS_BRIDGE_SECRET", "supersecret") + monkeypatch.setattr(main_module, "_CALORIEAPP_CLIENT_ID", "calorieapp-backend") + now = datetime.now(UTC) + claims = IdentityClaimsResponse( + external_subject="wp:calorietoken.net:locale-test", + xrpl_address="rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + issued_at=now, + expires_at=now + timedelta(seconds=60), + jti="jti-locale-test", + ) + monkeypatch.setattr(main_module, "_exchange_code_for_claims", lambda code, state: claims) + + start = client.post("/api/identity/login/start", json={"locale": "nl-NL"}).json() + headers = _build_bridge_headers(state=start["state"], secret="supersecret") + validation = client.post( + "/api/identity/login/state/validate", + json={"state": start["state"]}, + headers=headers, + ) + 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 start["locale"] == "nl" + assert validation.status_code == 200 + assert validation.json()["locale"] == "nl" + assert callback.status_code == 200 + assert callback.json()["locale"] == "nl" + assert status.status_code == 200 + assert status.json()["locale"] == "nl" def test_bridge_state_validate_rejects_invalid_signature(self, client: TestClient, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(main_module, "_WORDPRESS_BRIDGE_SECRET", "supersecret") @@ -1180,7 +1263,7 @@ def test_origin_handoff_is_pending_before_callback(self, client: TestClient): ) assert status.status_code == 200 - assert status.json() == {"status": "pending", "redirect_to": None} + assert status.json() == {"status": "pending", "redirect_to": None, "locale": "en"} assert SESSION_COOKIE_NAME not in status.cookies def test_origin_handoff_rejects_wrong_browser_proof(self, client: TestClient): diff --git a/contracts/identity-bridge/v1/README.md b/contracts/identity-bridge/v1/README.md index 0e73696..82291ba 100644 --- a/contracts/identity-bridge/v1/README.md +++ b/contracts/identity-bridge/v1/README.md @@ -25,6 +25,13 @@ artifacts are deployed and packaged independently. English is the source and fallback locale. The registry contains the fixed set of ten selected world languages plus Dutch. Arabic and Urdu are right-to-left. All products must resolve unsupported or malformed locale input to English. +The canonical locale is bound to the hashed, short-lived login state and must +remain identical across the WordPress flow, iframe messages, backend callback +and origin-browser status claim. Locale is request context, not identity proof +or a persisted user preference. + +`login-test-matrix.json` freezes the eleven-locale login and failure-path +coverage used to prevent repetitive manual testing. Run `python tools/sync_identity_contracts.py` after changing the canonical registry. CI uses `--check` and rejects drift between the source and the three diff --git a/contracts/identity-bridge/v1/login-test-matrix.json b/contracts/identity-bridge/v1/login-test-matrix.json new file mode 100644 index 0000000..573a0da --- /dev/null +++ b/contracts/identity-bridge/v1/login-test-matrix.json @@ -0,0 +1,42 @@ +{ + "contract_id": "gallery-token.identity-bridge-login-test-matrix", + "contract_version": "1.0.0", + "source_locale": "en", + "fallback_locale": "en", + "locales": ["en", "zh-Hans", "hi", "es", "ar", "fr", "bn", "pt", "id", "ur", "nl"], + "required_context_fields": ["locale", "state", "request_id"], + "scenarios": [ + { + "id": "happy_path", + "expected": "The canonical locale remains identical from embed start through callback and status." + }, + { + "id": "backend_cold_retry", + "expected": "Retries reuse the same canonical locale and create one accepted login state." + }, + { + "id": "xaman_rejected", + "expected": "The flow fails without authorizing WordPress or CalorieApp." + }, + { + "id": "flow_expired", + "expected": "Expired WordPress or backend state cannot be reused." + }, + { + "id": "wordpress_authenticated_backend_retry", + "expected": "Backend retries retain the state-bound locale without repeating Xaman identity proof." + }, + { + "id": "state_or_locale_mismatch", + "expected": "A mismatched state or locale is rejected before an authorization code is issued." + }, + { + "id": "origin_browser_restore", + "expected": "The initiating browser can claim the session once and receives the same locale." + }, + { + "id": "unsupported_locale_fallback", + "expected": "Unsupported locale input resolves to English before state creation." + } + ] +} diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx index 749ae4e..c3d529b 100644 --- a/frontend/components/XamanLoginPanel.tsx +++ b/frontend/components/XamanLoginPanel.tsx @@ -9,6 +9,7 @@ import { BackendRequestTimeoutError, waitForBackendReady, } from "@/lib/backendRequest"; +import { resolveLocale } from "@/lib/locales"; type MeResponse = { user_id: string; @@ -20,17 +21,27 @@ type LoginStartResponse = { expires_at: string; wordpress_signin_url: string; browser_handoff_token: string; + locale: string; }; type LoginStatusResponse = { status: "pending" | "failed" | "authenticated"; redirect_to?: string | null; + locale: string; +}; + +type LoginCallbackResponse = { + user_id: string; + created: boolean; + redirect_to: string; + locale: string; }; type PendingLogin = { state: string; expiresAt: string; browserHandoffToken: string; + locale: string; }; const BACKEND_BASE_URL = "/api/backend"; @@ -55,8 +66,20 @@ type ParentBridgeMessage = { message?: unknown; code?: unknown; state?: unknown; + locale?: unknown; }; +function initialLocale(): string { + if (typeof window === "undefined") { + return "en"; + } + + const queryLocale = new URLSearchParams(window.location.search).get("locale"); + const documentLocale = document.documentElement.lang; + const browserLocale = navigator.languages?.join(",") || navigator.language; + return resolveLocale(queryLocale || documentLocale || browserLocale); +} + function isAllowedParentOrigin(value: string): boolean { try { const origin = new URL(value); @@ -118,6 +141,7 @@ function storePendingLogin(data: LoginStartResponse) { state: data.state, expiresAt: data.expires_at, browserHandoffToken: data.browser_handoff_token, + locale: data.locale, }; window.sessionStorage.setItem( @@ -151,6 +175,7 @@ function readPendingLogin(): PendingLogin | null { state: value.state, expiresAt: value.expiresAt as string, browserHandoffToken: value.browserHandoffToken, + locale: resolveLocale(typeof value.locale === "string" ? value.locale : "en"), }; } catch { clearPendingLogin(); @@ -181,7 +206,8 @@ async function waitForOriginLogin( state: string, browserHandoffToken: string, expiresAt: string, - signal: AbortSignal + signal: AbortSignal, + expectedLocale = "en" ) { const parsedExpiry = Date.parse(expiresAt); const deadline = Number.isFinite(parsedExpiry) @@ -233,6 +259,9 @@ async function waitForOriginLogin( } const payload = (await response.json()) as LoginStatusResponse; + if (resolveLocale(payload.locale) !== expectedLocale) { + throw new Error("Login status language context did not match"); + } if (payload.status === "authenticated") { return; } @@ -272,7 +301,8 @@ type EmbeddedLoginPreparationPhase = LoginStartRetryReason | "waking-up"; export async function startLoginWithRetry( signal: AbortSignal, onRetry: (reason: LoginStartRetryReason) => void, - retryWindowMs = LOGIN_START_RETRY_WINDOW_MS + retryWindowMs = LOGIN_START_RETRY_WINDOW_MS, + locale = "en" ): Promise { const deadline = Date.now() + retryWindowMs; @@ -281,7 +311,12 @@ export async function startLoginWithRetry( try { response = await backendRequest( `${BACKEND_BASE_URL}/api/identity/login/start`, - { method: "POST", signal }, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locale: resolveLocale(locale) }), + signal, + }, Math.max( 1, Math.min(LOGIN_START_REQUEST_TIMEOUT_MS, deadline - Date.now()) @@ -324,7 +359,8 @@ export async function startLoginWithRetry( export async function prepareEmbeddedLogin( signal: AbortSignal, onProgress: (phase: EmbeddedLoginPreparationPhase) => void, - retryWindowMs = EMBEDDED_LOGIN_START_RETRY_WINDOW_MS + retryWindowMs = EMBEDDED_LOGIN_START_RETRY_WINDOW_MS, + locale = "en" ): Promise { // A GET readiness probe reliably wakes a spun-down Render Free backend. // Sending login/start as the first request can be rejected by Render's edge @@ -334,7 +370,7 @@ export async function prepareEmbeddedLogin( } onProgress("waking-up"); await waitForBackendReady(BACKEND_WAKE_BASE_URL, signal); - return startLoginWithRetry(signal, onProgress, retryWindowMs); + return startLoginWithRetry(signal, onProgress, retryWindowMs, locale); } export function XamanLoginPanel() { @@ -349,6 +385,7 @@ export function XamanLoginPanel() { const parentOrigin = useRef(null); const embeddedRequestId = useRef(""); const embeddedLoginStart = useRef(null); + const activeLocale = useRef(initialLocale()); const refreshCurrentUser = useCallback(async (signal?: AbortSignal): Promise => { try { @@ -412,7 +449,8 @@ export function XamanLoginPanel() { pendingLogin.state, pendingLogin.browserHandoffToken, pendingLogin.expiresAt, - controller.signal + controller.signal, + pendingLogin.locale ); clearPendingLogin(); @@ -469,6 +507,7 @@ export function XamanLoginPanel() { type: "calorieapp:frame:height", requestId: embeddedRequestId.current, height, + locale: activeLocale.current, }, origin ); @@ -487,6 +526,9 @@ export function XamanLoginPanel() { ) { origin = event.origin; parentOrigin.current = event.origin; + activeLocale.current = resolveLocale( + typeof event.data.locale === "string" ? event.data.locale : activeLocale.current + ); setIsEmbedded(true); postHeight(); return; @@ -502,6 +544,17 @@ export function XamanLoginPanel() { return; } + if ( + typeof event.data.type === "string" && + event.data.type.startsWith("calorieapp:login:") && + event.data.locale !== activeLocale.current + ) { + setError("The WordPress sign-in language context did not match this page."); + setLoginStatus(null); + setIsLoading(false); + return; + } + if (event.data.type === "calorieapp:login:progress") { if (typeof event.data.message === "string") { setLoginStatus(event.data.message); @@ -527,7 +580,8 @@ export function XamanLoginPanel() { !pending || typeof event.data.code !== "string" || typeof event.data.state !== "string" || - event.data.state !== pending.state + event.data.state !== pending.state || + event.data.locale !== pending.locale ) { setError("The WordPress sign-in response did not match this CalorieApp request."); setLoginStatus(null); @@ -549,6 +603,10 @@ export function XamanLoginPanel() { if (!response.ok) { throw new Error(`Callback failed with ${response.status}`); } + const callback = (await response.json()) as LoginCallbackResponse; + if (callback.locale !== pending.locale) { + throw new Error("Callback language context did not match"); + } const restoredUser = await refreshCurrentUser(bridgeController.signal); if (!restoredUser) { @@ -566,6 +624,7 @@ export function XamanLoginPanel() { { type: "calorieapp:login:complete", requestId: embeddedRequestId.current, + locale: pending.locale, }, origin ); @@ -582,6 +641,7 @@ export function XamanLoginPanel() { type: "calorieapp:login:backend-error", requestId: embeddedRequestId.current, message, + locale: pending.locale, }, origin ); @@ -589,7 +649,10 @@ export function XamanLoginPanel() { }; window.addEventListener("message", handleParentMessage); - window.parent.postMessage({ type: "calorieapp:bridge:ready" }, "*"); + window.parent.postMessage( + { type: "calorieapp:bridge:ready", locale: activeLocale.current }, + "*" + ); return () => { bridgeController.abort(); resizeObserver?.disconnect(); @@ -618,7 +681,11 @@ export function XamanLoginPanel() { ); window.parent.postMessage( - { type: "calorieapp:login:start", requestId }, + { + type: "calorieapp:login:start", + requestId, + locale: activeLocale.current, + }, embeddedParentOrigin ); @@ -638,17 +705,20 @@ export function XamanLoginPanel() { type: "calorieapp:login:progress", requestId, message, + locale: activeLocale.current, }, embeddedParentOrigin ); }, - EMBEDDED_LOGIN_START_RETRY_WINDOW_MS + EMBEDDED_LOGIN_START_RETRY_WINDOW_MS, + activeLocale.current ); if ( data.state.length < 32 || data.browser_handoff_token.length < 32 || !Number.isFinite(Date.parse(data.expires_at)) || - Date.parse(data.expires_at) <= Date.now() + Date.parse(data.expires_at) <= Date.now() || + data.locale !== activeLocale.current ) { throw new Error("Missing CalorieApp login state"); } @@ -659,6 +729,7 @@ export function XamanLoginPanel() { type: "calorieapp:login:state", requestId, state: data.state, + locale: data.locale, }, embeddedParentOrigin ); @@ -678,6 +749,7 @@ export function XamanLoginPanel() { type: "calorieapp:login:backend-error", requestId, message, + locale: activeLocale.current, }, embeddedParentOrigin ); @@ -712,16 +784,22 @@ export function XamanLoginPanel() { startupNoticeTimer = null; setLoginStatus("Service ready. Opening Xaman..."); - const data = await startLoginWithRetry(controller.signal, () => { - setLoginStatus( - "CalorieApp is temporarily busy. Waiting safely before opening Xaman..." - ); - }); + const data = await startLoginWithRetry( + controller.signal, + () => { + setLoginStatus( + "CalorieApp is temporarily busy. Waiting safely before opening Xaman..." + ); + }, + LOGIN_START_RETRY_WINDOW_MS, + activeLocale.current + ); if ( data.state.length < 32 || data.browser_handoff_token.length < 32 || !Number.isFinite(Date.parse(data.expires_at)) || Date.parse(data.expires_at) <= Date.now() || + data.locale !== activeLocale.current || !isAllowedWordPressSigninUrl(data.wordpress_signin_url) ) { throw new Error("Missing signin handoff data"); @@ -743,7 +821,8 @@ export function XamanLoginPanel() { data.state, data.browser_handoff_token, data.expires_at, - controller.signal + controller.signal, + data.locale ); clearPendingLogin(); diff --git a/tools/tests/calorieapp_embed_readiness.test.mjs b/tools/tests/calorieapp_embed_readiness.test.mjs index 2b6c6ca..628c262 100644 --- a/tools/tests/calorieapp_embed_readiness.test.mjs +++ b/tools/tests/calorieapp_embed_readiness.test.mjs @@ -60,6 +60,7 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { startUrl: "/start", finishUrl: "/finish", authorizeUrl: "/authorize", + locale: "nl", }, querySelector(selector) { return selectors.get(selector) ?? null; @@ -86,12 +87,17 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { class FakeWebSocket { constructor() { websocketCount += 1; + lastSocket = this; } close() {} } + let lastSocket = null; const fetchCalls = []; - const fetch = async (url) => { + const fetchBodies = []; + let finishCount = 0; + const fetch = async (url, options = {}) => { fetchCalls.push(url); + fetchBodies.push(JSON.parse(options.body)); if (url === "/start") { return { ok: true, @@ -102,14 +108,31 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { next_url: "https://xumm.app/sign/payload", qr_png_url: "https://xumm.app/sign/payload.png", websocket_url: "wss://xumm.app/sign/payload", + locale: "nl", }), }; } if (url === "/finish") { + finishCount += 1; return { ok: true, - status: 202, - json: async () => ({ status: "pending" }), + status: finishCount === 1 ? 202 : 200, + json: async () => ({ + status: finishCount === 1 ? "pending" : "wordpress_authenticated", + }), + }; + } + if (url === "/authorize") { + const body = JSON.parse(options.body); + return { + ok: true, + status: 200, + json: async () => ({ + status: "authorized", + code: "authorization-code", + state: body.state, + locale: "nl", + }), }; } throw new Error(`Unexpected fetch: ${url}`); @@ -130,7 +153,7 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { const requestId = "request-12345678"; windowListeners.message({ - data: { type: "calorieapp:login:start", requestId }, + data: { type: "calorieapp:login:start", requestId, locale: "nl" }, origin: appOrigin, source: iframeWindow, }); @@ -149,6 +172,7 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { type: "calorieapp:login:state", requestId, state: "state-abcdefghijklmnopqrstuvwxyz-0123456789", + locale: "nl", }, origin: appOrigin, source: iframeWindow, @@ -169,11 +193,24 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { assert.deepEqual(fetchCalls, ["/start", "/finish"]); assert.match(status.textContent, /Waiting for the Xaman signature/); + lastSocket.onmessage({ data: JSON.stringify({ signed: true }) }); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(fetchCalls, ["/start", "/finish", "/finish", "/authorize"]); + assert.deepEqual(fetchBodies[0], { locale: "nl" }); + assert.deepEqual(fetchBodies[3], { + flow_id: "flow-id", + flow_proof: "flow-proof", + state: "state-abcdefghijklmnopqrstuvwxyz-0123456789", + locale: "nl", + }); + windowListeners.message({ data: { type: "calorieapp:login:backend-error", requestId, message: "CalorieApp startup failed", + locale: "nl", }, origin: appOrigin, source: iframeWindow, @@ -183,6 +220,6 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { windowListeners.focus(); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(fetchCalls, ["/start", "/finish"]); + assert.deepEqual(fetchCalls, ["/start", "/finish", "/finish", "/authorize"]); assert.equal(status.textContent, "CalorieApp startup failed"); }); diff --git a/tools/tests/test_identity_contracts.py b/tools/tests/test_identity_contracts.py index 2639dac..f07976b 100644 --- a/tools/tests/test_identity_contracts.py +++ b/tools/tests/test_identity_contracts.py @@ -50,6 +50,40 @@ def test_security_ttls_match_the_runtime_defaults(self) -> None: self.assertIn("'code_ttl_seconds' => 60", plugin_root) self.assertEqual(security["authorization_code"]["default_ttl_seconds"], 60) + def test_login_matrix_covers_all_locales_and_failure_paths(self) -> None: + matrix = json.loads( + ( + contracts.ROOT + / "contracts" + / "identity-bridge" + / "v1" + / "login-test-matrix.json" + ).read_text(encoding="utf-8") + ) + locales = json.loads(contracts.CANONICAL_LOCALES.read_text(encoding="utf-8")) + expected_tags = [locale["tag"] for locale in locales["locales"]] + + self.assertEqual(matrix["locales"], expected_tags) + self.assertEqual(matrix["source_locale"], locales["source_locale"]) + self.assertEqual(matrix["fallback_locale"], locales["fallback_locale"]) + self.assertEqual( + set(matrix["required_context_fields"]), + {"locale", "state", "request_id"}, + ) + self.assertEqual( + {scenario["id"] for scenario in matrix["scenarios"]}, + { + "happy_path", + "backend_cold_retry", + "xaman_rejected", + "flow_expired", + "wordpress_authenticated_backend_retry", + "state_or_locale_mismatch", + "origin_browser_restore", + "unsupported_locale_fallback", + }, + ) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/xaman_login_start_retry.test.mjs b/tools/tests/xaman_login_start_retry.test.mjs index ecfbdb4..076f91a 100644 --- a/tools/tests/xaman_login_start_retry.test.mjs +++ b/tools/tests/xaman_login_start_retry.test.mjs @@ -44,11 +44,14 @@ test("login start retries transport errors and transient responses", async () => expires_at: "2099-01-01T00:00:00Z", wordpress_signin_url: "https://calorietoken.net/?xl-signin=1", browser_handoff_token: "token-abcdefghijklmnopqrstuvwxyz-0123456789", + locale: "nl", }), }, ]; let requestCount = 0; - const backendRequest = async () => { + const requestBodies = []; + const backendRequest = async (_url, options) => { + requestBodies.push(JSON.parse(options.body)); const response = responses[requestCount]; requestCount += 1; if (response instanceof Error) { @@ -90,6 +93,9 @@ test("login start retries transport errors and transient responses", async () => waitForBackendReady: async () => {}, }; } + if (specifier === "@/lib/locales") { + return { resolveLocale: (value) => value || "en" }; + } throw new Error(`Unexpected require: ${specifier}`); }, setImmediate, @@ -108,7 +114,8 @@ test("login start retries transport errors and transient responses", async () => const result = await module.exports.startLoginWithRetry( new AbortController().signal, (reason) => retryReasons.push(reason), - 10_000 + 10_000, + "nl" ); assert.equal(requestCount, 4); @@ -118,6 +125,12 @@ test("login start retries transport errors and transient responses", async () => "temporarily-unavailable", ]); assert.equal(result.state, "state-abcdefghijklmnopqrstuvwxyz-0123456789"); + assert.deepEqual(requestBodies, [ + { locale: "nl" }, + { locale: "nl" }, + { locale: "nl" }, + { locale: "nl" }, + ]); }); test("embedded login wakes the backend before creating login state", async () => { @@ -141,6 +154,7 @@ test("embedded login wakes the backend before creating login state", async () => expires_at: "2099-01-01T00:00:00Z", wordpress_signin_url: "https://calorietoken.net/?xl-signin=1", browser_handoff_token: "token-abcdefghijklmnopqrstuvwxyz-0123456789", + locale: "en", }), }; @@ -182,6 +196,9 @@ test("embedded login wakes the backend before creating login state", async () => }, }; } + if (specifier === "@/lib/locales") { + return { resolveLocale: (value) => value || "en" }; + } throw new Error(`Unexpected require: ${specifier}`); }, setImmediate, @@ -260,6 +277,9 @@ test("embedded login does not report progress after cancellation", async () => { }, }; } + if (specifier === "@/lib/locales") { + return { resolveLocale: (value) => value || "en" }; + } throw new Error(`Unexpected require: ${specifier}`); }, setImmediate, diff --git a/wordpress-plugins/calorieapp-identity-bridge/README.md b/wordpress-plugins/calorieapp-identity-bridge/README.md index 0f61ee0..b49cb27 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/README.md +++ b/wordpress-plugins/calorieapp-identity-bridge/README.md @@ -22,6 +22,8 @@ code for CalorieApp backend exchange. payload server-side - Authenticates WordPress and CalorieApp in the browser that started the flow - Provides the `[calorieapp_embed]` shortcode for the WordPress page +- Binds the resolved locale to each short-lived integrated login flow and + rejects state/locale mixing before issuing a CalorieApp code ## Endpoints @@ -74,6 +76,10 @@ During custom-domain rollout, the iframe source can be overridden explicitly: [calorieapp_embed src="https://app.calorietoken.net"] ``` +The shortcode resolves the current WordPress locale automatically. A canonical +locale or supported alias can also be supplied explicitly for controlled +previews, for example `[calorieapp_embed locale="nl-NL"]`. + The CalorieApp frontend must permit `calorietoken.net` through its `frame-ancestors` Content Security Policy. A same-site custom domain is strongly recommended for production browser-cookie reliability. diff --git a/wordpress-plugins/calorieapp-identity-bridge/TESTING.md b/wordpress-plugins/calorieapp-identity-bridge/TESTING.md index 3d80bd6..57b01c9 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/TESTING.md +++ b/wordpress-plugins/calorieapp-identity-bridge/TESTING.md @@ -41,6 +41,9 @@ Covered scenarios: 33. shortcode embeds the app without exposing secrets 34. Xaman custom identifier stays within the 40-character API limit 35. embedded Xaman controls remain hidden until CalorieApp state is ready +36. all eleven canonical locales can start a backend login flow +37. iframe, WordPress flow, backend state, callback and status retain one locale +38. mixed state/locale authorization is rejected ## Files diff --git a/wordpress-plugins/calorieapp-identity-bridge/assets/calorieapp-embed.js b/wordpress-plugins/calorieapp-identity-bridge/assets/calorieapp-embed.js index ecdc679..900b3ef 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/assets/calorieapp-embed.js +++ b/wordpress-plugins/calorieapp-identity-bridge/assets/calorieapp-embed.js @@ -53,6 +53,7 @@ var startUrl = root.dataset.startUrl || ""; var finishUrl = root.dataset.finishUrl || ""; var authorizeUrl = root.dataset.authorizeUrl || ""; + var configuredLocale = root.dataset.locale || "en"; if ( !iframe || @@ -95,6 +96,7 @@ { type: MESSAGE_PREFIX + type, requestId: requestId, + locale: configuredLocale, }, detail || {} ), @@ -107,7 +109,7 @@ return; } iframe.contentWindow.postMessage( - { type: MESSAGE_PREFIX + "bridge:init" }, + { type: MESSAGE_PREFIX + "bridge:init", locale: configuredLocale }, appOrigin ); } @@ -255,16 +257,21 @@ requestId = message.requestId; resetFlow(); modal.hidden = false; + if (message.locale !== configuredLocale) { + fail("CalorieApp returned a different language context."); + return; + } setStatus("Preparing a secure Xaman sign-in request..."); - apiRequest(startUrl, {}).then(function (result) { + apiRequest(startUrl, { locale: configuredLocale }).then(function (result) { var payload = result.payload; if ( typeof payload.flow_id !== "string" || typeof payload.flow_proof !== "string" || typeof payload.next_url !== "string" || typeof payload.qr_png_url !== "string" || - typeof payload.websocket_url !== "string" + typeof payload.websocket_url !== "string" || + payload.locale !== configuredLocale ) { throw new Error("WordPress returned incomplete Xaman data."); } @@ -272,6 +279,7 @@ flow = { flowId: payload.flow_id, flowProof: payload.flow_proof, + locale: payload.locale, }; xamanLaunch = { nextUrl: payload.next_url, @@ -372,12 +380,14 @@ flow_id: flow.flowId, flow_proof: flow.flowProof, state: backendState, + locale: configuredLocale, }).then(function (result) { authorizeInFlight = false; if ( result.payload.status !== "authorized" || typeof result.payload.code !== "string" || - result.payload.state !== backendState + result.payload.state !== backendState || + result.payload.locale !== configuredLocale ) { throw new Error("CalorieApp authorization was incomplete."); } @@ -386,6 +396,7 @@ postToApp("login:authorization", { code: result.payload.code, state: result.payload.state, + locale: result.payload.locale, }); }).catch(function (error) { authorizeInFlight = false; @@ -450,8 +461,22 @@ return; } + if ( + message.type.indexOf(MESSAGE_PREFIX + "login:") === 0 && + message.locale !== configuredLocale + ) { + fail("CalorieApp returned a different language context."); + return; + } + if (message.type === MESSAGE_PREFIX + "login:state") { - if (typeof message.state !== "string" || message.state.length < 32) { + if ( + typeof message.state !== "string" || + message.state.length < 32 || + message.locale !== configuredLocale || + !flow || + flow.locale !== configuredLocale + ) { fail("CalorieApp returned an invalid login state."); return; } diff --git a/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-browser-authorize.php b/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-browser-authorize.php index 9854e29..ce0f79b 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-browser-authorize.php +++ b/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-browser-authorize.php @@ -53,8 +53,11 @@ public function maybe_authorize(): void { $callback_url = isset($_GET['callback_url']) ? trim(esc_url_raw(wp_unslash((string) $_GET['callback_url']))) : ''; + $locale = isset($_GET['locale']) + ? trim(sanitize_text_field(wp_unslash((string) $_GET['locale']))) + : ''; - $result = $this->rest_api->authorize_current_user($user_id, $state, $callback_url); + $result = $this->rest_api->authorize_current_user($user_id, $state, $callback_url, $locale); if ($result instanceof WP_Error) { $this->render_error($result); } diff --git a/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-integrated-login.php b/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-integrated-login.php index ce3667d..f5b7f35 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-integrated-login.php +++ b/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-integrated-login.php @@ -99,6 +99,7 @@ public function render_shortcode($attributes = []): string { [ 'src' => self::FRONTEND_DEFAULT, 'height' => '1200', + 'locale' => '', ], is_array($attributes) ? $attributes : [], 'calorieapp_embed' @@ -110,7 +111,18 @@ public function render_shortcode($attributes = []): string { } $height = max(700, min(4000, (int) $attributes['height'])); - $iframe_src = add_query_arg('embedded', '1', $src); + $requested_locale = trim((string) $attributes['locale']); + if ($requested_locale === '') { + $requested_locale = determine_locale(); + } + $locale = LocaleRegistry::resolve($requested_locale); + $iframe_src = add_query_arg( + [ + 'embedded' => '1', + 'locale' => $locale, + ], + $src + ); $instance_id = 'calorieapp-embed-' . wp_generate_uuid4(); if (!wp_script_is('calorieapp-identity-bridge-embed', 'registered')) { @@ -133,6 +145,7 @@ class="calorieapp-embed-shell" data-start-url="" data-finish-url="" data-authorize-url="" + data-locale="" >