diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a35654d..4dea26a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.5b2] - Unreleased +### Changed +- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing. + ### Fixed - **P2S RTSP timeout could leave the fan-out camera stream permanently stalled (#2580, reported and diagnosed by @ronaldheft, fix shape from PR #2581)** — After an RTSP read timeout, the stream cleanup killed the stalled ffmpeg and then waited *unbounded* for it to be reaped. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily long to exit, so the fan-out stream coroutine sat parked in that wait — in the reported case for 12 hours — while every new viewer attached to the stalled broadcaster and got no frames (snapshots and diagnostics kept working, since those open fresh connections). The post-kill wait is now bounded (2 s): on timeout the stream abandons the zombie — the orphan janitor's /proc scan reaps it on its next pass — and proceeds to its normal reconnect, so live view recovers by itself. The same unbounded wait hid in two more places, both bounded too: the camera *Stop* endpoint (which would hang the very request a user makes to recover a stuck stream) and the periodic orphan-cleanup janitor itself (which is the safety net that recovers stalled streams, and so can least afford to block). - **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again. diff --git a/backend/app/api/routes/orca_cloud.py b/backend/app/api/routes/orca_cloud.py index 5b09c9874..50bbe74d9 100644 --- a/backend/app/api/routes/orca_cloud.py +++ b/backend/app/api/routes/orca_cloud.py @@ -1,31 +1,34 @@ """ Orca Cloud API Routes -PKCE-based connect/disconnect + profile sync endpoints for the -Orca Cloud (Supabase) profile-sync surface. +Device-pairing (RFC 8628) connect/disconnect + profile sync endpoints for the +Orca Cloud external-app surface. Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive): - POST /orca-cloud/auth/start - Generate PKCE + state, persist them (TTL 10 min), return the auth URL. - POST /orca-cloud/auth/finish - Parse the pasted callback URL, validate state for CSRF, exchange the - code for tokens, persist them atomically. + POST /orca-cloud/device/start + Request a device code, persist it server-side (TTL 10 min), return the + user_code + verification URIs + poll interval. + POST /orca-cloud/device/poll + One poll of the token endpoint. Returns an in-progress status while the + user approves; on approval, persists the token pair and reports + connected. The frontend calls this every ``interval`` seconds. GET /orca-cloud/status - Connected/disconnected + email + user_id. + Connected/disconnected + user_id. POST /orca-cloud/logout - Clear stored tokens (no Supabase-side revocation — token still - survives until its 1h expiry, but Bambuddy has no way to use it). + Clear stored tokens (Bambuddy then has no token to use; the user can + also disconnect from Orca Cloud's own settings to revoke server-side). GET /orca-cloud/profiles - Paginated list of the user's Orca Cloud profiles. JIT-refreshes the - access token if it's within the 5-min leeway of expiry. + List of the user's Orca Cloud profiles, grouped by type. JIT-refreshes + the access token if it's within the refresh leeway of expiry. GET /orca-cloud/profiles/{id} Single profile's full content. -Storage shape mirrors the Bambu Cloud surface: per-user columns on -``users`` when auth is enabled, fallback to global ``settings`` keys when -auth is disabled. The transient PKCE state (verifier, state, pending_at) -is stored alongside the tokens — same dual-mode pattern. +Storage shape mirrors the Bambu Cloud surface: per-user columns on ``users`` +when auth is enabled, fallback to global ``settings`` keys when auth is +disabled. The transient pending device-code state (device_code, interval, +started_at) reuses the ``orca_cloud_pending_*`` columns — same dual-mode +pattern; no schema change from the previous PKCE flow. """ from __future__ import annotations @@ -33,7 +36,7 @@ from __future__ import annotations import logging from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -43,23 +46,19 @@ from backend.app.core.permissions import Permission from backend.app.models.settings import Settings from backend.app.models.user import User from backend.app.schemas.orca_cloud import ( - OrcaAuthFinishRequest, - OrcaAuthPasswordRequest, - OrcaAuthStartRequest, - OrcaAuthStartResponse, OrcaAuthStatusResponse, + OrcaDevicePollResponse, + OrcaDeviceStartResponse, OrcaProfileDetail, OrcaProfileListResponse, OrcaProfileMeta, ) from backend.app.services.orca_cloud import ( - PENDING_PKCE_TTL, + DEVICE_CODE_TTL, + DevicePoll, OrcaCloudAuthError, OrcaCloudError, OrcaCloudService, - build_authorize_url, - generate_pkce, - parse_callback_url, ) logger = logging.getLogger(__name__) @@ -90,9 +89,9 @@ _ORCA_TYPE_TO_BAMBU = { def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None: - """Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``) - into a ``SlicerSetting``-shaped row. Returns ``None`` if the content - isn't a dict or the type isn't one we render.""" + """Normalize one Orca profile (``{id, name, content, ...}``) into a + ``SlicerSetting``-shaped row. Returns ``None`` if the content isn't a dict + or the type isn't one we render.""" content = orca_profile.get("content") or {} if not isinstance(content, dict): return None @@ -130,15 +129,16 @@ def _str_or_none(value: object) -> str | None: # Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud # pattern (``bambu_cloud_token`` etc.) so administrators inspecting the -# settings table see a consistent prefix. +# settings table see a consistent prefix. The ``pending_*`` keys hold the +# transient device-code state (device_code / interval / started_at). _SETTINGS_KEYS = { "token": "orca_cloud_token", "refresh_token": "orca_cloud_refresh_token", "expires_at": "orca_cloud_expires_at", # ISO 8601 UTC string "email": "orca_cloud_email", "user_id": "orca_cloud_user_id", - "pending_verifier": "orca_cloud_pending_verifier", - "pending_state": "orca_cloud_pending_state", + "pending_device_code": "orca_cloud_pending_verifier", # reused column + "pending_interval": "orca_cloud_pending_state", # reused column "pending_at": "orca_cloud_pending_at", # ISO 8601 UTC string } @@ -184,7 +184,11 @@ def _parse_iso(value: str | None) -> datetime | None: class _OrcaCredentials: """Lightweight bag for stored Orca Cloud credentials. We use a class rather than a dataclass so the helpers can mutate it as needed during - JIT-refresh without rebuilding the whole object.""" + JIT-refresh without rebuilding the whole object. + + ``pending_device_code`` / ``pending_interval`` / ``pending_at`` hold the + in-flight device-code pairing state (reusing the ``orca_cloud_pending_*`` + columns that the old PKCE flow used for its verifier/state).""" __slots__ = ( "token", @@ -192,8 +196,8 @@ class _OrcaCredentials: "expires_at", "email", "user_id", - "pending_verifier", - "pending_state", + "pending_device_code", + "pending_interval", "pending_at", ) @@ -203,8 +207,8 @@ class _OrcaCredentials: self.expires_at: datetime | None = None self.email: str | None = None self.user_id: str | None = None - self.pending_verifier: str | None = None - self.pending_state: str | None = None + self.pending_device_code: str | None = None + self.pending_interval: str | None = None self.pending_at: datetime | None = None @@ -227,8 +231,8 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent creds.expires_at = _as_utc(user.orca_cloud_expires_at) creds.email = user.orca_cloud_email creds.user_id = user.orca_cloud_user_id - creds.pending_verifier = user.orca_cloud_pending_verifier - creds.pending_state = user.orca_cloud_pending_state + creds.pending_device_code = user.orca_cloud_pending_verifier + creds.pending_interval = user.orca_cloud_pending_state creds.pending_at = _as_utc(user.orca_cloud_pending_at) return creds @@ -239,27 +243,28 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"])) creds.email = raw.get(_SETTINGS_KEYS["email"]) creds.user_id = raw.get(_SETTINGS_KEYS["user_id"]) - creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"]) - creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"]) + creds.pending_device_code = raw.get(_SETTINGS_KEYS["pending_device_code"]) + creds.pending_interval = raw.get(_SETTINGS_KEYS["pending_interval"]) creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"])) return creds -async def _persist_pending_pkce( +async def _persist_pending_device( db: AsyncSession, user: User | None, - verifier: str, - state: str, + device_code: str, + interval: int, when: datetime, ) -> None: - """Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``.""" + """Store the transient device-code state used by ``/device/start`` -> + ``/device/poll``. The device_code is a secret kept server-side.""" if user is not None: await db.execute( update(User) .where(User.id == user.id) .values( - orca_cloud_pending_verifier=verifier, - orca_cloud_pending_state=state, + orca_cloud_pending_verifier=device_code, + orca_cloud_pending_state=str(interval), orca_cloud_pending_at=when, ) ) @@ -268,13 +273,38 @@ async def _persist_pending_pkce( await _upsert_settings( db, { - _SETTINGS_KEYS["pending_verifier"]: verifier, - _SETTINGS_KEYS["pending_state"]: state, + _SETTINGS_KEYS["pending_device_code"]: device_code, + _SETTINGS_KEYS["pending_interval"]: str(interval), _SETTINGS_KEYS["pending_at"]: _iso(when), }, ) +async def _clear_pending_device(db: AsyncSession, user: User | None) -> None: + """Wipe just the pending device-code state (on terminal poll outcomes), + leaving any existing tokens untouched.""" + if user is not None: + await db.execute( + update(User) + .where(User.id == user.id) + .values( + orca_cloud_pending_verifier=None, + orca_cloud_pending_state=None, + orca_cloud_pending_at=None, + ) + ) + await db.commit() + return + await _upsert_settings( + db, + { + _SETTINGS_KEYS["pending_device_code"]: None, + _SETTINGS_KEYS["pending_interval"]: None, + _SETTINGS_KEYS["pending_at"]: None, + }, + ) + + async def _persist_tokens( db: AsyncSession, user: User | None, @@ -285,8 +315,8 @@ async def _persist_tokens( user_id: str | None, ) -> None: """Atomically write the new access/refresh pair to whichever backing store - the deployment uses. Also clears the pending PKCE state on the same write, - since by this point the handshake is complete.""" + the deployment uses. Also clears the pending device-code state on the same + write, since by this point the pairing is complete.""" if user is not None: await db.execute( update(User) @@ -312,8 +342,8 @@ async def _persist_tokens( _SETTINGS_KEYS["expires_at"]: _iso(expires_at), _SETTINGS_KEYS["email"]: email, _SETTINGS_KEYS["user_id"]: user_id, - _SETTINGS_KEYS["pending_verifier"]: None, - _SETTINGS_KEYS["pending_state"]: None, + _SETTINGS_KEYS["pending_device_code"]: None, + _SETTINGS_KEYS["pending_interval"]: None, _SETTINGS_KEYS["pending_at"]: None, }, ) @@ -327,7 +357,7 @@ async def _persist_rotated_tokens( expires_at: datetime | None, ) -> None: """Persist tokens after a refresh — does NOT touch email/user_id and does - NOT touch the pending PKCE state (refresh happens long after the handshake).""" + NOT touch the pending state (refresh happens long after pairing).""" if user is not None: await db.execute( update(User) @@ -405,7 +435,12 @@ async def _build_authenticated_service( """Construct an :class:`OrcaCloudService` pre-populated with stored credentials. If the access token is within the refresh-leeway of expiry, proactively refresh and persist the new pair BEFORE returning, so the - next API call doesn't time out mid-flight on an expired token.""" + next API call doesn't time out mid-flight on an expired token. + + We don't lock around the refresh: Orca tolerates concurrent refreshes for + ~60s (each racer gets its own valid pair on the same connection rather than + a revoke), so a lost race here is harmless — last-write-wins on the stored + pair, and whichever pair we keep is valid.""" creds = await _load_credentials(db, user) if not creds.token: raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.") @@ -439,128 +474,97 @@ async def _build_authenticated_service( # --------------------------------------------------------------------------- -@router.post("/auth/start", response_model=OrcaAuthStartResponse) -async def auth_start( - payload: OrcaAuthStartRequest = OrcaAuthStartRequest(), +@router.post("/device/start", response_model=OrcaDeviceStartResponse) +async def device_start( + request: Request, db: AsyncSession = Depends(get_db), current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH), ): - """Generate PKCE state and return the Supabase authorize URL for the - requested OAuth provider (google / apple / github). The frontend opens - the URL in a new tab; after sign-in the user pastes the callback URL - back into ``/auth/finish``. - - ``state`` is generated but NOT sent to Supabase (it would clash with - GoTrue's internal redirect_to-tracking state). We still persist it so - a future flow change can re-introduce state-based CSRF if needed; CSRF - protection today comes from the PKCE verifier itself, which is - single-use, server-side, and bound to the caller's user row.""" - verifier, challenge, state = generate_pkce() - await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc)) - return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider)) - - -@router.post("/auth/password", response_model=OrcaAuthStatusResponse) -async def auth_password( - payload: OrcaAuthPasswordRequest, - db: AsyncSession = Depends(get_db), - current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH), -): - """Direct email+password sign-in. No browser redirect, no paste flow — - Bambuddy POSTs the credentials to Supabase and stores the returned - tokens. Whether this succeeds depends on Orca's Supabase project - accepting the password grant; if it rejects (the SDK refuses passwords - by design, the backend may follow suit), the caller falls back to an - OAuth provider via ``/auth/start``.""" + """Begin device pairing. Requests a device code from Orca, stores it + server-side (the device_code is a secret and never leaves the backend), + and returns the user_code + verification URIs + poll interval for the + frontend to display and poll against.""" svc = OrcaCloudService() + # instance_url/label are display-only anti-phishing context on the approval + # card. base_url may be off behind a reverse proxy, but it's harmless if so. + instance_url = str(request.base_url).rstrip("/") or None try: - await svc.password_login(payload.email, payload.password) + data = await svc.request_device_code(instance_url=instance_url, instance_label="Bambuddy") except OrcaCloudAuthError as e: - raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e + # invalid_client etc. — an operator misconfiguration, not user error. + raise HTTPException(status_code=502, detail=f"Orca Cloud pairing is misconfigured: {e}") from e except OrcaCloudError as e: raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e - email: str | None = None - user_id: str | None = None - try: - user_info = await svc.get_user_info() - if isinstance(user_info, dict): - email = user_info.get("email") - user_id = user_info.get("id") - except OrcaCloudError as e: - logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e) + device_code = data.get("device_code") + user_code = data.get("user_code") + if not device_code or not user_code: + raise HTTPException(status_code=502, detail="Orca Cloud returned an incomplete device-code response.") - await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id) - return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id) + interval = int(data.get("interval") or 5) + expires_in = int(data.get("expires_in") or DEVICE_CODE_TTL.total_seconds()) + await _persist_pending_device(db, current_user, device_code, interval, datetime.now(timezone.utc)) + + return OrcaDeviceStartResponse( + user_code=user_code, + verification_uri=str(data.get("verification_uri") or ""), + verification_uri_complete=str(data.get("verification_uri_complete") or ""), + interval=interval, + expires_in=expires_in, + ) -@router.post("/auth/finish", response_model=OrcaAuthStatusResponse) -async def auth_finish( - payload: OrcaAuthFinishRequest, +@router.post("/device/poll", response_model=OrcaDevicePollResponse) +async def device_poll( db: AsyncSession = Depends(get_db), current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH), ): - """Complete the PKCE handshake — parse the pasted callback URL, validate - state (CSRF), exchange the code for tokens, persist.""" + """Poll the token endpoint once for the in-flight pairing. Returns an + in-progress status while the user approves; on approval persists the token + pair (clearing the pending state) and reports connected.""" creds = await _load_credentials(db, current_user) - if not creds.pending_verifier or not creds.pending_state or not creds.pending_at: + if not creds.pending_device_code or not creds.pending_at: raise HTTPException( status_code=400, - detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.", + detail="No pending Orca Cloud pairing. Click Connect first to start the flow.", ) # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc - # normalization. Subtracting two aware UTC datetimes gives a real wall-clock - # delta with no local-offset shift. + # normalization. Subtracting two aware UTC datetimes gives a real delta. age = datetime.now(timezone.utc) - creds.pending_at - if age > PENDING_PKCE_TTL: - # Don't leave the stale state in the DB — clear it so the user has to - # restart fresh, which forces a new verifier/state pair. - await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc)) - raise HTTPException( - status_code=400, - detail=( - f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. " - "Click Connect again to start over." - ), - ) - - code, _callback_state = parse_callback_url(payload.callback_url) - if not code: - raise HTTPException( - status_code=400, - detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.", - ) - # We do NOT validate ``state`` here: Supabase doesn't echo back a state we - # don't send (see :func:`build_authorize_url` for why we can't send one). - # CSRF is protected by PKCE: the verifier is server-side and single-use, - # so an attacker can't complete the exchange with a code they obtained - # separately. ``pending_state`` is still stored for forward compatibility - # if Supabase ever supports a client-passed state alongside redirect_to. + if age > DEVICE_CODE_TTL: + await _clear_pending_device(db, current_user) + return OrcaDevicePollResponse(status=DevicePoll.EXPIRED, connected=False) svc = OrcaCloudService() try: - await svc.exchange_code(code, creds.pending_verifier) - except OrcaCloudAuthError as e: - raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e + status, token_data = await svc.poll_token(creds.pending_device_code) except OrcaCloudError as e: raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e - # Fetch user info so we can show the connected email in the UI. - email: str | None = None + if status in DevicePoll.ONGOING: + return OrcaDevicePollResponse(status=status, connected=False) + + if status in DevicePoll.TERMINAL: + # access_denied / expired_token — the attempt is dead; clear it so the + # user starts fresh next time. + await _clear_pending_device(db, current_user) + return OrcaDevicePollResponse(status=status, connected=False) + + # COMPLETE — tokens issued and applied to svc. Introspect for the user_id + # (the external API's /me doesn't return an email, so email stays None). user_id: str | None = None try: - user_info = await svc.get_user_info() - if isinstance(user_info, dict): - email = user_info.get("email") - user_id = user_info.get("id") + info = await svc.introspect() + if isinstance(info, dict): + user_id = _str_or_none(info.get("user_id")) except OrcaCloudError as e: - # Don't fail the whole connect flow just because the user-info side - # call hiccuped — we have valid tokens, that's the load-bearing part. - logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e) + # Don't fail the whole pairing over the side introspection call — we + # have valid tokens, which is the load-bearing part. + logger.warning("Orca Cloud introspection failed after successful pairing: %s", e) - await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id) - return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id) + await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, None, user_id) + return OrcaDevicePollResponse(status=DevicePoll.COMPLETE, connected=True, email=None, user_id=user_id) @router.get("/status", response_model=OrcaAuthStatusResponse) @@ -583,9 +587,9 @@ async def logout( db: AsyncSession = Depends(get_db), current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH), ): - """Clear stored Orca Cloud credentials. Does not call Supabase's - ``/logout`` endpoint (the token would still survive its 1h expiry there - either way, and Bambuddy will no longer have it to use).""" + """Clear stored Orca Cloud credentials. Does not call Orca's disconnect + endpoint (the user can revoke server-side from Orca Cloud's own settings; + Bambuddy will no longer have the token to use either way).""" await _clear_credentials(db, current_user) return {"success": True} diff --git a/backend/app/main.py b/backend/app/main.py index 72aa6af46..227f6c3ab 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6125,12 +6125,18 @@ async def lifespan(app: FastAPI): from backend.app.services.makerworld import ( set_shared_http_client as set_shared_makerworld_http_client, ) + from backend.app.services.orca_cloud import ( + set_shared_http_client as set_shared_orca_http_client, + ) _shared_cloud_http_client = _httpx.AsyncClient(timeout=30.0) set_shared_http_client(_shared_cloud_http_client) # Reuse the same connection pool for MakerWorld — different host, same # keep-alive pool saves a TLS handshake per request. set_shared_makerworld_http_client(_shared_cloud_http_client) + # Same for Orca Cloud — without this the per-request OrcaCloudService() + # each spun up (and never closed) its own client, leaking sockets. + set_shared_orca_http_client(_shared_cloud_http_client) # Fix queue items stuck with invalid "aborted" status (should be "cancelled"). # This can happen when a print was cancelled mid-print on versions before this fix. @@ -6450,6 +6456,7 @@ async def lifespan(app: FastAPI): # Drop the shared Bambu Cloud HTTP client we registered at startup. set_shared_http_client(None) set_shared_makerworld_http_client(None) + set_shared_orca_http_client(None) await _shared_cloud_http_client.aclose() # Checkpoint WAL (SQLite only) and close all database connections diff --git a/backend/app/schemas/orca_cloud.py b/backend/app/schemas/orca_cloud.py index 20a8761ac..310d810db 100644 --- a/backend/app/schemas/orca_cloud.py +++ b/backend/app/schemas/orca_cloud.py @@ -1,50 +1,42 @@ -"""Schemas for Orca Cloud auth + profile sync endpoints.""" +"""Schemas for Orca Cloud device-pairing auth + profile sync endpoints.""" from typing import Literal from pydantic import BaseModel, Field -# The three OAuth providers Orca's sign-in surface offers. Supabase -# accepts the bare lowercase provider name in the authorize query string. -OrcaOAuthProvider = Literal["google", "apple", "github"] + +class OrcaDeviceStartResponse(BaseModel): + """Returned by ``POST /orca-cloud/device/start``. The frontend shows + ``user_code`` and a clickable/QR ``verification_uri_complete``; the user + approves in their Orca Cloud settings. The ``device_code`` itself is a + secret and stays server-side — it is deliberately NOT in this response.""" + + user_code: str = Field(..., description="Short code the user confirms on the approval page") + verification_uri: str = Field(..., description="Approval page URL") + verification_uri_complete: str = Field(..., description="Approval page URL with the code pre-filled") + interval: int = Field(..., description="Seconds the frontend should wait between poll calls") + expires_in: int = Field(..., description="Seconds until this pairing attempt expires") -class OrcaAuthStartRequest(BaseModel): - """Body for ``POST /orca-cloud/auth/start``. Provider defaults to - ``google`` so existing clients that send an empty body keep working.""" - - provider: OrcaOAuthProvider = Field(default="google", description="OAuth provider to use for sign-in") +# Poll outcomes surfaced to the frontend. ``authorization_pending`` / +# ``slow_down`` mean keep polling; ``access_denied`` / ``expired_token`` are +# terminal (restart the flow); ``complete`` means paired. +OrcaDevicePollStatus = Literal[ + "authorization_pending", + "slow_down", + "access_denied", + "expired_token", + "complete", +] -class OrcaAuthStartResponse(BaseModel): - """Returned by ``POST /orca-cloud/auth/start``. The frontend opens - ``auth_url`` in a new tab. After the user signs in to Orca, they copy the - redirected URL from their address bar and POST it to - ``/orca-cloud/auth/finish`` to complete the handshake.""" +class OrcaDevicePollResponse(BaseModel): + """Returned by ``POST /orca-cloud/device/poll`` — one poll attempt.""" - auth_url: str = Field(..., description="URL to open for Orca Cloud sign-in") - - -class OrcaAuthFinishRequest(BaseModel): - """Submitted by the frontend after the user pastes the callback URL from - their browser. The URL contains a Supabase ``code`` (and our ``state``) - that we exchange for tokens.""" - - callback_url: str = Field(..., description="The full URL the browser was redirected to after sign-in") - - -class OrcaAuthPasswordRequest(BaseModel): - """Body for ``POST /orca-cloud/auth/password``. Whether this succeeds - depends on Orca's Supabase project — their desktop client refuses - password payloads, but the web sign-in offers email+password as one - option. We forward the credentials and surface the server's response. - ``email`` is plain ``str`` rather than Pydantic's ``EmailStr`` to avoid - pulling in the optional ``email-validator`` dependency — Supabase will - reject malformed addresses with a clear error itself, and the existing - Bambu Cloud login schema uses the same approach.""" - - email: str = Field(..., min_length=1) - password: str = Field(..., min_length=1) + status: OrcaDevicePollStatus + connected: bool = False + email: str | None = None + user_id: str | None = None class OrcaAuthStatusResponse(BaseModel): diff --git a/backend/app/services/orca_cloud.py b/backend/app/services/orca_cloud.py index e61b7c313..b786472e0 100644 --- a/backend/app/services/orca_cloud.py +++ b/backend/app/services/orca_cloud.py @@ -1,33 +1,42 @@ """ Orca Cloud API Service -Handles authentication and profile sync with the Orca Cloud (Supabase-backed). +Handles pairing and profile sync with the Orca Cloud external-app surface. -Auth shape: PKCE flow against ``auth.orcaslicer.com`` with the in-source public -publishable key. Bambuddy generates the verifier/challenge/state, redirects the -user's browser to Supabase's ``/auth/v1/authorize`` endpoint with -``redirect_to=http://localhost:41172/callback``, and the user pastes the -callback URL back into Bambuddy (the loopback URL is the only ``redirect_to`` -Orca's Supabase project actually honors as of v2.4.0-alpha — see -OrcaSlicer/OrcaSlicer#14028 for the open feature request asking SoftFever to -broaden this). +Auth shape: OAuth 2.0 Device Authorization Grant (RFC 8628). Bambuddy is a +public client (``client_id`` only, no secret) — there is no redirect URL, so +the flow works from a LAN IP, ``localhost``, or behind a reverse proxy. The +user approves a short ``user_code`` in their Orca Cloud settings; Bambuddy +polls the token endpoint until a token pair is issued. -Token shape: short-lived access JWT (1h) + rotating single-use refresh token. -Every refresh issues a new pair and invalidates the old one — the route layer -is responsible for atomically swapping the stored pair on each refresh, or a -mid-refresh crash strands the user. + POST /oauth/device/code -> {device_code, user_code, verification_uri, + verification_uri_complete, expires_in, interval} + POST /oauth/token -> poll with grant_type=device_code, then later + refresh with grant_type=refresh_token -Cloudflare protects ``api.orcaslicer.com`` with a User-Agent gate; sending an -honest ``Bambuddy/`` UA clears it. No TLS-fingerprint matching needed. +Token shape: opaque ``oc_ext_`` access token (24h) + single-use rotating +``oc_ext_rt_`` refresh token (90-day, renewed on each rotation). Reuse of a +consumed refresh token beyond a ~60s server-side grace window revokes the +whole pairing, so the route layer MUST persist the new pair atomically with +consuming the old one. Within the grace window a lost refresh race is a no-op +(each racer gets its own fresh pair), so single-flighting is hygiene, not a +correctness requirement. + +API surface: ``oc_ext_`` tokens authorize ONLY the ``/api/v1/external/*`` +endpoints (introspection + ``/external/sync/*``). The first-party +``/api/v1/sync/*`` surface used by the old Supabase flow is NOT reachable with +these tokens. + +Cloudflare fronts ``api.orcaslicer.com`` and blocks unusual User-Agents +(``python-urllib`` gets a ``403 "error code: 1010"``); an honest +``Bambuddy/`` UA clears it. No TLS-fingerprint matching needed. """ from __future__ import annotations -import base64 -import hashlib import json import logging -import secrets +import os from datetime import datetime, timedelta, timezone from typing import Any @@ -35,45 +44,82 @@ import httpx logger = logging.getLogger(__name__) -# Auth + API endpoints — extracted verbatim from OrcaCloudServiceAgent.cpp -# v2.4.0-alpha. The "publishable" key is documented in-source as a public -# client identifier (Supabase anon-key pattern); embedding it in our client -# is by-design and not a secret leak. -ORCA_AUTH_BASE = "https://auth.orcaslicer.com" -ORCA_API_BASE = "https://api.orcaslicer.com" -ORCA_ANON_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_" +# --------------------------------------------------------------------------- +# Endpoints + client identity (env-overridable so staging can be targeted +# without a code change). Defaults point at production. +# --------------------------------------------------------------------------- -# Loopback redirect from OrcaCloudServiceAgent.cpp. Supabase's redirect_to -# allowlist on Orca's project only honors localhost URIs — anything else -# silently falls through to the project Site URL after the OAuth dance. -ORCA_REDIRECT_URI = "http://localhost:41172/callback" +_DEFAULT_API_BASE = "https://api.orcaslicer.com" -# Honest client identity. Same posture as Bambu Cloud: identifies Bambuddy -# without impersonating Orca's desktop client (which would be CWE-style -# falsified-identity and was the exact thing called out in Bambu Lab's May 2026 -# blog post about cloud-access etiquette). +# Base for both the OAuth endpoints (/oauth/*) and the external API +# (/api/v1/external/*). Override with ORCA_CLOUD_API_BASE to point at +# staging (https://staging-api.orcaslicer.com) during testing. +ORCA_API_BASE = os.environ.get("ORCA_CLOUD_API_BASE", _DEFAULT_API_BASE).rstrip("/") + +# Public client id registered with the Orca Cloud team (see the External App +# Pairing developer guide). Not a secret — it appears in browser-visible +# requests — but it must accompany every /oauth/device/code and /oauth/token +# call (incl. refreshes) or the server returns ``invalid_client``. Overridable +# only for the (unlikely) case of a separate staging registration. +ORCA_CLIENT_ID = os.environ.get("ORCA_CLOUD_CLIENT_ID", "oc_app_e873d49ce7dbcc7dca8ba386") + +# Scope requested at pairing time. Bambuddy currently only READS the user's +# Orca Cloud profiles (list + view), so we request the minimum — read-only. +# ``sync:read`` grants pull + versions; bump to ``sync:write`` here if/when a +# push-to-cloud feature lands (which forces existing users to re-pair, since +# the granted scope is baked into the issued token). +ORCA_SCOPE = os.environ.get("ORCA_CLOUD_SCOPE", "sync:read") + +# Honest client identity. Same posture as the Bambu Cloud client: identifies +# Bambuddy without impersonating Orca's desktop client. Also the thing that +# clears Cloudflare's User-Agent gate in front of the API. _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)" -# Refresh access tokens when they have less than this much life left, on the -# theory that a slow downstream API call shouldn't expire the token mid-flight. +# Refresh the access token when it has less than this much life left, so a +# slow downstream API call doesn't expire the token mid-flight. _REFRESH_LEEWAY = timedelta(minutes=5) -# PKCE handshake state TTL. If the user clicks "Connect" then walks away, -# the stored verifier+state is invalid after this window — they have to -# restart. 10 minutes is the OAuth norm for desktop-app PKCE flows. -PENDING_PKCE_TTL = timedelta(minutes=10) +# How long a device-code pairing attempt stays valid before the user must +# restart. The server also enforces this (``expires_in`` on the device-code +# response is 600s); we mirror it client-side so we stop polling a dead code. +DEVICE_CODE_TTL = timedelta(minutes=10) + + +# --------------------------------------------------------------------------- +# Device-poll outcomes +# --------------------------------------------------------------------------- + + +class DevicePoll: + """String outcomes of one :meth:`OrcaCloudService.poll_token` attempt. + + ``PENDING`` / ``SLOW_DOWN`` are non-terminal (keep polling; on SLOW_DOWN + widen the interval). ``DENIED`` / ``EXPIRED`` are terminal — the pairing + attempt is dead and the user must restart. ``COMPLETE`` means tokens were + issued and applied to the service.""" + + PENDING = "authorization_pending" + SLOW_DOWN = "slow_down" + DENIED = "access_denied" + EXPIRED = "expired_token" + COMPLETE = "complete" + + #: Non-terminal — the frontend should poll again. + ONGOING = frozenset({PENDING, SLOW_DOWN}) + #: Terminal failure — the frontend should restart the flow. + TERMINAL = frozenset({DENIED, EXPIRED}) class OrcaCloudError(Exception): - """Base exception for Orca Cloud errors.""" + """Base exception for Orca Cloud errors (network / unexpected server).""" pass class OrcaCloudAuthError(OrcaCloudError): - """Authentication / token-related errors. Caller should typically prompt - the user to reconnect — neither a fresh access token nor a refresh will - recover without re-authentication.""" + """Authentication / token-related errors. The caller should typically + prompt the user to reconnect — neither a fresh access token nor a refresh + will recover without re-pairing.""" pass @@ -89,89 +135,18 @@ def set_shared_http_client(client: httpx.AsyncClient | None) -> None: _shared_http_client = client -# --------------------------------------------------------------------------- -# PKCE helpers (free functions — no service-instance state needed) -# --------------------------------------------------------------------------- - - -def _b64url(data: bytes) -> str: - """RFC 7636-style base64url encoding, no padding.""" - return base64.urlsafe_b64encode(data).decode().rstrip("=") - - -def generate_pkce() -> tuple[str, str, str]: - """Generate a fresh ``(verifier, challenge, state)`` triple for one PKCE - handshake. The verifier is the secret kept by Bambuddy until the code - exchange; the challenge is sent to Supabase as ``code_challenge``; the - state is the CSRF nonce we'll verify against the callback. - - Verifier = 32 random bytes (43 base64url chars), within RFC 7636's - 43-128 char range. Challenge = ``base64url(sha256(verifier))``. - """ - verifier = _b64url(secrets.token_bytes(32)) - challenge = _b64url(hashlib.sha256(verifier.encode()).digest()) - state = _b64url(secrets.token_bytes(16)) - return verifier, challenge, state - - -def build_authorize_url(challenge: str, provider: str = "google") -> str: - """Construct the URL the user's browser should visit to start the OAuth - handshake. - - Notably **does not** pass a ``state`` query parameter. Supabase's GoTrue - uses its own internal state encoding to remember which ``redirect_to`` - belongs to which OAuth session; a client-passed ``state`` overwrites - that, GoTrue can no longer decode the redirect_to from Google's - callback, and silently falls back to the project Site URL — which is - exactly the bug that broke the live test against our deployed integration. - - CSRF is still protected by the PKCE flow itself: the server-side - ``code_verifier`` is single-use and bound to the user's session, so an - attacker with a code-only URL can't complete the exchange. - """ - from urllib.parse import urlencode - - qs = urlencode( - { - "provider": provider, - "redirect_to": ORCA_REDIRECT_URI, - "code_challenge": challenge, - "code_challenge_method": "S256", - } - ) - return f"{ORCA_AUTH_BASE}/auth/v1/authorize?{qs}" - - -def parse_callback_url(callback_url: str) -> tuple[str | None, str | None]: - """Extract ``(code, state)`` from a pasted callback URL. Both query string - and fragment are checked — some Supabase configurations put PKCE codes in - the fragment rather than the query string. Returns ``(None, None)`` if - nothing parses out; the route layer surfaces the user-facing error.""" - from urllib.parse import parse_qs, urlparse - - parsed = urlparse(callback_url.strip()) - qsd = parse_qs(parsed.query) - code = qsd.get("code", [""])[0] or None - state = qsd.get("state", [""])[0] or None - if not code: - frag = parse_qs(parsed.fragment) - code = frag.get("code", [""])[0] or None - state = state or (frag.get("state", [""])[0] or None) - return code, state - - # --------------------------------------------------------------------------- # Service class # --------------------------------------------------------------------------- class OrcaCloudService: - """Stateful per-request client for the Orca Cloud API. + """Stateful per-request client for the Orca Cloud external API. Instantiated by the route layer, populated with a stored token via :meth:`set_tokens`, then used to call the sync endpoints. Token rotation - on refresh is the route layer's responsibility (see - :meth:`refresh` — returns the new pair, doesn't persist). + on refresh is the route layer's responsibility (see :meth:`refresh` — + mutates ``self`` and returns the new pair, but does NOT persist). """ def __init__(self, client: httpx.AsyncClient | None = None): @@ -227,136 +202,124 @@ class OrcaCloudService: self.refresh_token = None self.token_expiry = None - def _auth_headers(self) -> dict[str, str]: - """Headers for calls to ``auth.orcaslicer.com``. Always includes the - apikey; the ``Authorization`` header is added only if we already have - an access token (used by ``/logout``, not by token exchange).""" - headers = { - "User-Agent": _USER_AGENT, - "apikey": ORCA_ANON_KEY, - "Content-Type": "application/json", - } - if self.access_token: - headers["Authorization"] = f"Bearer {self.access_token}" - return headers - def _api_headers(self) -> dict[str, str]: - """Headers for calls to ``api.orcaslicer.com``. Requires a bearer - token — callers should ensure the service is authenticated first.""" + """Headers for calls to the external API. Requires a bearer token — + callers should ensure the service is authenticated first.""" if not self.access_token: raise OrcaCloudAuthError("Orca Cloud API requires an access token") return { "User-Agent": _USER_AGENT, - "apikey": ORCA_ANON_KEY, "Authorization": f"Bearer {self.access_token}", "Accept": "application/json", } # ------------------------------------------------------------------ - # Token lifecycle + # Device authorization grant (RFC 8628) # ------------------------------------------------------------------ - async def password_login(self, email: str, password: str) -> dict[str, Any]: - """Direct email+password login via ``/auth/v1/token?grant_type=password``. + async def request_device_code( + self, + scope: str = ORCA_SCOPE, + instance_url: str | None = None, + instance_label: str | None = None, + ) -> dict[str, Any]: + """Start a pairing attempt. Returns the raw device-code response + (``device_code``, ``user_code``, ``verification_uri``, + ``verification_uri_complete``, ``expires_in``, ``interval``). - Whether this works depends on the Supabase project's auth config — - Orca's web sign-in offers email/password as one option, but their - desktop client refuses ``{username, password}`` payloads with - ``"Username/password login is disabled. Use the Orca cloud PKCE - flow."`` (the SDK enforces PKCE regardless of what the backend - allows). The actual server behaviour is what matters for Bambuddy - — we POST the credentials and surface whatever response we get; - an ``OrcaCloudAuthError`` with the verbatim Supabase error message - is the right signal for callers to fall back to an OAuth provider. - """ - url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=password" - payload = {"email": email, "password": password} + ``instance_url`` / ``instance_label`` are display-only fields shown on + the user's approval card (anti-phishing context). The ``device_code`` + is a secret the caller must keep server-side; only ``user_code`` and + the verification URIs are safe to show the user.""" + url = f"{ORCA_API_BASE}/oauth/device/code" + form: dict[str, str] = {"client_id": ORCA_CLIENT_ID, "scope": scope} + if instance_url: + form["instance_url"] = instance_url + if instance_label: + form["instance_label"] = instance_label try: - resp = await self._client.post( - url, - json=payload, - headers={ - "User-Agent": _USER_AGENT, - "apikey": ORCA_ANON_KEY, - "Content-Type": "application/json", - }, - ) + resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT}) except httpx.HTTPError as e: - raise OrcaCloudError(f"Network error during Orca Cloud password login: {e}") from e + raise OrcaCloudError(f"Network error requesting Orca Cloud device code: {e}") from e if resp.status_code >= 400: detail = _describe_token_error(resp) - if resp.status_code in (400, 401, 403, 422): - raise OrcaCloudAuthError(f"Orca Cloud password login rejected: {detail}") - raise OrcaCloudError(f"Orca Cloud password login failed ({resp.status_code}): {detail}") - - data = resp.json() - self._apply_token_response(data) - return data - - async def exchange_code(self, auth_code: str, code_verifier: str) -> dict[str, Any]: - """Exchange a PKCE auth code for tokens. Mutates ``self`` so the - service is ready for API calls. Returns the raw Supabase token - response so the route layer can persist the new credentials.""" - url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=pkce" - payload = {"auth_code": auth_code, "code_verifier": code_verifier} - try: - resp = await self._client.post( - url, - json=payload, - headers={ - "User-Agent": _USER_AGENT, - "apikey": ORCA_ANON_KEY, - "Content-Type": "application/json", - }, - ) - except httpx.HTTPError as e: - raise OrcaCloudError(f"Network error during Orca Cloud token exchange: {e}") from e - - if resp.status_code >= 400: - # Supabase returns ``{"error":"...", "error_description":"..."}`` - # on most failures and ``{"msg":"..."}`` on a few. Surface - # whatever we can find. - detail = _describe_token_error(resp) + # invalid_client means our client_id is wrong / unregistered — an + # operator misconfiguration, not something the user can fix. if resp.status_code in (400, 401, 403): - raise OrcaCloudAuthError(f"Orca Cloud token exchange rejected: {detail}") - raise OrcaCloudError(f"Orca Cloud token exchange failed ({resp.status_code}): {detail}") + raise OrcaCloudAuthError(f"Orca Cloud rejected the device-code request: {detail}") + raise OrcaCloudError(f"Orca Cloud device-code request failed ({resp.status_code}): {detail}") + return resp.json() - data = resp.json() - self._apply_token_response(data) - return data + async def poll_token(self, device_code: str) -> tuple[str, dict[str, Any] | None]: + """Poll the token endpoint once for a pending device-code grant. + + Returns ``(status, data)`` where ``status`` is a :class:`DevicePoll` + value. On :data:`DevicePoll.COMPLETE` the service is mutated with the + new tokens and ``data`` is the raw token response (so the caller can + persist it); otherwise ``data`` is ``None``. + + Raises :class:`OrcaCloudError` only for genuinely unexpected responses + (5xx, network, or an unrecognized error code) — the four RFC error + codes are returned as statuses, not raised, because they're normal + control flow for a polling loop.""" + url = f"{ORCA_API_BASE}/oauth/token" + form = { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + "client_id": ORCA_CLIENT_ID, + } + try: + resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT}) + except httpx.HTTPError as e: + raise OrcaCloudError(f"Network error polling Orca Cloud token endpoint: {e}") from e + + if resp.status_code < 400: + data = resp.json() + self._apply_token_response(data) + return DevicePoll.COMPLETE, data + + # RFC 8628 error bodies: {"error": "authorization_pending" | ...}. + error = _error_code(resp) + if error == "authorization_pending": + return DevicePoll.PENDING, None + if error == "slow_down": + return DevicePoll.SLOW_DOWN, None + if error == "access_denied": + return DevicePoll.DENIED, None + # expired_token and invalid_grant both mean "this device code is dead, + # start over" — collapse them to a single terminal EXPIRED status. + if error in ("expired_token", "invalid_grant"): + return DevicePoll.EXPIRED, None + raise OrcaCloudError(f"Orca Cloud token poll failed ({resp.status_code}): {_describe_token_error(resp)}") async def refresh(self) -> dict[str, Any]: """Use the stored refresh token to obtain a fresh access/refresh pair. - Supabase issues single-use refresh tokens — the old refresh token is - invalidated the moment this call succeeds. The caller MUST persist the - new pair atomically with consuming the old one; otherwise a crash - between this return and the DB write strands the user. Returns the - raw token-response dict so the caller has the full new pair. - """ + Refresh tokens are single-use — the old one is consumed the moment + this succeeds. The caller MUST persist the new pair atomically; a + crash between this return and the DB write strands the user (though + Orca's ~60s grace window means a *replay* of the old token within that + window still yields a working pair rather than revoking). Returns the + raw token-response dict so the caller has the full new pair.""" if not self.refresh_token: raise OrcaCloudAuthError("Cannot refresh: no refresh token stored") - url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=refresh_token" - payload = {"refresh_token": self.refresh_token} + url = f"{ORCA_API_BASE}/oauth/token" + form = { + "grant_type": "refresh_token", + "refresh_token": self.refresh_token, + "client_id": ORCA_CLIENT_ID, + } try: - resp = await self._client.post( - url, - json=payload, - headers={ - "User-Agent": _USER_AGENT, - "apikey": ORCA_ANON_KEY, - "Content-Type": "application/json", - }, - ) + resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT}) except httpx.HTTPError as e: raise OrcaCloudError(f"Network error during Orca Cloud refresh: {e}") from e if resp.status_code >= 400: detail = _describe_token_error(resp) - # 400/401 typically means "refresh token rotated or revoked" — - # the user has to reconnect. Don't try to recover here. + # 400 invalid_grant on refresh = expired / already-used / the user + # disconnected us. Unrecoverable — clear and force a re-pair. if resp.status_code in (400, 401, 403): self.clear_tokens() raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}") @@ -368,17 +331,17 @@ class OrcaCloudService: def _apply_token_response(self, data: dict[str, Any]) -> None: """Update ``self.access_token`` / ``self.refresh_token`` / - ``self.token_expiry`` from a Supabase token-response payload. Caller - is still responsible for persisting the values to the DB.""" + ``self.token_expiry`` from a token-response payload. Caller is still + responsible for persisting the values to the DB.""" access = data.get("access_token") refresh = data.get("refresh_token") expires_in = data.get("expires_in") if not access: raise OrcaCloudAuthError("Orca Cloud token response missing access_token") self.access_token = access - # Supabase always rotates refresh tokens on /token calls; if the - # response omits one we keep the previous value to avoid stranding - # the session, but that shouldn't happen in practice. + # The token endpoint always rotates the refresh token; if a response + # omits one we keep the previous value to avoid stranding the session, + # but that shouldn't happen in practice. if refresh: self.refresh_token = refresh if isinstance(expires_in, (int, float)) and expires_in > 0: @@ -387,64 +350,46 @@ class OrcaCloudService: self.token_expiry = None # ------------------------------------------------------------------ - # Sync API + # External API # ------------------------------------------------------------------ - async def get_user_info(self) -> dict[str, Any]: - """Return Supabase's user record for the current token (id, email, - metadata, ...). Used after token exchange to record the user's email - for display in Bambuddy's UI.""" - url = f"{ORCA_AUTH_BASE}/auth/v1/user" + async def introspect(self) -> dict[str, Any]: + """Return the pairing's introspection record (``user_id``, + ``client_id``, ``connection_id``, ``scope``, ``expires_at``). Used + after pairing to record the user's id for display in Bambuddy's UI.""" + url = f"{ORCA_API_BASE}/api/v1/external-apps/me" try: - resp = await self._client.get(url, headers=self._auth_headers()) + resp = await self._client.get(url, headers=self._api_headers()) except httpx.HTTPError as e: - raise OrcaCloudError(f"Network error fetching Orca Cloud user info: {e}") from e + raise OrcaCloudError(f"Network error fetching Orca Cloud introspection: {e}") from e if resp.status_code == 401: - raise OrcaCloudAuthError("Orca Cloud user fetch unauthorized — token expired or revoked") + raise OrcaCloudAuthError("Orca Cloud introspection unauthorized — token expired or revoked") if resp.status_code >= 400: - raise OrcaCloudError(f"Orca Cloud user fetch failed ({resp.status_code}): {resp.text[:200]}") + raise OrcaCloudError(f"Orca Cloud introspection failed ({resp.status_code}): {resp.text[:200]}") return resp.json() async def list_profiles(self) -> list[dict[str, Any]]: - """Return the user's Orca Cloud profiles as a flat list of - ``ProfileUpsert`` entries (``{id, name, content, updated_time, - created_time}``) — forwarded verbatim; callers pick the fields they - need. + """Return the user's Orca Cloud profiles as a flat list of profile + entries (``{id, name, content, updated_time, created_time}``) — + forwarded verbatim; callers pick the fields they need. - Uses ``GET /api/v1/sync/pull`` with NO ``?cursor=`` parameter, which - is the same "first-sync bootstrap" path OrcaSlicer's own client - uses (``OrcaCloudServiceAgent.cpp::sync_pull``): - - std::string path = ORCA_SYNC_PULL_PATH; - if (sync_state.last_sync_timestamp != 0) { - path += "?cursor=" + std::to_string(sync_state.last_sync_timestamp); - } - ... - // Handle 410 Gone — cursor too old, need full resync - if (http_code == 410) { - clear_sync_state(); - path = ORCA_SYNC_PULL_PATH; // retry without cursor - ... - } - - Sending ``cursor=0`` explicitly trips ``410 cursor_too_old`` — the - server-side sync log doesn't reach back to the Unix epoch. Omitting - the parameter entirely is the documented "give me the full snapshot" - semantic. The previously-attempted ``/api/v1/sync/profiles`` is - declared as a constant in Orca's source but isn't deployed on the - production cloud (returns 404). - - The pull response is a ``SyncPullResponse`` (``{next_cursor, upserts, - deletes}``); we extract ``upserts`` and ignore ``deletes`` (no prior - state on the client side to invalidate). - """ - url = f"{ORCA_API_BASE}/api/v1/sync/pull" + Uses ``GET /api/v1/external/sync/pull`` with NO ``?cursor=`` parameter, + the documented "full snapshot" bootstrap. Sending ``cursor=0`` instead + trips ``410 cursor_too_old`` (the sync log doesn't reach back to the + Unix epoch). The pull response is ``{next_cursor, upserts, deletes}``; + we return ``upserts`` and ignore the rest (no prior client state to + invalidate on a read-only list).""" + url = f"{ORCA_API_BASE}/api/v1/external/sync/pull" try: resp = await self._client.get(url, headers=self._api_headers()) except httpx.HTTPError as e: raise OrcaCloudError(f"Network error listing Orca Cloud profiles: {e}") from e if resp.status_code == 401: raise OrcaCloudAuthError("Orca Cloud profile list unauthorized — token expired or revoked") + if resp.status_code == 410: + # cursor_too_old on a no-cursor request would be surprising, but + # surface it clearly rather than as an opaque 502. + raise OrcaCloudError("Orca Cloud sync cursor too old — a full resync is required") if resp.status_code >= 400: raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}") data = resp.json() @@ -452,24 +397,21 @@ class OrcaCloudService: upserts = data.get("upserts") if isinstance(upserts, list): return upserts - # Tolerate the shape we'd see if Orca ever rolls out a flat-list - # endpoint at this path — forward whatever array is on the dict. + # Tolerate a flat-list shape if Orca ever rolls one out here. for key in ("profiles", "data"): value = data.get(key) if isinstance(value, list): return value if isinstance(data, list): return data - logger.warning("Orca Cloud /sync/pull returned unexpected shape: %r", type(data).__name__) + logger.warning("Orca Cloud /external/sync/pull returned unexpected shape: %r", type(data).__name__) return [] async def get_profile(self, profile_id: str) -> dict[str, Any]: - """Fetch a single profile's full content. Orca's sync API doesn't - expose a per-profile GET, so we list and filter. For small profile - counts (the realistic case) this is fine; if it becomes a hot path - we'll add client-side caching at the route layer rather than hammer - the list endpoint. - """ + """Fetch a single profile's full content. The external sync API has no + per-profile GET, so we list and filter. For the realistic profile + counts this is fine; if it becomes a hot path we'll add caching at the + route layer rather than hammer the pull endpoint.""" profiles = await self.list_profiles() for profile in profiles: if str(profile.get("id")) == str(profile_id): @@ -487,10 +429,24 @@ class OrcaCloudService: await self._client.aclose() +def _error_code(resp: httpx.Response) -> str | None: + """Extract the RFC-style ``error`` code from a token-endpoint error body, + or ``None`` if the body doesn't parse as ``{"error": "..."}``.""" + try: + data = resp.json() + except (json.JSONDecodeError, ValueError): + return None + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, str) and err: + return err + return None + + def _describe_token_error(resp: httpx.Response) -> str: - """Best-effort extraction of a user-facing message from a Supabase token - endpoint error response. Tries JSON fields in order; falls back to the - raw body (truncated) if nothing parses.""" + """Best-effort extraction of a user-facing message from a token-endpoint + error response. Tries JSON fields in order; falls back to the raw body + (truncated) if nothing parses.""" try: data = resp.json() except (json.JSONDecodeError, ValueError): diff --git a/backend/tests/integration/test_orca_cloud_device.py b/backend/tests/integration/test_orca_cloud_device.py new file mode 100644 index 000000000..ad57d6b05 --- /dev/null +++ b/backend/tests/integration/test_orca_cloud_device.py @@ -0,0 +1,176 @@ +"""Integration tests for the Orca Cloud device-pairing routes. + +Covers the /device/start -> /device/poll pairing loop, its terminal outcomes, +token persistence, and status/logout — all in auth-disabled mode (the global +Settings-table fallback), with the service's network calls patched out. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from httpx import AsyncClient + +from backend.app.services import orca_cloud as orca_service +from backend.app.services.orca_cloud import DevicePoll, OrcaCloudService + +AUTH_DISABLED = "backend.app.core.auth.is_auth_enabled" + + +@pytest.fixture(autouse=True) +def _dummy_shared_client(): + """Register a throwaway shared HTTP client so per-request + OrcaCloudService() instances don't spin up (and leak) a real one — the + network methods are patched anyway.""" + from unittest.mock import MagicMock + + orca_service.set_shared_http_client(MagicMock()) + yield + orca_service.set_shared_http_client(None) + + +_DEVICE_CODE_RESPONSE = { + "device_code": "DEV-SECRET-1", + "user_code": "ABCD-EF12", + "verification_uri": "https://cloud.orcaslicer.com/app/settings", + "verification_uri_complete": "https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12", + "expires_in": 600, + "interval": 5, +} + + +async def _start(async_client: AsyncClient): + with ( + patch(AUTH_DISABLED, return_value=False), + patch.object(OrcaCloudService, "request_device_code", return_value=dict(_DEVICE_CODE_RESPONSE)), + ): + return await async_client.post("/api/v1/orca-cloud/device/start") + + +class TestDeviceStart: + @pytest.mark.asyncio + async def test_start_returns_user_code_and_hides_device_code(self, async_client: AsyncClient): + resp = await _start(async_client) + assert resp.status_code == 200 + body = resp.json() + assert body["user_code"] == "ABCD-EF12" + assert body["interval"] == 5 + assert body["verification_uri_complete"].endswith("user_code=ABCD-EF12") + # The device_code is a secret and must NOT be echoed to the client. + assert "device_code" not in body + + +class TestDevicePoll: + @pytest.mark.asyncio + async def test_poll_without_pending_is_400(self, async_client: AsyncClient): + with patch(AUTH_DISABLED, return_value=False): + resp = await async_client.post("/api/v1/orca-cloud/device/poll") + assert resp.status_code == 400 + + @pytest.mark.asyncio + async def test_poll_pending_reports_in_progress(self, async_client: AsyncClient): + await _start(async_client) + with ( + patch(AUTH_DISABLED, return_value=False), + patch.object(OrcaCloudService, "poll_token", return_value=(DevicePoll.PENDING, None)), + ): + resp = await async_client.post("/api/v1/orca-cloud/device/poll") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == DevicePoll.PENDING + assert body["connected"] is False + + @pytest.mark.asyncio + async def test_poll_complete_persists_tokens_and_connects(self, async_client: AsyncClient): + await _start(async_client) + + async def fake_complete(self, device_code): + assert device_code == "DEV-SECRET-1" # the stored secret is used + self.access_token = "oc_ext_new" + self.refresh_token = "oc_ext_rt_new" + self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=86400) + return DevicePoll.COMPLETE, {"access_token": "oc_ext_new"} + + with ( + patch(AUTH_DISABLED, return_value=False), + patch.object(OrcaCloudService, "poll_token", new=fake_complete), + patch.object(OrcaCloudService, "introspect", return_value={"user_id": "user-123"}), + ): + resp = await async_client.post("/api/v1/orca-cloud/device/poll") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == DevicePoll.COMPLETE + assert body["connected"] is True + assert body["user_id"] == "user-123" + + # Status now reflects the connection, and the pending state is + # cleared (a fresh poll finds nothing pending -> 400). + status = await async_client.get("/api/v1/orca-cloud/status") + assert status.json()["connected"] is True + again = await async_client.post("/api/v1/orca-cloud/device/poll") + assert again.status_code == 400 + + @pytest.mark.asyncio + async def test_poll_denied_clears_pending(self, async_client: AsyncClient): + await _start(async_client) + with ( + patch(AUTH_DISABLED, return_value=False), + patch.object(OrcaCloudService, "poll_token", return_value=(DevicePoll.DENIED, None)), + ): + resp = await async_client.post("/api/v1/orca-cloud/device/poll") + assert resp.json()["status"] == DevicePoll.DENIED + # Pending cleared -> next poll has nothing to poll. + with patch(AUTH_DISABLED, return_value=False): + again = await async_client.post("/api/v1/orca-cloud/device/poll") + assert again.status_code == 400 + + @pytest.mark.asyncio + async def test_poll_expires_by_ttl_without_network(self, async_client: AsyncClient): + """A pending code older than DEVICE_CODE_TTL is reported expired + without even calling the token endpoint. Shrinking the TTL to a + negative window makes any just-created pending state 'stale'.""" + await _start(async_client) + + # poll_token must NOT be called; if it were, this would blow up. + def _boom(*a, **k): + raise AssertionError("poll_token should not be called for an expired code") + + with ( + patch(AUTH_DISABLED, return_value=False), + patch("backend.app.api.routes.orca_cloud.DEVICE_CODE_TTL", timedelta(seconds=-1)), + patch.object(OrcaCloudService, "poll_token", new=_boom), + ): + resp = await async_client.post("/api/v1/orca-cloud/device/poll") + assert resp.status_code == 200 + assert resp.json()["status"] == DevicePoll.EXPIRED + # And the expired pending state is cleared. + with patch(AUTH_DISABLED, return_value=False): + again = await async_client.post("/api/v1/orca-cloud/device/poll") + assert again.status_code == 400 + + +class TestLogout: + @pytest.mark.asyncio + async def test_logout_clears_connection(self, async_client: AsyncClient): + await _start(async_client) + + async def fake_complete(self, device_code): + self.access_token = "oc_ext_new" + self.refresh_token = "oc_ext_rt_new" + self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=86400) + return DevicePoll.COMPLETE, {"access_token": "oc_ext_new"} + + with ( + patch(AUTH_DISABLED, return_value=False), + patch.object(OrcaCloudService, "poll_token", new=fake_complete), + patch.object(OrcaCloudService, "introspect", return_value={"user_id": "u"}), + ): + await async_client.post("/api/v1/orca-cloud/device/poll") + + with patch(AUTH_DISABLED, return_value=False): + out = await async_client.post("/api/v1/orca-cloud/logout") + assert out.status_code == 200 + status = await async_client.get("/api/v1/orca-cloud/status") + assert status.json()["connected"] is False diff --git a/backend/tests/unit/services/test_orca_cloud.py b/backend/tests/unit/services/test_orca_cloud.py index 82c303250..7f079546b 100644 --- a/backend/tests/unit/services/test_orca_cloud.py +++ b/backend/tests/unit/services/test_orca_cloud.py @@ -1,130 +1,29 @@ -"""Tests for the Orca Cloud service — PKCE generation, authorize URL shape, -token exchange / refresh round-trip, single-use refresh token rotation, -and Cloudflare-cleaning User-Agent header.""" +"""Tests for the Orca Cloud device-pairing service — device-code request, +token poll (the four RFC 8628 outcomes + success), single-use refresh +rotation, external-API headers (bearer, no apikey), and profile pull.""" from __future__ import annotations -import base64 -import hashlib import json from datetime import datetime, timedelta, timezone -from unittest.mock import AsyncMock, MagicMock, patch -from urllib.parse import parse_qs, urlparse +from unittest.mock import AsyncMock, MagicMock import httpx import pytest -from backend.app.services import orca_cloud from backend.app.services.orca_cloud import ( - ORCA_ANON_KEY, - ORCA_AUTH_BASE, - ORCA_REDIRECT_URI, + ORCA_CLIENT_ID, + DevicePoll, OrcaCloudAuthError, OrcaCloudError, OrcaCloudService, - build_authorize_url, - generate_pkce, - parse_callback_url, ) -# --------------------------------------------------------------------------- -# PKCE primitives -# --------------------------------------------------------------------------- - - -class TestPkce: - def test_challenge_is_sha256_of_verifier(self): - """The challenge must be base64url(sha256(verifier)) — this is the - RFC 7636 invariant Supabase will check on the exchange step. A bug - here means the exchange always fails with code_verifier mismatch.""" - verifier, challenge, _state = generate_pkce() - expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") - assert challenge == expected - - def test_verifier_length_in_rfc_range(self): - verifier, _challenge, _state = generate_pkce() - # 32 random bytes -> 43 chars after base64url-no-pad; RFC 7636 - # requires 43-128. - assert 43 <= len(verifier) <= 128 - - def test_state_is_unique_per_call(self): - """Two consecutive calls must not share state — otherwise a stolen - state from one flow could be replayed against another in-flight one.""" - _, _, s1 = generate_pkce() - _, _, s2 = generate_pkce() - assert s1 != s2 - - def test_characters_are_url_safe(self): - """Both verifier and challenge must be URL-safe base64 (no padding, - no + or /) so they can be sent as query-string values without - re-encoding.""" - verifier, challenge, state = generate_pkce() - for value in (verifier, challenge, state): - assert all(c.isalnum() or c in ("-", "_") for c in value), value - - -class TestAuthorizeUrl: - def test_url_targets_authorize_endpoint(self): - url = build_authorize_url("CHALLENGE") - assert url.startswith(f"{ORCA_AUTH_BASE}/auth/v1/authorize?") - - def test_url_contains_required_pkce_params(self): - """The four PKCE params Supabase needs at authorize time. Missing any - of these = Supabase 400s the request before redirecting to Google.""" - url = build_authorize_url("CHALLENGE") - params = parse_qs(urlparse(url).query) - assert params["provider"] == ["google"] - assert params["redirect_to"] == [ORCA_REDIRECT_URI] - assert params["code_challenge"] == ["CHALLENGE"] - assert params["code_challenge_method"] == ["S256"] - - def test_url_does_not_pass_state(self): - """Regression guard against re-introducing the bug we hit in the - first deployed integration: passing ``state`` to GoTrue's authorize - endpoint silently overrides its internal redirect_to tracking, so - the user lands at the project Site URL instead of our localhost - callback. CSRF is protected by PKCE alone — verifier is server-side - and single-use.""" - url = build_authorize_url("CHALLENGE") - params = parse_qs(urlparse(url).query) - assert "state" not in params - - -class TestParseCallback: - def test_extracts_code_and_state_from_query(self): - code, state = parse_callback_url("http://localhost:41172/callback?code=ABC&state=XYZ") - assert code == "ABC" - assert state == "XYZ" - - def test_falls_back_to_fragment(self): - """Some Supabase configurations put PKCE codes in the URL fragment - rather than the query (depends on response_mode setting). Both must - be handled or some users get a confusing 'no code in URL' error.""" - code, state = parse_callback_url("http://localhost:41172/callback#code=ABC&state=XYZ") - assert code == "ABC" - assert state == "XYZ" - - def test_returns_none_when_no_code(self): - code, state = parse_callback_url("http://localhost:41172/callback?error=denied") - assert code is None - assert state is None - - def test_handles_whitespace_padding(self): - """Users paste from address bars and sometimes accidentally include - a leading/trailing space — the parser must be forgiving.""" - code, _state = parse_callback_url(" http://localhost:41172/callback?code=ABC&state=XYZ ") - assert code == "ABC" - - -# --------------------------------------------------------------------------- -# Token exchange + refresh -# --------------------------------------------------------------------------- - def _mock_response( *, status_code: int = 200, - json_data: dict | None = None, + json_data: dict | list | None = None, text_body: str = "", ) -> MagicMock: """Build an httpx-like response mock with the only attributes the @@ -145,132 +44,184 @@ def svc() -> OrcaCloudService: return OrcaCloudService(client=MagicMock(spec=httpx.AsyncClient)) -class TestExchangeCode: +# --------------------------------------------------------------------------- +# Device-code request +# --------------------------------------------------------------------------- + + +class TestRequestDeviceCode: @pytest.mark.asyncio - async def test_success_populates_tokens_and_expiry(self, svc): - token_resp = _mock_response( - json_data={ - "access_token": "ACCESS-1", - "refresh_token": "REFRESH-1", - "expires_in": 3600, - "token_type": "bearer", - } - ) - svc._client.post = AsyncMock(return_value=token_resp) - - await svc.exchange_code("CODE", "VERIFIER") - - assert svc.access_token == "ACCESS-1" - assert svc.refresh_token == "REFRESH-1" - assert svc.token_expiry is not None - # Expiry should be approximately now + 3600s (within a 60s window). - delta = svc.token_expiry - datetime.now(timezone.utc) - assert timedelta(seconds=3540) <= delta <= timedelta(seconds=3660) - - @pytest.mark.asyncio - async def test_sends_apikey_and_user_agent_headers(self, svc): - """Two load-bearing headers: the publishable apikey (Supabase - requires it) and a non-default User-Agent (Cloudflare 1010s - ``Python-urllib/X.Y`` so an honest ``Bambuddy/`` UA is needed).""" - token_resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 3600}) - svc._client.post = AsyncMock(return_value=token_resp) - - await svc.exchange_code("CODE", "VERIFIER") - - _args, kwargs = svc._client.post.call_args - headers = kwargs["headers"] - assert headers["apikey"] == ORCA_ANON_KEY - assert headers["User-Agent"].startswith("Bambuddy/") - assert headers["Content-Type"] == "application/json" - - @pytest.mark.asyncio - async def test_400_raises_auth_error_not_generic(self, svc): - """400 from Supabase usually means a bad verifier or stale code — - the user has to restart sign-in. Raising auth-specific exception - lets the route map to a sensible 400 with a 'click Connect again' - message rather than a generic 502.""" - err_resp = _mock_response( - status_code=400, - json_data={"error": "invalid_grant", "error_description": "code expired"}, - ) - svc._client.post = AsyncMock(return_value=err_resp) - - with pytest.raises(OrcaCloudAuthError) as exc: - await svc.exchange_code("CODE", "VERIFIER") - assert "code expired" in str(exc.value) - - @pytest.mark.asyncio - async def test_network_error_wraps_as_orca_error(self, svc): - svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom")) - with pytest.raises(OrcaCloudError): - await svc.exchange_code("CODE", "VERIFIER") - - -class TestPasswordLogin: - @pytest.mark.asyncio - async def test_success_populates_tokens(self, svc): + async def test_success_returns_device_code_payload(self, svc): resp = _mock_response( json_data={ - "access_token": "PWD-A", - "refresh_token": "PWD-R", - "expires_in": 3600, + "device_code": "DEV-1", + "user_code": "ABCD-EF12", + "verification_uri": "https://cloud.orcaslicer.com/app/settings", + "verification_uri_complete": "https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12", + "expires_in": 600, + "interval": 5, } ) svc._client.post = AsyncMock(return_value=resp) - await svc.password_login("user@example.com", "secret") + data = await svc.request_device_code() - assert svc.access_token == "PWD-A" - assert svc.refresh_token == "PWD-R" + assert data["user_code"] == "ABCD-EF12" + assert data["device_code"] == "DEV-1" @pytest.mark.asyncio - async def test_disabled_provider_raises_auth_error_not_generic(self, svc): - """Whether Orca's Supabase project accepts password grant is config- - dependent. When it doesn't (their desktop SDK refuses passwords by - design, the backend may follow suit), the failure mode is a 400 / - 422 with an error like ``email_provider_disabled``. The caller maps - ``OrcaCloudAuthError`` to a 400 with a "use OAuth instead" hint — - a 502 would imply Orca is down, which would be wrong UX.""" - err = _mock_response( - status_code=422, - json_data={"error": "email_provider_disabled", "error_description": "Email logins are disabled"}, - ) - svc._client.post = AsyncMock(return_value=err) - with pytest.raises(OrcaCloudAuthError, match="Email logins are disabled"): - await svc.password_login("user@example.com", "secret") + async def test_sends_client_id_scope_and_user_agent(self, svc): + """The device-code request is form-encoded (NOT JSON) with our public + client_id and requested scope, and carries the Cloudflare-clearing + User-Agent. No ``apikey`` header (that was the old Supabase flow).""" + resp = _mock_response(json_data={"device_code": "D", "user_code": "U", "interval": 5, "expires_in": 600}) + svc._client.post = AsyncMock(return_value=resp) + + await svc.request_device_code() + + _args, kwargs = svc._client.post.call_args + assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID + assert kwargs["data"]["scope"] # a scope is always sent + assert kwargs["headers"]["User-Agent"].startswith("Bambuddy/") + assert "apikey" not in kwargs["headers"] @pytest.mark.asyncio - async def test_invalid_credentials_raises_auth_error(self, svc): - err = _mock_response( - status_code=400, - json_data={"error": "invalid_grant", "error_description": "Invalid login credentials"}, + async def test_forwards_instance_fields_when_given(self, svc): + resp = _mock_response(json_data={"device_code": "D", "user_code": "U", "interval": 5, "expires_in": 600}) + svc._client.post = AsyncMock(return_value=resp) + + await svc.request_device_code(instance_url="http://192.168.1.50:8080", instance_label="Garage") + + _args, kwargs = svc._client.post.call_args + assert kwargs["data"]["instance_url"] == "http://192.168.1.50:8080" + assert kwargs["data"]["instance_label"] == "Garage" + + @pytest.mark.asyncio + async def test_invalid_client_raises_auth_error(self, svc): + """A wrong/unregistered client_id returns ``invalid_client`` — an + operator misconfiguration surfaced as an auth error so the route can + map it distinctly from a transient outage.""" + resp = _mock_response(status_code=400, json_data={"error": "invalid_client"}) + svc._client.post = AsyncMock(return_value=resp) + with pytest.raises(OrcaCloudAuthError, match="invalid_client"): + await svc.request_device_code() + + @pytest.mark.asyncio + async def test_network_error_wraps(self, svc): + svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom")) + with pytest.raises(OrcaCloudError): + await svc.request_device_code() + + +# --------------------------------------------------------------------------- +# Token poll (RFC 8628 device_code grant) +# --------------------------------------------------------------------------- + + +class TestPollToken: + @pytest.mark.asyncio + async def test_success_applies_tokens_and_returns_complete(self, svc): + resp = _mock_response( + json_data={ + "access_token": "oc_ext_A", + "refresh_token": "oc_ext_rt_R", + "expires_in": 86400, + "token_type": "Bearer", + } ) - svc._client.post = AsyncMock(return_value=err) - with pytest.raises(OrcaCloudAuthError, match="Invalid login credentials"): - await svc.password_login("user@example.com", "wrong") + svc._client.post = AsyncMock(return_value=resp) + + status, data = await svc.poll_token("DEV-1") + + assert status == DevicePoll.COMPLETE + assert data["access_token"] == "oc_ext_A" + assert svc.access_token == "oc_ext_A" + assert svc.refresh_token == "oc_ext_rt_R" + assert svc.token_expiry is not None + + @pytest.mark.asyncio + async def test_sends_device_code_grant_and_client_id(self, svc): + resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 86400}) + svc._client.post = AsyncMock(return_value=resp) + + await svc.poll_token("DEV-1") + + _args, kwargs = svc._client.post.call_args + assert kwargs["data"]["grant_type"] == "urn:ietf:params:oauth:grant-type:device_code" + assert kwargs["data"]["device_code"] == "DEV-1" + assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "error_code,expected", + [ + ("authorization_pending", DevicePoll.PENDING), + ("slow_down", DevicePoll.SLOW_DOWN), + ("access_denied", DevicePoll.DENIED), + ("expired_token", DevicePoll.EXPIRED), + ("invalid_grant", DevicePoll.EXPIRED), # collapsed to EXPIRED + ], + ) + async def test_rfc_error_codes_map_to_statuses(self, svc, error_code, expected): + """The four RFC error codes (plus invalid_grant) are normal polling + control flow — returned as statuses, never raised.""" + resp = _mock_response(status_code=400, json_data={"error": error_code}) + svc._client.post = AsyncMock(return_value=resp) + + status, data = await svc.poll_token("DEV-1") + + assert status == expected + assert data is None + + @pytest.mark.asyncio + async def test_unknown_error_raises(self, svc): + """An unrecognized error body is a real problem, not a poll state — + raise so it doesn't silently masquerade as 'still pending' forever.""" + resp = _mock_response(status_code=400, json_data={"error": "teapot"}) + svc._client.post = AsyncMock(return_value=resp) + with pytest.raises(OrcaCloudError): + await svc.poll_token("DEV-1") + + @pytest.mark.asyncio + async def test_network_error_wraps(self, svc): + svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom")) + with pytest.raises(OrcaCloudError): + await svc.poll_token("DEV-1") + + +# --------------------------------------------------------------------------- +# Refresh +# --------------------------------------------------------------------------- class TestRefresh: @pytest.mark.asyncio async def test_rotates_refresh_token(self, svc): - """Supabase refresh tokens are single-use — every successful refresh - returns a NEW refresh token and invalidates the old. If the service - kept the old one, the next refresh would 400 and the user would be - force-logged-out.""" - svc.refresh_token = "REFRESH-1" + """Refresh tokens are single-use — every successful refresh returns a + NEW pair. Keeping the old refresh token would 400 the next refresh.""" + svc.refresh_token = "oc_ext_rt_1" resp = _mock_response( - json_data={ - "access_token": "ACCESS-2", - "refresh_token": "REFRESH-2", - "expires_in": 3600, - } + json_data={"access_token": "oc_ext_2", "refresh_token": "oc_ext_rt_2", "expires_in": 86400} ) svc._client.post = AsyncMock(return_value=resp) await svc.refresh() - assert svc.access_token == "ACCESS-2" - assert svc.refresh_token == "REFRESH-2" + assert svc.access_token == "oc_ext_2" + assert svc.refresh_token == "oc_ext_rt_2" + + @pytest.mark.asyncio + async def test_sends_refresh_grant_and_client_id(self, svc): + svc.refresh_token = "oc_ext_rt_1" + resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 86400}) + svc._client.post = AsyncMock(return_value=resp) + + await svc.refresh() + + _args, kwargs = svc._client.post.call_args + assert kwargs["data"]["grant_type"] == "refresh_token" + assert kwargs["data"]["refresh_token"] == "oc_ext_rt_1" + assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID @pytest.mark.asyncio async def test_no_refresh_token_raises_auth_error(self, svc): @@ -280,18 +231,14 @@ class TestRefresh: @pytest.mark.asyncio async def test_rejected_refresh_clears_tokens(self, svc): - """If Supabase rejects the refresh token (revoked / rotated out from - under us / hit by a token-replay defense), the service must clear - the now-useless stored credentials so the UI can flip to the - disconnected state rather than retrying forever.""" - svc.access_token = "OLD-ACCESS" - svc.refresh_token = "OLD-REFRESH" + """A rejected refresh (revoked / already-used / disconnected) is + unrecoverable — clear the stale credentials so the UI flips to + disconnected rather than retrying forever.""" + svc.access_token = "OLD" + svc.refresh_token = "oc_ext_rt_old" svc.token_expiry = datetime.now(timezone.utc) - err = _mock_response( - status_code=401, - json_data={"error": "invalid_grant", "error_description": "refresh token rotated"}, - ) - svc._client.post = AsyncMock(return_value=err) + resp = _mock_response(status_code=400, json_data={"error": "invalid_grant"}) + svc._client.post = AsyncMock(return_value=resp) with pytest.raises(OrcaCloudAuthError): await svc.refresh() @@ -301,39 +248,46 @@ class TestRefresh: assert svc.token_expiry is None +# --------------------------------------------------------------------------- +# is_authenticated +# --------------------------------------------------------------------------- + + class TestIsAuthenticated: def test_no_token_means_not_authenticated(self, svc): assert svc.is_authenticated is False def test_no_expiry_means_not_authenticated(self, svc): - """Pessimistic default: if we don't know when the token expires, - treat it as expired so the next API call triggers a refresh - rather than fails halfway through.""" - svc.access_token = "ACCESS" + svc.access_token = "A" svc.token_expiry = None assert svc.is_authenticated is False def test_within_refresh_leeway_is_not_authenticated(self, svc): - """The 5-minute leeway prevents a long-running API call from timing - out mid-flight on a token that was technically still valid when the - call started.""" - svc.access_token = "ACCESS" + svc.access_token = "A" svc.token_expiry = datetime.now(timezone.utc) + timedelta(minutes=2) assert svc.is_authenticated is False def test_with_comfortable_expiry_is_authenticated(self, svc): - svc.access_token = "ACCESS" - svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1) + svc.access_token = "A" + svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12) assert svc.is_authenticated is True +# --------------------------------------------------------------------------- +# External-API headers +# --------------------------------------------------------------------------- + + class TestApiHeaders: - def test_api_headers_include_apikey_and_bearer(self, svc): - svc.access_token = "ACCESS-123" + def test_api_headers_include_bearer_and_ua_no_apikey(self, svc): + """External API auth is a plain bearer token — the old Supabase + ``apikey`` header must NOT be sent (the ``oc_ext_`` token is the whole + credential).""" + svc.access_token = "oc_ext_123" headers = svc._api_headers() - assert headers["apikey"] == ORCA_ANON_KEY - assert headers["Authorization"] == "Bearer ACCESS-123" + assert headers["Authorization"] == "Bearer oc_ext_123" assert headers["User-Agent"].startswith("Bambuddy/") + assert "apikey" not in headers def test_api_headers_without_token_raises(self, svc): svc.access_token = None @@ -341,14 +295,41 @@ class TestApiHeaders: svc._api_headers() +# --------------------------------------------------------------------------- +# Introspection +# --------------------------------------------------------------------------- + + +class TestIntrospect: + @pytest.mark.asyncio + async def test_returns_record(self, svc): + svc.access_token = "oc_ext_A" + svc._client.get = AsyncMock( + return_value=_mock_response( + json_data={"user_id": "u-1", "client_id": ORCA_CLIENT_ID, "connection_id": "c-1"} + ) + ) + info = await svc.introspect() + assert info["user_id"] == "u-1" + + @pytest.mark.asyncio + async def test_401_raises_auth_error(self, svc): + svc.access_token = "oc_ext_A" + svc._client.get = AsyncMock(return_value=_mock_response(status_code=401, text_body="unauthorized")) + with pytest.raises(OrcaCloudAuthError): + await svc.introspect() + + +# --------------------------------------------------------------------------- +# Profile pull +# --------------------------------------------------------------------------- + + class TestListProfiles: @pytest.mark.asyncio async def test_pull_response_upserts_extracted(self, svc): - """The bare-cursor /sync/pull returns a ``SyncPullResponse`` shape; - we extract the ``upserts`` list and ignore ``next_cursor`` / ``deletes`` - (no prior client state to invalidate).""" - svc.access_token = "ACCESS" - svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1) + svc.access_token = "oc_ext_A" + svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12) svc._client.get = AsyncMock( return_value=_mock_response( json_data={ @@ -358,51 +339,55 @@ class TestListProfiles: {"id": "b", "name": "B", "content": {"x": 2}}, ], "deletes": ["zzz"], - }, + } ) ) result = await svc.list_profiles() assert [p["id"] for p in result] == ["a", "b"] @pytest.mark.asyncio - async def test_pull_hits_path_without_cursor(self, svc): - """Regression guard: ``cursor=0`` trips ``410 cursor_too_old`` on - the production endpoint. The first-sync bootstrap must hit - ``/api/v1/sync/pull`` with no ``?cursor=`` parameter — same behaviour - as OrcaSlicer's own client.""" - svc.access_token = "ACCESS" - svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1) - svc._client.get = AsyncMock( - return_value=_mock_response(json_data={"upserts": [], "deletes": []}), - ) + async def test_pull_hits_external_path_without_cursor(self, svc): + """Regression guard: the list must hit the EXTERNAL sync path + (``/api/v1/external/sync/pull``, not the first-party ``/api/v1/sync``) + with no ``?cursor=`` — ``cursor=0`` trips ``410 cursor_too_old``.""" + svc.access_token = "oc_ext_A" + svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12) + svc._client.get = AsyncMock(return_value=_mock_response(json_data={"upserts": [], "deletes": []})) await svc.list_profiles() called_url = svc._client.get.call_args.args[0] - assert called_url.endswith("/api/v1/sync/pull") + assert called_url.endswith("/api/v1/external/sync/pull") assert "cursor" not in called_url - # And no ``params`` kwarg either, which would be a second way to - # smuggle the cursor in. assert "params" not in svc._client.get.call_args.kwargs + @pytest.mark.asyncio + async def test_401_raises_auth_error(self, svc): + svc.access_token = "oc_ext_A" + svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12) + svc._client.get = AsyncMock(return_value=_mock_response(status_code=401, text_body="nope")) + with pytest.raises(OrcaCloudAuthError): + await svc.list_profiles() + + @pytest.mark.asyncio + async def test_410_cursor_too_old_raises(self, svc): + svc.access_token = "oc_ext_A" + svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12) + svc._client.get = AsyncMock(return_value=_mock_response(status_code=410, json_data={"error": "cursor_too_old"})) + with pytest.raises(OrcaCloudError, match="cursor too old"): + await svc.list_profiles() + @pytest.mark.asyncio async def test_bare_list_response_tolerated(self, svc): - """If the server ever rolls out a flat-list response shape, we - forward it verbatim rather than logging-and-empty.""" - svc.access_token = "ACCESS" - svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1) - svc._client.get = AsyncMock( - return_value=_mock_response(json_data=[{"id": "a", "name": "A"}]), - ) + svc.access_token = "oc_ext_A" + svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12) + svc._client.get = AsyncMock(return_value=_mock_response(json_data=[{"id": "a", "name": "A"}])) assert [p["id"] for p in await svc.list_profiles()] == ["a"] class TestGetProfile: @pytest.mark.asyncio async def test_returns_matching_profile_with_content(self, svc): - """``get_profile`` lists then filters since Orca has no dedicated - per-profile GET — verify the matched entry returns with full - content, not stripped to metadata.""" - svc.access_token = "ACCESS" - svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1) + svc.access_token = "oc_ext_A" + svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12) svc._client.get = AsyncMock( return_value=_mock_response( json_data={ @@ -411,23 +396,19 @@ class TestGetProfile: {"id": "target", "name": "Target", "content": {"hit": True}}, ], "deletes": [], - }, + } ) ) - profile = await svc.get_profile("target") - assert profile["id"] == "target" assert profile["content"] == {"hit": True} @pytest.mark.asyncio async def test_not_found_raises(self, svc): - svc.access_token = "ACCESS" - svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1) + svc.access_token = "oc_ext_A" + svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12) svc._client.get = AsyncMock( - return_value=_mock_response( - json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []}, - ), + return_value=_mock_response(json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []}) ) with pytest.raises(OrcaCloudError, match="not found"): await svc.get_profile("missing") diff --git a/frontend/src/__tests__/components/OrcaCloudView.test.tsx b/frontend/src/__tests__/components/OrcaCloudView.test.tsx index cebec42d7..b3095d0b9 100644 --- a/frontend/src/__tests__/components/OrcaCloudView.test.tsx +++ b/frontend/src/__tests__/components/OrcaCloudView.test.tsx @@ -1,9 +1,9 @@ /** - * Tests for OrcaCloudView component — covers the four UI phases of the - * paste-based PKCE handshake: disconnected, awaiting-paste, connected, - * and disconnect. + * Tests for OrcaCloudView — the RFC 8628 device-pairing flow: disconnected + * (Connect button), pairing (code + approval link + waiting), a completed + * poll flipping to connected, a denied poll surfacing an error, and disconnect. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { screen, waitFor, fireEvent } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; @@ -11,174 +11,111 @@ import { server } from '../mocks/server'; import { render } from '../utils'; import { OrcaCloudView } from '../../components/OrcaCloudView'; -// JSDOM doesn't implement window.open; the connect flow opens the auth URL -// in a new tab so we stub it to capture the call. -beforeEach(() => { - vi.stubGlobal('open', vi.fn()); -}); +const DEVICE_START = { + user_code: 'ABCD-EF12', + verification_uri: 'https://cloud.orcaslicer.com/app/settings', + verification_uri_complete: 'https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12', + interval: 5, + expires_in: 600, +}; -const noProfilesResponse = { profiles: [] }; +const NO_PROFILES = { filament: [], printer: [], process: [] }; + +const disconnectedStatus = () => + http.get('/api/v1/orca-cloud/status', () => + HttpResponse.json({ connected: false, email: null, user_id: null }), + ); describe('OrcaCloudView', () => { - it('shows all four sign-in options when not connected', async () => { + it('shows the Connect button when not connected', async () => { + server.use(disconnectedStatus()); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument(); + }); + }); + + it('shows the pairing code and approval link after clicking Connect', async () => { server.use( - http.get('/api/v1/orca-cloud/status', () => - HttpResponse.json({ connected: false, email: null, user_id: null }), + disconnectedStatus(), + http.post('/api/v1/orca-cloud/device/start', () => HttpResponse.json(DEVICE_START)), + http.post('/api/v1/orca-cloud/device/poll', () => + HttpResponse.json({ status: 'authorization_pending', connected: false, email: null, user_id: null }), ), ); render(); await waitFor(() => { - expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument(); }); - expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Sign in with GitHub/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument(); - }); - - it('passes the selected OAuth provider to auth/start', async () => { - let receivedProvider: string | undefined; - server.use( - http.get('/api/v1/orca-cloud/status', () => - HttpResponse.json({ connected: false, email: null, user_id: null }), - ), - http.post('/api/v1/orca-cloud/auth/start', async ({ request }) => { - const body = (await request.json()) as { provider?: string }; - receivedProvider = body.provider; - return HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }); - }), - ); - render(); + fireEvent.click(screen.getByRole('button', { name: /Connect Orca Cloud/i })); await waitFor(() => { - expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument(); + expect(screen.getByText('ABCD-EF12')).toBeInTheDocument(); }); - fireEvent.click(screen.getByRole('button', { name: /Sign in with Apple/i })); - - await waitFor(() => { - expect(receivedProvider).toBe('apple'); - }); - expect(window.open).toHaveBeenCalledWith( - 'https://auth.orcaslicer.com/auth/v1/authorize?test=1', - '_blank', - 'noopener,noreferrer', + expect(screen.getByText(/Waiting for you to approve/i)).toBeInTheDocument(); + // The approval link points at the verification_uri_complete. + expect(screen.getByRole('link', { name: /Open Orca Cloud approval page/i })).toHaveAttribute( + 'href', + DEVICE_START.verification_uri_complete, ); }); - it('connects via email and password without the paste flow', async () => { + it('flips to connected once a poll returns complete', async () => { let connected = false; - let receivedCreds: { email?: string; password?: string } = {}; server.use( http.get('/api/v1/orca-cloud/status', () => HttpResponse.json( connected - ? { connected: true, email: 'martin@example.com', user_id: 'u1' } + ? { connected: true, email: null, user_id: 'user-123' } : { connected: false, email: null, user_id: null }, ), ), - http.post('/api/v1/orca-cloud/auth/password', async ({ request }) => { - receivedCreds = (await request.json()) as { email?: string; password?: string }; + http.post('/api/v1/orca-cloud/device/start', () => HttpResponse.json(DEVICE_START)), + http.post('/api/v1/orca-cloud/device/poll', () => { connected = true; - return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' }); + return HttpResponse.json({ status: 'complete', connected: true, email: null, user_id: 'user-123' }); }), - http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)), + http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(NO_PROFILES)), ); render(); await waitFor(() => { - expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument(); }); - fireEvent.click(screen.getByRole('button', { name: /Sign in with email and password/i })); + fireEvent.click(screen.getByRole('button', { name: /Connect Orca Cloud/i })); - // The password form replaces the provider picker. + // The immediate first poll returns complete → status invalidates and the + // connected view (with the Disconnect control) appears. We key off the + // Disconnect button rather than the banner text, since the success toast + // also renders "Connected to Orca Cloud". await waitFor(() => { - expect(screen.getByLabelText(/^Email$/i)).toBeInTheDocument(); - }); - fireEvent.change(screen.getByLabelText(/^Email$/i), { target: { value: 'martin@example.com' } }); - fireEvent.change(screen.getByLabelText(/^Password$/i), { target: { value: 'hunter2' } }); - // Click the submit button inside the form (not the picker's email button). - const submitButtons = screen.getAllByRole('button', { name: /^Sign in$/i }); - fireEvent.click(submitButtons[submitButtons.length - 1]); - - await waitFor(() => { - expect(screen.getByText('martin@example.com')).toBeInTheDocument(); - }); - expect(receivedCreds).toEqual({ email: 'martin@example.com', password: 'hunter2' }); - }); - - it('rejects a URL without a code parameter with a client-side error', async () => { - server.use( - http.get('/api/v1/orca-cloud/status', () => - HttpResponse.json({ connected: false, email: null, user_id: null }), - ), - http.post('/api/v1/orca-cloud/auth/start', () => - HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }), - ), - ); - render(); - - await waitFor(() => { - expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument(); - }); - fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i })); - await waitFor(() => { - expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument(); - }); - - const textarea = screen.getByPlaceholderText(/http:\/\/localhost:41172/i); - // Paste something with no ?code= — the client-side guard should fire - // before we hit the server. - fireEvent.change(textarea, { target: { value: 'http://localhost:41172/callback?error=denied' } }); - fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i })); - - await waitFor(() => { - expect(screen.getByText(/does not look like an Orca Cloud callback/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument(); }); }); - it('shows the connected state with the email after a successful paste', async () => { - // Start disconnected; after a successful finish the status query refetches - // and returns connected. MSW lets us swap handlers mid-test. - let connected = false; + it('surfaces an error and returns to Connect when the pairing is denied', async () => { server.use( - http.get('/api/v1/orca-cloud/status', () => { - return HttpResponse.json( - connected - ? { connected: true, email: 'martin@example.com', user_id: 'u1' } - : { connected: false, email: null, user_id: null }, - ); - }), - http.post('/api/v1/orca-cloud/auth/start', () => - HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }), + disconnectedStatus(), + http.post('/api/v1/orca-cloud/device/start', () => HttpResponse.json(DEVICE_START)), + http.post('/api/v1/orca-cloud/device/poll', () => + HttpResponse.json({ status: 'access_denied', connected: false, email: null, user_id: null }), ), - http.post('/api/v1/orca-cloud/auth/finish', () => { - connected = true; - return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' }); - }), - http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)), ); render(); await waitFor(() => { - expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument(); - }); - fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i })); - await waitFor(() => { - expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument(); }); + fireEvent.click(screen.getByRole('button', { name: /Connect Orca Cloud/i })); - fireEvent.change(screen.getByPlaceholderText(/http:\/\/localhost:41172/i), { - target: { value: 'http://localhost:41172/callback?code=ABC&state=XYZ' }, - }); - fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i })); - - // After the finish call resolves, the status query is invalidated and - // refetches connected=true → the connection banner appears with the email. await waitFor(() => { - expect(screen.getByText('martin@example.com')).toBeInTheDocument(); + expect(screen.getByText(/denied/i)).toBeInTheDocument(); }); - expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument(); + // Back on the Connect card, not stuck on the waiting screen. + expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument(); + expect(screen.queryByText(/Waiting for you to approve/i)).not.toBeInTheDocument(); }); it('clears the connection on Disconnect', async () => { @@ -187,11 +124,11 @@ describe('OrcaCloudView', () => { http.get('/api/v1/orca-cloud/status', () => HttpResponse.json( connected - ? { connected: true, email: 'martin@example.com', user_id: 'u1' } + ? { connected: true, email: null, user_id: 'user-123' } : { connected: false, email: null, user_id: null }, ), ), - http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)), + http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(NO_PROFILES)), http.post('/api/v1/orca-cloud/logout', () => { connected = false; return HttpResponse.json({ success: true }); @@ -200,12 +137,12 @@ describe('OrcaCloudView', () => { render(); await waitFor(() => { - expect(screen.getByText('martin@example.com')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole('button', { name: /Disconnect/i })); await waitFor(() => { - expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e73c7f971..5b2b7fb35 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1320,10 +1320,26 @@ export interface CloudLoginResponse { // Orca Cloud types — paste-flow PKCE handshake against auth.orcaslicer.com. // See backend/app/services/orca_cloud.py for the deep dive on why this // flow is paste-based rather than callback-based. -export type OrcaOAuthProvider = 'google' | 'apple' | 'github'; +export interface OrcaDeviceStartResponse { + user_code: string; + verification_uri: string; + verification_uri_complete: string; + interval: number; + expires_in: number; +} -export interface OrcaAuthStartResponse { - auth_url: string; +export type OrcaDevicePollStatus = + | 'authorization_pending' + | 'slow_down' + | 'access_denied' + | 'expired_token' + | 'complete'; + +export interface OrcaDevicePollResponse { + status: OrcaDevicePollStatus; + connected: boolean; + email: string | null; + user_id: string | null; } export interface OrcaAuthStatusResponse { @@ -4802,24 +4818,17 @@ export const api = { cloudLogout: () => request<{ success: boolean }>('/cloud/logout', { method: 'POST' }), - // Orca Cloud — paste-based PKCE flow for OAuth (Google/Apple/GitHub), - // direct credentials for email+password. start() returns an auth URL the - // user opens in their browser; after sign-in they paste the callback URL - // back via finish(). password() skips the dance entirely. - orcaCloudStartAuth: (provider: OrcaOAuthProvider = 'google') => - request('/orca-cloud/auth/start', { + // Orca Cloud — RFC 8628 device pairing. deviceStart() returns a short + // user_code + verification link; the user approves it in their Orca Cloud + // settings while the frontend polls devicePoll() every `interval` seconds + // until the status flips to 'complete' (or a terminal deny/expire). + orcaCloudDeviceStart: () => + request('/orca-cloud/device/start', { method: 'POST', - body: JSON.stringify({ provider }), }), - orcaCloudFinishAuth: (callback_url: string) => - request('/orca-cloud/auth/finish', { + orcaCloudDevicePoll: () => + request('/orca-cloud/device/poll', { method: 'POST', - body: JSON.stringify({ callback_url }), - }), - orcaCloudPasswordLogin: (email: string, password: string) => - request('/orca-cloud/auth/password', { - method: 'POST', - body: JSON.stringify({ email, password }), }), orcaCloudStatus: () => request('/orca-cloud/status'), diff --git a/frontend/src/components/OrcaCloudView.tsx b/frontend/src/components/OrcaCloudView.tsx index f11029b98..510ddfb06 100644 --- a/frontend/src/components/OrcaCloudView.tsx +++ b/frontend/src/components/OrcaCloudView.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { Cloud, ExternalLink, LogOut, Loader2, AlertCircle, AlertTriangle, Check, Mail, ArrowLeft } from 'lucide-react'; +import { Cloud, ExternalLink, LogOut, Loader2, AlertCircle, Check } from 'lucide-react'; import { api } from '../api/client'; -import type { OrcaOAuthProvider } from '../api/client'; +import type { OrcaDeviceStartResponse, OrcaDevicePollStatus } from '../api/client'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; @@ -14,16 +14,12 @@ import { OrcaCloudProfilesView } from './OrcaCloudProfilesView'; /** * Orca Cloud profile sync tab. * - * Auth uses a paste-based PKCE handshake: backend generates the verifier and - * authorize URL, the user opens it in a new tab and signs in, the browser - * redirects to ``http://localhost:41172/callback`` (which fails to load since - * Bambuddy isn't on the user's localhost), and the user copies the URL from - * their address bar back into the paste textarea below. The backend extracts - * the code, validates state for CSRF, and exchanges for tokens. - * - * See OrcaSlicer/OrcaSlicer#14028 for the open feature request asking - * SoftFever to broaden the Supabase redirect_to allowlist so we could ship - * a clean OAuth callback instead. + * Auth uses the RFC 8628 device-authorization grant: the backend requests a + * device code from Orca and returns a short user_code plus a verification link. + * The user opens the link, approves the code in their Orca Cloud settings, and + * Bambuddy polls the backend (which polls Orca's token endpoint) until the + * pairing completes. No redirect URL, no callback paste, no client secret — + * see backend/app/services/orca_cloud.py for the deep dive. */ export function OrcaCloudView() { const { t } = useTranslation(); @@ -32,17 +28,11 @@ export function OrcaCloudView() { const { hasPermission } = useAuth(); const canManage = hasPermission('orca_cloud:auth'); - // Paste-flow local state: once the user clicks an OAuth provider, we hold - // the returned auth_url so the same URL stays clickable while they go - // fetch the callback URL from their browser. ``mode`` drives which - // sub-form is showing: picker → OAuth paste-flow → email/password form. - const [mode, setMode] = useState<'picker' | 'paste' | 'password'>('picker'); - const [authUrl, setAuthUrl] = useState(null); - const [pastedUrl, setPastedUrl] = useState(''); - const [pasteError, setPasteError] = useState(null); - const [passwordEmail, setPasswordEmail] = useState(''); - const [passwordValue, setPasswordValue] = useState(''); - const [passwordError, setPasswordError] = useState(null); + // Pairing sub-state: null until the user clicks Connect, then the device + // response (code + link) that we display while polling. + const [pairing, setPairing] = useState(null); + const [pollIntervalMs, setPollIntervalMs] = useState(5000); + const [connectError, setConnectError] = useState(null); const { data: status, isLoading: statusLoading } = useQuery({ queryKey: ['orcaCloudStatus'], @@ -80,54 +70,26 @@ export function OrcaCloudView() { if (profilesUpdatedAt) setLastSyncTime(new Date(profilesUpdatedAt)); }, [profilesUpdatedAt]); - const startAuthMutation = useMutation({ - mutationFn: (provider: OrcaOAuthProvider) => api.orcaCloudStartAuth(provider), - onSuccess: (data) => { - setAuthUrl(data.auth_url); - setPastedUrl(''); - setPasteError(null); - setMode('paste'); - // Open in a new tab so the user can keep Bambuddy open in their - // current tab while they sign in. - window.open(data.auth_url, '_blank', 'noopener,noreferrer'); - }, - onError: (err: Error) => { - showToast(err.message || t('profiles.orcaCloud.errors.startFailed'), 'error'); - }, - }); + const finishPairing = () => { + setPairing(null); + setPollIntervalMs(5000); + }; - const finishAuthMutation = useMutation({ - mutationFn: (url: string) => api.orcaCloudFinishAuth(url), - onSuccess: (data) => { - setAuthUrl(null); - setPastedUrl(''); - setPasteError(null); - setMode('picker'); - queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] }); - queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] }); - showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' })); - }, - onError: (err: Error) => { - // Surface the backend's error message in the paste-error slot so the - // user can fix the input (rather than a transient toast they might miss). - setPasteError(err.message || t('profiles.orcaCloud.errors.finishFailed')); - }, - }); + const handleTerminal = (status: OrcaDevicePollStatus) => { + if (status === 'access_denied') setConnectError(t('profiles.orcaCloud.errors.denied')); + else if (status === 'expired_token') setConnectError(t('profiles.orcaCloud.errors.expired')); + finishPairing(); + }; - const passwordLoginMutation = useMutation({ - mutationFn: ({ email, password }: { email: string; password: string }) => - api.orcaCloudPasswordLogin(email, password), + const startMutation = useMutation({ + mutationFn: api.orcaCloudDeviceStart, onSuccess: (data) => { - setPasswordEmail(''); - setPasswordValue(''); - setPasswordError(null); - setMode('picker'); - queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] }); - queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] }); - showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' })); + setConnectError(null); + setPollIntervalMs(Math.max(1, data.interval) * 1000); + setPairing(data); }, onError: (err: Error) => { - setPasswordError(err.message || t('profiles.orcaCloud.errors.passwordFailed')); + setConnectError(err.message || t('profiles.orcaCloud.errors.startFailed')); }, }); @@ -140,41 +102,60 @@ export function OrcaCloudView() { }, }); - const handleSubmitPaste = (e: React.FormEvent) => { - e.preventDefault(); - setPasteError(null); - const trimmed = pastedUrl.trim(); - if (!trimmed) { - setPasteError(t('profiles.orcaCloud.errors.emptyPaste')); - return; - } - if (!trimmed.includes('code=')) { - setPasteError(t('profiles.orcaCloud.errors.noCode')); - return; - } - finishAuthMutation.mutate(trimmed); - }; + // Poll the backend while a pairing is in flight. react-query drives the + // cadence; the effect below reacts to each poll result. refetchInterval + // returns false once we stop (pairing cleared), which halts polling. + const { data: pollData, error: pollError } = useQuery({ + // Scope the cache per pairing attempt so a fresh Connect never re-consumes + // a previous attempt's cached 'complete'/terminal result. + queryKey: ['orcaCloudDevicePoll', pairing?.user_code ?? 'none'], + queryFn: api.orcaCloudDevicePoll, + enabled: pairing !== null, + gcTime: 0, + retry: false, + refetchOnWindowFocus: false, + refetchInterval: pairing !== null ? pollIntervalMs : false, + }); - const handleSubmitPassword = (e: React.FormEvent) => { - e.preventDefault(); - setPasswordError(null); - const email = passwordEmail.trim(); - if (!email || !passwordValue) { - setPasswordError(t('profiles.orcaCloud.errors.passwordEmpty')); + // A ref so the poll-result effect can act exactly once per new result + // without re-running when unrelated state (interval, etc.) changes. + const lastHandledStatus = useRef(null); + useEffect(() => { + if (!pairing || !pollData) return; + const s = pollData.status; + if (s === 'slow_down') { + // Back off as the RFC prescribes, then keep waiting. + setPollIntervalMs((ms) => ms + 5000); return; } - passwordLoginMutation.mutate({ email, password: passwordValue }); - }; + if (s === 'authorization_pending') return; + if (lastHandledStatus.current === s) return; + lastHandledStatus.current = s; + if (s === 'complete') { + finishPairing(); + queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] }); + queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] }); + showToast(t('profiles.orcaCloud.connectedShort')); + } else { + handleTerminal(s); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pollData, pairing]); - const resetToPicker = () => { - setMode('picker'); - setAuthUrl(null); - setPastedUrl(''); - setPasteError(null); - setPasswordEmail(''); - setPasswordValue(''); - setPasswordError(null); - }; + // A poll HTTP error (e.g. the pending state vanished server-side) ends the + // flow rather than spinning forever. + useEffect(() => { + if (pairing && pollError) { + setConnectError(t('profiles.orcaCloud.errors.pollFailed')); + finishPairing(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pollError, pairing]); + + // Reset the one-shot guard whenever a new pairing starts. + useEffect(() => { + if (pairing) lastHandledStatus.current = null; + }, [pairing]); if (statusLoading) { return ( @@ -191,8 +172,13 @@ export function OrcaCloudView() {
- {t('profiles.orcaCloud.connectedAs')}{' '} - {status?.email} + {status?.email ? ( + <> + {t('profiles.orcaCloud.connectedAs')} {status.email} + + ) : ( + {t('profiles.orcaCloud.connectedShort')} + )}
+ +

+ {t('profiles.orcaCloud.device.manualHint', { url: pairing.verification_uri })} +

+ +
+ + {t('profiles.orcaCloud.device.waiting')} +
+ + + + ); } - if (props.mode === 'password') { - return ; - } - return ; -} -function PickerCard({ - onPickProvider, - onPickPassword, - isStarting, - canManage, - t, -}: ConnectFlowProps) { - // Orca's web sign-in offers four options: Google, Apple, GitHub (all - // OAuth, paste-flow) and email+password (direct). We mirror that surface - // so users with a non-Google account aren't blocked. return ( -

- {t('profiles.orcaCloud.connect.title')} -

-

- {t('profiles.orcaCloud.connect.description')} -

-
+

{t('profiles.orcaCloud.connect.title')}

+

{t('profiles.orcaCloud.connect.description')}

+
- - - + {connectError && ( +

+ + {connectError} +

+ )}
); } - -function PasteCard({ - authUrl, - pastedUrl, - setPastedUrl, - pasteError, - onSubmitPaste, - onBack, - isFinishing, - t, -}: ConnectFlowProps & { authUrl: string }) { - return ( - - - -

- {t('profiles.orcaCloud.paste.title')} -

- - {/* Numbered-step list with prominent visual treatment. Step 2 carries - the critical "the page failing is expected" message inside an - amber callout so users don't read the connection-refused page - as a Bambuddy error. */} -
    -
  1. - 1 -

    {t('profiles.orcaCloud.paste.step1')}

    -
  2. -
  3. - 2 -
    -
    - -

    {t('profiles.orcaCloud.paste.step2')}

    -
    -
    -
  4. -
  5. - 3 -

    {t('profiles.orcaCloud.paste.step3')}

    -
  6. -
- -
-

{t('profiles.orcaCloud.paste.signInUrl')}

- - {authUrl} - -
-
- -