From 03477712ad36be0402ce32b94e12358d0f0668f8 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Tue, 9 Jun 2026 09:39:46 +0200 Subject: [PATCH] cloud auth v0 --- backend/app/api/__init__.py | 1 + backend/app/api/auth.py | 5 + backend/app/api/cloud_auth.py | 939 ++++++++++++++++++ backend/app/api/tests/test_cloud_auth.py | 418 ++++++++ backend/app/config.py | 24 + backend/app/fastapi.py | 33 +- backend/app/models/__init__.py | 1 + backend/app/models/cloud_auth.py | 138 +++ backend/app/models/user.py | 1 + backend/app/schemas/cloud_auth.py | 80 ++ backend/app/utils/cloud_auth.py | 293 ++++++ .../2c4a6f8d9b10_cloud_auth_integration.py | 107 ++ frontend/package.json | 4 +- frontend/src/frameos-cloud-auth-client.d.ts | 16 + frontend/src/index.css | 111 +++ frontend/src/qrcode.d.ts | 15 + frontend/src/scenes/auth/AuthScreen.tsx | 4 +- frontend/src/scenes/auth/SetupUnavailable.tsx | 2 +- frontend/src/scenes/auth/cloudAuthLogic.ts | 49 + frontend/src/scenes/login/Login.tsx | 86 +- frontend/src/scenes/settings/Settings.tsx | 348 ++++++- .../scenes/settings/cloudSettingsLogic.tsx | 334 +++++++ frontend/src/scenes/signup/Signup.tsx | 106 +- frontend/src/types.tsx | 61 ++ frontend/src/utils/cloudAuth.ts | 32 + frontend/src/utils/projectApi.ts | 1 + package.json | 2 +- pnpm-lock.yaml | 151 +++ 28 files changed, 3270 insertions(+), 92 deletions(-) create mode 100644 backend/app/api/cloud_auth.py create mode 100644 backend/app/api/tests/test_cloud_auth.py create mode 100644 backend/app/models/cloud_auth.py create mode 100644 backend/app/schemas/cloud_auth.py create mode 100644 backend/app/utils/cloud_auth.py create mode 100644 backend/migrations/versions/2c4a6f8d9b10_cloud_auth_integration.py create mode 100644 frontend/src/frameos-cloud-auth-client.d.ts create mode 100644 frontend/src/qrcode.d.ts create mode 100644 frontend/src/scenes/auth/cloudAuthLogic.ts create mode 100644 frontend/src/scenes/settings/cloudSettingsLogic.tsx create mode 100644 frontend/src/utils/cloudAuth.ts diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 38045067e..a9913180b 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -22,6 +22,7 @@ from .ai_scenes import * # noqa: E402, F403 from .apps import * # noqa: E402, F403 from .assets import * # noqa: E402, F403 +from .cloud_auth import * # noqa: E402, F403 from .chats import * # noqa: E402, F403 from .frame_bootstrap import * # noqa: E402, F403 from .frames import * # noqa: E402, F403 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index a193332ff..f08b3cf2f 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from arq import ArqRedis as Redis from app import config as app_config +from app.models.cloud_auth import local_fallback_enabled from app.models.user import User from app.database import get_db from app.redis import get_redis @@ -152,6 +153,8 @@ 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") + if not local_fallback_enabled(db): + raise HTTPException(status_code=401, detail="Local login is disabled. Continue with FrameOS Cloud Auth.") email = form_data.username password = form_data.password ip = request.client.host @@ -190,6 +193,8 @@ async def login( async def signup(request: Request, data: UserSignup, response: Response, db: Session = Depends(get_db)): if app_config.config.HASSIO_RUN_MODE is not None: raise HTTPException(status_code=401, detail="Signup not allowed with HASSIO_RUN_MODE") + if not local_fallback_enabled(db): + raise HTTPException(status_code=401, detail="Local signup is disabled. Continue with FrameOS Cloud Auth.") # Check if there is already a user registered (one-user system) if db.query(User).first() is not None: diff --git a/backend/app/api/cloud_auth.py b/backend/app/api/cloud_auth.py new file mode 100644 index 000000000..d993bdc11 --- /dev/null +++ b/backend/app/api/cloud_auth.py @@ -0,0 +1,939 @@ +from __future__ import annotations + +import datetime +import hashlib +import secrets +from typing import Any +from urllib.parse import urlparse + +from fastapi import Depends, HTTPException, Query, Request, status +from fastapi.responses import RedirectResponse +from jose import JWTError +from sqlalchemy.orm import Session +from werkzeug.security import generate_password_hash + +from app import config as app_config +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_auth import ( + CloudBackendLink, + CloudIdentity, + CloudMembership, + current_cloud_backend_link, + local_fallback_enabled, +) +from app.models.frame import Frame +from app.models.organization import OrganizationMember +from app.models.user import User +from app.schemas.cloud_auth import ( + CloudAuthPublicStatus, + CloudAuthStatusResponse, + CloudBackendLinkPollResponse, + CloudBackendLinkStartRequest, + CloudBackendLinkStartResponse, + CloudLinkSyncResponse, + CloudLocalFallbackUpdateRequest, + CloudTokenRotateResponse, +) +from app.tenancy import current_project_context +from app.utils.cloud_auth import ( + CLOUD_OIDC_COOKIE_MAX_AGE_SECONDS, + CLOUD_OIDC_COOKIE_NAME, + build_authorization_url, + create_cloud_oidc_cookie_value, + create_pkce_pair, + decode_cloud_oidc_cookie_value, + decrypt_cloud_secret, + discover_oidc_provider, + encrypt_cloud_secret, + exchange_authorization_code, + provider_json_request, + random_urlsafe_token, + verify_oidc_id_token, +) +from app.utils.session_cookie import SESSION_COOKIE_NAME, create_session_cookie_value +from app.utils.versions import current_frameos_version + +from . import api_open, api_project + + +OWNER_ADMIN_ROLES = {"owner", "admin"} +ROLE_PRIORITY = {"viewer": 0, "member": 1, "admin": 2, "owner": 3} + + +def _provider_enabled() -> bool: + return not app_config.config.FRAMEOS_AUTH_PROVIDER_DISABLED and bool(app_config.config.FRAMEOS_AUTH_PROVIDER_URL) + + +def _provider_url() -> str: + if not _provider_enabled() or not app_config.config.FRAMEOS_AUTH_PROVIDER_URL: + raise HTTPException(status_code=404, detail="FrameOS Cloud Auth is disabled") + return app_config.config.FRAMEOS_AUTH_PROVIDER_URL + + +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() + scheme = forwarded_proto or request.url.scheme + host = forwarded_host or request.headers.get("host") or request.url.netloc + return f"{scheme}://{host}".rstrip("/") + + +def _safe_origin(value: str | None) -> str | None: + if not value: + return None + try: + parsed = urlparse(value) + except ValueError: + return None + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + return parsed._replace(path="", params="", query="", fragment="").geturl().rstrip("/") + + +def _cloud_auth_callback_url(request: Request, callback_origin: str | None = None) -> str: + origin = _safe_origin(callback_origin) or _request_origin(request) + return f"{origin}{request.app.url_path_for('cloud_auth_callback')}" + + +def _safe_redirect_path(value: str | None) -> str: + if not value or not value.startswith("/") or value.startswith("//"): + return "/" + return value + + +def _set_cloud_oidc_cookie(request: Request, response: RedirectResponse, payload: dict[str, Any]) -> None: + response.set_cookie( + key=CLOUD_OIDC_COOKIE_NAME, + value=create_cloud_oidc_cookie_value( + { + **payload, + "issued_at": int(datetime.datetime.utcnow().timestamp()), + } + ), + max_age=CLOUD_OIDC_COOKIE_MAX_AGE_SECONDS, + httponly=True, + samesite="lax", + secure=_should_use_secure_cookie(request), + ) + + +def _session_redirect(request: Request, user: User, redirect_to: str | None) -> RedirectResponse: + access_token_expires = datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + session_value, max_age = create_session_cookie_value(email=user.email, expires_delta=access_token_expires) + redirect = RedirectResponse(url=_safe_redirect_path(redirect_to), status_code=302) + redirect.set_cookie( + key=SESSION_COOKIE_NAME, + value=session_value, + max_age=max_age, + httponly=True, + samesite="lax", + secure=_should_use_secure_cookie(request), + ) + redirect.delete_cookie(key=CLOUD_OIDC_COOKIE_NAME) + return redirect + + +def _http_client(request: Request): + return getattr(request.app.state, "http_client", None) + + +def _link_response(link: CloudBackendLink | None) -> dict | None: + return link.to_public_dict() if link else None + + +def _current_user_cloud_identities(user: User | None) -> list[dict[str, Any]]: + if user is None: + return [] + return [ + { + "provider_url": identity.provider_url, + "provider_issuer": identity.provider_issuer, + "provider_subject": identity.provider_subject, + "cloud_account_id": identity.cloud_account_id, + "email": identity.email, + "email_verified": identity.email_verified, + "name": identity.name, + "last_login_at": identity.last_login_at.isoformat() if identity.last_login_at else None, + } + for identity in user.cloud_identities + ] + + +def _status_payload(db: Session, user: User | None = None) -> dict: + enabled = _provider_enabled() + link = current_cloud_backend_link(db) + link_payload = _link_response(link) + memberships = [membership.to_dict() for membership in link.memberships] if link else [] + return { + "provider_enabled": enabled, + "provider_url": app_config.config.FRAMEOS_AUTH_PROVIDER_URL if enabled else None, + "status": link.status if enabled and link else "disconnected" if enabled else "provider_disabled", + "local_fallback_enabled": local_fallback_enabled(db), + "link": link_payload, + "memberships": memberships, + "current_user_cloud_identities": _current_user_cloud_identities(user), + } + + +def _cloud_account_id_from_claims(claims: dict[str, Any]) -> str | None: + for key in ("frameos_account_id", "account_id", "https://frameos.net/account_id"): + value = claims.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _claim_string(claims: dict[str, Any], key: str) -> str | None: + value = claims.get(key) + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _generated_cloud_email(issuer: str, subject: str) -> str: + digest = hashlib.sha256(f"{issuer}:{subject}".encode()).hexdigest()[:24] + return f"cloud-{digest}@frameos.cloud.local" + + +def _email_available_for_cloud_user(db: Session, email: str | None) -> str | None: + if not email: + return None + if email.count("@") != 1: + return None + if db.query(User).filter(User.email == email).first() is not None: + return None + return email + + +def _membership_matches_identity(membership: CloudMembership, identity: CloudIdentity) -> bool: + return bool(identity.cloud_account_id and membership.cloud_account_id == identity.cloud_account_id) + + +def _preferred_cloud_role(current: str | None, candidate: str) -> str: + if current is None: + return candidate + if ROLE_PRIORITY.get(candidate.lower(), 0) > ROLE_PRIORITY.get(current.lower(), 0): + return candidate + return current + + +def _ensure_user_cloud_memberships(db: Session, identity: CloudIdentity) -> None: + if not identity.cloud_account_id or not identity.user_id: + return + + memberships = db.query(CloudMembership).filter(CloudMembership.cloud_account_id == identity.cloud_account_id).all() + roles_by_organization_id: dict[int, str] = {} + for membership in memberships: + if not membership.local_organization_id: + continue + organization_id = int(membership.local_organization_id) + roles_by_organization_id[organization_id] = _preferred_cloud_role( + roles_by_organization_id.get(organization_id), + membership.role, + ) + + if not roles_by_organization_id: + return + + organization_ids = list(roles_by_organization_id) + existing_members = { + int(member.organization_id): member + for member in db.query(OrganizationMember) + .filter( + OrganizationMember.organization_id.in_(organization_ids), + OrganizationMember.user_id == identity.user_id, + ) + .all() + } + pending_members = { + int(member.organization_id): member + for member in db.new + if isinstance(member, OrganizationMember) + and member.user_id == identity.user_id + and member.organization_id in roles_by_organization_id + } + for organization_id, role in roles_by_organization_id.items(): + existing = existing_members.get(organization_id) or pending_members.get(organization_id) + if existing: + existing.role = role + continue + db.add( + OrganizationMember( + organization_id=organization_id, + user_id=identity.user_id, + role=role, + ) + ) + + +def _connected_link_requires_grant(db: Session) -> CloudBackendLink | None: + link = current_cloud_backend_link(db) + if link and link.status == "connected" and link.local_organization_id: + return link + return None + + +def _user_has_link_grant(db: Session, user: User, link: CloudBackendLink) -> bool: + return ( + db.query(OrganizationMember) + .filter( + OrganizationMember.organization_id == link.local_organization_id, + OrganizationMember.user_id == user.id, + ) + .first() + is not None + ) + + +def _get_or_create_cloud_user( + db: Session, + *, + provider_url: str, + issuer: str, + claims: dict[str, Any], +) -> User: + subject = str(claims["sub"]) + now = datetime.datetime.utcnow() + identity = ( + db.query(CloudIdentity) + .filter(CloudIdentity.provider_issuer == issuer, CloudIdentity.provider_subject == subject) + .first() + ) + email = _claim_string(claims, "email") + email_verified = bool(claims.get("email_verified")) + name = _claim_string(claims, "name") + cloud_account_id = _cloud_account_id_from_claims(claims) + + if identity: + identity.provider_url = provider_url + identity.email = email + identity.email_verified = email_verified + identity.name = name + identity.cloud_account_id = cloud_account_id + identity.last_login_at = now + identity.updated_at = now + _ensure_user_cloud_memberships(db, identity) + db.commit() + return identity.user + + local_email = _email_available_for_cloud_user(db, email if email_verified else None) + user = User(email=local_email or _generated_cloud_email(issuer, subject)) + user.password = generate_password_hash(secrets.token_urlsafe(32)) + db.add(user) + db.flush() + + identity = CloudIdentity( + user_id=user.id, + provider_url=provider_url, + provider_issuer=issuer, + provider_subject=subject, + cloud_account_id=cloud_account_id, + email=email, + email_verified=email_verified, + name=name, + last_login_at=now, + updated_at=now, + ) + db.add(identity) + _ensure_user_cloud_memberships(db, identity) + + if _connected_link_requires_grant(db) is None: + from app.tenancy import ensure_default_project_for_user + + ensure_default_project_for_user(db, user) + db.commit() + db.refresh(user) + return user + + +async def _sync_inventory(db: Session, link: CloudBackendLink, request: Request) -> bool: + access_token = decrypt_cloud_secret(link.access_token) + if not access_token: + return False + + frames = [] + if link.local_project_id: + for frame in db.query(Frame).filter(Frame.project_id == link.local_project_id).all(): + frames.append( + { + "frame_id": str(frame.id), + "display_name": frame.name or f"Frame {frame.id}", + "connection_status": frame.status or "unknown", + "device_metadata": {"device": frame.device or "web_only"}, + } + ) + + status_code, payload = await provider_json_request( + "POST", + link.provider_url, + "/api/backends/inventory", + access_token=access_token, + http_client=_http_client(request), + json_body={ + "reported_frameos_version": current_frameos_version(), + "capabilities": {"projects": True, "frames": True, "localFallback": link.local_fallback_enabled}, + "health": {"status": "ok"}, + "frames": frames, + }, + ) + if status_code == 401 and payload.get("error") == "invalid_link_token": + link.status = "revoked" + link.revoked_at = datetime.datetime.utcnow() + link.updated_at = datetime.datetime.utcnow() + db.commit() + return False + if status_code < 200 or status_code >= 300: + return False + link.last_inventory_sync_at = datetime.datetime.utcnow() + link.updated_at = link.last_inventory_sync_at + db.commit() + return True + + +def _parse_provider_datetime(value: Any) -> datetime.datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError: + return None + + +def _replace_cloud_memberships(db: Session, link: CloudBackendLink, grants: list[dict[str, Any]]) -> None: + db.query(CloudMembership).filter(CloudMembership.backend_link_id == link.id).delete() + for grant in grants: + account_id = grant.get("account_id") + organization_id = grant.get("organization_id") + role = grant.get("role") + if not isinstance(account_id, str) or not isinstance(organization_id, str) or not isinstance(role, str): + continue + cloud_project_id = grant.get("project_id") if isinstance(grant.get("project_id"), str) else None + if link.cloud_project_id and cloud_project_id and cloud_project_id != link.cloud_project_id: + continue + db.add( + CloudMembership( + backend_link_id=link.id, + cloud_account_id=account_id, + cloud_organization_id=organization_id, + cloud_project_id=cloud_project_id, + role=role, + local_organization_id=link.local_organization_id, + local_project_id=link.local_project_id, + updated_at=_parse_provider_datetime(grant.get("updated_at")), + ) + ) + + db.flush() + for identity in db.query(CloudIdentity).all(): + _ensure_user_cloud_memberships(db, identity) + + +async def _sync_grants(db: Session, link: CloudBackendLink, request: Request) -> bool: + access_token = decrypt_cloud_secret(link.access_token) + if not access_token: + return False + + status_code, payload = await provider_json_request( + "GET", + link.provider_url, + "/api/backends/grants", + access_token=access_token, + http_client=_http_client(request), + ) + if status_code == 401 and payload.get("error") == "invalid_link_token": + link.status = "revoked" + link.revoked_at = datetime.datetime.utcnow() + link.updated_at = datetime.datetime.utcnow() + db.commit() + return False + if status_code < 200 or status_code >= 300: + return False + + memberships = payload.get("memberships") + _replace_cloud_memberships(db, link, memberships if isinstance(memberships, list) else []) + link.last_grant_sync_at = datetime.datetime.utcnow() + link.updated_at = link.last_grant_sync_at + db.commit() + return True + + +async def _sync_link(db: Session, link: CloudBackendLink, request: Request) -> tuple[bool, bool]: + inventory_synced = await _sync_inventory(db, link, request) + grants_synced = await _sync_grants(db, link, request) + return inventory_synced, grants_synced + + +def _current_user_can_disable_fallback(db: Session, current_user: User) -> bool: + link = current_cloud_backend_link(db) + if not link or link.status != "connected": + return False + for identity in current_user.cloud_identities: + for membership in link.memberships: + if _membership_matches_identity(membership, identity) and membership.role.lower() in OWNER_ADMIN_ROLES: + return True + return False + + +async def _start_brokered_cloud_login( + *, + request: Request, + db: Session, + provider_url: str, + intent: str, + redirect_to: str | None, + callback_origin: str | None = None, +) -> RedirectResponse | None: + link = current_cloud_backend_link(db) + if not link or link.status != "connected": + return None + access_token = decrypt_cloud_secret(link.access_token) + if not access_token: + return None + + state = random_urlsafe_token() + redirect_uri = _cloud_auth_callback_url(request, callback_origin) + try: + status_code, payload = await provider_json_request( + "POST", + provider_url, + "/api/frameos/login/start", + access_token=access_token, + http_client=_http_client(request), + json_body={ + "redirect_uri": redirect_uri, + "state": state, + "intent": "signup" if intent == "signup" else "login", + "redirect_to": _safe_redirect_path(redirect_to), + }, + ) + except Exception: + return None + if status_code < 200 or status_code >= 300: + return None + authorization_url = payload.get("authorization_url") + if not isinstance(authorization_url, str) or not authorization_url: + return None + + response = RedirectResponse(url=authorization_url, status_code=302) + _set_cloud_oidc_cookie( + request, + response, + { + "flow": "broker", + "state": state, + "provider_url": provider_url, + "redirect_to": _safe_redirect_path(redirect_to), + }, + ) + return response + + +async def _exchange_brokered_cloud_login( + *, + request: Request, + db: Session, + provider_url: str, + code: str, +) -> tuple[str, dict[str, Any]]: + link = current_cloud_backend_link(db) + if not link or link.status != "connected": + raise HTTPException(status_code=400, detail="Cloud backend is not connected") + access_token = decrypt_cloud_secret(link.access_token) + if not access_token: + raise HTTPException(status_code=400, detail="Cloud backend token is missing") + + status_code, payload = await provider_json_request( + "POST", + provider_url, + "/api/frameos/login/token", + access_token=access_token, + http_client=_http_client(request), + json_body={"code": code}, + ) + if status_code < 200 or status_code >= 300: + raise HTTPException(status_code=502, detail=payload.get("error") or "Cloud login exchange failed") + + claims_payload = payload.get("claims") if isinstance(payload.get("claims"), dict) else payload + subject = claims_payload.get("sub") or claims_payload.get("provider_subject") + if not isinstance(subject, str) or not subject: + raise HTTPException(status_code=502, detail="Cloud login exchange returned no subject") + + claims = { + "sub": subject, + "email": claims_payload.get("email"), + "email_verified": bool(claims_payload.get("email_verified")), + "name": claims_payload.get("name"), + "account_id": claims_payload.get("account_id") or claims_payload.get("cloud_account_id"), + } + issuer = payload.get("provider_issuer") or claims_payload.get("provider_issuer") or provider_url + return str(issuer), claims + + +async def _finish_cloud_login( + *, + request: Request, + db: Session, + provider_url: str, + issuer: str, + claims: dict[str, Any], + redirect_to: str | None, +) -> RedirectResponse: + user = _get_or_create_cloud_user(db, provider_url=provider_url, issuer=issuer, claims=claims) + required_link = _connected_link_requires_grant(db) + if required_link is not None and not _user_has_link_grant(db, user, required_link): + return RedirectResponse(url="/login?error=cloud_grant_required", status_code=302) + return _session_redirect(request, user, redirect_to) + + +@api_open.get("/cloud-auth/status", response_model=CloudAuthPublicStatus) +async def get_public_cloud_auth_status(db: Session = Depends(get_db)): + payload = _status_payload(db) + return { + "provider_enabled": payload["provider_enabled"], + "provider_url": payload["provider_url"], + "status": payload["status"], + "local_fallback_enabled": payload["local_fallback_enabled"], + } + + +@api_project.get("/cloud-auth/status", response_model=CloudAuthStatusResponse) +async def get_project_cloud_auth_status( + current_user: User | None = Depends(get_current_user), + db: Session = Depends(get_db), +): + return _status_payload(db, current_user) + + +@api_open.get("/cloud-auth/login") +async def start_cloud_login( + request: Request, + intent: str = Query("login"), + redirect_to: str | None = Query(None), + callback_origin: str | None = Query(None), + db: Session = Depends(get_db), +): + provider_url = _provider_url() + brokered = await _start_brokered_cloud_login( + request=request, + db=db, + provider_url=provider_url, + intent=intent, + redirect_to=redirect_to, + callback_origin=callback_origin, + ) + if brokered: + return brokered + + try: + discovery = await discover_oidc_provider(provider_url, _http_client(request)) + except Exception: + return RedirectResponse(url=f"/login?error=provider_unavailable", status_code=302) + + state = random_urlsafe_token() + nonce = random_urlsafe_token() + verifier, challenge = create_pkce_pair() + redirect_uri = _cloud_auth_callback_url(request, callback_origin) + authorization_url = build_authorization_url( + discovery, + client_id=app_config.config.FRAMEOS_AUTH_CLIENT_ID, + code_challenge=challenge, + nonce=nonce, + redirect_uri=redirect_uri, + state=state, + intent="signup" if intent == "signup" else "login", + ) + + response = RedirectResponse(url=authorization_url, status_code=302) + _set_cloud_oidc_cookie( + request, + response, + { + "flow": "oidc", + "state": state, + "nonce": nonce, + "verifier": verifier, + "provider_url": provider_url, + "redirect_to": _safe_redirect_path(redirect_to), + }, + ) + return response + + +@api_open.get("/cloud-auth/callback", name="cloud_auth_callback") +async def cloud_auth_callback( + request: Request, + code: str | None = Query(None), + state: str | None = Query(None), + error: str | None = Query(None), + db: Session = Depends(get_db), +): + if error: + return RedirectResponse(url=f"/login?error={error}", status_code=302) + + cookie_payload = decode_cloud_oidc_cookie_value(request.cookies.get(CLOUD_OIDC_COOKIE_NAME)) + if not code or not state or not cookie_payload or cookie_payload.get("state") != state: + return RedirectResponse(url="/login?error=invalid_state", status_code=302) + issued_at = cookie_payload.get("issued_at") + if not isinstance(issued_at, int) or int(datetime.datetime.utcnow().timestamp()) - issued_at > CLOUD_OIDC_COOKIE_MAX_AGE_SECONDS: + return RedirectResponse(url="/login?error=invalid_state", status_code=302) + + provider_url = str(cookie_payload.get("provider_url") or _provider_url()) + redirect_uri = _cloud_auth_callback_url(request) + if cookie_payload.get("flow") == "broker": + try: + issuer, claims = await _exchange_brokered_cloud_login( + request=request, + db=db, + provider_url=provider_url, + code=code, + ) + except Exception: + return RedirectResponse(url="/login?error=provider_unavailable", status_code=302) + return await _finish_cloud_login( + request=request, + db=db, + provider_url=provider_url, + issuer=issuer, + claims=claims, + redirect_to=str(cookie_payload.get("redirect_to") or "/"), + ) + + try: + discovery = await discover_oidc_provider(provider_url, _http_client(request)) + token_set = await exchange_authorization_code( + discovery, + client_id=app_config.config.FRAMEOS_AUTH_CLIENT_ID, + client_secret=app_config.config.FRAMEOS_AUTH_CLIENT_SECRET or None, + code=code, + code_verifier=str(cookie_payload["verifier"]), + redirect_uri=redirect_uri, + http_client=_http_client(request), + ) + claims = await verify_oidc_id_token( + str(token_set["id_token"]), + audience=app_config.config.FRAMEOS_AUTH_CLIENT_ID, + discovery=discovery, + nonce=str(cookie_payload["nonce"]), + http_client=_http_client(request), + ) + except (JWTError, Exception): + return RedirectResponse(url="/login?error=provider_unavailable", status_code=302) + + return await _finish_cloud_login( + request=request, + db=db, + provider_url=provider_url, + issuer=discovery.issuer, + claims=claims, + redirect_to=str(cookie_payload.get("redirect_to") or "/"), + ) + + +@api_project.post("/cloud-auth/backend-link/start", response_model=CloudBackendLinkStartResponse) +async def start_backend_link( + request: Request, + data: CloudBackendLinkStartRequest | None = None, + current_user: User | None = Depends(get_current_user), + db: Session = Depends(get_db), +): + provider_url = _provider_url() + project_context = current_project_context() + local_origin = data.local_origin if data and data.local_origin else _request_origin(request) + display_name = ( + data.public_display_name + if data and data.public_display_name + else f"FrameOS backend at {request.headers.get('host') or request.url.netloc}" + ) + try: + status_code, payload = await provider_json_request( + "POST", + provider_url, + "/api/device/start", + http_client=_http_client(request), + json_body={ + "client_type": "backend", + "public_display_name": display_name, + "local_origin": local_origin, + "reported_frameos_version": current_frameos_version(), + "capabilities": {"projects": True, "frames": True, "localFallback": True}, + "scopes": ["backend:link", "backend:read", "project:read"], + }, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Cloud auth provider unavailable: {exc}") from exc + if status_code < 200 or status_code >= 300: + raise HTTPException(status_code=502, detail=payload.get("error") or "Cloud auth provider rejected link start") + + link = current_cloud_backend_link(db) or CloudBackendLink(provider_url=provider_url) + link.provider_url = provider_url + link.provider_issuer = None + link.status = "connecting" + link.public_display_name = display_name + link.local_origin = local_origin + link.device_code = encrypt_cloud_secret(str(payload["device_code"])) + link.user_code = str(payload["user_code"]) + link.verification_uri = str(payload["verification_uri"]) + link.verification_uri_complete = str(payload["verification_uri_complete"]) + link.expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=int(payload.get("expires_in") or 600)) + link.interval_seconds = int(payload.get("interval") or 5) + link.poll_error = None + link.local_project_id = project_context.project_id + link.local_organization_id = project_context.organization_id + link.local_fallback_enabled = True if link.local_fallback_enabled is None else link.local_fallback_enabled + link.updated_at = datetime.datetime.utcnow() + db.add(link) + db.query(CloudMembership).filter(CloudMembership.backend_link_id == link.id).delete() + db.commit() + db.refresh(link) + return _status_payload(db, current_user) + + +@api_project.post("/cloud-auth/backend-link/poll", response_model=CloudBackendLinkPollResponse) +async def poll_backend_link( + request: Request, + current_user: User | None = Depends(get_current_user), + db: Session = Depends(get_db), +): + link = current_cloud_backend_link(db) + if not link or link.status != "connecting": + raise HTTPException(status_code=400, detail="No cloud backend link is currently connecting") + if link.expires_at and link.expires_at < datetime.datetime.utcnow(): + link.status = "disconnected" + link.poll_error = "expired_token" + link.updated_at = datetime.datetime.utcnow() + db.commit() + return _status_payload(db, current_user) + device_code = decrypt_cloud_secret(link.device_code) + if not device_code: + raise HTTPException(status_code=400, detail="Cloud backend link device code is missing") + + status_code, payload = await provider_json_request( + "POST", + link.provider_url, + "/api/device/poll", + http_client=_http_client(request), + json_body={"device_code": device_code}, + ) + error = payload.get("error") + if error in {"authorization_pending", "slow_down"}: + link.interval_seconds = int(payload.get("interval") or link.interval_seconds or 5) + link.poll_error = str(error) + link.updated_at = datetime.datetime.utcnow() + db.commit() + return _status_payload(db, current_user) + if error in {"access_denied", "expired_token", "invalid_device_code"}: + link.status = "disconnected" + link.poll_error = str(error) + link.device_code = None + link.updated_at = datetime.datetime.utcnow() + db.commit() + return _status_payload(db, current_user) + if status_code < 200 or status_code >= 300: + link.poll_error = str(error or f"provider_status_{status_code}") + link.updated_at = datetime.datetime.utcnow() + db.commit() + return _status_payload(db, current_user) + + access_token = payload.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise HTTPException(status_code=502, detail="Cloud auth provider returned no access token") + + link.status = "connected" + link.access_token = encrypt_cloud_secret(access_token) + link.device_code = None + link.poll_error = None + link.token_reference = str(payload.get("token_reference") or "") + link.linked_client_id = str(payload.get("linked_client_id") or "") + link.cloud_organization_id = str(payload.get("organization_id") or "") + link.cloud_project_id = str(payload.get("project_id") or "") or None + link.scope = str(payload.get("scope") or "") + link.revoked_at = None + link.updated_at = datetime.datetime.utcnow() + db.commit() + await _sync_link(db, link, request) + return _status_payload(db, current_user) + + +@api_project.post("/cloud-auth/backend-link/sync", response_model=CloudLinkSyncResponse) +async def sync_backend_link( + request: Request, + current_user: User | None = Depends(get_current_user), + db: Session = Depends(get_db), +): + link = current_cloud_backend_link(db) + if not link or link.status not in {"connected", "revoked"}: + raise HTTPException(status_code=400, detail="Cloud backend is not connected") + inventory_synced, grants_synced = await _sync_link(db, link, request) + return { + **_status_payload(db, current_user), + "inventory_synced": inventory_synced, + "grants_synced": grants_synced, + "errors": [] if inventory_synced and grants_synced else ["Cloud sync was incomplete"], + } + + +@api_project.post("/cloud-auth/backend-link/rotate-token", response_model=CloudTokenRotateResponse) +async def rotate_backend_link_token( + request: Request, + current_user: User | None = Depends(get_current_user), + db: Session = Depends(get_db), +): + link = current_cloud_backend_link(db) + if not link or link.status != "connected": + raise HTTPException(status_code=400, detail="Cloud backend is not connected") + access_token = decrypt_cloud_secret(link.access_token) + if not access_token: + raise HTTPException(status_code=400, detail="Cloud backend token is missing") + status_code, payload = await provider_json_request( + "POST", + link.provider_url, + "/api/backends/rotate-token", + access_token=access_token, + http_client=_http_client(request), + ) + if status_code < 200 or status_code >= 300: + raise HTTPException(status_code=502, detail=payload.get("error") or "Cloud token rotation failed") + new_token = payload.get("access_token") + if not isinstance(new_token, str) or not new_token: + raise HTTPException(status_code=502, detail="Cloud token rotation returned no access token") + link.access_token = encrypt_cloud_secret(new_token) + link.token_reference = str(payload.get("token_reference") or link.token_reference or "") + link.updated_at = datetime.datetime.utcnow() + db.commit() + return {**_status_payload(db, current_user), "rotated": True} + + +@api_project.delete("/cloud-auth/backend-link", response_model=CloudAuthStatusResponse) +async def disconnect_backend_link( + current_user: User | None = Depends(get_current_user), + db: Session = Depends(get_db), +): + link = current_cloud_backend_link(db) + if link: + link.status = "disconnected" + link.access_token = None + link.device_code = None + link.poll_error = None + link.updated_at = datetime.datetime.utcnow() + db.query(CloudMembership).filter(CloudMembership.backend_link_id == link.id).delete() + db.commit() + return _status_payload(db, current_user) + + +@api_project.post("/cloud-auth/local-fallback", response_model=CloudAuthStatusResponse) +async def set_local_fallback( + data: CloudLocalFallbackUpdateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + link = current_cloud_backend_link(db) + if not link or link.status != "connected": + raise HTTPException(status_code=400, detail="Connect FrameOS Cloud before changing local fallback.") + if not data.enabled and not _current_user_can_disable_fallback(db, current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Disabling local fallback requires a working cloud owner/admin session.", + ) + link.local_fallback_enabled = data.enabled + link.updated_at = datetime.datetime.utcnow() + db.commit() + return _status_payload(db, current_user) diff --git a/backend/app/api/tests/test_cloud_auth.py b/backend/app/api/tests/test_cloud_auth.py new file mode 100644 index 000000000..0c33d7868 --- /dev/null +++ b/backend/app/api/tests/test_cloud_auth.py @@ -0,0 +1,418 @@ +import datetime +from urllib.parse import parse_qs, urlparse + +import pytest + +from app import config as app_config +from app.api import cloud_auth as cloud_auth_api +from app.config import normalize_frameos_auth_provider_url +from app.models.cloud_auth import CloudBackendLink, CloudIdentity, CloudMembership +from app.models.organization import OrganizationMember +from app.models.user import User +from app.utils import cloud_auth as cloud_auth_utils +from app.utils.cloud_auth import OidcDiscovery, encrypt_cloud_secret + + +def discovery() -> OidcDiscovery: + return OidcDiscovery( + issuer="https://auth.example.test", + authorization_endpoint="https://auth.example.test/oauth/authorize", + token_endpoint="https://auth.example.test/oauth/token", + jwks_uri="https://auth.example.test/oauth/keys", + ) + + +def test_normalize_frameos_auth_provider_url(): + assert normalize_frameos_auth_provider_url(None) == { + "disabled": False, + "provider_url": "https://auth.frameos.net", + } + assert normalize_frameos_auth_provider_url(" disabled ") == {"disabled": True, "provider_url": None} + assert normalize_frameos_auth_provider_url("https://auth.example.test/path/?x=1#hash") == { + "disabled": False, + "provider_url": "https://auth.example.test/path", + } + + +@pytest.mark.asyncio +async def test_discover_oidc_provider_falls_back_to_oidc_path(monkeypatch): + calls = [] + + async def fake_request_json(method, url, **_kwargs): + calls.append((method, url)) + if url == "https://auth.example.test/.well-known/openid-configuration": + return 404, {} + if url == "https://auth.example.test/oidc/.well-known/openid-configuration": + return 200, { + "issuer": "https://auth.example.test/oidc", + "authorization_endpoint": "https://auth.example.test/oidc/auth", + "token_endpoint": "https://auth.example.test/oidc/token", + "jwks_uri": "https://auth.example.test/oidc/jwks", + } + raise AssertionError(f"Unexpected discovery URL: {url}") + + cloud_auth_utils._OIDC_DISCOVERY_CACHE.clear() + monkeypatch.setattr(cloud_auth_utils, "_request_json", fake_request_json) + + discovered = await cloud_auth_utils.discover_oidc_provider("https://auth.example.test") + + assert discovered.issuer == "https://auth.example.test/oidc" + assert calls == [ + ("GET", "https://auth.example.test/.well-known/openid-configuration"), + ("GET", "https://auth.example.test/oidc/.well-known/openid-configuration"), + ] + + +@pytest.mark.asyncio +async def test_public_cloud_auth_status_default(no_auth_client): + response = await no_auth_client.get("/api/cloud-auth/status") + + assert response.status_code == 200 + assert response.json() == { + "provider_enabled": True, + "provider_url": "https://auth.frameos.net", + "status": "disconnected", + "local_fallback_enabled": True, + } + + +@pytest.mark.asyncio +async def test_public_cloud_auth_status_disabled(no_auth_client, monkeypatch): + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_DISABLED", True) + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_URL", None) + + response = await no_auth_client.get("/api/cloud-auth/status") + + assert response.status_code == 200 + assert response.json()["provider_enabled"] is False + assert response.json()["status"] == "provider_disabled" + + +@pytest.mark.asyncio +async def test_backend_device_link_start_poll_syncs_inventory_and_grants(async_client, db, monkeypatch): + calls = [] + + async def fake_discover(_provider_url, _http_client=None): + raise AssertionError("Backend device linking should not run OIDC discovery") + + async def fake_provider_json_request(method, provider_url, path, **kwargs): + calls.append((method, provider_url, path, kwargs.get("json_body"))) + if path == "/api/device/start": + return 200, { + "device_code": "device-code", + "expires_in": 600, + "interval": 3, + "user_code": "ABCD-EFGH", + "verification_uri": "https://auth.example.test/device", + "verification_uri_complete": "https://auth.example.test/device?user_code=ABCD-EFGH", + } + if path == "/api/device/poll": + return 200, { + "access_token": "link-token", + "linked_client_id": "linked-client", + "organization_id": "cloud-org", + "project_id": "cloud-project", + "scope": "backend:link backend:read project:read", + "token_reference": "tok_ref", + "token_type": "Bearer", + } + if path == "/api/backends/inventory": + return 200, {"synced_frames": 0} + if path == "/api/backends/grants": + return 200, { + "memberships": [ + { + "account_id": "cloud-account", + "organization_id": "cloud-org", + "project_id": "cloud-project", + "role": "owner", + "updated_at": "2026-06-07T00:00:00Z", + } + ] + } + raise AssertionError(f"Unexpected cloud API call: {method} {path}") + + monkeypatch.setattr(cloud_auth_api, "discover_oidc_provider", fake_discover) + monkeypatch.setattr(cloud_auth_api, "provider_json_request", fake_provider_json_request) + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_URL", "https://auth.example.test") + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_DISABLED", False) + + start_response = await async_client.post(f"/api/projects/{async_client.project_id}/cloud-auth/backend-link/start") + + assert start_response.status_code == 200 + assert start_response.json()["status"] == "connecting" + assert start_response.json()["link"]["user_code"] == "ABCD-EFGH" + + poll_response = await async_client.post(f"/api/projects/{async_client.project_id}/cloud-auth/backend-link/poll") + + assert poll_response.status_code == 200 + payload = poll_response.json() + assert payload["status"] == "connected" + assert payload["link"]["token_reference"] == "tok_ref" + assert payload["link"]["cloud_organization_id"] == "cloud-org" + assert payload["memberships"][0]["cloud_account_id"] == "cloud-account" + assert [call[2] for call in calls] == [ + "/api/device/start", + "/api/device/poll", + "/api/backends/inventory", + "/api/backends/grants", + ] + + +@pytest.mark.asyncio +async def test_local_fallback_disabled_rejects_password_login(no_auth_client, db, redis): + user = User(email="local@example.com") + user.set_password("testpassword") + db.add(user) + db.add( + CloudBackendLink( + provider_url="https://auth.example.test", + status="connected", + local_fallback_enabled=False, + ) + ) + db.commit() + + response = await no_auth_client.post("/api/login", data={"username": "local@example.com", "password": "testpassword"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Local login is disabled. Continue with FrameOS Cloud Auth." + + +@pytest.mark.asyncio +async def test_disabling_local_fallback_requires_cloud_owner_admin(async_client, db): + link = CloudBackendLink( + provider_url="https://auth.example.test", + status="connected", + cloud_organization_id="cloud-org", + cloud_project_id="cloud-project", + local_project_id=async_client.project_id, + local_fallback_enabled=True, + ) + db.add(link) + db.commit() + db.refresh(link) + + denied = await async_client.post( + f"/api/projects/{async_client.project_id}/cloud-auth/local-fallback", + json={"enabled": False}, + ) + + assert denied.status_code == 403 + + user = db.query(User).filter(User.email == "test@example.com").one() + identity = CloudIdentity( + user_id=user.id, + provider_url="https://auth.example.test", + provider_issuer="https://auth.example.test", + provider_subject="subject", + cloud_account_id="cloud-account", + email="test@example.com", + email_verified=True, + last_login_at=datetime.datetime.utcnow(), + ) + db.add(identity) + db.add( + CloudMembership( + backend_link_id=link.id, + cloud_account_id="cloud-account", + cloud_organization_id="cloud-org", + cloud_project_id="cloud-project", + role="owner", + local_project_id=async_client.project_id, + local_organization_id=db.query(OrganizationMember).filter_by(user_id=user.id).first().organization_id, + ) + ) + db.commit() + + allowed = await async_client.post( + f"/api/projects/{async_client.project_id}/cloud-auth/local-fallback", + json={"enabled": False}, + ) + + assert allowed.status_code == 200 + assert allowed.json()["local_fallback_enabled"] is False + + +@pytest.mark.asyncio +async def test_cloud_oidc_callback_creates_local_cloud_user(no_auth_client, db, monkeypatch): + db.query(User).delete() + db.commit() + + async def fake_discover(_provider_url, _http_client=None): + return discovery() + + async def fake_exchange(*_args, **_kwargs): + return {"id_token": "id-token", "token_type": "Bearer"} + + async def fake_verify(*_args, **_kwargs): + return { + "sub": "cloud-subject", + "email": "cloud@example.com", + "email_verified": True, + "name": "Cloud User", + "account_id": "cloud-account", + } + + monkeypatch.setattr(cloud_auth_api, "discover_oidc_provider", fake_discover) + monkeypatch.setattr(cloud_auth_api, "exchange_authorization_code", fake_exchange) + monkeypatch.setattr(cloud_auth_api, "verify_oidc_id_token", fake_verify) + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_URL", "https://auth.example.test") + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_DISABLED", False) + + start = await no_auth_client.get("/api/cloud-auth/login", follow_redirects=False) + assert start.status_code == 302 + state = parse_qs(urlparse(start.headers["location"]).query)["state"][0] + + callback = await no_auth_client.get(f"/api/cloud-auth/callback?code=code&state={state}", follow_redirects=False) + + assert callback.status_code == 302 + assert "frameos_session" in callback.headers.get("set-cookie", "") + user = db.query(User).filter(User.email == "cloud@example.com").one() + identity = db.query(CloudIdentity).filter(CloudIdentity.user_id == user.id).one() + assert identity.provider_subject == "cloud-subject" + assert identity.cloud_account_id == "cloud-account" + + +@pytest.mark.asyncio +async def test_cloud_login_uses_broker_when_provider_is_cloud_app(no_auth_client, db, default_project, monkeypatch): + db.query(User).filter(User.email == "broker@example.com").delete() + link = CloudBackendLink( + provider_url="http://localhost:3000", + status="connected", + access_token=encrypt_cloud_secret("link-token"), + cloud_organization_id="cloud-org", + cloud_project_id="cloud-project", + local_organization_id=default_project.organization_id, + local_project_id=default_project.id, + local_fallback_enabled=True, + ) + db.add(link) + db.flush() + db.add( + CloudMembership( + backend_link_id=link.id, + cloud_account_id="cloud-account", + cloud_organization_id="cloud-org", + cloud_project_id="cloud-project", + role="owner", + local_organization_id=default_project.organization_id, + local_project_id=default_project.id, + ) + ) + db.add( + CloudMembership( + backend_link_id=link.id, + cloud_account_id="cloud-account", + cloud_organization_id="cloud-org", + cloud_project_id="cloud-project-secondary", + role="member", + local_organization_id=default_project.organization_id, + local_project_id=default_project.id, + ) + ) + db.commit() + + async def fake_discover(_provider_url, _http_client=None): + raise ValueError("cloud app is not an OIDC issuer") + + async def fake_provider_json_request(method, provider_url, path, **kwargs): + assert provider_url == "http://localhost:3000" + assert kwargs.get("access_token") == "link-token" + if path == "/api/frameos/login/start": + body = kwargs.get("json_body") or {} + assert method == "POST" + assert body["redirect_uri"] == "http://localhost:8616/api/cloud-auth/callback" + return 200, {"authorization_url": f"https://auth.example.test/frameos-login?state={body['state']}"} + if path == "/api/frameos/login/token": + assert method == "POST" + assert kwargs.get("json_body") == {"code": "broker-code"} + return 200, { + "provider_issuer": "https://auth.example.test", + "claims": { + "sub": "cloud-subject", + "account_id": "cloud-account", + "email": "broker@example.com", + "email_verified": True, + "name": "Broker User", + }, + } + raise AssertionError(f"Unexpected cloud API call: {method} {path}") + + monkeypatch.setattr(cloud_auth_api, "discover_oidc_provider", fake_discover) + monkeypatch.setattr(cloud_auth_api, "provider_json_request", fake_provider_json_request) + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_URL", "http://localhost:3000") + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_DISABLED", False) + + start = await no_auth_client.get( + "/api/cloud-auth/login?callback_origin=http%3A%2F%2Flocalhost%3A8616", + follow_redirects=False, + ) + assert start.status_code == 302 + state = parse_qs(urlparse(start.headers["location"]).query)["state"][0] + + callback = await no_auth_client.get( + f"/api/cloud-auth/callback?code=broker-code&state={state}", + follow_redirects=False, + ) + + assert callback.status_code == 302 + assert "frameos_session" in callback.headers.get("set-cookie", "") + user = db.query(User).filter(User.email == "broker@example.com").one() + identity = db.query(CloudIdentity).filter(CloudIdentity.user_id == user.id).one() + assert identity.provider_subject == "cloud-subject" + assert identity.cloud_account_id == "cloud-account" + local_members = ( + db.query(OrganizationMember) + .filter( + OrganizationMember.organization_id == default_project.organization_id, + OrganizationMember.user_id == user.id, + ) + .all() + ) + assert len(local_members) == 1 + assert local_members[0].role == "owner" + + +@pytest.mark.asyncio +async def test_cloud_oidc_callback_requires_grant_when_backend_connected(no_auth_client, db, default_project, monkeypatch): + async def fake_discover(_provider_url, _http_client=None): + return discovery() + + async def fake_exchange(*_args, **_kwargs): + return {"id_token": "id-token", "token_type": "Bearer"} + + async def fake_verify(*_args, **_kwargs): + return { + "sub": "no-grant-subject", + "email": "nogrant@example.com", + "email_verified": True, + "account_id": "cloud-account-without-grant", + } + + monkeypatch.setattr(cloud_auth_api, "discover_oidc_provider", fake_discover) + monkeypatch.setattr(cloud_auth_api, "exchange_authorization_code", fake_exchange) + monkeypatch.setattr(cloud_auth_api, "verify_oidc_id_token", fake_verify) + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_URL", "https://auth.example.test") + monkeypatch.setattr(app_config.config, "FRAMEOS_AUTH_PROVIDER_DISABLED", False) + + db.add( + CloudBackendLink( + provider_url="https://auth.example.test", + status="connected", + cloud_organization_id="cloud-org", + cloud_project_id="cloud-project", + local_organization_id=default_project.organization_id, + local_project_id=default_project.id, + local_fallback_enabled=True, + ) + ) + db.commit() + + start = await no_auth_client.get("/api/cloud-auth/login", follow_redirects=False) + state = parse_qs(urlparse(start.headers["location"]).query)["state"][0] + callback = await no_auth_client.get(f"/api/cloud-auth/callback?code=code&state={state}", follow_redirects=False) + + assert callback.status_code == 302 + assert callback.headers["location"] == "/login?error=cloud_grant_required" + assert "frameos_session" not in callback.headers.get("set-cookie", "") diff --git a/backend/app/config.py b/backend/app/config.py index 52a8ee5ef..ab30f80de 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -5,6 +5,25 @@ from dotenv import load_dotenv import requests +DEFAULT_FRAMEOS_AUTH_PROVIDER_URL = "https://auth.frameos.net" + + +def normalize_frameos_auth_provider_url(value: str | None, default_provider_url: str = DEFAULT_FRAMEOS_AUTH_PROVIDER_URL) -> dict: + normalized = (value or "").strip() + if normalized.lower() == "disabled": + return {"disabled": True, "provider_url": None} + + provider_url = normalized or default_provider_url + parsed = urlparse(provider_url) + if not parsed.scheme or not parsed.netloc: + raise ValueError("FRAMEOS_AUTH_PROVIDER_URL must be a URL, empty, or disabled") + + path = parsed.path.rstrip("/") + return { + "disabled": False, + "provider_url": parsed._replace(path=path, params="", query="", fragment="").geturl().rstrip("/"), + } + def get_bool_env(key: str) -> bool: return os.environ.get(key, '0').lower() in ['true', '1', 'yes'] @@ -48,6 +67,11 @@ class Config: HASSIO_RUN_MODE = os.environ.get('HASSIO_RUN_MODE', None) HASSIO_TOKEN = os.environ.get('HASSIO_TOKEN', None) SUPERVISOR_TOKEN = os.environ.get('SUPERVISOR_TOKEN', None) + FRAMEOS_AUTH_PROVIDER = normalize_frameos_auth_provider_url(os.environ.get("FRAMEOS_AUTH_PROVIDER_URL")) + FRAMEOS_AUTH_PROVIDER_DISABLED = bool(FRAMEOS_AUTH_PROVIDER["disabled"]) + FRAMEOS_AUTH_PROVIDER_URL = FRAMEOS_AUTH_PROVIDER["provider_url"] + FRAMEOS_AUTH_CLIENT_ID = os.environ.get("FRAMEOS_AUTH_CLIENT_ID", "frameos-backend") + FRAMEOS_AUTH_CLIENT_SECRET = os.environ.get("FRAMEOS_AUTH_CLIENT_SECRET", "") ingress_path = '' def __init__(self): diff --git a/backend/app/fastapi.py b/backend/app/fastapi.py index c4511f260..8506e02aa 100644 --- a/backend/app/fastapi.py +++ b/backend/app/fastapi.py @@ -57,6 +57,20 @@ async def lifespan(app: FastAPI): # Serve HTML and static files in all cases except for public HASSIO_RUN_MODE serve_html = config.HASSIO_RUN_MODE != "public" if serve_html: + def frameos_app_config(request: Request | None = None) -> dict: + app_config = {} + if config.HASSIO_RUN_MODE: + app_config["HASSIO_RUN_MODE"] = config.HASSIO_RUN_MODE + header_ingress_path = ( + normalize_ingress_path(request.headers.get("x-ingress-path")) + if request is not None and config.HASSIO_RUN_MODE == "ingress" + else "" + ) + ingress_path = header_ingress_path or config.ingress_path + if ingress_path: + app_config["ingress_path"] = ingress_path + return app_config + # only if frontend/dist exists, might not if we're using vite if os.path.exists("../frontend/dist"): app.mount("/assets", StaticFiles(directory="../frontend/dist/assets"), name="assets") @@ -72,20 +86,6 @@ async def lifespan(app: FastAPI): else: raise - def frameos_app_config(request: Request | None = None) -> dict: - app_config = {} - if config.HASSIO_RUN_MODE: - app_config["HASSIO_RUN_MODE"] = config.HASSIO_RUN_MODE - header_ingress_path = ( - normalize_ingress_path(request.headers.get("x-ingress-path")) - if request is not None and config.HASSIO_RUN_MODE == "ingress" - else "" - ) - ingress_path = header_ingress_path or config.ingress_path - if ingress_path: - app_config["ingress_path"] = ingress_path - return app_config - def index_html(request: Request | None = None) -> str: return index_html_template.replace( '', @@ -98,7 +98,10 @@ def index_html(request: Request | None = None) -> str: index_html_template += "" def index_html(request: Request | None = None) -> str: - return index_html_template + return index_html_template.replace( + "", + f"", + ) @app.get("/") async def read_index(request: Request): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 106735fa6..8fe1a2a71 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from .apps import * # noqa: F403 from .assets import * # noqa: F403 from .chat import * # noqa: F403 +from .cloud_auth import * # noqa: F403 from .frame import * # noqa: F403 from .log import * # noqa: F403 from .metrics import * # noqa: F403 diff --git a/backend/app/models/cloud_auth.py b/backend/app/models/cloud_auth.py new file mode 100644 index 000000000..69ad8c6c5 --- /dev/null +++ b/backend/app/models/cloud_auth.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Session, mapped_column, relationship + +from app.database import Base + + +class CloudIdentity(Base): + __tablename__ = "cloud_identity" + __table_args__ = ( + UniqueConstraint("provider_issuer", "provider_subject", name="uq_cloud_identity_provider_subject"), + ) + + id = mapped_column(Integer, primary_key=True) + user_id = mapped_column(Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True) + provider_url = mapped_column(String(512), nullable=False) + provider_issuer = mapped_column(String(512), nullable=False) + provider_subject = mapped_column(String(512), nullable=False) + cloud_account_id = mapped_column(String(128), nullable=True, index=True) + email = mapped_column(String(256), nullable=True) + email_verified = mapped_column(Boolean, nullable=False, default=False) + name = mapped_column(String(256), nullable=True) + last_login_at = mapped_column(DateTime, nullable=True) + created_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + updated_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + + user = relationship("User", back_populates="cloud_identities") + + +class CloudBackendLink(Base): + __tablename__ = "cloud_backend_link" + + id = mapped_column(Integer, primary_key=True) + provider_url = mapped_column(String(512), nullable=False) + provider_issuer = mapped_column(String(512), nullable=True) + status = mapped_column(String(32), nullable=False, default="disconnected") + public_display_name = mapped_column(String(256), nullable=True) + local_origin = mapped_column(String(512), nullable=True) + device_code = mapped_column(String(2048), nullable=True) + user_code = mapped_column(String(64), nullable=True) + verification_uri = mapped_column(String(1024), nullable=True) + verification_uri_complete = mapped_column(String(1024), nullable=True) + expires_at = mapped_column(DateTime, nullable=True) + interval_seconds = mapped_column(Integer, nullable=False, default=5) + poll_error = mapped_column(String(128), nullable=True) + access_token = mapped_column(String(4096), nullable=True) + token_reference = mapped_column(String(256), nullable=True) + linked_client_id = mapped_column(String(128), nullable=True) + cloud_organization_id = mapped_column(String(128), nullable=True) + cloud_project_id = mapped_column(String(128), nullable=True) + scope = mapped_column(String(1024), nullable=True) + local_organization_id = mapped_column(Integer, ForeignKey("organization.id", ondelete="SET NULL"), nullable=True) + local_project_id = mapped_column(Integer, ForeignKey("project.id", ondelete="SET NULL"), nullable=True) + local_fallback_enabled = mapped_column(Boolean, nullable=False, default=True) + last_inventory_sync_at = mapped_column(DateTime, nullable=True) + last_grant_sync_at = mapped_column(DateTime, nullable=True) + revoked_at = mapped_column(DateTime, nullable=True) + created_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + updated_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + + memberships = relationship("CloudMembership", back_populates="backend_link", cascade="all, delete-orphan") + + def to_public_dict(self) -> dict: + return { + "status": self.status, + "provider_url": self.provider_url, + "provider_issuer": self.provider_issuer, + "user_code": self.user_code, + "verification_uri": self.verification_uri, + "verification_uri_complete": self.verification_uri_complete, + "expires_at": self.expires_at.isoformat() if isinstance(self.expires_at, datetime) else None, + "interval_seconds": self.interval_seconds, + "poll_error": self.poll_error, + "token_reference": self.token_reference, + "linked_client_id": self.linked_client_id, + "cloud_organization_id": self.cloud_organization_id, + "cloud_project_id": self.cloud_project_id, + "local_project_id": self.local_project_id, + "local_organization_id": self.local_organization_id, + "local_fallback_enabled": self.local_fallback_enabled, + "last_inventory_sync_at": self.last_inventory_sync_at.isoformat() + if isinstance(self.last_inventory_sync_at, datetime) + else None, + "last_grant_sync_at": self.last_grant_sync_at.isoformat() + if isinstance(self.last_grant_sync_at, datetime) + else None, + "revoked_at": self.revoked_at.isoformat() if isinstance(self.revoked_at, datetime) else None, + } + + +class CloudMembership(Base): + __tablename__ = "cloud_membership" + __table_args__ = ( + UniqueConstraint( + "backend_link_id", + "cloud_account_id", + "cloud_organization_id", + "cloud_project_id", + name="uq_cloud_membership_grant", + ), + ) + + id = mapped_column(Integer, primary_key=True) + backend_link_id = mapped_column(Integer, ForeignKey("cloud_backend_link.id", ondelete="CASCADE"), nullable=False) + cloud_account_id = mapped_column(String(128), nullable=False, index=True) + cloud_organization_id = mapped_column(String(128), nullable=False) + cloud_project_id = mapped_column(String(128), nullable=True) + role = mapped_column(String(32), nullable=False) + local_organization_id = mapped_column(Integer, ForeignKey("organization.id", ondelete="SET NULL"), nullable=True) + local_project_id = mapped_column(Integer, ForeignKey("project.id", ondelete="SET NULL"), nullable=True) + updated_at = mapped_column(DateTime, nullable=True) + synced_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + + backend_link = relationship("CloudBackendLink", back_populates="memberships") + + def to_dict(self) -> dict: + return { + "cloud_account_id": self.cloud_account_id, + "cloud_organization_id": self.cloud_organization_id, + "cloud_project_id": self.cloud_project_id, + "role": self.role, + "local_organization_id": self.local_organization_id, + "local_project_id": self.local_project_id, + "updated_at": self.updated_at.isoformat() if isinstance(self.updated_at, datetime) else None, + "synced_at": self.synced_at.isoformat() if isinstance(self.synced_at, datetime) else None, + } + + +def current_cloud_backend_link(db: Session) -> CloudBackendLink | None: + return db.query(CloudBackendLink).order_by(CloudBackendLink.id.desc()).first() + + +def local_fallback_enabled(db: Session) -> bool: + link = current_cloud_backend_link(db) + return link is None or link.local_fallback_enabled is not False diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 24bb78216..082f35c2d 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -10,6 +10,7 @@ class User(Base): password = mapped_column(String(128)) organization_memberships = relationship("OrganizationMember", back_populates="user", cascade="all, delete-orphan") + cloud_identities = relationship("CloudIdentity", back_populates="user", cascade="all, delete-orphan") def set_password(self, password): self.password = generate_password_hash(password) diff --git a/backend/app/schemas/cloud_auth.py b/backend/app/schemas/cloud_auth.py new file mode 100644 index 000000000..6088a87db --- /dev/null +++ b/backend/app/schemas/cloud_auth.py @@ -0,0 +1,80 @@ +from typing import Any, Literal + +from pydantic import BaseModel + + +class CloudAuthPublicStatus(BaseModel): + provider_enabled: bool + provider_url: str | None = None + status: str = "provider_disabled" + local_fallback_enabled: bool = True + + +class CloudMembershipResponse(BaseModel): + cloud_account_id: str + cloud_organization_id: str + cloud_project_id: str | None = None + role: str + local_organization_id: int | None = None + local_project_id: int | None = None + updated_at: str | None = None + synced_at: str | None = None + + +class CloudBackendLinkResponse(BaseModel): + status: str + provider_url: str + provider_issuer: str | None = None + user_code: str | None = None + verification_uri: str | None = None + verification_uri_complete: str | None = None + expires_at: str | None = None + interval_seconds: int = 5 + poll_error: str | None = None + token_reference: str | None = None + linked_client_id: str | None = None + cloud_organization_id: str | None = None + cloud_project_id: str | None = None + local_project_id: int | None = None + local_organization_id: int | None = None + local_fallback_enabled: bool = True + last_inventory_sync_at: str | None = None + last_grant_sync_at: str | None = None + revoked_at: str | None = None + + +class CloudAuthStatusResponse(CloudAuthPublicStatus): + link: CloudBackendLinkResponse | None = None + memberships: list[CloudMembershipResponse] = [] + current_user_cloud_identities: list[dict[str, Any]] = [] + + +class CloudBackendLinkStartRequest(BaseModel): + public_display_name: str | None = None + local_origin: str | None = None + + +class CloudBackendLinkStartResponse(CloudAuthStatusResponse): + pass + + +class CloudBackendLinkPollResponse(CloudAuthStatusResponse): + pass + + +class CloudLocalFallbackUpdateRequest(BaseModel): + enabled: bool + + +class CloudLinkSyncResponse(CloudAuthStatusResponse): + inventory_synced: bool = False + grants_synced: bool = False + errors: list[str] = [] + + +class CloudTokenRotateResponse(CloudAuthStatusResponse): + rotated: bool = False + + +class CloudOidcIntentResponse(BaseModel): + intent: Literal["login", "signup"] = "login" diff --git a/backend/app/utils/cloud_auth.py b/backend/app/utils/cloud_auth.py new file mode 100644 index 000000000..844732db1 --- /dev/null +++ b/backend/app/utils/cloud_auth.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import base64 +import datetime +import hashlib +import json +import secrets +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +import httpx +from cryptography.fernet import Fernet, InvalidToken +from jose import JWTError, jwt + +from app.config import DEFAULT_FRAMEOS_AUTH_PROVIDER_URL, config, normalize_frameos_auth_provider_url + +CLOUD_OIDC_COOKIE_NAME = "frameos_cloud_oidc" +CLOUD_OIDC_COOKIE_MAX_AGE_SECONDS = 10 * 60 +CLOUD_AUTH_SCOPES = ["openid", "profile", "email", "offline_access"] + +_OIDC_DISCOVERY_CACHE: dict[str, tuple[datetime.datetime, "OidcDiscovery"]] = {} +_JWKS_CACHE: dict[str, tuple[datetime.datetime, dict[str, Any]]] = {} +_CACHE_TTL = datetime.timedelta(hours=1) + + +@dataclass(frozen=True) +class OidcDiscovery: + issuer: str + authorization_endpoint: str + token_endpoint: str + jwks_uri: str + + +def cloud_provider_config(value: str | None = None) -> dict: + if value is None: + if config.FRAMEOS_AUTH_PROVIDER_DISABLED: + return {"disabled": True, "provider_url": None} + return normalize_frameos_auth_provider_url(config.FRAMEOS_AUTH_PROVIDER_URL or DEFAULT_FRAMEOS_AUTH_PROVIDER_URL) + return normalize_frameos_auth_provider_url(value) + + +def _cloud_fernet() -> Fernet: + digest = hashlib.sha256(config.SECRET_KEY.encode()).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def encrypt_cloud_secret(value: str | None) -> str | None: + if not value: + return None + return _cloud_fernet().encrypt(value.encode()).decode() + + +def decrypt_cloud_secret(value: str | None) -> str | None: + if not value: + return None + try: + return _cloud_fernet().decrypt(value.encode()).decode() + except (InvalidToken, UnicodeDecodeError): + return None + + +def create_cloud_oidc_cookie_value(payload: dict[str, Any]) -> str: + return _cloud_fernet().encrypt(json.dumps(payload).encode()).decode() + + +def decode_cloud_oidc_cookie_value(value: str | None) -> dict[str, Any] | None: + if not value: + return None + try: + payload_raw = _cloud_fernet().decrypt(value.encode()) + payload = json.loads(payload_raw.decode()) + except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _cache_fresh(stored_at: datetime.datetime) -> bool: + return datetime.datetime.utcnow() - stored_at < _CACHE_TTL + + +async def _request_json( + method: str, + url: str, + *, + http_client: httpx.AsyncClient | None = None, + **kwargs, +) -> tuple[int, dict[str, Any]]: + owns_client = http_client is None + client = http_client or httpx.AsyncClient() + try: + response = await client.request(method, url, timeout=15.0, **kwargs) + try: + payload = response.json() + except json.JSONDecodeError: + payload = {} + return response.status_code, payload if isinstance(payload, dict) else {} + finally: + if owns_client: + await client.aclose() + + +async def discover_oidc_provider(provider_url: str, http_client: httpx.AsyncClient | None = None) -> OidcDiscovery: + issuer = provider_url.rstrip("/") + issuers = [issuer] + if not issuer.endswith("/oidc"): + issuers.append(f"{issuer}/oidc") + + last_error: Exception | None = None + for candidate_issuer in issuers: + cached = _OIDC_DISCOVERY_CACHE.get(candidate_issuer) + if cached and _cache_fresh(cached[0]): + return cached[1] + + discovery_url = f"{candidate_issuer}/.well-known/openid-configuration" + try: + status_code, metadata = await _request_json( + "GET", + discovery_url, + http_client=http_client, + headers={"accept": "application/json"}, + ) + if status_code < 200 or status_code >= 300: + raise ValueError(f"OIDC discovery failed with status {status_code}") + discovery = OidcDiscovery( + issuer=str(metadata["issuer"]), + authorization_endpoint=str(metadata["authorization_endpoint"]), + token_endpoint=str(metadata["token_endpoint"]), + jwks_uri=str(metadata["jwks_uri"]), + ) + except Exception as exc: + if cached: + return cached[1] + last_error = exc + continue + + _OIDC_DISCOVERY_CACHE[candidate_issuer] = (datetime.datetime.utcnow(), discovery) + return discovery + + if last_error: + raise last_error + raise ValueError("OIDC discovery failed") + + +async def fetch_jwks(jwks_uri: str, http_client: httpx.AsyncClient | None = None) -> dict[str, Any]: + cached = _JWKS_CACHE.get(jwks_uri) + if cached and _cache_fresh(cached[0]): + return cached[1] + + try: + status_code, jwks = await _request_json( + "GET", + jwks_uri, + http_client=http_client, + headers={"accept": "application/json"}, + ) + if status_code < 200 or status_code >= 300: + raise ValueError(f"JWKS fetch failed with status {status_code}") + if not isinstance(jwks.get("keys"), list): + raise ValueError("JWKS response is missing keys") + except Exception: + if cached: + return cached[1] + raise + + _JWKS_CACHE[jwks_uri] = (datetime.datetime.utcnow(), jwks) + return jwks + + +async def verify_oidc_id_token( + id_token: str, + *, + audience: str, + discovery: OidcDiscovery, + nonce: str, + http_client: httpx.AsyncClient | None = None, +) -> dict[str, Any]: + jwks = await fetch_jwks(discovery.jwks_uri, http_client=http_client) + try: + claims = jwt.decode( + id_token, + jwks, + algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"], + audience=audience, + issuer=discovery.issuer, + ) + except JWTError: + raise + + if claims.get("nonce") != nonce: + raise JWTError("OIDC id_token nonce mismatch") + if not claims.get("sub"): + raise JWTError("OIDC id_token is missing subject") + return claims + + +def _base64_url_no_padding(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode().rstrip("=") + + +def create_pkce_pair() -> tuple[str, str]: + verifier = _base64_url_no_padding(secrets.token_bytes(48)) + challenge = _base64_url_no_padding(hashlib.sha256(verifier.encode()).digest()) + return verifier, challenge + + +def random_urlsafe_token(byte_length: int = 32) -> str: + return _base64_url_no_padding(secrets.token_bytes(byte_length)) + + +def build_authorization_url( + discovery: OidcDiscovery, + *, + client_id: str, + code_challenge: str, + nonce: str, + redirect_uri: str, + state: str, + intent: str, +) -> str: + extra_params = {"prompt": "create"} if intent == "signup" else {} + query = { + "client_id": client_id, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "nonce": nonce, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": " ".join(CLOUD_AUTH_SCOPES), + "state": state, + **extra_params, + } + return f"{discovery.authorization_endpoint}?{urlencode(query)}" + + +async def exchange_authorization_code( + discovery: OidcDiscovery, + *, + client_id: str, + client_secret: str | None, + code: str, + code_verifier: str, + redirect_uri: str, + http_client: httpx.AsyncClient | None = None, +) -> dict[str, Any]: + body = { + "client_id": client_id, + "code": code, + "code_verifier": code_verifier, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + } + auth = (client_id, client_secret) if client_secret else None + status_code, token_set = await _request_json( + "POST", + discovery.token_endpoint, + http_client=http_client, + data=body, + auth=auth, + headers={"accept": "application/json", "content-type": "application/x-www-form-urlencoded"}, + ) + if status_code < 200 or status_code >= 300: + raise ValueError(f"OIDC token exchange failed with status {status_code}") + if not token_set.get("id_token"): + raise ValueError("OIDC token response is missing id_token") + return token_set + + +def provider_api_url(provider_url: str, path: str) -> str: + return f"{provider_url.rstrip('/')}/{path.lstrip('/')}" + + +async def provider_json_request( + method: str, + provider_url: str, + path: str, + *, + access_token: str | None = None, + http_client: httpx.AsyncClient | None = None, + json_body: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + headers = {"accept": "application/json"} + if json_body is not None: + headers["content-type"] = "application/json" + if access_token: + headers["authorization"] = f"Bearer {access_token}" + return await _request_json( + method, + provider_api_url(provider_url, path), + http_client=http_client, + headers=headers, + json=json_body, + ) diff --git a/backend/migrations/versions/2c4a6f8d9b10_cloud_auth_integration.py b/backend/migrations/versions/2c4a6f8d9b10_cloud_auth_integration.py new file mode 100644 index 000000000..4b0a94aff --- /dev/null +++ b/backend/migrations/versions/2c4a6f8d9b10_cloud_auth_integration.py @@ -0,0 +1,107 @@ +"""cloud auth integration + +Revision ID: 2c4a6f8d9b10 +Revises: b4c8d2e6f901 +Create Date: 2026-06-07 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = "2c4a6f8d9b10" +down_revision = "b4c8d2e6f901" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "cloud_identity", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("provider_url", sa.String(length=512), nullable=False), + sa.Column("provider_issuer", sa.String(length=512), nullable=False), + sa.Column("provider_subject", sa.String(length=512), nullable=False), + sa.Column("cloud_account_id", sa.String(length=128), nullable=True), + sa.Column("email", sa.String(length=256), nullable=True), + sa.Column("email_verified", sa.Boolean(), nullable=False), + sa.Column("name", sa.String(length=256), nullable=True), + sa.Column("last_login_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("provider_issuer", "provider_subject", name="uq_cloud_identity_provider_subject"), + ) + op.create_index("ix_cloud_identity_user_id", "cloud_identity", ["user_id"]) + op.create_index("ix_cloud_identity_cloud_account_id", "cloud_identity", ["cloud_account_id"]) + + op.create_table( + "cloud_backend_link", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("provider_url", sa.String(length=512), nullable=False), + sa.Column("provider_issuer", sa.String(length=512), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("public_display_name", sa.String(length=256), nullable=True), + sa.Column("local_origin", sa.String(length=512), nullable=True), + sa.Column("device_code", sa.String(length=2048), nullable=True), + sa.Column("user_code", sa.String(length=64), nullable=True), + sa.Column("verification_uri", sa.String(length=1024), nullable=True), + sa.Column("verification_uri_complete", sa.String(length=1024), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("interval_seconds", sa.Integer(), nullable=False), + sa.Column("poll_error", sa.String(length=128), nullable=True), + sa.Column("access_token", sa.String(length=4096), nullable=True), + sa.Column("token_reference", sa.String(length=256), nullable=True), + sa.Column("linked_client_id", sa.String(length=128), nullable=True), + sa.Column("cloud_organization_id", sa.String(length=128), nullable=True), + sa.Column("cloud_project_id", sa.String(length=128), nullable=True), + sa.Column("scope", sa.String(length=1024), nullable=True), + sa.Column("local_organization_id", sa.Integer(), nullable=True), + sa.Column("local_project_id", sa.Integer(), nullable=True), + sa.Column("local_fallback_enabled", sa.Boolean(), nullable=False), + sa.Column("last_inventory_sync_at", sa.DateTime(), nullable=True), + sa.Column("last_grant_sync_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["local_organization_id"], ["organization.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["local_project_id"], ["project.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "cloud_membership", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("backend_link_id", sa.Integer(), nullable=False), + sa.Column("cloud_account_id", sa.String(length=128), nullable=False), + sa.Column("cloud_organization_id", sa.String(length=128), nullable=False), + sa.Column("cloud_project_id", sa.String(length=128), nullable=True), + sa.Column("role", sa.String(length=32), nullable=False), + sa.Column("local_organization_id", sa.Integer(), nullable=True), + sa.Column("local_project_id", sa.Integer(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("synced_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["backend_link_id"], ["cloud_backend_link.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["local_organization_id"], ["organization.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["local_project_id"], ["project.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "backend_link_id", + "cloud_account_id", + "cloud_organization_id", + "cloud_project_id", + name="uq_cloud_membership_grant", + ), + ) + op.create_index("ix_cloud_membership_cloud_account_id", "cloud_membership", ["cloud_account_id"]) + + +def downgrade(): + op.drop_index("ix_cloud_membership_cloud_account_id", table_name="cloud_membership") + op.drop_table("cloud_membership") + op.drop_table("cloud_backend_link") + op.drop_index("ix_cloud_identity_cloud_account_id", table_name="cloud_identity") + op.drop_index("ix_cloud_identity_user_id", table_name="cloud_identity") + op.drop_table("cloud_identity") diff --git a/frontend/package.json b/frontend/package.json index da7b01319..faf0dd166 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,11 +22,11 @@ "dependencies": { "@babel/runtime": "^7.28.4", "@dagrejs/dagre": "^1.1.8", + "@frameos-cloud/auth-client": "link:../../frameos-cloud/packages/auth-client", "@headlessui/react": "^1.7.19", "@heroicons/react": "^2.0.18", "@microlink/react-json-view": "^1.23.0", "@monaco-editor/react": "^4.5.2", - "monaco-editor": "^0.43.0", "@popperjs/core": "^2.11.8", "@reactflow/core": "^11.8.3", "@tailwindcss/container-queries": "^0.1.1", @@ -59,6 +59,8 @@ "kea-router": "^3.4.1", "kea-subscriptions": "^3.0.1", "messg": "^2.2.2", + "monaco-editor": "^0.43.0", + "qrcode": "^1.5.4", "react": "18.2.0", "react-dom": "18.2.0", "react-markdown": "^9.0.0", diff --git a/frontend/src/frameos-cloud-auth-client.d.ts b/frontend/src/frameos-cloud-auth-client.d.ts new file mode 100644 index 000000000..c5a8ffac6 --- /dev/null +++ b/frontend/src/frameos-cloud-auth-client.d.ts @@ -0,0 +1,16 @@ +declare module '@frameos-cloud/auth-client' { + export type FrameosAuthProviderConfig = + | { + disabled: true + providerUrl?: undefined + } + | { + disabled: false + providerUrl: string + } + + export function normalizeFrameosAuthProviderUrl( + value: string | null | undefined, + defaultProviderUrl?: string + ): FrameosAuthProviderConfig +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 48a5225eb..905ec1bb4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -233,6 +233,12 @@ body.frameos-workspace-scroll-locked { color: var(--frameos-warning-text); } +.frameos-warning-surface { + background: var(--frameos-warning-bg); + border-color: var(--frameos-warning-border); + color: var(--frameos-warning-text); +} + .frameos-change-status-button { position: relative; overflow: hidden; @@ -1315,6 +1321,111 @@ html[data-frameos-theme='dark'] .frameos-chat-bubble-log { border-color: rgba(255, 255, 255, 0.14); } +.frameos-auth-screen .auth-cloud-button { + position: relative; + overflow: hidden; + border-color: rgb(var(--frameos-color-evergreen-rgb) / 0.5); + background: + radial-gradient(circle at 16% 12%, rgb(var(--frameos-color-moss-rgb) / 0.58), transparent 36%), + radial-gradient(circle at 84% 8%, rgb(var(--frameos-color-brass-rgb) / 0.3), transparent 28%), + linear-gradient( + 135deg, + color-mix(in srgb, var(--frameos-color-evergreen) 92%, #0f172a), + var(--frameos-color-graphite) + ); + color: #ffffff; + box-shadow: 0 18px 36px rgb(var(--frameos-color-evergreen-rgb) / 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.24); +} + +.frameos-auth-screen .auth-cloud-button::before { + content: ''; + position: absolute; + inset: 1px; + border-radius: calc(1rem - 1px); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 52%); + pointer-events: none; +} + +.frameos-auth-screen .auth-cloud-button:hover { + transform: translateY(-1px); + border-color: rgb(var(--frameos-color-moss-rgb) / 0.56); + background: + radial-gradient(circle at 16% 12%, rgb(var(--frameos-color-moss-rgb) / 0.66), transparent 36%), + radial-gradient(circle at 84% 8%, rgb(var(--frameos-color-brass-rgb) / 0.36), transparent 28%), + linear-gradient( + 135deg, + color-mix(in srgb, var(--frameos-color-evergreen) 96%, #0f172a), + color-mix(in srgb, var(--frameos-color-graphite) 88%, var(--frameos-color-evergreen)) + ); + box-shadow: 0 22px 44px rgb(var(--frameos-color-evergreen-rgb) / 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.3); +} + +.frameos-auth-screen .auth-cloud-button:active { + transform: translateY(0); +} + +.frameos-auth-screen .auth-cloud-button:disabled { + transform: none; +} + +.frameos-auth-screen .auth-cloud-icon { + border: 1px solid rgba(255, 255, 255, 0.34); + background: rgba(255, 255, 255, 0.17); + color: #ffffff; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28); +} + +.frameos-auth-screen .auth-local-divider { + margin: 1.25rem 0; + color: #64748b; +} + +.frameos-auth-screen .auth-local-divider-line { + background: linear-gradient(90deg, transparent, rgba(100, 116, 139, 0.42), transparent); +} + +.frameos-auth-screen.frameos-theme-dark .auth-cloud-button { + border-color: rgb(var(--frameos-color-evergreen-rgb) / 0.62); + background: + radial-gradient(circle at 16% 12%, rgb(var(--frameos-color-moss-rgb) / 0.32), transparent 36%), + radial-gradient(circle at 84% 8%, rgb(var(--frameos-color-brass-rgb) / 0.24), transparent 28%), + linear-gradient( + 135deg, + color-mix(in srgb, var(--frameos-color-evergreen) 78%, #0f172a), + color-mix(in srgb, var(--frameos-color-graphite) 78%, #020617) + ); + color: #f8fafc; + box-shadow: 0 20px 42px rgba(0, 0, 0, 0.34), 0 0 32px rgb(var(--frameos-color-evergreen-rgb) / 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.frameos-auth-screen.frameos-theme-dark .auth-cloud-button:hover { + border-color: rgb(var(--frameos-color-moss-rgb) / 0.48); + background: + radial-gradient(circle at 16% 12%, rgb(var(--frameos-color-moss-rgb) / 0.4), transparent 36%), + radial-gradient(circle at 84% 8%, rgb(var(--frameos-color-brass-rgb) / 0.3), transparent 28%), + linear-gradient( + 135deg, + color-mix(in srgb, var(--frameos-color-evergreen) 86%, #0f172a), + color-mix(in srgb, var(--frameos-color-graphite) 68%, #020617) + ); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 36px rgb(var(--frameos-color-evergreen-rgb) / 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.22); +} + +.frameos-auth-screen.frameos-theme-dark .auth-cloud-icon { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.1); +} + +.frameos-auth-screen.frameos-theme-dark .auth-local-divider { + color: #94a3b8; +} + +.frameos-auth-screen.frameos-theme-dark .auth-local-divider-line { + background: linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.34), transparent); +} + .frameos-scene-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr)); diff --git a/frontend/src/qrcode.d.ts b/frontend/src/qrcode.d.ts new file mode 100644 index 000000000..1fa8e9bbf --- /dev/null +++ b/frontend/src/qrcode.d.ts @@ -0,0 +1,15 @@ +declare module 'qrcode' { + export type QRCodeErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H' + + export interface QRCodeToDataURLOptions { + errorCorrectionLevel?: QRCodeErrorCorrectionLevel + margin?: number + width?: number + } + + const QRCode: { + toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise + } + + export default QRCode +} diff --git a/frontend/src/scenes/auth/AuthScreen.tsx b/frontend/src/scenes/auth/AuthScreen.tsx index 0c604fd94..72a870716 100644 --- a/frontend/src/scenes/auth/AuthScreen.tsx +++ b/frontend/src/scenes/auth/AuthScreen.tsx @@ -28,7 +28,7 @@ export function AuthScreen({ title, subtitle, children, footer }: AuthScreenProp type="button" title={theme === 'dark' ? 'Use light mode' : 'Use dark mode'} onClick={toggleTheme} - className="frameos-icon-button auth-button fixed right-5 top-5 flex h-12 w-12 items-center justify-center rounded-xl bg-white/80 text-slate-500 shadow-lg shadow-slate-300/25 transition hover:bg-white hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400" + className="frameos-icon-button auth-button fixed right-5 top-5 flex h-12 w-12 items-center justify-center rounded-xl shadow-lg shadow-slate-300/25 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400" > {theme === 'dark' ? : } @@ -45,7 +45,7 @@ export function AuthScreen({ title, subtitle, children, footer }: AuthScreenProp {children} {footer ? ( -
+
{footer}
) : null} diff --git a/frontend/src/scenes/auth/SetupUnavailable.tsx b/frontend/src/scenes/auth/SetupUnavailable.tsx index 3ed01d231..087c39dc8 100644 --- a/frontend/src/scenes/auth/SetupUnavailable.tsx +++ b/frontend/src/scenes/auth/SetupUnavailable.tsx @@ -8,7 +8,7 @@ export function SetupUnavailable(): JSX.Element { subtitle="FrameOS could not verify whether this installation is already configured. Check the backend and database migrations, then try again." footer={Try again} > -
+
Account setup is paused until the server can answer the setup status check.
diff --git a/frontend/src/scenes/auth/cloudAuthLogic.ts b/frontend/src/scenes/auth/cloudAuthLogic.ts new file mode 100644 index 000000000..884ae5176 --- /dev/null +++ b/frontend/src/scenes/auth/cloudAuthLogic.ts @@ -0,0 +1,49 @@ +import { actions, afterMount, kea, listeners, path } from 'kea' +import { loaders } from 'kea-loaders' + +import type { cloudAuthLogicType } from './cloudAuthLogicType' +import { defaultCloudAuthPublicStatus, normalizeCloudAuthPublicStatus } from '../../utils/cloudAuth' +import { getBasePath } from '../../utils/getBasePath' +import { urls } from '../../urls' + +export type CloudAuthIntent = 'login' | 'signup' + +export const cloudAuthLogic = kea([ + path(['src', 'scenes', 'auth', 'cloudAuthLogic']), + actions({ + continueWithCloudAuth: (intent: CloudAuthIntent) => ({ intent }), + }), + loaders(() => ({ + cloudAuthStatus: [ + defaultCloudAuthPublicStatus as any, + { + loadCloudAuthStatus: async (): Promise => { + try { + const response = await fetch(`${getBasePath()}/api/cloud-auth/status`, { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if (!response.ok) { + return defaultCloudAuthPublicStatus + } + return normalizeCloudAuthPublicStatus(await response.json()) + } catch { + return defaultCloudAuthPublicStatus + } + }, + }, + ], + })), + listeners(() => ({ + continueWithCloudAuth: ({ intent }) => { + const redirectTo = urls.frames() + const callbackOrigin = window.location.origin + window.location.href = `${getBasePath()}/api/cloud-auth/login?intent=${encodeURIComponent( + intent + )}&redirect_to=${encodeURIComponent(redirectTo)}&callback_origin=${encodeURIComponent(callbackOrigin)}` + }, + })), + afterMount(({ actions }) => { + actions.loadCloudAuthStatus() + }), +]) diff --git a/frontend/src/scenes/login/Login.tsx b/frontend/src/scenes/login/Login.tsx index 5a8e9c032..049e6b99b 100644 --- a/frontend/src/scenes/login/Login.tsx +++ b/frontend/src/scenes/login/Login.tsx @@ -2,45 +2,71 @@ 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 { cloudAuthLogic } from '../auth/cloudAuthLogic' +import { CloudArrowUpIcon } from '@heroicons/react/24/outline' 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 { cloudAuthStatus, cloudAuthStatusLoading } = useValues(cloudAuthLogic) + const { continueWithCloudAuth } = useActions(cloudAuthLogic) return ( -
- - - - - - - -
+ <> + {cloudAuthStatus.provider_enabled ? ( + + ) : null} + {cloudAuthStatus.provider_enabled ? ( +
+
+ or login locally +
+
+ ) : null} +
+ + + + + + + +
+ ) } diff --git a/frontend/src/scenes/settings/Settings.tsx b/frontend/src/scenes/settings/Settings.tsx index 0a7897039..6622f10f7 100644 --- a/frontend/src/scenes/settings/Settings.tsx +++ b/frontend/src/scenes/settings/Settings.tsx @@ -11,7 +11,16 @@ import { Button } from '../../components/Button' import { Field } from '../../components/Field' import { TextArea } from '../../components/TextArea' import { sceneLogic } from '../sceneLogic' -import { PencilSquareIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid' +import { + ArrowPathIcon, + ArrowTopRightOnSquareIcon, + ChevronDownIcon, + ChevronRightIcon, + PencilSquareIcon, + PlusIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/24/solid' import { NumberTextInput } from '../../components/NumberTextInput' import { Switch } from '../../components/Switch' import { Select } from '../../components/Select' @@ -29,6 +38,8 @@ import { accountLogic } from './accountLogic' import versions from '../../../../versions.json' import { timezoneOptions } from '../../decorators/timezones' import { systemInfoLogic } from './systemInfoLogic' +import { cloudSettingsLogic } from './cloudSettingsLogic' +import type { CloudMembership } from '../../types' type SettingsNavItem = readonly [string, string] type SettingsNavSection = { @@ -40,7 +51,10 @@ type SettingsSectionId = string const settingsNavSections: readonly SettingsNavSection[] = [ { label: '', - items: [['Account', '#settings-account']], + items: [ + ['Account', '#settings-account'], + ['FrameOS Cloud', '#settings-cloud'], + ], }, { label: 'Settings', @@ -282,6 +296,335 @@ function IngressAccountSettingsSection(): JSX.Element { ) } +function formatCloudDate(value?: string | null): string { + if (!value) { + return 'Never' + } + const timestamp = Date.parse(value) + if (!Number.isFinite(timestamp)) { + return value + } + return new Date(timestamp).toLocaleString() +} + +function formatCountdown(seconds: number | null): string { + if (seconds === null) { + return '' + } + const safeSeconds = Math.max(0, seconds) + const minutes = Math.floor(safeSeconds / 60) + const remainder = safeSeconds % 60 + return `${minutes}:${String(remainder).padStart(2, '0')}` +} + +function CloudSettingsSection(): JSX.Element { + const { + cloudAuthStatus, + cloudAuthStatusLoading, + backendLinkPolling, + cloudNow, + manualSetupOpen, + pendingLocalFallbackEnabled, + verificationQrCodeDataUrl, + } = useValues(cloudSettingsLogic) + const { + beginBackendLink, + pollBackendLink, + syncBackendLink, + rotateBackendToken, + disconnectBackendLink, + openBackendLinkVerification, + setManualSetupOpen, + setLocalFallbackEnabled, + } = useActions(cloudSettingsLogic) + const link = cloudAuthStatus.link + const status = cloudAuthStatus.status + const connected = status === 'connected' + const connecting = status === 'connecting' + const revoked = status === 'revoked' + const expiresAt = link?.expires_at ? Date.parse(link.expires_at) : null + const expiresInSeconds = + expiresAt !== null && Number.isFinite(expiresAt) ? Math.ceil((expiresAt - cloudNow) / 1000) : null + const expired = !connected && !!link && (link.poll_error === 'expired_token' || (expiresInSeconds ?? 1) <= 0) + const activeConnecting = connecting && !expired + const verificationUrl = link?.verification_uri_complete ?? link?.verification_uri ?? null + const pollError = + link?.poll_error && link.poll_error !== 'authorization_pending' && link.poll_error !== 'expired_token' + ? link.poll_error + : null + const localFallbackEnabled = cloudAuthStatus.local_fallback_enabled + const fallbackUpdating = pendingLocalFallbackEnabled !== null + + return ( +
+
+ FrameOS Cloud +
+ + {!cloudAuthStatus.provider_enabled ? ( +
FrameOS Cloud Auth is disabled for this backend.
+ ) : ( + <> +
+
+
+ {connected + ? 'Connected' + : expired + ? 'Expired' + : connecting + ? 'Connecting' + : revoked + ? 'Revoked' + : 'Disconnected'} +
+
{cloudAuthStatus.provider_url}
+
+ {!connected && !connecting && !expired ? ( + + ) : null} +
+ {(connecting || expired) && link ? ( +
+
+
+
+ {expired ? 'Connection request expired' : 'Waiting for FrameOS Cloud'} +
+
+ {expired + ? 'Start a new connection request to link this backend.' + : 'Approve this backend in FrameOS Cloud to finish linking.'} +
+ {!expired && expiresInSeconds !== null ? ( +
Expires in {formatCountdown(expiresInSeconds)}
+ ) : null} +
+ {activeConnecting && backendLinkPolling ? ( +
+ + Checking... +
+ ) : null} +
+
+ {expired ? ( + <> + + + + ) : ( + <> + + + + + )} +
+ {pollError ?
{pollError}
: null} + {link.user_code || verificationUrl ? ( +
+ + {manualSetupOpen ? ( +
+
+ {link.user_code ? ( +
+ +
+ {link.user_code} +
+
+ ) : null} + {expiresInSeconds !== null ? ( +
+ +
+ {expired ? 'Expired' : formatCountdown(expiresInSeconds)} +
+
+ ) : null} +
+ {verificationUrl ? ( +
+ {verificationQrCodeDataUrl ? ( + FrameOS Cloud verification QR code + ) : null} + + {verificationUrl} + +
+ ) : null} + {!expired ? ( + + ) : null} +
+ ) : null} +
+ ) : null} +
+ ) : null} + {connected && link ? ( +
+
+
+ +
{link.cloud_organization_id || 'Unknown'}
+
+
+ +
{link.cloud_project_id || 'All projects'}
+
+
+ +
{link.token_reference || 'Unknown'}
+
+
+ +
{formatCloudDate(link.last_grant_sync_at)}
+
+
+
+ + + +
+
+
+
+
Local fallback
+
+ Keep local email/password access available unless a cloud owner/admin session is verified. +
+
+ setLocalFallbackEnabled(enabled)} + disabled={fallbackUpdating} + label={localFallbackEnabled ? 'Enabled' : 'Disabled'} + /> +
+
+
+ + {cloudAuthStatus.memberships?.length ? ( +
+ {cloudAuthStatus.memberships.map((membership: CloudMembership) => ( +
+ {membership.cloud_account_id} + + {membership.role} + +
+ ))} +
+ ) : ( +
No member grants have been synced yet.
+ )} +
+
+ ) : null} + {revoked ? ( +
+ The cloud link token was rejected by the provider. Reconnect this backend to resume cloud sync. +
+ ) : null} + + )} +
+
+ ) +} + function SettingsGroupDivider({ label }: { label: string }): JSX.Element { return (
{isHassioIngress ? : } + {!isHassioIngress ? : null} {savedSettingsLoading ? ( ) : ( diff --git a/frontend/src/scenes/settings/cloudSettingsLogic.tsx b/frontend/src/scenes/settings/cloudSettingsLogic.tsx new file mode 100644 index 000000000..481fc6d39 --- /dev/null +++ b/frontend/src/scenes/settings/cloudSettingsLogic.tsx @@ -0,0 +1,334 @@ +import { actions, afterMount, kea, listeners, path, reducers } from 'kea' +import { loaders } from 'kea-loaders' +import QRCode from 'qrcode' + +import type { cloudSettingsLogicType } from './cloudSettingsLogicType' +import type { CloudAuthStatus } from '../../types' +import { urls } from '../../urls' +import { apiFetch } from '../../utils/apiFetch' +import { defaultCloudAuthPublicStatus, normalizeCloudAuthPublicStatus } from '../../utils/cloudAuth' +import { showWorkingMessage } from '../../utils/workingMessage' + +export const defaultCloudAuthStatus: CloudAuthStatus = { + ...defaultCloudAuthPublicStatus, + link: null, + memberships: [], + current_user_cloud_identities: [], +} + +async function responseErrorMessage(response: Response, fallback: string): Promise { + try { + const payload = await response.json() + if (typeof payload?.detail === 'string') { + return payload.detail + } + if (typeof payload?.error === 'string') { + return payload.error + } + } catch { + // Use fallback below. + } + return fallback +} + +function normalizeCloudAuthStatus(payload: Partial | null): CloudAuthStatus { + const publicStatus = normalizeCloudAuthPublicStatus(payload) + return { + ...defaultCloudAuthStatus, + ...payload, + ...publicStatus, + link: payload?.link ?? null, + memberships: payload?.memberships ?? [], + current_user_cloud_identities: payload?.current_user_cloud_identities ?? [], + } +} + +async function verificationQrCodeDataUrl(cloudAuthStatus: CloudAuthStatus): Promise { + const verificationUrl = cloudAuthStatus.link?.verification_uri_complete + return verificationUrl + ? await QRCode.toDataURL(verificationUrl, { + errorCorrectionLevel: 'M', + margin: 1, + width: 176, + }) + : null +} + +async function cloudAuthRequest(path: string, options: RequestInit = {}, fallback: string): Promise { + const response = await apiFetch(path, options) + if (!response.ok) { + throw new Error(await responseErrorMessage(response, fallback)) + } + return normalizeCloudAuthStatus((await response.json()) as Partial) +} + +function verificationUrlWithReturnTo(verificationUrl: string): string { + try { + const url = new URL(verificationUrl) + const returnUrl = new URL(`${urls.settings()}#settings-cloud`, window.location.origin) + url.searchParams.set('return_to', returnUrl.toString()) + return url.toString() + } catch { + return verificationUrl + } +} + +let reservedVerificationWindow: Window | null = null + +function reserveVerificationWindow(): void { + try { + reservedVerificationWindow = window.open('about:blank', 'frameos-cloud-verification') + if (reservedVerificationWindow) { + reservedVerificationWindow.opener = null + } + } catch { + reservedVerificationWindow = null + } +} + +function closeReservedVerificationWindow(): void { + const reservedWindow = reservedVerificationWindow + reservedVerificationWindow = null + try { + if (reservedWindow && !reservedWindow.closed) { + reservedWindow.close() + } + } catch { + // The tab may already have navigated. + } +} + +function linkIsActive(cloudAuthStatus: CloudAuthStatus): boolean { + if (cloudAuthStatus.status !== 'connecting') { + return false + } + const expiresAt = cloudAuthStatus.link?.expires_at ? Date.parse(cloudAuthStatus.link.expires_at) : null + return !expiresAt || !Number.isFinite(expiresAt) || expiresAt > Date.now() +} + +function openVerificationUrl(verificationUrl: string | null | undefined): void { + if (!verificationUrl) { + closeReservedVerificationWindow() + return + } + const url = verificationUrlWithReturnTo(verificationUrl) + const reservedWindow = reservedVerificationWindow + reservedVerificationWindow = null + try { + if (reservedWindow && !reservedWindow.closed) { + reservedWindow.opener = null + reservedWindow.location.href = url + reservedWindow.focus() + return + } + } catch { + // Fall back to opening the URL below. + } + const opened = window.open(url, '_blank', 'noopener,noreferrer') + if (!opened) { + window.location.href = url + } +} + +export const cloudSettingsLogic = kea([ + path(['src', 'scenes', 'settings', 'cloudSettingsLogic']), + actions({ + beginBackendLink: true, + setCloudNow: (now: number) => ({ now }), + setBackendLinkPolling: (polling: boolean) => ({ polling }), + setPendingLocalFallbackEnabled: (enabled: boolean | null) => ({ enabled }), + setVerificationQrCodeDataUrl: (dataUrl: string | null) => ({ dataUrl }), + setManualSetupOpen: (open: boolean) => ({ open }), + openBackendLinkVerification: (verificationUrl: string | null | undefined) => ({ verificationUrl }), + }), + reducers({ + cloudNow: [ + Date.now(), + { + setCloudNow: (_, { now }) => now, + }, + ], + backendLinkPolling: [ + false, + { + setBackendLinkPolling: (_, { polling }) => polling, + }, + ], + pendingLocalFallbackEnabled: [ + null as boolean | null, + { + setPendingLocalFallbackEnabled: (_, { enabled }) => enabled, + }, + ], + verificationQrCodeDataUrl: [ + null as string | null, + { + setVerificationQrCodeDataUrl: (_, { dataUrl }) => dataUrl, + }, + ], + manualSetupOpen: [ + false, + { + setManualSetupOpen: (_, { open }) => open, + }, + ], + }), + loaders(() => ({ + cloudAuthStatus: [ + defaultCloudAuthStatus as any, + { + loadCloudAuthStatus: async (): Promise => { + return cloudAuthRequest('/api/cloud-auth/status', {}, 'Failed to load FrameOS Cloud status') + }, + startBackendLink: async (): Promise => { + const workingMessage = showWorkingMessage('Starting FrameOS Cloud linking...') + try { + const status = await cloudAuthRequest( + '/api/cloud-auth/backend-link/start', + { method: 'POST' }, + 'Failed to start FrameOS Cloud linking' + ) + workingMessage.success('FrameOS Cloud linking started') + return status + } catch (error) { + workingMessage.error(error instanceof Error ? error.message : 'Failed to start FrameOS Cloud linking') + throw error + } + }, + pollBackendLink: async (): Promise => { + return cloudAuthRequest( + '/api/cloud-auth/backend-link/poll', + { method: 'POST' }, + 'Failed to check FrameOS Cloud linking' + ) + }, + syncBackendLink: async (): Promise => { + const workingMessage = showWorkingMessage('Syncing FrameOS Cloud...') + try { + const status = await cloudAuthRequest( + '/api/cloud-auth/backend-link/sync', + { method: 'POST' }, + 'Failed to sync FrameOS Cloud' + ) + workingMessage.success('FrameOS Cloud synced') + return status + } catch (error) { + workingMessage.error(error instanceof Error ? error.message : 'Failed to sync FrameOS Cloud') + throw error + } + }, + rotateBackendToken: async (): Promise => { + const workingMessage = showWorkingMessage('Rotating FrameOS Cloud token...') + try { + const status = await cloudAuthRequest( + '/api/cloud-auth/backend-link/rotate-token', + { method: 'POST' }, + 'Failed to rotate FrameOS Cloud token' + ) + workingMessage.success('FrameOS Cloud token rotated') + return status + } catch (error) { + workingMessage.error(error instanceof Error ? error.message : 'Failed to rotate FrameOS Cloud token') + throw error + } + }, + disconnectBackendLink: async (): Promise => { + const workingMessage = showWorkingMessage('Disconnecting FrameOS Cloud...') + try { + const status = await cloudAuthRequest( + '/api/cloud-auth/backend-link', + { method: 'DELETE' }, + 'Failed to disconnect FrameOS Cloud' + ) + workingMessage.success('FrameOS Cloud disconnected') + return status + } catch (error) { + workingMessage.error(error instanceof Error ? error.message : 'Failed to disconnect FrameOS Cloud') + throw error + } + }, + setLocalFallbackEnabled: async (enabled: boolean): Promise => { + const status = await cloudAuthRequest( + '/api/cloud-auth/local-fallback', + { + method: 'POST', + body: JSON.stringify({ enabled }), + headers: { 'Content-Type': 'application/json' }, + }, + 'Failed to update local fallback' + ) + return status + }, + }, + ], + })), + listeners(({ actions }) => ({ + beginBackendLink: () => { + reserveVerificationWindow() + actions.startBackendLink() + }, + loadCloudAuthStatusSuccess: async ({ cloudAuthStatus }, breakpoint) => { + const active = linkIsActive(cloudAuthStatus) + actions.setBackendLinkPolling(active) + actions.setVerificationQrCodeDataUrl(await verificationQrCodeDataUrl(cloudAuthStatus)) + if (active) { + await breakpoint(Math.max(1, cloudAuthStatus.link?.interval_seconds ?? 5) * 1000) + actions.pollBackendLink() + } + }, + startBackendLinkSuccess: async ({ cloudAuthStatus }, breakpoint) => { + const active = linkIsActive(cloudAuthStatus) + actions.setManualSetupOpen(false) + actions.setBackendLinkPolling(active) + actions.setVerificationQrCodeDataUrl(await verificationQrCodeDataUrl(cloudAuthStatus)) + if (active) { + openVerificationUrl(cloudAuthStatus.link?.verification_uri_complete ?? cloudAuthStatus.link?.verification_uri) + } else { + closeReservedVerificationWindow() + } + if (active) { + await breakpoint(Math.max(1, cloudAuthStatus.link?.interval_seconds ?? 5) * 1000) + actions.pollBackendLink() + } + }, + pollBackendLinkSuccess: async ({ cloudAuthStatus }, breakpoint) => { + const active = linkIsActive(cloudAuthStatus) + actions.setBackendLinkPolling(active) + actions.setVerificationQrCodeDataUrl(await verificationQrCodeDataUrl(cloudAuthStatus)) + if (active) { + await breakpoint(Math.max(1, cloudAuthStatus.link?.interval_seconds ?? 5) * 1000) + actions.pollBackendLink() + } + }, + startBackendLinkFailure: () => { + closeReservedVerificationWindow() + }, + syncBackendLinkSuccess: async ({ cloudAuthStatus }) => { + actions.setVerificationQrCodeDataUrl(await verificationQrCodeDataUrl(cloudAuthStatus)) + }, + rotateBackendTokenSuccess: async ({ cloudAuthStatus }) => { + actions.setVerificationQrCodeDataUrl(await verificationQrCodeDataUrl(cloudAuthStatus)) + }, + disconnectBackendLinkSuccess: () => { + actions.setManualSetupOpen(false) + actions.setVerificationQrCodeDataUrl(null) + }, + openBackendLinkVerification: ({ verificationUrl }) => { + openVerificationUrl(verificationUrl) + }, + setLocalFallbackEnabled: (enabled) => { + actions.setPendingLocalFallbackEnabled(enabled) + }, + setLocalFallbackEnabledSuccess: () => { + actions.setPendingLocalFallbackEnabled(null) + }, + setLocalFallbackEnabledFailure: () => { + actions.setPendingLocalFallbackEnabled(null) + }, + })), + afterMount(({ actions }) => { + actions.loadCloudAuthStatus() + const interval = window.setInterval(() => actions.setCloudNow(Date.now()), 1000) + return () => window.clearInterval(interval) + }), +]) diff --git a/frontend/src/scenes/signup/Signup.tsx b/frontend/src/scenes/signup/Signup.tsx index 8b3fa122a..bcc0e9aff 100644 --- a/frontend/src/scenes/signup/Signup.tsx +++ b/frontend/src/scenes/signup/Signup.tsx @@ -2,15 +2,19 @@ import { Form } from 'kea-forms' import { Field } from '../../components/Field' import { TextInput } from '../../components/TextInput' import { signupLogic } from './signupLogic' -import { useValues } from 'kea' +import { useActions, useValues } from 'kea' import { AuthScreen, AuthLink } from '../auth/AuthScreen' import { urls } from '../../urls' +import { cloudAuthLogic } from '../auth/cloudAuthLogic' +import { CloudArrowUpIcon } from '@heroicons/react/24/outline' 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 Signup() { const { isSignupFormSubmitting } = useValues(signupLogic) + const { cloudAuthStatus, cloudAuthStatusLoading } = useValues(cloudAuthLogic) + const { continueWithCloudAuth } = useActions(cloudAuthLogic) return ( } > -
- - - - - - - - - - -
+ <> + {cloudAuthStatus.provider_enabled ? ( + + ) : null} + {cloudAuthStatus.provider_enabled ? ( +
+
+ Local account +
+
+ ) : null} +
+ + + + + + + + + + +
+ ) } diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx index e00a02929..aba363a26 100644 --- a/frontend/src/types.tsx +++ b/frontend/src/types.tsx @@ -705,6 +705,67 @@ export interface FrameOSSettings { } } +export interface CloudAuthPublicStatus { + provider_enabled: boolean + provider_url?: string | null + status: 'provider_disabled' | 'disconnected' | 'connecting' | 'connected' | 'revoked' | string + local_fallback_enabled: boolean +} + +export interface CloudMembership { + cloud_account_id: string + cloud_organization_id: string + cloud_project_id?: string | null + role: string + local_organization_id?: number | null + local_project_id?: number | null + updated_at?: string | null + synced_at?: string | null +} + +export interface CloudBackendLinkStatus { + status: string + provider_url: string + provider_issuer?: string | null + user_code?: string | null + verification_uri?: string | null + verification_uri_complete?: string | null + expires_at?: string | null + interval_seconds?: number + poll_error?: string | null + token_reference?: string | null + linked_client_id?: string | null + cloud_organization_id?: string | null + cloud_project_id?: string | null + local_project_id?: number | null + local_organization_id?: number | null + local_fallback_enabled?: boolean + last_inventory_sync_at?: string | null + last_grant_sync_at?: string | null + revoked_at?: string | null +} + +export interface CloudIdentityStatus { + provider_url: string + provider_issuer: string + provider_subject: string + cloud_account_id?: string | null + email?: string | null + email_verified?: boolean + name?: string | null + last_login_at?: string | null +} + +export interface CloudAuthStatus extends CloudAuthPublicStatus { + link?: CloudBackendLinkStatus | null + memberships?: CloudMembership[] + current_user_cloud_identities?: CloudIdentityStatus[] + inventory_synced?: boolean + grants_synced?: boolean + errors?: string[] + rotated?: boolean +} + export interface SSHKeyEntry { id: string name?: string diff --git a/frontend/src/utils/cloudAuth.ts b/frontend/src/utils/cloudAuth.ts new file mode 100644 index 000000000..018198134 --- /dev/null +++ b/frontend/src/utils/cloudAuth.ts @@ -0,0 +1,32 @@ +import { normalizeFrameosAuthProviderUrl } from '@frameos-cloud/auth-client' +import type { CloudAuthPublicStatus } from '../types' + +export const defaultCloudAuthPublicStatus: CloudAuthPublicStatus = { + provider_enabled: false, + provider_url: null, + status: 'provider_disabled', + local_fallback_enabled: true, +} + +export function normalizeCloudAuthPublicStatus(value: Partial | null): CloudAuthPublicStatus { + if (!value?.provider_enabled) { + return { ...defaultCloudAuthPublicStatus, local_fallback_enabled: value?.local_fallback_enabled ?? true } + } + + try { + const provider = normalizeFrameosAuthProviderUrl(value.provider_url) + return { + provider_enabled: !provider.disabled, + provider_url: provider.disabled ? null : provider.providerUrl, + status: value.status ?? 'disconnected', + local_fallback_enabled: value.local_fallback_enabled ?? true, + } + } catch { + return { + provider_enabled: true, + provider_url: value.provider_url ?? null, + status: value.status ?? 'disconnected', + local_fallback_enabled: value.local_fallback_enabled ?? true, + } + } +} diff --git a/frontend/src/utils/projectApi.ts b/frontend/src/utils/projectApi.ts index 853d988bf..0f4ff8e66 100644 --- a/frontend/src/utils/projectApi.ts +++ b/frontend/src/utils/projectApi.ts @@ -98,6 +98,7 @@ export function isProjectScopedApiPath(path: string): boolean { '/api/fonts', '/api/frame-bootstrap', '/api/frames', + '/api/cloud-auth', '/api/repositories', '/api/settings', '/api/templates', diff --git a/package.json b/package.json index fb0b6bd04..3b82f3722 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "packageManager": "pnpm@10.27.0", "scripts": { "dev": "mprocs", - "dev:backend": "DEBUG=1 ./backend/bin/dev-api", + "dev:backend": "FRAMEOS_AUTH_PROVIDER_URL=http://localhost:3000 DEBUG=1 ./backend/bin/dev-api", "dev:migrate": "DEBUG=1 ./backend/bin/dev-migrate", "dev:worker": "DEBUG=1 ./backend/bin/dev-worker", "dev:frontend": "pnpm --dir frontend run dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1444c6749..40f169fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: '@dagrejs/dagre': specifier: ^1.1.8 version: 1.1.8 + '@frameos-cloud/auth-client': + specifier: link:../../frameos-cloud/packages/auth-client + version: link:../../frameos-cloud/packages/auth-client '@headlessui/react': specifier: ^1.7.19 version: 1.7.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -323,6 +326,9 @@ importers: monaco-editor: specifier: ^0.43.0 version: 0.43.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: 18.2.0 version: 18.2.0 @@ -2287,6 +2293,10 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -2329,6 +2339,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -2585,6 +2598,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2613,6 +2630,9 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -2751,6 +2771,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -3025,6 +3049,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} @@ -3305,6 +3333,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3316,6 +3356,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -3355,6 +3399,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-attribute-case-insensitive@6.0.3: resolution: {integrity: sha512-KHkmCILThWBRtg+Jn1owTnHPnFit4OkqS+eKiGEOPIGke54DCeYGJ6r0Fx/HjfE9M9kznApCLcU0DvnPchazMQ==} engines: {node: ^14 || ^16 || >=18} @@ -3743,6 +3791,11 @@ packages: prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -3887,6 +3940,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@4.1.8: resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} @@ -4060,6 +4116,9 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -4386,6 +4445,13 @@ packages: warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4393,6 +4459,9 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4400,6 +4469,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -4408,6 +4481,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -6382,6 +6459,8 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@5.3.1: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.28.1 @@ -6428,6 +6507,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -6696,6 +6781,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -6719,6 +6806,8 @@ snapshots: didyoumean@1.2.2: {} + dijkstrajs@1.0.3: {} + dlv@1.1.3: {} dom-serializer@2.0.0: @@ -6926,6 +7015,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + format@0.2.2: {} forwarded@0.2.0: {} @@ -7198,6 +7292,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash-es@4.17.23: {} lodash.debounce@4.0.8: {} @@ -7659,6 +7757,16 @@ snapshots: dependencies: wrappy: 1.0.2 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -7673,6 +7781,8 @@ snapshots: parseurl@1.3.3: {} + path-exists@4.0.0: {} + path-parse@1.0.7: {} path-to-regexp@0.1.12: {} @@ -7698,6 +7808,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + postcss-attribute-case-insensitive@6.0.3(postcss@8.5.8): dependencies: postcss: 8.5.8 @@ -8119,6 +8231,12 @@ snapshots: prr@1.0.1: optional: true + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -8324,6 +8442,8 @@ snapshots: require-directory@2.1.1: {} + require-main-filename@2.0.0: {} + reselect@4.1.8: {} resolve@1.22.11: @@ -8514,6 +8634,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} shell-quote@1.8.3: {} @@ -8832,6 +8954,14 @@ snapshots: dependencies: loose-envify: 1.4.0 + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -8840,14 +8970,35 @@ snapshots: wrappy@1.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@16.2.0: dependencies: cliui: 7.0.4