orca_cloud.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. """
  2. Orca Cloud API Service
  3. Handles authentication and profile sync with the Orca Cloud (Supabase-backed).
  4. Auth shape: PKCE flow against ``auth.orcaslicer.com`` with the in-source public
  5. publishable key. Bambuddy generates the verifier/challenge/state, redirects the
  6. user's browser to Supabase's ``/auth/v1/authorize`` endpoint with
  7. ``redirect_to=http://localhost:41172/callback``, and the user pastes the
  8. callback URL back into Bambuddy (the loopback URL is the only ``redirect_to``
  9. Orca's Supabase project actually honors as of v2.4.0-alpha — see
  10. OrcaSlicer/OrcaSlicer#14028 for the open feature request asking SoftFever to
  11. broaden this).
  12. Token shape: short-lived access JWT (1h) + rotating single-use refresh token.
  13. Every refresh issues a new pair and invalidates the old one — the route layer
  14. is responsible for atomically swapping the stored pair on each refresh, or a
  15. mid-refresh crash strands the user.
  16. Cloudflare protects ``api.orcaslicer.com`` with a User-Agent gate; sending an
  17. honest ``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
  18. """
  19. from __future__ import annotations
  20. import base64
  21. import hashlib
  22. import json
  23. import logging
  24. import secrets
  25. from datetime import datetime, timedelta, timezone
  26. from typing import Any
  27. import httpx
  28. logger = logging.getLogger(__name__)
  29. # Auth + API endpoints — extracted verbatim from OrcaCloudServiceAgent.cpp
  30. # v2.4.0-alpha. The "publishable" key is documented in-source as a public
  31. # client identifier (Supabase anon-key pattern); embedding it in our client
  32. # is by-design and not a secret leak.
  33. ORCA_AUTH_BASE = "https://auth.orcaslicer.com"
  34. ORCA_API_BASE = "https://api.orcaslicer.com"
  35. ORCA_ANON_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_"
  36. # Loopback redirect from OrcaCloudServiceAgent.cpp. Supabase's redirect_to
  37. # allowlist on Orca's project only honors localhost URIs — anything else
  38. # silently falls through to the project Site URL after the OAuth dance.
  39. ORCA_REDIRECT_URI = "http://localhost:41172/callback"
  40. # Honest client identity. Same posture as Bambu Cloud: identifies Bambuddy
  41. # without impersonating Orca's desktop client (which would be CWE-style
  42. # falsified-identity and was the exact thing called out in Bambu Lab's May 2026
  43. # blog post about cloud-access etiquette).
  44. _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
  45. # Refresh access tokens when they have less than this much life left, on the
  46. # theory that a slow downstream API call shouldn't expire the token mid-flight.
  47. _REFRESH_LEEWAY = timedelta(minutes=5)
  48. # PKCE handshake state TTL. If the user clicks "Connect" then walks away,
  49. # the stored verifier+state is invalid after this window — they have to
  50. # restart. 10 minutes is the OAuth norm for desktop-app PKCE flows.
  51. PENDING_PKCE_TTL = timedelta(minutes=10)
  52. class OrcaCloudError(Exception):
  53. """Base exception for Orca Cloud errors."""
  54. pass
  55. class OrcaCloudAuthError(OrcaCloudError):
  56. """Authentication / token-related errors. Caller should typically prompt
  57. the user to reconnect — neither a fresh access token nor a refresh will
  58. recover without re-authentication."""
  59. pass
  60. _shared_http_client: httpx.AsyncClient | None = None
  61. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  62. """Register an app-scoped ``httpx.AsyncClient`` so per-request
  63. ``OrcaCloudService`` instances can reuse its connection pool. Mirrors the
  64. pattern used by :mod:`backend.app.services.bambu_cloud`."""
  65. global _shared_http_client
  66. _shared_http_client = client
  67. # ---------------------------------------------------------------------------
  68. # PKCE helpers (free functions — no service-instance state needed)
  69. # ---------------------------------------------------------------------------
  70. def _b64url(data: bytes) -> str:
  71. """RFC 7636-style base64url encoding, no padding."""
  72. return base64.urlsafe_b64encode(data).decode().rstrip("=")
  73. def generate_pkce() -> tuple[str, str, str]:
  74. """Generate a fresh ``(verifier, challenge, state)`` triple for one PKCE
  75. handshake. The verifier is the secret kept by Bambuddy until the code
  76. exchange; the challenge is sent to Supabase as ``code_challenge``; the
  77. state is the CSRF nonce we'll verify against the callback.
  78. Verifier = 32 random bytes (43 base64url chars), within RFC 7636's
  79. 43-128 char range. Challenge = ``base64url(sha256(verifier))``.
  80. """
  81. verifier = _b64url(secrets.token_bytes(32))
  82. challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
  83. state = _b64url(secrets.token_bytes(16))
  84. return verifier, challenge, state
  85. def build_authorize_url(challenge: str, provider: str = "google") -> str:
  86. """Construct the URL the user's browser should visit to start the OAuth
  87. handshake.
  88. Notably **does not** pass a ``state`` query parameter. Supabase's GoTrue
  89. uses its own internal state encoding to remember which ``redirect_to``
  90. belongs to which OAuth session; a client-passed ``state`` overwrites
  91. that, GoTrue can no longer decode the redirect_to from Google's
  92. callback, and silently falls back to the project Site URL — which is
  93. exactly the bug that broke the live test against our deployed integration.
  94. CSRF is still protected by the PKCE flow itself: the server-side
  95. ``code_verifier`` is single-use and bound to the user's session, so an
  96. attacker with a code-only URL can't complete the exchange.
  97. """
  98. from urllib.parse import urlencode
  99. qs = urlencode(
  100. {
  101. "provider": provider,
  102. "redirect_to": ORCA_REDIRECT_URI,
  103. "code_challenge": challenge,
  104. "code_challenge_method": "S256",
  105. }
  106. )
  107. return f"{ORCA_AUTH_BASE}/auth/v1/authorize?{qs}"
  108. def parse_callback_url(callback_url: str) -> tuple[str | None, str | None]:
  109. """Extract ``(code, state)`` from a pasted callback URL. Both query string
  110. and fragment are checked — some Supabase configurations put PKCE codes in
  111. the fragment rather than the query string. Returns ``(None, None)`` if
  112. nothing parses out; the route layer surfaces the user-facing error."""
  113. from urllib.parse import parse_qs, urlparse
  114. parsed = urlparse(callback_url.strip())
  115. qsd = parse_qs(parsed.query)
  116. code = qsd.get("code", [""])[0] or None
  117. state = qsd.get("state", [""])[0] or None
  118. if not code:
  119. frag = parse_qs(parsed.fragment)
  120. code = frag.get("code", [""])[0] or None
  121. state = state or (frag.get("state", [""])[0] or None)
  122. return code, state
  123. # ---------------------------------------------------------------------------
  124. # Service class
  125. # ---------------------------------------------------------------------------
  126. class OrcaCloudService:
  127. """Stateful per-request client for the Orca Cloud API.
  128. Instantiated by the route layer, populated with a stored token via
  129. :meth:`set_tokens`, then used to call the sync endpoints. Token rotation
  130. on refresh is the route layer's responsibility (see
  131. :meth:`refresh` — returns the new pair, doesn't persist).
  132. """
  133. def __init__(self, client: httpx.AsyncClient | None = None):
  134. self.access_token: str | None = None
  135. self.refresh_token: str | None = None
  136. self.token_expiry: datetime | None = None
  137. # Mirror the bambu_cloud pattern for client ownership: prefer injected
  138. # client (tests), fall back to app-scoped shared client (production),
  139. # else create our own so ad-hoc scripts still work.
  140. if client is not None:
  141. self._client = client
  142. self._owns_client = False
  143. elif _shared_http_client is not None:
  144. self._client = _shared_http_client
  145. self._owns_client = False
  146. else:
  147. self._client = httpx.AsyncClient(timeout=30.0)
  148. self._owns_client = True
  149. @property
  150. def is_authenticated(self) -> bool:
  151. """True iff we have an access token that won't expire within
  152. :data:`_REFRESH_LEEWAY`. The leeway prevents a slow API call from
  153. timing out mid-flight on a token that was nominally still valid."""
  154. if not self.access_token:
  155. return False
  156. if self.token_expiry is None:
  157. # No expiry recorded — pessimistically treat as expired so the
  158. # caller refreshes before use.
  159. return False
  160. return datetime.now(timezone.utc) + _REFRESH_LEEWAY < self.token_expiry
  161. def set_tokens(
  162. self,
  163. access_token: str | None,
  164. refresh_token: str | None,
  165. expires_at: datetime | None,
  166. ) -> None:
  167. """Hydrate the service from stored credentials."""
  168. self.access_token = access_token
  169. self.refresh_token = refresh_token
  170. # Normalize to timezone-aware UTC so subsequent comparisons against
  171. # ``datetime.now(timezone.utc)`` are well-defined. asyncpg returns
  172. # naive datetimes from a ``TIMESTAMP WITHOUT TIME ZONE`` column —
  173. # we treat naive values as UTC since that's how we stored them.
  174. if expires_at is not None and expires_at.tzinfo is None:
  175. expires_at = expires_at.replace(tzinfo=timezone.utc)
  176. self.token_expiry = expires_at
  177. def clear_tokens(self) -> None:
  178. """Forget all credentials. Used on logout and after auth failures."""
  179. self.access_token = None
  180. self.refresh_token = None
  181. self.token_expiry = None
  182. def _auth_headers(self) -> dict[str, str]:
  183. """Headers for calls to ``auth.orcaslicer.com``. Always includes the
  184. apikey; the ``Authorization`` header is added only if we already have
  185. an access token (used by ``/logout``, not by token exchange)."""
  186. headers = {
  187. "User-Agent": _USER_AGENT,
  188. "apikey": ORCA_ANON_KEY,
  189. "Content-Type": "application/json",
  190. }
  191. if self.access_token:
  192. headers["Authorization"] = f"Bearer {self.access_token}"
  193. return headers
  194. def _api_headers(self) -> dict[str, str]:
  195. """Headers for calls to ``api.orcaslicer.com``. Requires a bearer
  196. token — callers should ensure the service is authenticated first."""
  197. if not self.access_token:
  198. raise OrcaCloudAuthError("Orca Cloud API requires an access token")
  199. return {
  200. "User-Agent": _USER_AGENT,
  201. "apikey": ORCA_ANON_KEY,
  202. "Authorization": f"Bearer {self.access_token}",
  203. "Accept": "application/json",
  204. }
  205. # ------------------------------------------------------------------
  206. # Token lifecycle
  207. # ------------------------------------------------------------------
  208. async def password_login(self, email: str, password: str) -> dict[str, Any]:
  209. """Direct email+password login via ``/auth/v1/token?grant_type=password``.
  210. Whether this works depends on the Supabase project's auth config —
  211. Orca's web sign-in offers email/password as one option, but their
  212. desktop client refuses ``{username, password}`` payloads with
  213. ``"Username/password login is disabled. Use the Orca cloud PKCE
  214. flow."`` (the SDK enforces PKCE regardless of what the backend
  215. allows). The actual server behaviour is what matters for Bambuddy
  216. — we POST the credentials and surface whatever response we get;
  217. an ``OrcaCloudAuthError`` with the verbatim Supabase error message
  218. is the right signal for callers to fall back to an OAuth provider.
  219. """
  220. url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=password"
  221. payload = {"email": email, "password": password}
  222. try:
  223. resp = await self._client.post(
  224. url,
  225. json=payload,
  226. headers={
  227. "User-Agent": _USER_AGENT,
  228. "apikey": ORCA_ANON_KEY,
  229. "Content-Type": "application/json",
  230. },
  231. )
  232. except httpx.HTTPError as e:
  233. raise OrcaCloudError(f"Network error during Orca Cloud password login: {e}") from e
  234. if resp.status_code >= 400:
  235. detail = _describe_token_error(resp)
  236. if resp.status_code in (400, 401, 403, 422):
  237. raise OrcaCloudAuthError(f"Orca Cloud password login rejected: {detail}")
  238. raise OrcaCloudError(f"Orca Cloud password login failed ({resp.status_code}): {detail}")
  239. data = resp.json()
  240. self._apply_token_response(data)
  241. return data
  242. async def exchange_code(self, auth_code: str, code_verifier: str) -> dict[str, Any]:
  243. """Exchange a PKCE auth code for tokens. Mutates ``self`` so the
  244. service is ready for API calls. Returns the raw Supabase token
  245. response so the route layer can persist the new credentials."""
  246. url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=pkce"
  247. payload = {"auth_code": auth_code, "code_verifier": code_verifier}
  248. try:
  249. resp = await self._client.post(
  250. url,
  251. json=payload,
  252. headers={
  253. "User-Agent": _USER_AGENT,
  254. "apikey": ORCA_ANON_KEY,
  255. "Content-Type": "application/json",
  256. },
  257. )
  258. except httpx.HTTPError as e:
  259. raise OrcaCloudError(f"Network error during Orca Cloud token exchange: {e}") from e
  260. if resp.status_code >= 400:
  261. # Supabase returns ``{"error":"...", "error_description":"..."}``
  262. # on most failures and ``{"msg":"..."}`` on a few. Surface
  263. # whatever we can find.
  264. detail = _describe_token_error(resp)
  265. if resp.status_code in (400, 401, 403):
  266. raise OrcaCloudAuthError(f"Orca Cloud token exchange rejected: {detail}")
  267. raise OrcaCloudError(f"Orca Cloud token exchange failed ({resp.status_code}): {detail}")
  268. data = resp.json()
  269. self._apply_token_response(data)
  270. return data
  271. async def refresh(self) -> dict[str, Any]:
  272. """Use the stored refresh token to obtain a fresh access/refresh pair.
  273. Supabase issues single-use refresh tokens — the old refresh token is
  274. invalidated the moment this call succeeds. The caller MUST persist the
  275. new pair atomically with consuming the old one; otherwise a crash
  276. between this return and the DB write strands the user. Returns the
  277. raw token-response dict so the caller has the full new pair.
  278. """
  279. if not self.refresh_token:
  280. raise OrcaCloudAuthError("Cannot refresh: no refresh token stored")
  281. url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=refresh_token"
  282. payload = {"refresh_token": self.refresh_token}
  283. try:
  284. resp = await self._client.post(
  285. url,
  286. json=payload,
  287. headers={
  288. "User-Agent": _USER_AGENT,
  289. "apikey": ORCA_ANON_KEY,
  290. "Content-Type": "application/json",
  291. },
  292. )
  293. except httpx.HTTPError as e:
  294. raise OrcaCloudError(f"Network error during Orca Cloud refresh: {e}") from e
  295. if resp.status_code >= 400:
  296. detail = _describe_token_error(resp)
  297. # 400/401 typically means "refresh token rotated or revoked" —
  298. # the user has to reconnect. Don't try to recover here.
  299. if resp.status_code in (400, 401, 403):
  300. self.clear_tokens()
  301. raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}")
  302. raise OrcaCloudError(f"Orca Cloud refresh failed ({resp.status_code}): {detail}")
  303. data = resp.json()
  304. self._apply_token_response(data)
  305. return data
  306. def _apply_token_response(self, data: dict[str, Any]) -> None:
  307. """Update ``self.access_token`` / ``self.refresh_token`` /
  308. ``self.token_expiry`` from a Supabase token-response payload. Caller
  309. is still responsible for persisting the values to the DB."""
  310. access = data.get("access_token")
  311. refresh = data.get("refresh_token")
  312. expires_in = data.get("expires_in")
  313. if not access:
  314. raise OrcaCloudAuthError("Orca Cloud token response missing access_token")
  315. self.access_token = access
  316. # Supabase always rotates refresh tokens on /token calls; if the
  317. # response omits one we keep the previous value to avoid stranding
  318. # the session, but that shouldn't happen in practice.
  319. if refresh:
  320. self.refresh_token = refresh
  321. if isinstance(expires_in, (int, float)) and expires_in > 0:
  322. self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
  323. else:
  324. self.token_expiry = None
  325. # ------------------------------------------------------------------
  326. # Sync API
  327. # ------------------------------------------------------------------
  328. async def get_user_info(self) -> dict[str, Any]:
  329. """Return Supabase's user record for the current token (id, email,
  330. metadata, ...). Used after token exchange to record the user's email
  331. for display in Bambuddy's UI."""
  332. url = f"{ORCA_AUTH_BASE}/auth/v1/user"
  333. try:
  334. resp = await self._client.get(url, headers=self._auth_headers())
  335. except httpx.HTTPError as e:
  336. raise OrcaCloudError(f"Network error fetching Orca Cloud user info: {e}") from e
  337. if resp.status_code == 401:
  338. raise OrcaCloudAuthError("Orca Cloud user fetch unauthorized — token expired or revoked")
  339. if resp.status_code >= 400:
  340. raise OrcaCloudError(f"Orca Cloud user fetch failed ({resp.status_code}): {resp.text[:200]}")
  341. return resp.json()
  342. async def list_profiles(self) -> list[dict[str, Any]]:
  343. """Return the user's Orca Cloud profiles as a flat list of
  344. ``ProfileUpsert`` entries (``{id, name, content, updated_time,
  345. created_time}``) — forwarded verbatim; callers pick the fields they
  346. need.
  347. Uses ``GET /api/v1/sync/pull`` with NO ``?cursor=`` parameter, which
  348. is the same "first-sync bootstrap" path OrcaSlicer's own client
  349. uses (``OrcaCloudServiceAgent.cpp::sync_pull``):
  350. std::string path = ORCA_SYNC_PULL_PATH;
  351. if (sync_state.last_sync_timestamp != 0) {
  352. path += "?cursor=" + std::to_string(sync_state.last_sync_timestamp);
  353. }
  354. ...
  355. // Handle 410 Gone — cursor too old, need full resync
  356. if (http_code == 410) {
  357. clear_sync_state();
  358. path = ORCA_SYNC_PULL_PATH; // retry without cursor
  359. ...
  360. }
  361. Sending ``cursor=0`` explicitly trips ``410 cursor_too_old`` — the
  362. server-side sync log doesn't reach back to the Unix epoch. Omitting
  363. the parameter entirely is the documented "give me the full snapshot"
  364. semantic. The previously-attempted ``/api/v1/sync/profiles`` is
  365. declared as a constant in Orca's source but isn't deployed on the
  366. production cloud (returns 404).
  367. The pull response is a ``SyncPullResponse`` (``{next_cursor, upserts,
  368. deletes}``); we extract ``upserts`` and ignore ``deletes`` (no prior
  369. state on the client side to invalidate).
  370. """
  371. url = f"{ORCA_API_BASE}/api/v1/sync/pull"
  372. try:
  373. resp = await self._client.get(url, headers=self._api_headers())
  374. except httpx.HTTPError as e:
  375. raise OrcaCloudError(f"Network error listing Orca Cloud profiles: {e}") from e
  376. if resp.status_code == 401:
  377. raise OrcaCloudAuthError("Orca Cloud profile list unauthorized — token expired or revoked")
  378. if resp.status_code >= 400:
  379. raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}")
  380. data = resp.json()
  381. if isinstance(data, dict):
  382. upserts = data.get("upserts")
  383. if isinstance(upserts, list):
  384. return upserts
  385. # Tolerate the shape we'd see if Orca ever rolls out a flat-list
  386. # endpoint at this path — forward whatever array is on the dict.
  387. for key in ("profiles", "data"):
  388. value = data.get(key)
  389. if isinstance(value, list):
  390. return value
  391. if isinstance(data, list):
  392. return data
  393. logger.warning("Orca Cloud /sync/pull returned unexpected shape: %r", type(data).__name__)
  394. return []
  395. async def get_profile(self, profile_id: str) -> dict[str, Any]:
  396. """Fetch a single profile's full content. Orca's sync API doesn't
  397. expose a per-profile GET, so we list and filter. For small profile
  398. counts (the realistic case) this is fine; if it becomes a hot path
  399. we'll add client-side caching at the route layer rather than hammer
  400. the list endpoint.
  401. """
  402. profiles = await self.list_profiles()
  403. for profile in profiles:
  404. if str(profile.get("id")) == str(profile_id):
  405. return profile
  406. raise OrcaCloudError(f"Orca Cloud profile {profile_id!r} not found (scanned {len(profiles)} profiles)")
  407. # ------------------------------------------------------------------
  408. # Lifecycle
  409. # ------------------------------------------------------------------
  410. async def close(self) -> None:
  411. """Release the underlying httpx client iff we own it. No-op if we're
  412. using an injected or app-shared client (those are managed elsewhere)."""
  413. if self._owns_client:
  414. await self._client.aclose()
  415. def _describe_token_error(resp: httpx.Response) -> str:
  416. """Best-effort extraction of a user-facing message from a Supabase token
  417. endpoint error response. Tries JSON fields in order; falls back to the
  418. raw body (truncated) if nothing parses."""
  419. try:
  420. data = resp.json()
  421. except (json.JSONDecodeError, ValueError):
  422. return (resp.text or "<empty body>")[:200]
  423. if not isinstance(data, dict):
  424. return str(data)[:200]
  425. for key in ("error_description", "msg", "error", "message"):
  426. val = data.get(key)
  427. if isinstance(val, str) and val:
  428. return val
  429. return str(data)[:200]