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/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 6a84f5a5b..8eb4eb5ef 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png index ef5b07536..09a78153c 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png index 200c43785..ccab3f6f9 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png index c0dd81bd1..b9c470a1d 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png index 749857c4e..a5c7929e9 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png index e1511251c..d4af88ead 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png differ 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 {