From 9c42220ba99b01a1db83a558b81a38ae59c94824 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 18 Jul 2026 23:29:16 +0200 Subject: [PATCH 1/2] Cloud login: sign in with FrameOS Cloud on backends and frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of CLOUD-TODO.md, on top of the device-flow link: - "Continue with FrameOS Cloud" on /login and the first-run /signup (which can create the first user from a cloud principal), plus the frame's on-device /admin login - Explicit identity linking — an email match is not proof of ownership; the account that approved the link is auto-linked at connect time - Local-fallback toggle with lockout prevention: losing the cloud link (or dropping the auth:login scope) always re-enables local passwords - No cloud-login option under Home Assistant ingress - In-place feature (scope) changes with cloud-side approval for additions; the auth:login toggle is the first consumer - Grants/inventory sync loop in the ARQ worker; a cloud-side revocation resets the link and re-enables local login - Logging out hands the browser to the cloud's logout so it does not sign straight back in Co-Authored-By: Claude Fable 5 --- backend/app/api/auth.py | 45 +- backend/app/api/cloud.py | 707 +++++++++++++++++- backend/app/api/tests/test_cloud.py | 53 ++ backend/app/api/tests/test_cloud_features.py | 227 ++++++ backend/app/api/tests/test_cloud_login.py | 454 +++++++++++ backend/app/cloud/__init__.py | 4 + backend/app/cloud/sync.py | 204 +++++ backend/app/cloud/tests/__init__.py | 0 backend/app/cloud/tests/test_sync.py | 117 +++ backend/app/schemas/cloud.py | 28 + backend/app/tasks/worker.py | 17 +- backend/app/utils/cloud_link.py | 21 + frameos/frontend/src/scenes/login/Login.tsx | 13 +- .../frontend/src/scenes/login/loginLogic.tsx | 39 +- frameos/src/frameos/server/auth.nim | 2 +- .../server/routes/cloud_api_routes.nim | 157 +++- frontend/src/scenes/auth/cloudLoginLogic.ts | 104 +++ frontend/src/scenes/login/Login.tsx | 96 ++- frontend/src/scenes/sceneLogic.tsx | 13 +- .../src/scenes/settings/CloudSettings.tsx | 156 +++- frontend/src/scenes/settings/cloudLogic.tsx | 152 +++- frontend/src/scenes/signup/Signup.tsx | 167 ++++- .../src/scenes/signup/signupCloudLogic.ts | 103 +++ frontend/src/types.tsx | 26 + 24 files changed, 2788 insertions(+), 117 deletions(-) create mode 100644 backend/app/api/tests/test_cloud_features.py create mode 100644 backend/app/api/tests/test_cloud_login.py create mode 100644 backend/app/cloud/__init__.py create mode 100644 backend/app/cloud/sync.py create mode 100644 backend/app/cloud/tests/__init__.py create mode 100644 backend/app/cloud/tests/test_sync.py create mode 100644 frontend/src/scenes/auth/cloudLoginLogic.ts create mode 100644 frontend/src/scenes/signup/signupCloudLogic.ts diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index a193332ff..aa287647a 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -152,6 +152,23 @@ async def login( ): if app_config.config.HASSIO_RUN_MODE is not None: raise HTTPException(status_code=401, detail="Login not allowed with HASSIO_RUN_MODE") + + # Cloud login (Phase 1) can disable local passwords; the flag lives on the + # cloud link and flips back to True whenever the link is lost, so this can + # never lock an install out entirely. + from app.models.cloud import current_cloud_backend_link + + cloud_link_row = current_cloud_backend_link(db) + if ( + cloud_link_row is not None + and cloud_link_row.status == "connected" + and not cloud_link_row.local_fallback_enabled + ): + raise HTTPException( + status_code=403, + detail="Local password login is disabled on this install. Sign in with FrameOS Cloud.", + ) + email = form_data.username password = form_data.password ip = request.client.host @@ -163,7 +180,8 @@ async def login( raise HTTPException(status_code=429, detail="Too many login attempts") user = db.query(User).filter_by(email=email).first() - if user is None or not check_password_hash(user.password, password): + # Cloud-created users (first-run cloud signup) have no local password. + if user is None or not user.password or not check_password_hash(user.password, password): await redis.incr(key) await redis.expire(key, 300) # expire after 5 minutes raise HTTPException(status_code=401, detail="Invalid email or password") @@ -243,6 +261,27 @@ async def signup(request: Request, data: UserSignup, response: Response, db: Ses @api_user.post("/logout") -async def logout(response: Response): +async def logout(request: Request, response: Response, db: Session = Depends(get_db)): + # If this user signs in through FrameOS Cloud, the browser must also leave + # the cloud session, or "Continue with FrameOS Cloud" on the login screen + # would sign them straight back in. The frontend navigates to this URL; the + # cloud validates the return_to against the link's origin and bounces back + # to our login page. + cloud_logout_url = None + user = await get_current_user_from_request(request, db, request.headers.get("authorization")) + if user is not None: + from urllib.parse import quote + + from app.api.cloud import _browser_origin, _connected_link, _link_has_scope + from app.models.cloud import CloudIdentity + + link = _connected_link(db) + if _link_has_scope(link, "auth:login"): + identity = db.query(CloudIdentity).filter(CloudIdentity.user_id == user.id).first() + if identity is not None: + origin = _browser_origin(request) or "" + return_to = quote(f"{origin}/login", safe="") + cloud_logout_url = f"{link.provider_url}/logout?return_to={return_to}" + response.delete_cookie(key=SESSION_COOKIE_NAME) - return {"success": True} + return {"success": True, "cloud_logout_url": cloud_logout_url} diff --git a/backend/app/api/cloud.py b/backend/app/api/cloud.py index 403f3b99a..a32f40106 100644 --- a/backend/app/api/cloud.py +++ b/backend/app/api/cloud.py @@ -1,29 +1,48 @@ """Linking this FrameOS installation to a FrameOS Cloud provider. Phase 0 of CLOUD-TODO.md: establish and hold a scoped link token via the -OAuth 2.0 Device Authorization Grant. The protocol is documented in docs/cloud-link.md; the provider URL is +OAuth 2.0 Device Authorization Grant. Phase 1: cloud login — a browser +handoff that signs local users in with their FrameOS Cloud account, plus the +first-run setup flow that can create the first user from a cloud principal. +The protocol is documented in docs/cloud-link.md; the provider URL is user-editable so any compatible server works. All connections are outbound-only, initiated here. """ from __future__ import annotations import datetime +import json +import secrets from http import HTTPStatus +from arq import ArqRedis as Redis from fastapi import Depends, HTTPException, Request +from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session +from app.api.auth import ACCESS_TOKEN_EXPIRE_MINUTES, _should_use_secure_cookie, get_current_user from app.database import get_db -from app.models.cloud import CloudBackendLink, current_cloud_backend_link, link_is_expired +from app.models.cloud import CloudBackendLink, CloudIdentity, current_cloud_backend_link, link_is_expired +from app.models.user import User +from app.redis import get_redis from app.schemas.cloud import ( CloudConnectRequest, + CloudFeaturesRequest, + CloudLocalFallbackRequest, + CloudLoginOptionsResponse, + CloudLoginStartRequest, + CloudLoginStartResponse, CloudProviderUpdateRequest, CloudStatusResponse, ) from app.utils import cloud_link as cloud +from app.utils.session_cookie import SESSION_COOKIE_NAME, create_session_cookie_value from app.utils.versions import current_frameos_version -from . import api_user +from . import api_open, api_user + +CLOUD_LOGIN_STATE_PREFIX = "cloud_login_state:" +CLOUD_LOGIN_STATE_TTL_SECONDS = 600 # Scopes a link may request; must stay in sync with the table in CLOUD-TODO.md # and docs/cloud-link.md. @@ -47,6 +66,17 @@ def _now() -> datetime.datetime: return datetime.datetime.utcnow() +async def _nudge_cloud_sync() -> None: + """Wake the worker's cloud sync service (best effort).""" + try: + from app.cloud import CLOUD_SYNC_CHANNEL + from app.redis import get_shared_redis + + await get_shared_redis().publish(CLOUD_SYNC_CHANNEL, json.dumps({"event": "sync_now"})) + except Exception: # noqa: BLE001 + pass + + def _request_origin(request: Request) -> str: forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip() forwarded_host = request.headers.get("x-forwarded-host", "").split(",", 1)[0].strip() @@ -61,12 +91,30 @@ def _effective_provider_url(link: CloudBackendLink | None) -> str | None: return cloud.default_cloud_provider_url() -def _status_payload(db: Session, link: CloudBackendLink | None) -> dict: +def _clear_upgrade(link: CloudBackendLink, poll_error: str | None = None) -> None: + """Forget a pending feature-change approval; the link itself stays up.""" + link.device_code = None + link.user_code = None + link.verification_uri = None + link.verification_uri_complete = None + link.expires_at = None + link.poll_error = poll_error + link.updated_at = _now() + + +def _upgrade_pending(link: CloudBackendLink | None) -> bool: + return link is not None and link.status == "connected" and bool(link.device_code) + + +def _status_payload(db: Session, link: CloudBackendLink | None, user: User | None = None) -> dict: default_url = cloud.default_cloud_provider_url() enabled = default_url is not None if link is not None and link_is_expired(link, _now()): _reset_link(link, poll_error="expired") db.commit() + if _upgrade_pending(link) and link.expires_at is not None and link.expires_at <= _now(): + _clear_upgrade(link, poll_error="expired") + db.commit() status = link.status if link else "disconnected" payload: dict = { @@ -76,8 +124,10 @@ def _status_payload(db: Session, link: CloudBackendLink | None) -> dict: "status": status, "can_edit_provider": status == "disconnected", "poll_error": link.poll_error if link else None, + "local_fallback_enabled": link.local_fallback_enabled if link else True, "connection": None, "link": None, + "identity": _identity_payload(db, user), } if link is None: return payload @@ -100,9 +150,50 @@ def _status_payload(db: Session, link: CloudBackendLink | None) -> dict: if link.last_inventory_sync_at else None, } + # A pending feature change awaiting owner approval on the provider. + if link.device_code: + payload["upgrade"] = { + "user_code": link.user_code, + "verification_uri": link.verification_uri, + "verification_uri_complete": link.verification_uri_complete, + "expires_at": link.expires_at.isoformat() if link.expires_at else None, + "interval_seconds": link.interval_seconds, + } return payload +def _identity_payload(db: Session, user: User | None) -> dict | None: + """The current user's linked cloud identity, for the settings UI.""" + if user is None: + return None + identity = ( + db.query(CloudIdentity) + .filter(CloudIdentity.user_id == user.id) + .order_by(CloudIdentity.id.desc()) + .first() + ) + if identity is None: + return None + return { + "cloud_account_id": identity.cloud_account_id, + "email": identity.email, + "name": identity.name, + "provider_url": identity.provider_url, + "last_login_at": identity.last_login_at.isoformat() if identity.last_login_at else None, + } + + +def _link_has_scope(link: CloudBackendLink | None, scope: str) -> bool: + return link is not None and scope in link.scopes + + +def _connected_link(db: Session) -> CloudBackendLink | None: + link = current_cloud_backend_link(db) + if link is None or link.status != "connected": + return None + return link + + def _reset_link(link: CloudBackendLink, poll_error: str | None = None) -> None: link.status = "disconnected" link.device_code = None @@ -118,12 +209,17 @@ def _reset_link(link: CloudBackendLink, poll_error: str | None = None) -> None: link.scope = None link.poll_error = poll_error link.revoked_at = None + # Never leave an install without a way to log in: losing the cloud link + # always re-enables local password login. + link.local_fallback_enabled = True link.updated_at = _now() @api_user.get("/cloud/status", response_model=CloudStatusResponse) -async def get_cloud_status(db: Session = Depends(get_db)): - return _status_payload(db, current_cloud_backend_link(db)) +async def get_cloud_status( + db: Session = Depends(get_db), current_user: User | None = Depends(get_current_user) +): + return _status_payload(db, current_cloud_backend_link(db), current_user) async def _update_provider(data: CloudProviderUpdateRequest, db: Session) -> dict: @@ -221,10 +317,39 @@ async def connect_cloud(request: Request, data: CloudConnectRequest, db: Session return await _start_connect(request, data, db) -async def _poll_link(db: Session) -> dict: +async def _poll_upgrade(db: Session, link: CloudBackendLink) -> dict: + """One poll step for a pending feature change on a connected link.""" + try: + status_code, response = await cloud.device_poll(link.provider_url, link.device_code) + except Exception: # noqa: BLE001 + link.poll_error = "network_error" + db.commit() + return _status_payload(db, link) + + error = response.get("error") + if error == "authorization_pending": + link.poll_error = None + db.commit() + return _status_payload(db, link) + if status_code == 200 and response.get("scope") and not response.get("access_token"): + link.scope = response["scope"] + _clear_upgrade(link) + db.commit() + await _nudge_cloud_sync() + return _status_payload(db, link) + + # denied / expired / anything unexpected: drop the pending change, keep the link + _clear_upgrade(link, poll_error=error or f"unexpected status {status_code}") + db.commit() + return _status_payload(db, link) + + +async def _poll_link(db: Session, user: User | None = None) -> dict: link = current_cloud_backend_link(db) + if _upgrade_pending(link): + return await _poll_upgrade(db, link) if link is None or link.status != "connecting" or not link.device_code: - return _status_payload(db, link) + return _status_payload(db, link, user) try: status_code, response = await cloud.device_poll(link.provider_url, link.device_code) @@ -254,17 +379,43 @@ async def _poll_link(db: Session) -> dict: link.updated_at = _now() db.commit() await _sync_after_connect(db, link, response["access_token"]) - return _status_payload(db, link) + # The cloud account that approved the link IS the person connecting: + # map it to the local user right away so cloud login works without a + # separate "link my account" step. + _auto_link_identity(db, user, link, response.get("approved_by")) + await _nudge_cloud_sync() + return _status_payload(db, link, user) # denied / expired / anything else: back to square one with the reason kept _reset_link(link, poll_error=error or f"unexpected status {status_code}") db.commit() - return _status_payload(db, link) + return _status_payload(db, link, user) + + +def _auto_link_identity(db: Session, user: User | None, link: CloudBackendLink, approved_by) -> None: + if user is None or not isinstance(approved_by, dict): + return + issuer = approved_by.get("provider_issuer") + subject = str(approved_by.get("provider_subject") or approved_by.get("sub") or "") + if not issuer or not subject: + return + existing = ( + db.query(CloudIdentity) + .filter(CloudIdentity.provider_issuer == issuer, CloudIdentity.provider_subject == subject) + .first() + ) + if existing is not None and existing.user_id != user.id: + # Someone else already owns this cloud identity locally; never steal it. + return + _upsert_cloud_identity(db, user, link, issuer, approved_by) + db.commit() @api_user.post("/cloud/poll", response_model=CloudStatusResponse) -async def poll_cloud(db: Session = Depends(get_db)): - return await _poll_link(db) +async def poll_cloud( + db: Session = Depends(get_db), current_user: User | None = Depends(get_current_user) +): + return await _poll_link(db, current_user) async def _sync_after_connect(db: Session, link: CloudBackendLink, access_token: str) -> None: @@ -312,3 +463,535 @@ async def disconnect_cloud(db: Session = Depends(get_db)): _reset_link(link) db.commit() return _status_payload(db, link) + + +# ---- first-run setup (no users yet) ------------------------------------------ +# +# A fresh install has no user to log in with, but the setup screen must be able +# to link to the cloud so the first user can be created from a cloud account +# (and Phase 3 backups restored). Anyone who can reach a fresh install can +# already claim it through the open /api/signup, so these carry the same trust. + + +def _require_setup_mode(db: Session) -> None: + if db.query(User).first() is not None: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Setup is complete; log in first") + + +@api_open.get("/cloud/setup/status", response_model=CloudStatusResponse) +async def setup_cloud_status(db: Session = Depends(get_db)): + _require_setup_mode(db) + return _status_payload(db, current_cloud_backend_link(db)) + + +@api_open.post("/cloud/setup/provider", response_model=CloudStatusResponse) +async def setup_cloud_provider(data: CloudProviderUpdateRequest, db: Session = Depends(get_db)): + _require_setup_mode(db) + return await _update_provider(data, db) + + +@api_open.post("/cloud/setup/connect", response_model=CloudStatusResponse) +async def setup_cloud_connect(request: Request, data: CloudConnectRequest, db: Session = Depends(get_db)): + _require_setup_mode(db) + return await _start_connect(request, data, db) + + +@api_open.post("/cloud/setup/poll", response_model=CloudStatusResponse) +async def setup_cloud_poll(db: Session = Depends(get_db)): + _require_setup_mode(db) + return await _poll_link(db) + + +@api_open.post("/cloud/setup/disconnect", response_model=CloudStatusResponse) +async def setup_cloud_disconnect(db: Session = Depends(get_db)): + _require_setup_mode(db) + return await disconnect_cloud(db) + + +# ---- cloud login (Phase 1) --------------------------------------------------- + + +def _safe_next_path(value: str | None) -> str | None: + """Only same-origin absolute paths may be used as a post-login target.""" + if not value or not value.startswith("/") or value.startswith("//"): + return None + return value + + +LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + +def _browser_origin(request: Request) -> str | None: + """The origin the user's browser is actually on. In development the UI + (e.g. Vite on :8616) often proxies to the backend (:8989), and the link's + registered local_origin is the proxied one — remember where the user + started so the login callback can send them back there.""" + origin = (request.headers.get("origin") or "").strip().rstrip("/") + if origin.startswith("http://") or origin.startswith("https://"): + return origin + return _request_origin(request) + + +def _allowed_return_origin(origin: str | None, link: CloudBackendLink) -> str | None: + """An origin the callback may bounce back to: the link's own origin, or a + loopback host (development). Anything else could turn the callback into an + open redirect for whoever started the flow.""" + if not origin: + return None + from urllib.parse import urlparse + + parsed = urlparse(origin) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + normalized = origin.rstrip("/") + if parsed.hostname in LOOPBACK_HOSTS: + return normalized + if link.local_origin and normalized == link.local_origin.rstrip("/"): + return normalized + return None + + +def _login_redirect(reason: str) -> RedirectResponse: + return RedirectResponse(f"/login?cloudError={reason}", status_code=HTTPStatus.SEE_OTHER) + + +async def _handoff_authorization_url( + link: CloudBackendLink, + redis: Redis, + *, + mode: str, + next_path: str | None, + user_id: int | None, + intent: str, + return_origin: str | None = None, +) -> str: + """Start a login handoff with the provider and remember our state token.""" + access_token = cloud.decrypt_cloud_secret(link.access_token) + if not access_token or not link.local_origin: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="The cloud link is incomplete; reconnect first") + + state = secrets.token_urlsafe(32) + payload = { + "redirect_uri": f"{link.local_origin.rstrip('/')}/api/cloud/login/callback", + "state": state, + "intent": intent, + } + try: + status_code, response = await cloud.frameos_login_start(link.provider_url, access_token, payload) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + if status_code != 200 or not response.get("authorization_url"): + detail = response.get("error") or f"unexpected status {status_code}" + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud rejected the login request: {detail}" + ) + + await redis.set( + f"{CLOUD_LOGIN_STATE_PREFIX}{state}", + json.dumps( + { + "mode": mode, + "next": next_path, + "user_id": user_id, + "return_origin": _allowed_return_origin(return_origin, link), + } + ), + ex=CLOUD_LOGIN_STATE_TTL_SECONDS, + ) + return str(response["authorization_url"]) + + +@api_open.get("/cloud/login/options", response_model=CloudLoginOptionsResponse) +async def cloud_login_options(db: Session = Depends(get_db)): + """What the login and setup screens may offer. Intentionally unauthenticated + and minimal: it reveals only that cloud login is possible, not who owns it.""" + link = _connected_link(db) + # FRAMEOS_CLOUD_URL=disabled hides the cloud feature entirely. + enabled = cloud.default_cloud_provider_url() is not None + available = enabled and _link_has_scope(link, "auth:login") + return { + "available": available, + "provider_url": link.provider_url if link else None, + "local_login_enabled": link.local_fallback_enabled if link else True, + "setup_mode": db.query(User).first() is None, + } + + +@api_open.post("/cloud/login/start", response_model=CloudLoginStartResponse) +async def cloud_login_start( + request: Request, + data: CloudLoginStartRequest, + db: Session = Depends(get_db), + redis: Redis = Depends(get_redis), +): + link = _connected_link(db) + if link is None: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="This install is not linked to FrameOS Cloud") + if not _link_has_scope(link, "auth:login"): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="The cloud link is missing the auth:login permission; reconnect with it enabled", + ) + setup_mode = db.query(User).first() is None + authorization_url = await _handoff_authorization_url( + link, + redis, + mode="login", + next_path=_safe_next_path(data.next), + user_id=None, + intent="signup" if setup_mode else "login", + return_origin=_browser_origin(request), + ) + return {"authorization_url": authorization_url} + + +@api_user.post("/cloud/identity/link", response_model=CloudLoginStartResponse) +async def cloud_identity_link_start( + request: Request, + data: CloudLoginStartRequest, + db: Session = Depends(get_db), + redis: Redis = Depends(get_redis), + current_user: User | None = Depends(get_current_user), +): + """Explicitly link the logged-in local user to their cloud account. Runs the + same browser handoff as login; the callback stores the identity mapping + instead of creating a session.""" + if current_user is None: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Log in first") + link = _connected_link(db) + if link is None: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="This install is not linked to FrameOS Cloud") + if not _link_has_scope(link, "auth:login"): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="The cloud link is missing the auth:login permission; reconnect with it enabled", + ) + authorization_url = await _handoff_authorization_url( + link, + redis, + mode="link", + next_path=_safe_next_path(data.next), + user_id=current_user.id, + intent="login", + return_origin=_browser_origin(request), + ) + return {"authorization_url": authorization_url} + + +def _upsert_cloud_identity( + db: Session, user: User, link: CloudBackendLink, issuer: str, claims: dict +) -> CloudIdentity: + subject = str(claims.get("provider_subject") or claims.get("sub") or "") + identity = ( + db.query(CloudIdentity) + .filter(CloudIdentity.provider_issuer == issuer, CloudIdentity.provider_subject == subject) + .first() + ) + if identity is None: + identity = CloudIdentity(provider_issuer=issuer, provider_subject=subject, user_id=user.id) + db.add(identity) + identity.user_id = user.id + identity.provider_url = link.provider_url + identity.cloud_account_id = claims.get("account_id") + identity.email = claims.get("email") + identity.email_verified = bool(claims.get("email_verified")) + identity.name = claims.get("name") + identity.updated_at = _now() + return identity + + +@api_open.get("/cloud/login/callback") +async def cloud_login_callback( + request: Request, + code: str | None = None, + state: str | None = None, + error: str | None = None, + db: Session = Depends(get_db), + redis: Redis = Depends(get_redis), +): + """The browser lands here after approving (or failing) a cloud login.""" + if not state: + return _login_redirect("invalid_state") + state_raw = await redis.get(f"{CLOUD_LOGIN_STATE_PREFIX}{state}") + if state_raw: + await redis.delete(f"{CLOUD_LOGIN_STATE_PREFIX}{state}") + try: + state_data = json.loads(state_raw) if state_raw else None + except (TypeError, ValueError): + state_data = None + if not isinstance(state_data, dict): + return _login_redirect("invalid_state") + + mode = state_data.get("mode") or "login" + next_path = _safe_next_path(state_data.get("next")) + + link = _connected_link(db) + # In development the UI origin (e.g. Vite on :8616) differs from the + # link's registered origin that this callback runs on; send the browser + # back to where it started. Only origins vetted at start time are stored. + return_origin = _allowed_return_origin(state_data.get("return_origin"), link) if link else None + + def _target(path: str) -> str: + if return_origin and return_origin != _request_origin(request): + return f"{return_origin}{path}" + return path + + def _fail(reason: str) -> RedirectResponse: + return RedirectResponse(_target(f"/login?cloudError={reason}"), status_code=HTTPStatus.SEE_OTHER) + + if error: + return _fail(error) + + access_token = cloud.decrypt_cloud_secret(link.access_token) if link else None + if link is None or not access_token or not code: + return _fail("not_connected") + + try: + status_code, response = await cloud.frameos_login_token(link.provider_url, access_token, code) + except Exception: # noqa: BLE001 + return _fail("network_error") + claims = response.get("claims") + issuer = response.get("provider_issuer") + if status_code != 200 or not isinstance(claims, dict) or not issuer: + return _fail("exchange_failed") + subject = str(claims.get("provider_subject") or claims.get("sub") or "") + if not subject: + return _fail("exchange_failed") + + if mode == "link": + user = db.get(User, state_data.get("user_id")) + if user is None: + return _fail("invalid_state") + existing = ( + db.query(CloudIdentity) + .filter(CloudIdentity.provider_issuer == issuer, CloudIdentity.provider_subject == subject) + .first() + ) + if existing is not None and existing.user_id != user.id: + return RedirectResponse( + _target(f"{next_path or '/settings'}?cloudError=identity_in_use"), + status_code=HTTPStatus.SEE_OTHER, + ) + _upsert_cloud_identity(db, user, link, issuer, claims) + db.commit() + return RedirectResponse(_target(next_path or "/settings"), status_code=HTTPStatus.SEE_OTHER) + + # mode == "login" + identity = ( + db.query(CloudIdentity) + .filter(CloudIdentity.provider_issuer == issuer, CloudIdentity.provider_subject == subject) + .first() + ) + if identity is None and claims.get("account_id"): + # The same cloud account may sign in through different methods + # (password vs. Google), each with its own issuer/subject. The + # account id is the stable key, so fall back to it. + identity = ( + db.query(CloudIdentity) + .filter( + CloudIdentity.cloud_account_id == str(claims["account_id"]), + CloudIdentity.provider_url == link.provider_url, + ) + .order_by(CloudIdentity.id.desc()) + .first() + ) + if identity is not None: + user = identity.user + elif db.query(User).first() is None: + # First-run setup: the cloud principal who approved this install's link + # becomes the first local user. There is no password; they log in + # through the cloud (and can add a local password later if needed). + email = claims.get("email") or f"cloud-{claims.get('account_id') or subject}@frameos.local" + user = User(email=email) + db.add(user) + db.flush() + identity = _upsert_cloud_identity(db, user, link, issuer, claims) + else: + # A matching email is not proof of ownership: an existing local user + # must log in and explicitly link their cloud account first. + return _fail("not_linked") + + identity.last_login_at = _now() + db.commit() + + from app.tenancy import ensure_default_project_for_user + + ensure_default_project_for_user(db, user) + + expires = datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + session_value, max_age = create_session_cookie_value(email=user.email, expires_delta=expires) + redirect = RedirectResponse(_target(next_path or "/"), status_code=HTTPStatus.SEE_OTHER) + redirect.set_cookie( + key=SESSION_COOKIE_NAME, + value=session_value, + max_age=max_age, + httponly=True, + samesite="lax", + secure=_should_use_secure_cookie(request), + ) + return redirect + + +@api_user.post("/cloud/identity/unlink", response_model=CloudStatusResponse) +async def cloud_identity_unlink( + db: Session = Depends(get_db), current_user: User | None = Depends(get_current_user) +): + if current_user is None: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Log in first") + link = current_cloud_backend_link(db) + if link is not None and link.status == "connected" and not link.local_fallback_enabled: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="Local password login is disabled; re-enable it before unlinking your cloud account", + ) + db.query(CloudIdentity).filter(CloudIdentity.user_id == current_user.id).delete() + db.commit() + return _status_payload(db, link, current_user) + + +@api_user.post("/cloud/local-fallback", response_model=CloudStatusResponse) +async def set_local_fallback( + data: CloudLocalFallbackRequest, + db: Session = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + """Enable or disable local password login. Disabling requires a verified, + working cloud login for the link's owner, so nobody locks themselves out.""" + link = current_cloud_backend_link(db) + if link is None: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="This install is not linked to FrameOS Cloud") + + if data.enabled: + link.local_fallback_enabled = True + link.updated_at = _now() + db.commit() + return _status_payload(db, link, current_user) + + if current_user is None: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Log in first") + if link.status != "connected" or not _link_has_scope(link, "auth:login"): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="Connect to FrameOS Cloud with the auth:login permission first", + ) + identity = ( + db.query(CloudIdentity) + .filter( + CloudIdentity.user_id == current_user.id, + CloudIdentity.provider_url == link.provider_url, + ) + .order_by(CloudIdentity.id.desc()) + .first() + ) + if identity is None or not identity.cloud_account_id or identity.cloud_account_id != link.cloud_account_id: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="Link your account to the cloud account that owns this install first", + ) + + # Verify the link actually works right now — a dead link plus disabled + # passwords would lock the install. + access_token = cloud.decrypt_cloud_secret(link.access_token) + try: + status_code, _ = await cloud.backend_grants(link.provider_url, access_token or "") + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not verify the cloud link: {exc}" + ) from exc + if status_code != 200: + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail="The cloud link did not verify; local login stays enabled", + ) + + link.local_fallback_enabled = False + link.updated_at = _now() + db.commit() + return _status_payload(db, link, current_user) + + +# ---- enabled features (in-place scope changes) -------------------------------- + +BASE_LINK_SCOPES = ("backend:link", "backend:read") + +# Features included with every cloud account. Only security-sensitive features +# (cloud login, later remote access/telemetry) get an opt-in toggle in the UI; +# these safe scopes are always kept on the link, so a feature change can never +# drop them. +INCLUDED_FEATURE_SCOPES = ("backup:scenes", "backup:frames", "store:publish") + + +@api_user.post("/cloud/features", response_model=CloudStatusResponse) +async def set_cloud_features( + data: CloudFeaturesRequest, + db: Session = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + """Change which features this link may use, without disconnecting. + Removals apply immediately; additions need the owner's approval on the + provider (the status payload gains an "upgrade" block to poll).""" + link = _connected_link(db) + access_token = cloud.decrypt_cloud_secret(link.access_token) if link else None + if link is None or not access_token: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="This install is not linked to FrameOS Cloud") + if link.device_code: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="A feature change is already waiting for approval; approve or cancel it first", + ) + + features = [s for s in data.scopes if s in KNOWN_SCOPES and s not in BASE_LINK_SCOPES] + features += [s for s in INCLUDED_FEATURE_SCOPES if s not in features] + scopes = list(BASE_LINK_SCOPES) + features + + try: + status_code, response = await cloud.backend_set_scopes(link.provider_url, access_token, scopes) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + + if status_code == 200 and response.get("status") == "updated": + if response.get("scope"): + link.scope = response["scope"] + if "auth:login" not in scopes or "auth:login" not in link.scopes: + # Cloud login is no longer available, so local passwords must be + # restored before the current authenticated session can expire. + link.local_fallback_enabled = True + link.poll_error = None + link.updated_at = _now() + db.commit() + await _nudge_cloud_sync() + return _status_payload(db, link, current_user) + + if status_code == 200 and response.get("status") == "approval_required" and response.get("device_code"): + link.device_code = response["device_code"] + link.user_code = response.get("user_code") + link.verification_uri = response.get("verification_uri") + link.verification_uri_complete = response.get("verification_uri_complete") + link.interval_seconds = int(response.get("interval") or 5) + expires_in = response.get("expires_in") + link.expires_at = _now() + datetime.timedelta(seconds=int(expires_in)) if expires_in else None + link.poll_error = None + link.updated_at = _now() + db.commit() + return _status_payload(db, link, current_user) + + detail = response.get("error") or f"unexpected status {status_code}" + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud rejected the feature change: {detail}" + ) + + +@api_user.post("/cloud/features/cancel", response_model=CloudStatusResponse) +async def cancel_cloud_features( + db: Session = Depends(get_db), current_user: User | None = Depends(get_current_user) +): + """Forget a pending feature-change approval (it expires provider-side).""" + link = current_cloud_backend_link(db) + if _upgrade_pending(link): + _clear_upgrade(link) + db.commit() + return _status_payload(db, link, current_user) diff --git a/backend/app/api/tests/test_cloud.py b/backend/app/api/tests/test_cloud.py index abedd322f..ca8728c23 100644 --- a/backend/app/api/tests/test_cloud.py +++ b/backend/app/api/tests/test_cloud.py @@ -161,6 +161,59 @@ async def test_poll_pending_then_connected(async_client, cloud_calls, db): assert link.device_code is None +@pytest.mark.asyncio +async def test_poll_auto_links_identity_of_approver(async_client, cloud_calls, db): + """The cloud account that approved the link is the person connecting, so + the identity mapping is created without a separate handoff.""" + from app.models.cloud import CloudIdentity + from app.models.user import User + + _calls, responses = cloud_calls + await async_client.post("/api/cloud/connect", json={}) + responses["poll"] = (200, POLL_SUCCESS) + response = await async_client.post("/api/cloud/poll") + data = response.json() + assert data["identity"]["email"] == "owner@example.com" + + identity = db.query(CloudIdentity).first() + user = db.query(User).filter_by(email="test@example.com").first() + assert identity is not None + assert identity.user_id == user.id + assert identity.provider_subject == "subject-1" + assert identity.cloud_account_id == "acc-1" + + +@pytest.mark.asyncio +async def test_poll_never_steals_an_existing_identity(async_client, cloud_calls, db): + from app.models.cloud import CloudIdentity + from app.models.user import User + + other = User(email="other@example.com") + other.set_password("password123") + db.add(other) + db.commit() + db.add( + CloudIdentity( + user_id=other.id, + provider_url=PROVIDER, + provider_issuer=PROVIDER, + provider_subject="subject-1", + cloud_account_id="acc-1", + ) + ) + db.commit() + + _calls, responses = cloud_calls + await async_client.post("/api/cloud/connect", json={}) + responses["poll"] = (200, POLL_SUCCESS) + response = await async_client.post("/api/cloud/poll") + assert response.json()["status"] == "connected" + + identity = db.query(CloudIdentity).first() + assert identity.user_id == other.id # unchanged + assert db.query(CloudIdentity).count() == 1 + + @pytest.mark.asyncio async def test_poll_denied_resets_link(async_client, cloud_calls): _calls, responses = cloud_calls diff --git a/backend/app/api/tests/test_cloud_features.py b/backend/app/api/tests/test_cloud_features.py new file mode 100644 index 000000000..ff4cbba11 --- /dev/null +++ b/backend/app/api/tests/test_cloud_features.py @@ -0,0 +1,227 @@ +"""In-place feature (scope) changes and the cloud logout handoff.""" +import pytest + +from app.models.cloud import CloudBackendLink, CloudIdentity +from app.utils import cloud_link + +PROVIDER = "https://cloud.frameos.net" + + +def make_connected_link(db, scope="backend:link backend:read auth:login", local_origin="http://test"): + link = CloudBackendLink( + provider_url=PROVIDER, + status="connected", + access_token=cloud_link.encrypt_cloud_secret("link-token-secret"), + linked_client_id="lc-1", + scope=scope, + local_origin=local_origin, + cloud_account_id="acc-1", + ) + db.add(link) + db.commit() + db.refresh(link) + return link + + +@pytest.fixture +def scope_calls(monkeypatch): + calls = {"set_scopes": [], "poll": []} + responses = { + "set_scopes": ( + 200, + { + "status": "updated", + "scope": "backend:link backend:read backup:scenes backup:frames store:publish", + "linked_client_id": "lc-1", + }, + ), + "poll": (428, {"error": "authorization_pending", "interval": 5}), + } + + async def fake_set_scopes(provider_url, access_token, scopes): + calls["set_scopes"].append((provider_url, access_token, scopes)) + return responses["set_scopes"] + + async def fake_poll(provider_url, device_code): + calls["poll"].append((provider_url, device_code)) + return responses["poll"] + + monkeypatch.setattr(cloud_link, "backend_set_scopes", fake_set_scopes) + monkeypatch.setattr(cloud_link, "device_poll", fake_poll) + return calls, responses + + +@pytest.mark.asyncio +async def test_features_requires_link(async_client): + response = await async_client.post("/api/cloud/features", json={"scopes": ["auth:login"]}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_feature_removal_applies_immediately(async_client, db, scope_calls): + calls, _ = scope_calls + link = make_connected_link(db) + link.local_fallback_enabled = False + db.commit() + + response = await async_client.post("/api/cloud/features", json={"scopes": []}) + assert response.status_code == 200, response.text + data = response.json() + # auth:login is dropped; the included features are always kept on the link + assert data["link"]["scopes"] == [ + "backend:link", + "backend:read", + "backup:scenes", + "backup:frames", + "store:publish", + ] + assert data["local_fallback_enabled"] is True + assert data.get("upgrade") is None + + db.refresh(link) + assert link.local_fallback_enabled is True + + _url, token, scopes = calls["set_scopes"][0] + assert token == "link-token-secret" + assert scopes == ["backend:link", "backend:read", "backup:scenes", "backup:frames", "store:publish"] + + +@pytest.mark.asyncio +async def test_feature_addition_needs_approval_then_polls(async_client, db, scope_calls): + calls, responses = scope_calls + make_connected_link(db, scope="backend:link backend:read") + responses["set_scopes"] = ( + 200, + { + "status": "approval_required", + "device_code": "upgrade-device-1", + "user_code": "WXYZ-1234", + "verification_uri": f"{PROVIDER}/device", + "verification_uri_complete": f"{PROVIDER}/device?user_code=WXYZ-1234", + "expires_in": 600, + "interval": 5, + "scope": "backend:link backend:read auth:login", + }, + ) + + response = await async_client.post("/api/cloud/features", json={"scopes": ["auth:login"]}) + assert response.status_code == 200, response.text + # the included features ride along with the security-scope request + assert calls["set_scopes"][0][2] == [ + "backend:link", + "backend:read", + "auth:login", + "backup:scenes", + "backup:frames", + "store:publish", + ] + data = response.json() + assert data["status"] == "connected" # the link never drops + assert data["upgrade"]["user_code"] == "WXYZ-1234" + # the old scopes stay granted until approval + assert data["link"]["scopes"] == ["backend:link", "backend:read"] + + # A second change while one is pending is rejected. + response = await async_client.post("/api/cloud/features", json={"scopes": []}) + assert response.status_code == 409 + + # Pending poll keeps the upgrade block. + response = await async_client.post("/api/cloud/poll") + assert response.json()["upgrade"]["user_code"] == "WXYZ-1234" + assert calls["poll"][0][1] == "upgrade-device-1" + + # Approval: poll returns the new scope set but no token. + responses["poll"] = ( + 200, + {"status": "approved", "scope": "backend:link backend:read auth:login", "linked_client_id": "lc-1"}, + ) + response = await async_client.post("/api/cloud/poll") + data = response.json() + assert data.get("upgrade") is None + assert data["link"]["scopes"] == ["backend:link", "backend:read", "auth:login"] + assert data["status"] == "connected" + + link = db.query(CloudBackendLink).first() + assert link.device_code is None + # the token was never replaced + assert cloud_link.decrypt_cloud_secret(link.access_token) == "link-token-secret" + + +@pytest.mark.asyncio +async def test_feature_denial_keeps_link(async_client, db, scope_calls): + calls, responses = scope_calls + make_connected_link(db, scope="backend:link backend:read") + responses["set_scopes"] = ( + 200, + { + "status": "approval_required", + "device_code": "upgrade-device-2", + "user_code": "WXYZ-5678", + "expires_in": 600, + "interval": 5, + }, + ) + await async_client.post("/api/cloud/features", json={"scopes": ["auth:login"]}) + + responses["poll"] = (403, {"error": "access_denied"}) + response = await async_client.post("/api/cloud/poll") + data = response.json() + assert data["status"] == "connected" + assert data.get("upgrade") is None + assert data["poll_error"] == "access_denied" + assert data["link"]["scopes"] == ["backend:link", "backend:read"] + + +@pytest.mark.asyncio +async def test_feature_change_cancel(async_client, db, scope_calls): + _calls, responses = scope_calls + make_connected_link(db, scope="backend:link backend:read") + responses["set_scopes"] = ( + 200, + { + "status": "approval_required", + "device_code": "upgrade-device-3", + "user_code": "WXYZ-9999", + "expires_in": 600, + "interval": 5, + }, + ) + await async_client.post("/api/cloud/features", json={"scopes": ["auth:login"]}) + + response = await async_client.post("/api/cloud/features/cancel") + data = response.json() + assert data.get("upgrade") is None + assert data["status"] == "connected" + + +@pytest.mark.asyncio +async def test_logout_returns_cloud_logout_url_for_cloud_users(async_client, db): + link = make_connected_link(db) + from app.models.user import User + + user = db.query(User).filter_by(email="test@example.com").first() + db.add( + CloudIdentity( + user_id=user.id, + provider_url=PROVIDER, + provider_issuer=PROVIDER, + provider_subject="subject-1", + cloud_account_id="acc-1", + ) + ) + db.commit() + + response = await async_client.post("/api/logout") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["cloud_logout_url"].startswith(f"{PROVIDER}/logout?return_to=") + assert "%2Flogin" in data["cloud_logout_url"] + + +@pytest.mark.asyncio +async def test_logout_without_cloud_identity_has_no_cloud_url(async_client, db): + make_connected_link(db) + response = await async_client.post("/api/logout") + assert response.status_code == 200 + assert response.json()["cloud_logout_url"] is None diff --git a/backend/app/api/tests/test_cloud_login.py b/backend/app/api/tests/test_cloud_login.py new file mode 100644 index 000000000..61bb4b33b --- /dev/null +++ b/backend/app/api/tests/test_cloud_login.py @@ -0,0 +1,454 @@ +"""Cloud login (Phase 1): login handoff, identity linking, first-run setup, +and the local password fallback guard.""" +import json + +import pytest + +from app.models.cloud import CloudBackendLink, CloudIdentity +from app.models.user import User +from app.utils import cloud_link + +PROVIDER = "https://cloud.frameos.net" +ISSUER = "https://cloud.frameos.net" + +CLAIMS = { + "account_id": "acc-1", + "email": "owner@example.com", + "email_verified": True, + "name": "Owner", + "provider_subject": "subject-1", + "sub": "subject-1", +} + + +def make_connected_link(db, scope="backend:link backend:read auth:login", local_origin="http://test"): + link = CloudBackendLink( + provider_url=PROVIDER, + status="connected", + access_token=cloud_link.encrypt_cloud_secret("link-token-secret"), + linked_client_id="lc-1", + token_reference="tokref-1", + scope=scope, + local_origin=local_origin, + cloud_account_id="acc-1", + cloud_account_email="owner@example.com", + ) + db.add(link) + db.commit() + db.refresh(link) + return link + + +@pytest.fixture +def login_handoff(monkeypatch): + """Fake provider for the login handoff; records the start payloads.""" + calls = {"start": [], "token": []} + responses = { + "start": (200, {"authorization_url": f"{PROVIDER}/api/frameos/login/authorize?request=jwt", "expires_in": 600}), + "token": (200, {"claims": dict(CLAIMS), "provider_issuer": ISSUER}), + } + + async def fake_start(provider_url, access_token, payload): + calls["start"].append((provider_url, access_token, payload)) + return responses["start"] + + async def fake_token(provider_url, access_token, code): + calls["token"].append((provider_url, access_token, code)) + return responses["token"] + + monkeypatch.setattr(cloud_link, "frameos_login_start", fake_start) + monkeypatch.setattr(cloud_link, "frameos_login_token", fake_token) + return calls, responses + + +@pytest.mark.asyncio +async def test_login_options_without_link(async_client): + response = await async_client.get("/api/cloud/login/options") + assert response.status_code == 200 + data = response.json() + assert data["available"] is False + assert data["local_login_enabled"] is True + assert data["setup_mode"] is False + + +@pytest.mark.asyncio +async def test_login_options_with_auth_scope(async_client, db): + make_connected_link(db) + response = await async_client.get("/api/cloud/login/options") + data = response.json() + assert data["available"] is True + assert data["provider_url"] == PROVIDER + + +@pytest.mark.asyncio +async def test_login_start_requires_scope(async_client, db): + make_connected_link(db, scope="backend:link backend:read") + response = await async_client.post("/api/cloud/login/start", json={}) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_login_start_requires_link(async_client): + response = await async_client.post("/api/cloud/login/start", json={}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_login_start_returns_authorization_url(async_client, db, redis, login_handoff): + calls, _ = login_handoff + make_connected_link(db) + response = await async_client.post("/api/cloud/login/start", json={"next": "/frames"}) + assert response.status_code == 200 + assert response.json()["authorization_url"].startswith(PROVIDER) + + provider_url, access_token, payload = calls["start"][0] + assert provider_url == PROVIDER + assert access_token == "link-token-secret" + assert payload["redirect_uri"] == "http://test/api/cloud/login/callback" + assert payload["intent"] == "login" + assert payload["state"] + + +async def _start_and_get_state(async_client, calls, next_path=None, path="/api/cloud/login/start"): + response = await async_client.post(path, json={"next": next_path} if next_path else {}) + assert response.status_code == 200, response.text + return calls["start"][-1][2]["state"] + + +@pytest.mark.asyncio +async def test_login_callback_unknown_identity_is_rejected(async_client, db, redis, login_handoff): + calls, _ = login_handoff + make_connected_link(db) + state = await _start_and_get_state(async_client, calls) + + response = await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + assert response.status_code == 303 + assert response.headers["location"] == "/login?cloudError=not_linked" + # Email match alone must never log anyone in or create a second user. + assert db.query(User).count() == 1 + + +@pytest.mark.asyncio +async def test_login_callback_returns_to_dev_origin(async_client, db, redis, login_handoff): + """In dev the UI (e.g. Vite on :8616) proxies to the backend: the callback + must send the browser back to the origin it started from, not the link's + registered origin.""" + calls, _ = login_handoff + make_connected_link(db) + + # Link the identity first so the login succeeds. + state = await _start_and_get_state(async_client, calls, path="/api/cloud/identity/link") + await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + + response = await async_client.post( + "/api/cloud/login/start", + json={"next": "/frames"}, + headers={"origin": "http://localhost:8616"}, + ) + assert response.status_code == 200 + state = calls["start"][-1][2]["state"] + + response = await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + assert response.status_code == 303 + assert response.headers["location"] == "http://localhost:8616/frames" + assert "frameos_session" in response.headers.get("set-cookie", "") + + # A non-loopback origin that is not the link's own is never used. + response = await async_client.post( + "/api/cloud/login/start", + json={}, + headers={"origin": "https://evil.example"}, + ) + state = calls["start"][-1][2]["state"] + response = await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + assert response.status_code == 303 + assert response.headers["location"] == "/" + + +@pytest.mark.asyncio +async def test_login_callback_invalid_state(async_client, db, login_handoff): + make_connected_link(db) + response = await async_client.get("/api/cloud/login/callback?code=abc&state=bogus") + assert response.status_code == 303 + assert response.headers["location"] == "/login?cloudError=invalid_state" + + +@pytest.mark.asyncio +async def test_identity_link_then_cloud_login(async_client, db, redis, login_handoff): + calls, _ = login_handoff + make_connected_link(db) + + # 1. The logged-in user explicitly links their cloud account. + state = await _start_and_get_state(async_client, calls, path="/api/cloud/identity/link") + response = await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + assert response.status_code == 303 + assert response.headers["location"] == "/settings" + + identity = db.query(CloudIdentity).first() + assert identity is not None + assert identity.provider_issuer == ISSUER + assert identity.provider_subject == "subject-1" + assert identity.cloud_account_id == "acc-1" + user = db.query(User).filter_by(email="test@example.com").first() + assert identity.user_id == user.id + + # Status now reports the identity. + status = (await async_client.get("/api/cloud/status")).json() + assert status["identity"]["email"] == "owner@example.com" + + # 2. A later cloud login signs that user in with a session cookie. + state = await _start_and_get_state(async_client, calls, next_path="/frames") + response = await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + assert response.status_code == 303 + assert response.headers["location"] == "/frames" + assert "frameos_session" in response.headers.get("set-cookie", "") + + +@pytest.mark.asyncio +async def test_login_matches_identity_by_account_id(async_client, db, redis, login_handoff): + """The same cloud account signing in through another method (different + issuer/subject) still finds the identity via the stable account id.""" + calls, _ = login_handoff + make_connected_link(db) + user = db.query(User).filter_by(email="test@example.com").first() + db.add( + CloudIdentity( + user_id=user.id, + provider_url=PROVIDER, + provider_issuer="https://accounts.google.com", + provider_subject="google-subject-999", + cloud_account_id="acc-1", + ) + ) + db.commit() + + # The handoff claims carry subject-1 (password identity) but the same acc-1. + state = await _start_and_get_state(async_client, calls) + response = await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + assert response.status_code == 303 + assert response.headers["location"] == "/" + assert "frameos_session" in response.headers.get("set-cookie", "") + + +@pytest.mark.asyncio +async def test_identity_link_conflict(async_client, db, redis, login_handoff): + calls, _ = login_handoff + make_connected_link(db) + other = User(email="other@example.com") + other.set_password("password123") + db.add(other) + db.commit() + identity = CloudIdentity( + user_id=other.id, + provider_url=PROVIDER, + provider_issuer=ISSUER, + provider_subject="subject-1", + cloud_account_id="acc-1", + ) + db.add(identity) + db.commit() + + state = await _start_and_get_state(async_client, calls, path="/api/cloud/identity/link") + response = await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + assert response.status_code == 303 + assert "identity_in_use" in response.headers["location"] + + +@pytest.mark.asyncio +async def test_setup_mode_creates_first_user(no_auth_client, db, redis, login_handoff): + calls, _ = login_handoff + make_connected_link(db) + + options = (await no_auth_client.get("/api/cloud/login/options")).json() + assert options["setup_mode"] is True + assert options["available"] is True + + response = await no_auth_client.post("/api/cloud/login/start", json={}) + assert response.status_code == 200 + state = calls["start"][0][2]["state"] + assert calls["start"][0][2]["intent"] == "signup" + + response = await no_auth_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + assert response.status_code == 303 + assert response.headers["location"] == "/" + assert "frameos_session" in response.headers.get("set-cookie", "") + + user = db.query(User).first() + assert user is not None + assert user.email == "owner@example.com" + assert user.password is None # cloud-only user, no local password + identity = db.query(CloudIdentity).first() + assert identity.user_id == user.id + + # The cookie authenticates follow-up requests. + me = await no_auth_client.get("/api/user") + assert me.status_code == 200 + assert me.json()["email"] == "owner@example.com" + + # Password login for that user fails cleanly (no crash on empty hash). + login = await no_auth_client.post( + "/api/login", data={"username": "owner@example.com", "password": "whatever123"} + ) + assert login.status_code == 401 + + +@pytest.mark.asyncio +async def test_setup_endpoints_forbidden_after_first_user(async_client): + for path in ("/api/cloud/setup/status",): + response = await async_client.get(path) + assert response.status_code == 403 + response = await async_client.post("/api/cloud/setup/connect", json={}) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_setup_connect_flow_without_login(no_auth_client, db, monkeypatch): + async def fake_start(provider_url, payload): + return ( + 200, + { + "device_code": "device-code-1", + "user_code": "ABCD-1234", + "verification_uri": f"{PROVIDER}/device", + "verification_uri_complete": f"{PROVIDER}/device?code=ABCD-1234", + "expires_in": 600, + "interval": 5, + }, + ) + + monkeypatch.setattr(cloud_link, "device_start", fake_start) + response = await no_auth_client.get("/api/cloud/setup/status") + assert response.status_code == 200 + assert response.json()["status"] == "disconnected" + + response = await no_auth_client.post( + "/api/cloud/setup/connect", json={"scopes": ["backend:link", "backend:read", "auth:login"]} + ) + assert response.status_code == 200 + assert response.json()["status"] == "connecting" + assert response.json()["connection"]["user_code"] == "ABCD-1234" + + +@pytest.mark.asyncio +async def test_local_fallback_disable_and_login_guard(async_client, db, redis, login_handoff, monkeypatch): + calls, _ = login_handoff + link = make_connected_link(db) + + # Cannot disable before the user has linked the owning cloud account. + response = await async_client.post("/api/cloud/local-fallback", json={"enabled": False}) + assert response.status_code == 409 + + # Link the identity through the handoff. + state = await _start_and_get_state(async_client, calls, path="/api/cloud/identity/link") + await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + + async def fake_grants(provider_url, access_token): + return 200, {"grants": [{"account_id": "acc-1", "role": "owner"}]} + + monkeypatch.setattr(cloud_link, "backend_grants", fake_grants) + + response = await async_client.post("/api/cloud/local-fallback", json={"enabled": False}) + assert response.status_code == 200, response.text + assert response.json()["local_fallback_enabled"] is False + + # Password login is now rejected with a clear error. + login = await async_client.post( + "/api/login", data={"username": "test@example.com", "password": "testpassword"} + ) + assert login.status_code == 403 + assert "FrameOS Cloud" in login.json()["detail"] + + # Unlinking the identity while passwords are disabled would lock the user out. + response = await async_client.post("/api/cloud/identity/unlink") + assert response.status_code == 409 + + # Re-enable and everything works again. + response = await async_client.post("/api/cloud/local-fallback", json={"enabled": True}) + assert response.json()["local_fallback_enabled"] is True + login = await async_client.post( + "/api/login", data={"username": "test@example.com", "password": "testpassword"} + ) + assert login.status_code == 200 + + +@pytest.mark.asyncio +async def test_local_fallback_disable_requires_live_link(async_client, db, redis, login_handoff, monkeypatch): + calls, _ = login_handoff + make_connected_link(db) + state = await _start_and_get_state(async_client, calls, path="/api/cloud/identity/link") + await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + + async def dead_grants(provider_url, access_token): + return 401, {"error": "invalid_link_token"} + + monkeypatch.setattr(cloud_link, "backend_grants", dead_grants) + response = await async_client.post("/api/cloud/local-fallback", json={"enabled": False}) + assert response.status_code == 502 + + status = (await async_client.get("/api/cloud/status")).json() + assert status["local_fallback_enabled"] is True + + +@pytest.mark.asyncio +async def test_local_fallback_uses_identity_for_active_provider(async_client, db, monkeypatch): + link = make_connected_link(db) + user = db.query(User).filter_by(email="test@example.com").first() + db.add( + CloudIdentity( + user_id=user.id, + provider_url=PROVIDER, + provider_issuer=ISSUER, + provider_subject="active-provider-subject", + cloud_account_id="acc-1", + ) + ) + db.flush() + db.add( + CloudIdentity( + user_id=user.id, + provider_url="https://other-cloud.example", + provider_issuer="https://other-cloud.example", + provider_subject="newer-other-provider-subject", + cloud_account_id="other-account", + ) + ) + db.commit() + + async def fake_grants(provider_url, access_token): + assert provider_url == link.provider_url + return 200, {"grants": [{"account_id": "acc-1", "role": "owner"}]} + + monkeypatch.setattr(cloud_link, "backend_grants", fake_grants) + response = await async_client.post("/api/cloud/local-fallback", json={"enabled": False}) + + assert response.status_code == 200, response.text + assert response.json()["local_fallback_enabled"] is False + + +@pytest.mark.asyncio +async def test_disconnect_reenables_local_fallback(async_client, db, redis, login_handoff, monkeypatch): + calls, _ = login_handoff + link = make_connected_link(db) + state = await _start_and_get_state(async_client, calls, path="/api/cloud/identity/link") + await async_client.get(f"/api/cloud/login/callback?code=abc&state={state}") + + async def fake_grants(provider_url, access_token): + return 200, {"grants": [{"account_id": "acc-1", "role": "owner"}]} + + async def fake_unlink(provider_url, access_token): + return 200, {"status": "unlinked"} + + monkeypatch.setattr(cloud_link, "backend_grants", fake_grants) + monkeypatch.setattr(cloud_link, "backend_unlink", fake_unlink) + await async_client.post("/api/cloud/local-fallback", json={"enabled": False}) + + response = await async_client.post("/api/cloud/disconnect") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "disconnected" + assert data["local_fallback_enabled"] is True + + login = await async_client.post( + "/api/login", data={"username": "test@example.com", "password": "testpassword"} + ) + assert login.status_code == 200 diff --git a/backend/app/cloud/__init__.py b/backend/app/cloud/__init__.py new file mode 100644 index 000000000..816d0c8ae --- /dev/null +++ b/backend/app/cloud/__init__.py @@ -0,0 +1,4 @@ +# Control channel for the cloud sync service in the arq worker. Publish +# {"event": "sync_now"} after connect/disconnect so grant changes and the +# first inventory heartbeat land immediately instead of on the next tick. +CLOUD_SYNC_CHANNEL = "cloud_sync" diff --git a/backend/app/cloud/sync.py b/backend/app/cloud/sync.py new file mode 100644 index 000000000..67b010620 --- /dev/null +++ b/backend/app/cloud/sync.py @@ -0,0 +1,204 @@ +"""FrameOS Cloud sync service (CLOUD-TODO Phase 1). + +Runs as a single asyncio task inside the arq worker (same singleton slot as +the Home Assistant sync). Responsibilities: + +- Grants sync: periodically re-reads /api/backends/grants so a cloud-side + revocation takes effect quickly. A 401 means the link was revoked: the local + link resets and local password login is re-enabled so nobody is locked out. +- Inventory heartbeat: keeps version/health fresh on the provider. +""" +from __future__ import annotations + +import asyncio +import json +from typing import Optional + +from redis.asyncio import from_url as create_redis + +from app.cloud import CLOUD_SYNC_CHANNEL +from app.config import config +from app.database import SessionLocal +from app.models.cloud import CloudBackendLink, CloudMembership, current_cloud_backend_link +from app.utils import cloud_link + +GRANT_SYNC_INTERVAL_SECONDS = 15 * 60 + + +class CloudSync: + # ---- lifecycle ---------------------------------------------------------- + + async def run(self): + backoff = 5.0 + while True: + try: + await self._run_once() + backoff = 5.0 + except asyncio.CancelledError: + raise + except Exception as e: # noqa: BLE001 + print(f"🔴 FrameOS Cloud sync error, retrying in {backoff:.0f}s: {e}") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 300.0) + + async def _run_once(self): + redis_sub = create_redis(config.REDIS_URL, decode_responses=True) + try: + pubsub = redis_sub.pubsub() + await pubsub.subscribe(CLOUD_SYNC_CHANNEL) + await self._sync_link() + next_sync = asyncio.get_running_loop().time() + GRANT_SYNC_INTERVAL_SECONDS + while True: + timeout = max(next_sync - asyncio.get_running_loop().time(), 1.0) + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=timeout) + if message is not None: + await self._handle_message(message) + if asyncio.get_running_loop().time() >= next_sync: + await self._sync_link() + next_sync = asyncio.get_running_loop().time() + GRANT_SYNC_INTERVAL_SECONDS + finally: + try: + await redis_sub.close() + except Exception: # noqa: BLE001 + pass + + async def _handle_message(self, message: dict): + try: + parsed = json.loads(message["data"]) + except (TypeError, ValueError): + return + if not isinstance(parsed, dict): + return + if message.get("channel") == CLOUD_SYNC_CHANNEL: + if parsed.get("event") == "sync_now": + await self._sync_link() + + # ---- grants + inventory -------------------------------------------------- + + def _load_link(self) -> tuple[Optional[CloudBackendLink], Optional[str], int]: + db = SessionLocal() + try: + link = current_cloud_backend_link(db) + token = ( + cloud_link.decrypt_cloud_secret(link.access_token) + if link is not None and link.status == "connected" + else None + ) + return link, token, link.id if link else 0 + finally: + db.close() + + async def _sync_link(self): + link, access_token, link_id = self._load_link() + if link is None or access_token is None: + return + + try: + status_code, response = await cloud_link.backend_grants(link.provider_url, access_token) + except Exception as e: # noqa: BLE001 + print(f"🟡 FrameOS Cloud sync: grants check failed ({e}); keeping cached state") + return + + if status_code == 401: + self._revoke_link_locally(link_id) + return + if status_code != 200: + print(f"🟡 FrameOS Cloud sync: grants returned {status_code}; keeping cached state") + return + + self._store_grants(link_id, response) + + try: + await cloud_link.backend_inventory( + link.provider_url, + access_token, + { + "reported_frameos_version": _frameos_version(), + "capabilities": {"localFallback": True}, + "health": {"status": "ok"}, + }, + ) + self._stamp_inventory(link_id) + except Exception: # noqa: BLE001 + pass + + def _store_grants(self, link_id: int, response: dict): + import datetime + + grants = [g for g in (response.get("grants") or []) if isinstance(g, dict)] + db = SessionLocal() + try: + link = db.get(CloudBackendLink, link_id) + if link is None or link.status != "connected": + return + owner = next((g for g in grants if g.get("role") == "owner"), None) + if owner: + link.cloud_account_id = owner.get("account_id") + link.cloud_account_email = owner.get("account_email") + link.last_grant_sync_at = datetime.datetime.utcnow() + + seen_accounts = set() + for grant in grants: + account_id = grant.get("account_id") + if not account_id: + continue + seen_accounts.add(account_id) + membership = ( + db.query(CloudMembership) + .filter( + CloudMembership.backend_link_id == link.id, + CloudMembership.cloud_account_id == account_id, + ) + .first() + ) + if membership is None: + membership = CloudMembership( + backend_link_id=link.id, + cloud_account_id=account_id, + cloud_organization_id="", + ) + db.add(membership) + membership.role = grant.get("role") or "member" + membership.synced_at = datetime.datetime.utcnow() + stale = db.query(CloudMembership).filter(CloudMembership.backend_link_id == link.id) + if seen_accounts: + stale = stale.filter(CloudMembership.cloud_account_id.notin_(seen_accounts)) + stale.delete(synchronize_session=False) + db.commit() + finally: + db.close() + + def _stamp_inventory(self, link_id: int): + import datetime + + db = SessionLocal() + try: + link = db.get(CloudBackendLink, link_id) + if link is not None: + link.last_inventory_sync_at = datetime.datetime.utcnow() + db.commit() + finally: + db.close() + + def _revoke_link_locally(self, link_id: int): + db = SessionLocal() + try: + link = db.get(CloudBackendLink, link_id) + if link is None or link.status != "connected": + return + from app.api.cloud import _reset_link + + _reset_link(link, poll_error="revoked") + db.commit() + print("🟠 FrameOS Cloud sync: the provider revoked this link; local login re-enabled") + finally: + db.close() + + +def _frameos_version() -> str: + from app.utils.versions import current_frameos_version + + return current_frameos_version() + + +cloud_sync_service = CloudSync() diff --git a/backend/app/cloud/tests/__init__.py b/backend/app/cloud/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/cloud/tests/test_sync.py b/backend/app/cloud/tests/test_sync.py new file mode 100644 index 000000000..e2b102669 --- /dev/null +++ b/backend/app/cloud/tests/test_sync.py @@ -0,0 +1,117 @@ +"""The cloud sync singleton: grants sync and revocation handling.""" +import pytest + +from app.cloud.sync import CloudSync +from app.models.cloud import CloudBackendLink, CloudMembership +from app.utils import cloud_link + +PROVIDER = "https://cloud.frameos.net" + + +def make_connected_link(db, scope="backend:link backend:read"): + link = CloudBackendLink( + provider_url=PROVIDER, + status="connected", + access_token=cloud_link.encrypt_cloud_secret("link-token-secret"), + linked_client_id="lc-1", + scope=scope, + local_origin="http://test", + local_fallback_enabled=False, + ) + db.add(link) + db.commit() + db.refresh(link) + return link + + +@pytest.fixture +def service(): + return CloudSync() + + +@pytest.fixture +def cloud_calls(monkeypatch): + calls = {"grants": [], "inventory": []} + responses = { + "grants": ( + 200, + { + "grants": [ + {"account_id": "acc-1", "account_email": "owner@example.com", "role": "owner"}, + {"account_id": "acc-2", "account_email": "guest@example.com", "role": "member"}, + ] + }, + ), + "inventory": (200, {"status": "synced"}), + } + + def make(name): + async def call(*args): + calls[name].append(args) + response = responses[name] + if isinstance(response, Exception): + raise response + return response + + return call + + monkeypatch.setattr(cloud_link, "backend_grants", make("grants")) + monkeypatch.setattr(cloud_link, "backend_inventory", make("inventory")) + return calls, responses + + +@pytest.mark.asyncio +async def test_sync_link_updates_grants_and_memberships(db, service, cloud_calls): + link = make_connected_link(db) + await service._sync_link() + + db.expire_all() + link = db.get(CloudBackendLink, link.id) + assert link.cloud_account_id == "acc-1" + assert link.cloud_account_email == "owner@example.com" + assert link.last_grant_sync_at is not None + assert link.last_inventory_sync_at is not None + + memberships = db.query(CloudMembership).order_by(CloudMembership.cloud_account_id).all() + assert [(m.cloud_account_id, m.role) for m in memberships] == [("acc-1", "owner"), ("acc-2", "member")] + + # A removed grant disappears on the next sync. + calls, responses = cloud_calls + responses["grants"] = (200, {"grants": [{"account_id": "acc-1", "role": "owner"}]}) + await service._sync_link() + db.expire_all() + memberships = db.query(CloudMembership).all() + assert [m.cloud_account_id for m in memberships] == ["acc-1"] + + +@pytest.mark.asyncio +async def test_sync_link_handles_revocation(db, service, cloud_calls): + calls, responses = cloud_calls + link = make_connected_link(db) + assert link.local_fallback_enabled is False + + responses["grants"] = (401, {"error": "invalid_link_token"}) + await service._sync_link() + + db.expire_all() + link = db.get(CloudBackendLink, link.id) + assert link.status == "disconnected" + assert link.poll_error == "revoked" + assert link.access_token is None + # Revocation must never lock the install: local login comes back. + assert link.local_fallback_enabled is True + + +@pytest.mark.asyncio +async def test_sync_link_keeps_state_on_network_error(db, service, monkeypatch): + link = make_connected_link(db) + + async def boom(*_args): + raise RuntimeError("connection refused") + + monkeypatch.setattr(cloud_link, "backend_grants", boom) + await service._sync_link() + + db.expire_all() + link = db.get(CloudBackendLink, link.id) + assert link.status == "connected" diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py index 6d8dd5b7d..cfca70347 100644 --- a/backend/app/schemas/cloud.py +++ b/backend/app/schemas/cloud.py @@ -12,3 +12,31 @@ class CloudConnectRequest(BaseModel): class CloudProviderUpdateRequest(BaseModel): provider_url: str + + +class CloudLoginStartRequest(BaseModel): + # Same-origin path to land on after a successful cloud login. + next: str | None = None + + +class CloudLoginStartResponse(BaseModel): + authorization_url: str + + +class CloudLoginOptionsResponse(BaseModel): + # True when "Continue with FrameOS Cloud" can be offered on the login page. + available: bool + provider_url: str | None = None + # False when local password login has been disabled in favor of cloud login. + local_login_enabled: bool = True + # True while no local user exists yet (first-run setup). + setup_mode: bool = False + + +class CloudLocalFallbackRequest(BaseModel): + enabled: bool + + +class CloudFeaturesRequest(BaseModel): + # The full desired set of feature scopes (base link scopes are implied). + scopes: list[str] diff --git a/backend/app/tasks/worker.py b/backend/app/tasks/worker.py index 2ecac76be..e13133289 100644 --- a/backend/app/tasks/worker.py +++ b/backend/app/tasks/worker.py @@ -56,18 +56,23 @@ async def startup(ctx: Dict[str, Any]): if not config.TEST: from app.ha.sync import ha_sync_service ctx['ha_sync_task'] = asyncio.create_task(ha_sync_service.run()) + # Same singleton slot for the FrameOS Cloud sync (grants revocation, + # inventory heartbeat, automatic frame backups after deploys). + from app.cloud.sync import cloud_sync_service + ctx['cloud_sync_task'] = asyncio.create_task(cloud_sync_service.run()) print("Worker startup: created shared HTTPX client and Redis") # Optional: on_shutdown logic async def shutdown(ctx: Dict[str, Any]): if 'client' in ctx: await ctx['client'].aclose() - if task := ctx.pop('ha_sync_task', None): - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass + for task_key in ('ha_sync_task', 'cloud_sync_task'): + if task := ctx.pop(task_key, None): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass if 'redis' in ctx: await close_redis_connection(ctx['redis']) diff --git a/backend/app/utils/cloud_link.py b/backend/app/utils/cloud_link.py index e90145467..7f81df5a1 100644 --- a/backend/app/utils/cloud_link.py +++ b/backend/app/utils/cloud_link.py @@ -145,3 +145,24 @@ async def backend_set_scopes( return await cloud_request( "POST", provider_url, "/api/backends/scopes", access_token=access_token, json_body={"scopes": scopes} ) + + +# ---- login handoff (Phase 1) ------------------------------------------------- + + +async def frameos_login_start( + provider_url: str, access_token: str, payload: dict[str, Any] +) -> tuple[int, dict[str, Any]]: + """Ask the provider for an authorization URL for a browser login handoff.""" + return await cloud_request( + "POST", provider_url, "/api/frameos/login/start", access_token=access_token, json_body=payload + ) + + +async def frameos_login_token( + provider_url: str, access_token: str, code: str +) -> tuple[int, dict[str, Any]]: + """Redeem the single-use code from the login callback for identity claims.""" + return await cloud_request( + "POST", provider_url, "/api/frameos/login/token", access_token=access_token, json_body={"code": code} + ) diff --git a/frameos/frontend/src/scenes/login/Login.tsx b/frameos/frontend/src/scenes/login/Login.tsx index d7f9d5f22..1b124804f 100644 --- a/frameos/frontend/src/scenes/login/Login.tsx +++ b/frameos/frontend/src/scenes/login/Login.tsx @@ -6,8 +6,8 @@ import { TextInput } from '../../../../../frontend/src/components/TextInput' import { loginLogic } from './loginLogic' export default function Login() { - const { username, password, loading, error, theme } = useValues(loginLogic) - const { setUsername, setPassword, submitLogin, toggleTheme } = useActions(loginLogic) + const { username, password, loading, error, theme, cloudLoginAvailable } = useValues(loginLogic) + const { setUsername, setPassword, submitLogin, toggleTheme, startCloudLogin } = useActions(loginLogic) const darkMode = theme === 'dark' return ( @@ -60,6 +60,15 @@ export default function Login() { > {loading ? 'Signing in…' : 'Sign in'} + {cloudLoginAvailable ? ( + + ) : null} ) diff --git a/frameos/frontend/src/scenes/login/loginLogic.tsx b/frameos/frontend/src/scenes/login/loginLogic.tsx index 8d7a7a892..ed9d4083a 100644 --- a/frameos/frontend/src/scenes/login/loginLogic.tsx +++ b/frameos/frontend/src/scenes/login/loginLogic.tsx @@ -33,6 +33,8 @@ export const loginLogic = kea([ submitLogin: (credentials?: { username?: string; password?: string }) => ({ credentials }), setLoading: (loading: boolean) => ({ loading }), setError: (error: string | null) => ({ error }), + setCloudLoginAvailable: (available: boolean) => ({ available }), + startCloudLogin: true, bootstrapLoginPage: true, toggleTheme: true, }), @@ -40,7 +42,8 @@ export const loginLogic = kea([ username: ['', { setUsername: (_, { username }) => username }], password: ['', { setPassword: (_, { password }) => password }], loading: [false, { setLoading: (_, { loading }) => loading }], - error: [null as string | null, { setError: (_, { error }) => error }], + error: [null as string | null, { setError: (_, { error }) => error, startCloudLogin: () => null }], + cloudLoginAvailable: [false, { setCloudLoginAvailable: (_, { available }) => available }], theme: [getInitialTheme(), { toggleTheme: (theme) => (theme === 'dark' ? 'light' : 'dark') }], }), listeners(({ actions, values }) => ({ @@ -79,6 +82,40 @@ export const loginLogic = kea([ if (username !== null && password !== null) { actions.submitLogin({ username, password }) } + + // Offer "Continue with FrameOS Cloud" when this frame's cloud link has + // the auth:login permission. The callback redirects back with + // ?cloudError=… on failure. + try { + const optionsResponse = await fetch('/api/cloud/login/options') + if (optionsResponse.ok) { + const options = await optionsResponse.json() + actions.setCloudLoginAvailable(Boolean(options?.available)) + } + const cloudError = new URLSearchParams(window.location.search).get('cloudError') + if (cloudError) { + actions.setError(`FrameOS Cloud login failed: ${cloudError}`) + } + } catch { + // No cloud link; password login only. + } + }, + startCloudLogin: async () => { + try { + const response = await fetch('/api/cloud/login/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const payload = await response.json().catch(() => ({})) + if (!response.ok || !payload.authorization_url) { + actions.setError(payload.detail || 'Could not start the FrameOS Cloud login') + return + } + window.location.href = payload.authorization_url + } catch { + actions.setError('Could not start the FrameOS Cloud login') + } }, submitLogin: async ({ credentials }) => { if (values.loading) { diff --git a/frameos/src/frameos/server/auth.nim b/frameos/src/frameos/server/auth.nim index 07e01142d..32803021d 100644 --- a/frameos/src/frameos/server/auth.nim +++ b/frameos/src/frameos/server/auth.nim @@ -39,7 +39,7 @@ proc secureRandomBytes(byteCount: int): string = for i in 0 ..< result.len: result[i] = char(rand(255)) -proc secureRandomToken(byteCount = 32): string = +proc secureRandomToken*(byteCount = 32): string = let bytes = secureRandomBytes(byteCount) result = newStringOfCap(bytes.len * 2) for value in bytes: diff --git a/frameos/src/frameos/server/routes/cloud_api_routes.nim b/frameos/src/frameos/server/routes/cloud_api_routes.nim index 6c2b0fb5c..67c8923e2 100644 --- a/frameos/src/frameos/server/routes/cloud_api_routes.nim +++ b/frameos/src/frameos/server/routes/cloud_api_routes.nim @@ -28,6 +28,7 @@ const # Scopes a frame link may request; must stay in sync with docs/cloud-link.md. const KNOWN_FRAME_SCOPES = [ "frame:link", + "auth:login", "backup:assets", "remote:access", "telemetry:logs", @@ -82,7 +83,7 @@ proc resetLinkState(state: JsonNode, pollError: string = "") = for key in ["device_code", "user_code", "verification_uri", "verification_uri_complete", "expires_epoch", "access_token", "token_reference", "linked_client_id", "account_id", "account_email", "scope", "poll_error", "local_origin", - "connected_at", "last_inventory_sync_at"]: + "connected_at", "last_inventory_sync_at", "login_states"]: if state.hasKey(key): state.delete(key) state["provider_url"] = %providerUrl @@ -183,6 +184,27 @@ proc localOrigin*(request: Request): string = host = "localhost" scheme & "://" & host +const CLOUD_LOGIN_STATE_TTL_SECONDS = 600 + +proc linkHasScope(state: JsonNode, scope: string): bool = + scope in state{"scope"}.getStr("").splitWhitespace() + +proc pruneLoginStates(state: JsonNode) = + ## Drop expired pending login-handoff states (stored under login_states). + if state{"login_states"} == nil or state{"login_states"}.kind != JObject: + return + var expired: seq[string] + for key, value in state{"login_states"}: + if int64(value{"expires_epoch"}.getInt(0)) <= int64(epochTime()): + expired.add(key) + for key in expired: + state["login_states"].delete(key) + +proc redirectResponse(request: Request, location: string) = + var headers: mummy.HttpHeaders + headers["Location"] = location + request.respond(303, headers, "") + proc syncAfterConnect(state: JsonNode, providerUrl, accessToken: string) = ## Best effort: report inventory and learn which account owns us. try: @@ -302,6 +324,7 @@ proc addCloudApiRoutes*(router: var Router) = resetLinkState(state) state["provider_url"] = %providerUrl state["status"] = %"connecting" + # The provider only accepts login-handoff redirects on this origin. state["local_origin"] = %origin state["device_code"] = startResponse{"device_code"} state["user_code"] = startResponse{"user_code"} @@ -381,6 +404,138 @@ proc addCloudApiRoutes*(router: var Router) = jsonResponse(request, Http200, cloudStatusPayload(state)) ) + # ---- cloud login for the on-device admin (Phase 1) ------------------------- + # Open endpoints: the user is not logged in yet. The provider only mints a + # login code for the cloud account that owns this frame's link, so a + # completed handoff is proof of ownership. + + router.get("/api/cloud/login/options", proc(request: Request) {.gcsafe.} = + {.gcsafe.}: + withLock cloudLinkLock: + let state = loadCloudLinkState() + let available = state{"status"}.getStr("") == "connected" and + linkHasScope(state, "auth:login") and adminAuthEnabled() + jsonResponse(request, Http200, %*{ + "available": available, + "provider_url": providerUrlFromState(state), + "local_login_enabled": true, + "setup_mode": false, + }) + ) + + router.post("/api/cloud/login/start", proc(request: Request) {.gcsafe.} = + {.gcsafe.}: + if not adminAuthEnabled(): + jsonResponse(request, Http409, %*{"detail": "The admin panel is disabled on this frame"}) + return + var providerUrl = "" + var accessToken = "" + var origin = "" + withLock cloudLinkLock: + let state = loadCloudLinkState() + if state{"status"}.getStr("") != "connected" or state{"access_token"}.getStr("") == "": + jsonResponse(request, Http409, %*{"detail": "This frame is not linked to FrameOS Cloud"}) + return + if not linkHasScope(state, "auth:login"): + jsonResponse(request, Http403, + %*{"detail": "The cloud link is missing the auth:login permission; reconnect with it enabled"}) + return + providerUrl = providerUrlFromState(state) + accessToken = state{"access_token"}.getStr("") + origin = state{"local_origin"}.getStr("") + if origin.len == 0: + origin = localOrigin(request) + + let loginState = secureRandomToken(32) + var startCode = 0 + var startResponse: JsonNode = %*{} + try: + (startCode, startResponse) = cloudRequest(providerUrl, "/api/frameos/login/start", + accessToken = accessToken, body = %*{ + "redirect_uri": origin & "/api/cloud/login/callback", + "state": loginState, + "intent": "login", + }) + except CatchableError as error: + jsonResponse(request, Http502, %*{"detail": "Could not reach " & providerUrl & ": " & error.msg}) + return + if startCode != 200 or startResponse{"authorization_url"}.getStr("") == "": + let detail = startResponse{"error"}.getStr("unexpected status " & $startCode) + jsonResponse(request, Http502, %*{"detail": "FrameOS Cloud rejected the login request: " & detail}) + return + + withLock cloudLinkLock: + let state = loadCloudLinkState() + if state{"login_states"} == nil or state{"login_states"}.kind != JObject: + state["login_states"] = %*{} + pruneLoginStates(state) + state["login_states"][loginState] = %*{ + "expires_epoch": int(epochTime() + float(CLOUD_LOGIN_STATE_TTL_SECONDS)), + } + saveCloudLinkState(state) + jsonResponse(request, Http200, %*{ + "authorization_url": startResponse{"authorization_url"}.getStr(""), + }) + ) + + router.get("/api/cloud/login/callback", proc(request: Request) {.gcsafe.} = + {.gcsafe.}: + let stateParam = request.queryParams.getOrDefault("state", "") + let code = request.queryParams.getOrDefault("code", "") + let errorParam = request.queryParams.getOrDefault("error", "") + + var providerUrl = "" + var accessToken = "" + var ownerAccountId = "" + withLock cloudLinkLock: + let state = loadCloudLinkState() + pruneLoginStates(state) + if stateParam.len == 0 or state{"login_states"} == nil or + state{"login_states"}{stateParam} == nil: + saveCloudLinkState(state) + redirectResponse(request, "/login?cloudError=invalid_state") + return + state["login_states"].delete(stateParam) + saveCloudLinkState(state) + if state{"status"}.getStr("") != "connected" or state{"access_token"}.getStr("") == "": + redirectResponse(request, "/login?cloudError=not_connected") + return + providerUrl = providerUrlFromState(state) + accessToken = state{"access_token"}.getStr("") + ownerAccountId = state{"account_id"}.getStr("") + + if errorParam.len > 0: + redirectResponse(request, "/login?cloudError=" & errorParam) + return + if code.len == 0 or not adminAuthEnabled(): + redirectResponse(request, "/login?cloudError=exchange_failed") + return + + var tokenCode = 0 + var tokenResponse: JsonNode = %*{} + try: + (tokenCode, tokenResponse) = cloudRequest(providerUrl, "/api/frameos/login/token", + accessToken = accessToken, body = %*{"code": code}) + except CatchableError: + redirectResponse(request, "/login?cloudError=network_error") + return + let claims = tokenResponse{"claims"} + if tokenCode != 200 or claims == nil or claims.kind != JObject: + redirectResponse(request, "/login?cloudError=exchange_failed") + return + # The provider only completes a handoff for the account that owns this + # link; double-check when we know who that is. + if ownerAccountId.len > 0 and claims{"account_id"}.getStr("") != ownerAccountId: + redirectResponse(request, "/login?cloudError=linked_client_required") + return + + let sessionToken = createAdminSession() + var headers: mummy.HttpHeaders + headers["Set-Cookie"] = adminSessionCookieHeader(request, sessionToken) + headers["Location"] = "/admin" + request.respond(303, headers, "") + ) + router.post("/api/cloud/disconnect", proc(request: Request) {.gcsafe.} = if not hasAdminAccess(request): jsonResponse(request, Http401, %*{"detail": "Unauthorized"}) diff --git a/frontend/src/scenes/auth/cloudLoginLogic.ts b/frontend/src/scenes/auth/cloudLoginLogic.ts new file mode 100644 index 000000000..e8d429a67 --- /dev/null +++ b/frontend/src/scenes/auth/cloudLoginLogic.ts @@ -0,0 +1,104 @@ +import { actions, afterMount, kea, listeners, path, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' + +import { CloudLoginOptions } from '../../types' +import { getBasePath } from '../../utils/getBasePath' + +import type { cloudLoginLogicType } from './cloudLoginLogicType' + +/** "Continue with FrameOS Cloud" on the login and first-run setup screens. + * Uses only open endpoints (the user is not logged in yet); the same paths + * exist on the backend and on the frame's on-device admin server. */ +export const cloudLoginLogic = kea([ + path(['src', 'scenes', 'auth', 'cloudLoginLogic']), + actions({ + startCloudLogin: (next?: string) => ({ next: next ?? null }), + setCloudLoginError: (error: string | null) => ({ error }), + }), + loaders({ + cloudLoginOptions: [ + null as CloudLoginOptions | null, + { + loadCloudLoginOptions: async () => { + try { + const response = await fetch(`${getBasePath()}/api/cloud/login/options`, { + headers: { Accept: 'application/json' }, + }) + if (!response.ok) { + return null + } + return (await response.json()) as CloudLoginOptions + } catch { + return null + } + }, + }, + ], + }), + reducers({ + cloudLoginError: [ + // The login callback redirects back with ?cloudError=… on failure. + (typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('cloudError') : null) as + | string + | null, + { + setCloudLoginError: (_, { error }) => error, + startCloudLogin: () => null, + }, + ], + isCloudLoginStarting: [ + false, + { + startCloudLogin: () => true, + setCloudLoginError: () => false, + }, + ], + }), + selectors({ + cloudLoginAvailable: [(s) => [s.cloudLoginOptions], (options): boolean => options?.available ?? false], + localLoginEnabled: [(s) => [s.cloudLoginOptions], (options): boolean => options?.local_login_enabled ?? true], + }), + listeners(({ actions }) => ({ + startCloudLogin: async ({ next }) => { + try { + const response = await fetch(`${getBasePath()}/api/cloud/login/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(next ? { next } : {}), + }) + const payload = await response.json().catch(() => ({})) + if (!response.ok || !payload.authorization_url) { + actions.setCloudLoginError(payload.detail || 'Could not start the FrameOS Cloud login') + return + } + window.location.href = payload.authorization_url + } catch { + actions.setCloudLoginError('Could not reach this server to start the FrameOS Cloud login') + } + }, + })), + afterMount(({ actions }) => { + actions.loadCloudLoginOptions() + }), +]) + +export function cloudLoginErrorMessage(error: string): string { + switch (error) { + case 'not_linked': + return 'That FrameOS Cloud account is not linked to a user on this install. Log in locally and link it under Settings → FrameOS Cloud.' + case 'not_connected': + return 'This install is not connected to FrameOS Cloud.' + case 'invalid_state': + return 'The cloud login expired or was already used. Try again.' + case 'exchange_failed': + return 'FrameOS Cloud did not accept the login. Try again.' + case 'network_error': + return 'Could not reach the FrameOS Cloud server.' + case 'access_denied': + return 'The login was denied in FrameOS Cloud.' + case 'linked_client_required': + return 'Only the cloud account that owns this install can log in with FrameOS Cloud.' + default: + return `FrameOS Cloud login failed: ${error}` + } +} diff --git a/frontend/src/scenes/login/Login.tsx b/frontend/src/scenes/login/Login.tsx index 5a8e9c032..52e688bc6 100644 --- a/frontend/src/scenes/login/Login.tsx +++ b/frontend/src/scenes/login/Login.tsx @@ -2,45 +2,81 @@ import { Form } from 'kea-forms' import { Field } from '../../components/Field' import { TextInput } from '../../components/TextInput' import { loginLogic } from './loginLogic' -import { useValues } from 'kea' +import { useActions, useValues } from 'kea' import { AuthScreen } from '../auth/AuthScreen' +import { cloudLoginErrorMessage, cloudLoginLogic } from '../auth/cloudLoginLogic' const authInputClassName = 'frameos-input auth-input h-12 rounded-2xl px-4 py-3 text-base shadow-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-400' export function Login() { const { isLoginFormSubmitting } = useValues(loginLogic) + const { cloudLoginAvailable, localLoginEnabled, cloudLoginError, isCloudLoginStarting } = useValues(cloudLoginLogic) + const { startCloudLogin } = useActions(cloudLoginLogic) + return ( -
- - - - - - - -
+
+ {cloudLoginError ? ( +
+ {cloudLoginErrorMessage(cloudLoginError)} +
+ ) : null} + {cloudLoginAvailable ? ( + <> + + {localLoginEnabled ? ( +
+ + or + +
+ ) : null} + + ) : null} + {localLoginEnabled ? ( +
+ + + + + + + +
+ ) : ( +
+ Local password login is disabled on this install. Use FrameOS Cloud to sign in. +
+ )} +
) } diff --git a/frontend/src/scenes/sceneLogic.tsx b/frontend/src/scenes/sceneLogic.tsx index 061f82921..c226064aa 100644 --- a/frontend/src/scenes/sceneLogic.tsx +++ b/frontend/src/scenes/sceneLogic.tsx @@ -40,12 +40,23 @@ export const sceneLogic = kea([ }), listeners(({ actions }) => ({ logout: async () => { + let cloudLogoutUrl: string | null = null try { - await fetch(`${getBasePath()}/api/logout`, { method: 'POST' }) + const response = await fetch(`${getBasePath()}/api/logout`, { method: 'POST' }) + if (response.ok) { + cloudLogoutUrl = (await response.json())?.cloud_logout_url ?? null + } } catch (error) { console.error('Logout failed', error) } clearCachedProjectId() + if (cloudLogoutUrl) { + // Cloud-login users must also leave their FrameOS Cloud session, or + // the login screen's cloud button would sign them straight back in. + // The cloud bounces back to our /login page. + location.href = cloudLogoutUrl + return + } location.href = urls.frames() }, })), diff --git a/frontend/src/scenes/settings/CloudSettings.tsx b/frontend/src/scenes/settings/CloudSettings.tsx index 0e0c544f4..91e185890 100644 --- a/frontend/src/scenes/settings/CloudSettings.tsx +++ b/frontend/src/scenes/settings/CloudSettings.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { useActions, useValues } from 'kea' import { Form } from 'kea-forms' import { PencilSquareIcon } from '@heroicons/react/24/solid' @@ -11,7 +12,8 @@ import { Spinner } from '../../components/Spinner' import { Tag } from '../../components/Tag' import { TextInput } from '../../components/TextInput' import { isInFrameAdminMode } from '../../utils/frameAdmin' -import { CLOUD_FEATURES, cloudLogic } from './cloudLogic' +import { inHassioIngress } from '../../utils/inHassioIngress' +import { availableCloudFeatures, cloudLogic } from './cloudLogic' function pollErrorMessage(pollError: string): string { switch (pollError) { @@ -53,8 +55,22 @@ export function CloudSettingsSection({ headingId = 'settings-cloud' }: { heading isProviderUrlSubmitting, isCloudConnecting, isCloudDisconnecting, + enabledFeatureDraft, + featureChangesPending, + isFeatureChangeSubmitting, } = useValues(cloudLogic) - const { connectCloud, disconnectCloud, setProviderEditorOpen } = useActions(cloudLogic) + const { + connectCloud, + disconnectCloud, + setProviderEditorOpen, + toggleEnabledFeature, + applyFeatureChanges, + cancelFeatureChange, + resetFeatureDraft, + linkCloudIdentity, + unlinkCloudIdentity, + setLocalFallback, + } = useActions(cloudLogic) const frameAdminMode = isInFrameAdminMode() if (cloudStatus && !cloudStatus.enabled) { @@ -105,15 +121,135 @@ export function CloudSettingsSection({ headingId = 'settings-cloud' }: { heading
- {CLOUD_FEATURES.map(({ scope, label, description }) => ( -
+ + ) : null} + {!frameAdminMode && !inHassioIngress() && link.scopes.includes('auth:login') ? ( +
+
+ +
+
+ {cloudStatus?.identity ? ( + <> + + Your account is linked as{' '} + {cloudStatus.identity.email ?? cloudStatus.identity.name ?? 'cloud user'} + + + + ) : ( + <> + Link your cloud account to log in here with FrameOS Cloud. + + + )} +
+
+ ) : null} + {!frameAdminMode && !inHassioIngress() && cloudStatus?.identity && link.scopes.includes('auth:login') ? ( +
+
+ +
+
+ {cloudStatus?.local_fallback_enabled === false ? ( + <> + Disabled + + + ) : ( + <> + Enabled + + + Requires a verified cloud login by the account that owns this install. - - ))} + + )}
) : null} diff --git a/frontend/src/scenes/settings/cloudLogic.tsx b/frontend/src/scenes/settings/cloudLogic.tsx index 614377182..d249c974b 100644 --- a/frontend/src/scenes/settings/cloudLogic.tsx +++ b/frontend/src/scenes/settings/cloudLogic.tsx @@ -5,6 +5,7 @@ import { loaders } from 'kea-loaders' import { CloudStatus } from '../../types' import { apiFetch } from '../../utils/apiFetch' import { isInFrameAdminMode } from '../../utils/frameAdmin' +import { inHassioIngress } from '../../utils/inHassioIngress' import type { cloudLogicType } from './cloudLogicType' @@ -15,15 +16,14 @@ export interface CloudProviderForm { /** The features a link can enable, in the wording the consent screen uses. * Kept in sync with the scope table in CLOUD-TODO.md. * - * Everything that is safe comes with the cloud account itself and is - * requested with the link; 'locked' renders an always-on checkbox. - * Security-sensitive features (cloud login, remote access, ...) will get a - * cloud-approved opt-in toggle when they ship. */ + * Only security-sensitive features ('toggle') need a cloud-approved opt-in; + * everything that is safe comes with the cloud account itself: 'locked' + * renders an always-on checkbox. */ export const CLOUD_FEATURES: { scope: string label: string description: string - control: 'locked' + control: 'toggle' | 'locked' }[] = [ { scope: 'store:publish', @@ -43,10 +43,29 @@ export const CLOUD_FEATURES: { description: 'Back up frame settings + scenes automatically after each deploy', control: 'locked', }, + { + scope: 'auth:login', + label: 'Cloud login', + description: 'Sign in to this FrameOS with your cloud account', + control: 'toggle', + }, ] -/** Scopes that come with every connected cloud account; requested at link time. */ -export const INCLUDED_FEATURE_SCOPES = CLOUD_FEATURES.map(({ scope }) => scope) +/** Security-sensitive scopes the user toggles on and off explicitly. */ +const TOGGLED_FEATURE_SCOPES = CLOUD_FEATURES.filter(({ control }) => control === 'toggle').map(({ scope }) => scope) + +/** Scopes that come with every connected cloud account; requested at link + * time and silently kept on every scope change. */ +export const INCLUDED_FEATURE_SCOPES = CLOUD_FEATURES.filter(({ control }) => control !== 'toggle').map( + ({ scope }) => scope +) + +/** The features this runtime can offer. Home Assistant ingress has no login + * of its own (Home Assistant authenticates the user), so there is no + * cloud-login permission to ask for or receive there. */ +export function availableCloudFeatures(): typeof CLOUD_FEATURES { + return inHassioIngress() ? CLOUD_FEATURES.filter(({ scope }) => scope !== 'auth:login') : CLOUD_FEATURES +} const BASE_SCOPES = ['backend:link', 'backend:read'] @@ -72,6 +91,14 @@ export const cloudLogic = kea([ disconnectCloud: true, setProviderEditorOpen: (open: boolean) => ({ open }), setCloudError: (error: string | null) => ({ error }), + toggleEnabledFeature: (scope: string) => ({ scope }), + setFeatureDraft: (draft: string[] | null) => ({ draft }), + applyFeatureChanges: true, + cancelFeatureChange: true, + resetFeatureDraft: true, + linkCloudIdentity: true, + unlinkCloudIdentity: true, + setLocalFallback: (enabled: boolean) => ({ enabled }), }), loaders(() => ({ cloudStatus: [ @@ -120,6 +147,24 @@ export const cloudLogic = kea([ setCloudError: () => false, }, ], + // Staged (unapplied) feature set while connected; null mirrors what is + // currently granted. applyFeatureChanges submits it. + featureDraft: [ + null as string[] | null, + { + setFeatureDraft: (_, { draft }) => draft, + resetFeatureDraft: () => null, + disconnectCloud: () => null, + }, + ], + isFeatureChangeSubmitting: [ + false, + { + applyFeatureChanges: () => true, + loadCloudStatusSuccess: () => false, + setCloudError: () => false, + }, + ], }), forms(({ actions }) => ({ providerUrl: { @@ -149,12 +194,31 @@ export const cloudLogic = kea([ (cloudStatus): string => cloudStatus?.provider_url ?? 'https://cloud.frameos.net', ], grantedScopes: [(s) => [s.cloudStatus], (cloudStatus): string[] => cloudStatus?.link?.scopes ?? []], + // Only the toggleable (security) features; included features are always on. + grantedFeatures: [ + (s) => [s.grantedScopes], + (scopes): string[] => scopes.filter((scope) => TOGGLED_FEATURE_SCOPES.includes(scope)), + ], + enabledFeatureDraft: [ + (s) => [s.featureDraft, s.grantedFeatures], + (featureDraft, grantedFeatures): string[] => featureDraft ?? grantedFeatures, + ], + featureChangesPending: [ + (s) => [s.featureDraft, s.grantedFeatures], + (featureDraft, grantedFeatures): boolean => + featureDraft !== null && + (featureDraft.length !== grantedFeatures.length || + featureDraft.some((scope) => !grantedFeatures.includes(scope))), + ], + featureUpgradePending: [(s) => [s.cloudStatus], (cloudStatus): boolean => Boolean(cloudStatus?.upgrade)], }), listeners(({ actions, values }) => ({ connectCloud: async () => { // Connecting asks for the link plus every included ("safe") feature in - // one approval. - const scopes = isInFrameAdminMode() ? ['frame:link'] : [...BASE_SCOPES, ...INCLUDED_FEATURE_SCOPES] + // one approval; security features (cloud login) are toggled on + // afterwards. Frames still bundle auth:login (they have no feature + // manager yet, and cloud login is their one cloud feature). + const scopes = isInFrameAdminMode() ? ['frame:link', 'auth:login'] : [...BASE_SCOPES, ...INCLUDED_FEATURE_SCOPES] const response = await apiFetch('/api/cloud/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -167,7 +231,7 @@ export const cloudLogic = kea([ actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) }, pollCloud: async (_, breakpoint) => { - if (values.cloudStatus?.status !== 'connecting') { + if (values.cloudStatus?.status !== 'connecting' && !values.cloudStatus?.upgrade) { return } const response = await apiFetch('/api/cloud/poll', { method: 'POST' }) @@ -185,6 +249,10 @@ export const cloudLogic = kea([ if (cloudStatus?.status === 'connecting') { await breakpoint((cloudStatus.connection?.interval_seconds ?? 5) * 1000) actions.pollCloud() + } else if (cloudStatus?.upgrade) { + // A feature change is waiting for approval on the provider. + await breakpoint((cloudStatus.upgrade.interval_seconds ?? 5) * 1000) + actions.pollCloud() } }, disconnectCloud: async () => { @@ -202,6 +270,70 @@ export const cloudLogic = kea([ actions.resetProviderUrl() } }, + toggleEnabledFeature: ({ scope }) => { + if (!TOGGLED_FEATURE_SCOPES.includes(scope)) { + return // included features are always on + } + const current = values.enabledFeatureDraft + const next = current.includes(scope) ? current.filter((s) => s !== scope) : [...current, scope] + actions.setFeatureDraft(next) + }, + applyFeatureChanges: async () => { + const response = await apiFetch('/api/cloud/features', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scopes: [...INCLUDED_FEATURE_SCOPES, ...values.enabledFeatureDraft] }), + }) + if (!response.ok) { + actions.setCloudError(await cloudErrorMessage(response, 'Failed to change the enabled features')) + return + } + actions.resetFeatureDraft() + actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) + }, + cancelFeatureChange: async () => { + const response = await apiFetch('/api/cloud/features/cancel', { method: 'POST' }) + if (!response.ok) { + actions.setCloudError(await cloudErrorMessage(response, 'Failed to cancel the feature change')) + return + } + actions.resetFeatureDraft() + actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) + }, + linkCloudIdentity: async () => { + // Browser handoff: the callback returns to /settings with the identity stored. + const response = await apiFetch('/api/cloud/identity/link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ next: window.location.pathname }), + }) + const payload = await response.json().catch(() => ({})) + if (!response.ok || !payload.authorization_url) { + actions.setCloudError(payload.detail || 'Could not start the cloud account link') + return + } + window.location.href = payload.authorization_url + }, + unlinkCloudIdentity: async () => { + const response = await apiFetch('/api/cloud/identity/unlink', { method: 'POST' }) + if (!response.ok) { + actions.setCloudError(await cloudErrorMessage(response, 'Could not unlink the cloud account')) + return + } + actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) + }, + setLocalFallback: async ({ enabled }) => { + const response = await apiFetch('/api/cloud/local-fallback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }) + if (!response.ok) { + actions.setCloudError(await cloudErrorMessage(response, 'Could not change local password login')) + return + } + actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) + }, })), afterMount(({ actions }) => { actions.loadCloudStatus() diff --git a/frontend/src/scenes/signup/Signup.tsx b/frontend/src/scenes/signup/Signup.tsx index 8b3fa122a..cf96c192c 100644 --- a/frontend/src/scenes/signup/Signup.tsx +++ b/frontend/src/scenes/signup/Signup.tsx @@ -2,10 +2,94 @@ import { Form } from 'kea-forms' import { Field } from '../../components/Field' import { TextInput } from '../../components/TextInput' import { signupLogic } from './signupLogic' -import { useValues } from 'kea' +import { signupCloudLogic } from './signupCloudLogic' +import { useActions, useValues } from 'kea' import { AuthScreen, AuthLink } from '../auth/AuthScreen' +import { cloudLoginErrorMessage, cloudLoginLogic } from '../auth/cloudLoginLogic' import { urls } from '../../urls' +/** First-run cloud option: link this install to FrameOS Cloud and create the + * first user from the approving cloud account — or restore a previous setup. */ +function SignupCloudSection(): JSX.Element | null { + const { setupCloudStatus, setupCloudError, isSetupCloudConnecting } = useValues(signupCloudLogic) + const { connectSetupCloud, cancelSetupCloud } = useActions(signupCloudLogic) + const { isCloudLoginStarting, cloudLoginError } = useValues(cloudLoginLogic) + const { startCloudLogin } = useActions(cloudLoginLogic) + + if (!setupCloudStatus || !setupCloudStatus.enabled) { + return null + } + const providerHost = (setupCloudStatus.provider_url ?? 'cloud.frameos.net').replace(/^https?:\/\//, '') + const connection = setupCloudStatus.connection + + return ( +
+
+ + or + +
+ {cloudLoginError ? ( +
+ {cloudLoginErrorMessage(cloudLoginError)} +
+ ) : null} + {setupCloudStatus.status === 'connected' ? ( + + ) : setupCloudStatus.status === 'connecting' && connection ? ( +
+
Enter this code on the approval page to link this install:
+
+ + {connection.user_code} + + +
+
+ Waiting for approval…{' '} + +
+
+ ) : ( + + )} + {setupCloudError ?
{setupCloudError}
: null} +
+ Links this install to your {providerHost} account, signs you in with it, and lets you restore cloud backups of a + previous install. A local account keeps working either way. +
+
+ ) +} + const authInputClassName = 'frameos-input auth-input h-12 rounded-2xl px-4 py-3 text-base shadow-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-400' @@ -21,45 +105,48 @@ export function Signup() { } > -
- - - - - - - - - - -
+ <> +
+ + + + + + + + + + +
+ + ) } diff --git a/frontend/src/scenes/signup/signupCloudLogic.ts b/frontend/src/scenes/signup/signupCloudLogic.ts new file mode 100644 index 000000000..4ff878cb3 --- /dev/null +++ b/frontend/src/scenes/signup/signupCloudLogic.ts @@ -0,0 +1,103 @@ +import { actions, afterMount, kea, listeners, path, reducers } from 'kea' +import { loaders } from 'kea-loaders' + +import { CloudStatus } from '../../types' +import { getBasePath } from '../../utils/getBasePath' +import { INCLUDED_FEATURE_SCOPES } from '../settings/cloudLogic' + +import type { signupCloudLogicType } from './signupCloudLogicType' + +// First-run setup: no user exists yet, so this uses the open +// /api/cloud/setup/* endpoints (they stop working the moment a user exists). +// Once linked with auth:login, cloudLoginLogic's "Continue with FrameOS +// Cloud" creates the first user from the approving cloud account. +const SETUP_SCOPES = ['backend:link', 'backend:read', 'auth:login', ...INCLUDED_FEATURE_SCOPES] + +async function setupFetch(path: string, init?: RequestInit): Promise { + return await fetch(`${getBasePath()}${path}`, init) +} + +export const signupCloudLogic = kea([ + path(['src', 'scenes', 'signup', 'signupCloudLogic']), + actions({ + connectSetupCloud: true, + pollSetupCloud: true, + cancelSetupCloud: true, + setSetupCloudError: (error: string | null) => ({ error }), + }), + loaders({ + setupCloudStatus: [ + null as CloudStatus | null, + { + loadSetupCloudStatus: async () => { + try { + const response = await setupFetch('/api/cloud/setup/status') + if (!response.ok) { + return null // setup already complete, or cloud disabled + } + return (await response.json()) as CloudStatus + } catch { + return null + } + }, + }, + ], + }), + reducers({ + setupCloudError: [ + null as string | null, + { + setSetupCloudError: (_, { error }) => error, + connectSetupCloud: () => null, + }, + ], + isSetupCloudConnecting: [ + false, + { + connectSetupCloud: () => true, + loadSetupCloudStatusSuccess: () => false, + setSetupCloudError: () => false, + }, + ], + }), + listeners(({ actions, values }) => ({ + connectSetupCloud: async () => { + const response = await setupFetch('/api/cloud/setup/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scopes: SETUP_SCOPES }), + }) + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + actions.setSetupCloudError(payload.detail || 'Could not reach FrameOS Cloud') + return + } + actions.loadSetupCloudStatusSuccess((await response.json()) as CloudStatus) + }, + pollSetupCloud: async () => { + if (values.setupCloudStatus?.status !== 'connecting') { + return + } + const response = await setupFetch('/api/cloud/setup/poll', { method: 'POST' }) + if (!response.ok) { + return + } + actions.loadSetupCloudStatusSuccess((await response.json()) as CloudStatus) + }, + loadSetupCloudStatusSuccess: async ({ setupCloudStatus }, breakpoint) => { + if (setupCloudStatus?.status === 'connecting') { + await breakpoint((setupCloudStatus.connection?.interval_seconds ?? 5) * 1000) + actions.pollSetupCloud() + } + }, + cancelSetupCloud: async () => { + const response = await setupFetch('/api/cloud/setup/disconnect', { method: 'POST' }) + if (response.ok) { + actions.loadSetupCloudStatusSuccess((await response.json()) as CloudStatus) + } + }, + })), + afterMount(({ actions }) => { + actions.loadSetupCloudStatus() + }), +]) diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx index b4a6a10f2..7d447265e 100644 --- a/frontend/src/types.tsx +++ b/frontend/src/types.tsx @@ -830,6 +830,32 @@ export interface CloudStatus { connected_at: string | null last_inventory_sync_at: string | null } | null + /** True unless cloud login is enforced (local passwords disabled). */ + local_fallback_enabled?: boolean + /** A pending feature change awaiting owner approval on the provider. */ + upgrade?: { + user_code: string | null + verification_uri: string | null + verification_uri_complete: string | null + expires_at: string | null + interval_seconds: number + } | null + /** The current user's linked cloud identity, if any. */ + identity?: { + cloud_account_id: string | null + email: string | null + name: string | null + provider_url: string | null + last_login_at: string | null + } | null +} + +/** Mirrors GET /api/cloud/login/options (open endpoint for the login/setup screens) */ +export interface CloudLoginOptions { + available: boolean + provider_url: string | null + local_login_enabled: boolean + setup_mode: boolean } export interface SSHKeyEntry { From ab05bfb3835ac460840ecf97a0c126f715f3b9b6 Mon Sep 17 00:00:00 2001 From: FrameOS Bot Date: Sat, 18 Jul 2026 22:00:42 +0000 Subject: [PATCH 2/2] Update frontend visual snapshots --- .../global-settings--default--dark--full.png | Bin 891291 -> 953079 bytes .../global-settings--default--dark--mid.png | Bin 564039 -> 610928 bytes ...global-settings--default--dark--mobile.png | Bin 448151 -> 488513 bytes .../global-settings--default--light--full.png | Bin 625538 -> 662201 bytes .../global-settings--default--light--mid.png | Bin 487646 -> 523985 bytes ...lobal-settings--default--light--mobile.png | Bin 396998 -> 441926 bytes 6 files changed, 0 insertions(+), 0 deletions(-) diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png index 6a84f5a5bb2db0b33c88ab5446629d666f49367c..8eb4eb5efa6dbe390fca9562e04722fdcb70185b 100644 GIT binary patch literal 953079 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 IlsBMPz7NNU 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