Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f75afc2b37 | |||
| 34c922feb1 | |||
| cee05260c1 |
@@ -70,6 +70,10 @@ from .connector.blender_operators.model_card_settings import (
|
||||
)
|
||||
from .connector.blender_operators.select_objects import SPECKLE_OT_select_objects
|
||||
from .connector.blender_operators.add_account_button import SPECKLE_OT_add_account
|
||||
from .connector.blender_operators.add_account_button import (
|
||||
SPECKLE_OT_show_auth_error,
|
||||
SPECKLE_OT_dismiss_popup,
|
||||
)
|
||||
from .connector.blender_operators.model_card_load_button import (
|
||||
SPECKLE_OT_load_model_card,
|
||||
)
|
||||
@@ -184,6 +188,8 @@ classes = (
|
||||
SPECKLE_OT_delete_model_card,
|
||||
SPECKLE_OT_select_objects,
|
||||
SPECKLE_OT_add_account,
|
||||
SPECKLE_OT_show_auth_error,
|
||||
SPECKLE_OT_dismiss_popup,
|
||||
SPECKLE_OT_load_model_card,
|
||||
SPECKLE_OT_publish_model_card,
|
||||
SPECKLE_OT_add_project_by_url,
|
||||
@@ -232,6 +238,11 @@ def unregister():
|
||||
if bpy.app.timers.is_registered(delayed_version_check):
|
||||
bpy.app.timers.unregister(delayed_version_check)
|
||||
|
||||
# Clean up authentication server
|
||||
from .connector.blender_operators.add_account_button import cleanup_auth_server
|
||||
|
||||
cleanup_auth_server()
|
||||
|
||||
icons.unload_icons()
|
||||
unregister_speckle_state() # Unregister SpeckleState
|
||||
_client_cache.clear()
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import bpy
|
||||
import webbrowser
|
||||
import textwrap
|
||||
from bpy.types import Event, Context
|
||||
from typing import Optional
|
||||
from ..utils.authentication import (
|
||||
AuthenticationServer,
|
||||
SPECKLE_AUTH_PORT,
|
||||
)
|
||||
|
||||
|
||||
# Global auth server instance
|
||||
_auth_server = None
|
||||
|
||||
|
||||
class SPECKLE_OT_add_account(bpy.types.Operator):
|
||||
@@ -16,6 +25,10 @@ class SPECKLE_OT_add_account(bpy.types.Operator):
|
||||
default="https://app.speckle.systems",
|
||||
)
|
||||
|
||||
_timer = None
|
||||
_timeout_counter = 0
|
||||
_max_timeout = 300 # 5 minutes in seconds (300 checks at ~1 sec intervals)
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
@@ -25,14 +38,222 @@ class SPECKLE_OT_add_account(bpy.types.Operator):
|
||||
layout.prop(self, "server_url", text="Server URL")
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
# Logic to handle sign in
|
||||
api_url = "http://localhost:29364"
|
||||
url = f"{api_url}/auth/add-account?serverUrl={self.server_url}"
|
||||
webbrowser.open(url)
|
||||
self.report({"INFO"}, f"Adding account from {self.server_url}: {url}")
|
||||
print(f"[Add Account] Starting authentication for server: {self.server_url}")
|
||||
cleanup_auth_server()
|
||||
|
||||
# Force redraw
|
||||
context.window.screen = context.window.screen
|
||||
context.area.tag_redraw()
|
||||
# Try to start own auth server first - it will fail gracefully if port is in use
|
||||
global _auth_server
|
||||
_auth_server = AuthenticationServer(port=SPECKLE_AUTH_PORT)
|
||||
|
||||
if _auth_server.start():
|
||||
return self._initiate_own_server_flow(context)
|
||||
|
||||
# Server failed to start - port is in use
|
||||
_auth_server = None
|
||||
print(f"[Add Account] Port {SPECKLE_AUTH_PORT} is already in use")
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Port {SPECKLE_AUTH_PORT} is already in use. Please close any application using it and try again.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
def _initiate_own_server_flow(self, context: Context) -> set[str]:
|
||||
"""Start auth flow with our own server."""
|
||||
try:
|
||||
_auth_server.open_auth_url(self.server_url)
|
||||
self._start_modal_timer(context)
|
||||
return {"RUNNING_MODAL"}
|
||||
except Exception as e:
|
||||
print(f"[Add Account] Failed to open browser: {e}")
|
||||
self.report({"ERROR"}, f"Failed to open browser: {e}")
|
||||
cleanup_auth_server()
|
||||
return {"CANCELLED"}
|
||||
|
||||
def _start_modal_timer(self, context: Context):
|
||||
"""Start modal timer for auth polling."""
|
||||
self._timeout_counter = 0
|
||||
wm = context.window_manager
|
||||
self._timer = wm.event_timer_add(1.0, window=context.window)
|
||||
wm.modal_handler_add(self)
|
||||
|
||||
def modal(self, context: Context, event: Event) -> set[str]:
|
||||
global _auth_server
|
||||
|
||||
if event.type != "TIMER":
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
# Check for timeout
|
||||
self._timeout_counter += 1
|
||||
if self._timeout_counter >= self._max_timeout:
|
||||
print("[Add Account] Authentication timed out after 5 minutes")
|
||||
self._cleanup(context)
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
"Authentication timed out after 5 minutes. Please try again.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Check for no active auth server
|
||||
if not _auth_server:
|
||||
print("[Add Account] No active auth server, cancelling")
|
||||
self._cleanup(context)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Check auth server completion
|
||||
if _auth_server.is_complete():
|
||||
return self._finish_auth(
|
||||
context,
|
||||
_auth_server.is_successful(),
|
||||
_auth_server.get_error_message(),
|
||||
"Auth server",
|
||||
)
|
||||
|
||||
# Still waiting
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _finish_auth(
|
||||
self,
|
||||
context: Context,
|
||||
is_successful: bool,
|
||||
error_msg: Optional[str],
|
||||
auth_type: str,
|
||||
) -> set[str]:
|
||||
"""Complete authentication and cleanup."""
|
||||
print(
|
||||
f"[Add Account] {auth_type} authentication complete. Success: {is_successful}"
|
||||
)
|
||||
self._cleanup(context)
|
||||
return self._handle_auth_complete(context, is_successful, error_msg)
|
||||
|
||||
def _handle_auth_complete(
|
||||
self, context: Context, is_successful: bool, error_msg: Optional[str]
|
||||
) -> set[str]:
|
||||
"""Handle authentication completion and update UI state."""
|
||||
if is_successful:
|
||||
print("[Add Account] Account added successfully - refreshing UI")
|
||||
|
||||
# Import account management functions
|
||||
from ..utils.account_manager import get_account_enum_items, _client_cache
|
||||
from ..ui.account_selection_dialog import (
|
||||
update_workspaces_list,
|
||||
update_projects_list,
|
||||
)
|
||||
|
||||
# Get the newly added account (most recent one)
|
||||
accounts = get_account_enum_items()
|
||||
if accounts and accounts[0][0] != "NO_ACCOUNTS":
|
||||
new_account_id = accounts[-1][0] # Last account added
|
||||
|
||||
# Set as selected account
|
||||
context.window_manager.selected_account_id = new_account_id
|
||||
|
||||
# Clear client cache to force re-authentication
|
||||
_client_cache.clear()
|
||||
|
||||
# Refresh UI state
|
||||
try:
|
||||
update_workspaces_list(context)
|
||||
update_projects_list(context)
|
||||
except Exception as e:
|
||||
print(f"[Add Account] Error refreshing UI state: {e}")
|
||||
|
||||
self.report({"INFO"}, "Account added successfully and is now active!")
|
||||
else:
|
||||
self.report({"INFO"}, "Account added successfully!")
|
||||
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
error_details = error_msg if error_msg else "Unknown error"
|
||||
print(f"[Add Account] Authentication failed: {error_details}")
|
||||
self.report({"ERROR"}, f"Authentication failed: {error_details}")
|
||||
|
||||
# Show persistent error popup with details
|
||||
# Store error in window manager for the popup operator
|
||||
context.window_manager["speckle_auth_error"] = error_details
|
||||
bpy.ops.speckle.show_auth_error("INVOKE_DEFAULT")
|
||||
|
||||
return {"CANCELLED"}
|
||||
|
||||
def _cleanup(self, context: Context):
|
||||
# Remove timer
|
||||
if self._timer is not None:
|
||||
context.window_manager.event_timer_remove(self._timer)
|
||||
self._timer = None
|
||||
|
||||
# Shutdown auth server/authenticator
|
||||
cleanup_auth_server()
|
||||
|
||||
|
||||
def cleanup_auth_server():
|
||||
"""Shutdown auth server on addon unload."""
|
||||
global _auth_server
|
||||
|
||||
if _auth_server is not None:
|
||||
try:
|
||||
_auth_server.shutdown()
|
||||
except Exception as e:
|
||||
print(f"[Add Account] Failed to cleanup auth server: {e}")
|
||||
print(f"[Add Account] Port {SPECKLE_AUTH_PORT} may still be occupied")
|
||||
_auth_server = None
|
||||
|
||||
|
||||
class SPECKLE_OT_show_auth_error(bpy.types.Operator):
|
||||
"""Show persistent error dialog for authentication failures."""
|
||||
|
||||
bl_idname = "speckle.show_auth_error"
|
||||
bl_label = "Authentication Error"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
# Clean up the temporary error message
|
||||
if "speckle_auth_error" in context.window_manager:
|
||||
del context.window_manager["speckle_auth_error"]
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_popup(self, width=450)
|
||||
|
||||
def draw(self, context: Context):
|
||||
layout = self.layout
|
||||
|
||||
# Error header
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.label(text="", icon="ERROR")
|
||||
row.label(text="Authentication Failed", icon="NONE")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Error details
|
||||
error_details = context.window_manager.get(
|
||||
"speckle_auth_error", "Unknown error"
|
||||
)
|
||||
col = layout.column(align=True)
|
||||
|
||||
# Wrap long error messages
|
||||
wrapper = textwrap.TextWrapper(width=60)
|
||||
for line in error_details.split("\n"):
|
||||
if line:
|
||||
for wrapped_line in wrapper.wrap(line):
|
||||
col.label(text=wrapped_line)
|
||||
else:
|
||||
col.label(text="")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Close button
|
||||
layout.operator("speckle.dismiss_popup", text="Close", icon="X")
|
||||
|
||||
|
||||
class SPECKLE_OT_dismiss_popup(bpy.types.Operator):
|
||||
"""Dismiss popup dialog."""
|
||||
|
||||
bl_idname = "speckle.dismiss_popup"
|
||||
bl_label = "Dismiss"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
# Clean up any temporary data
|
||||
if "speckle_auth_error" in context.window_manager:
|
||||
del context.window_manager["speckle_auth_error"]
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
"""
|
||||
Speckle authentication module for Blender connector.
|
||||
|
||||
Implements OAuth-style authentication flow with a local HTTP server,
|
||||
eliminating the dependency on the desktop service.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
|
||||
# Speckle Blender dedicated app constants (registered server-side)
|
||||
SPECKLE_APP_ID = "sblndrdui" # Dedicated app ID for Blender connector
|
||||
SPECKLE_AUTH_PORT = 29365 # Port for local auth callback server
|
||||
|
||||
|
||||
def get_user_agent() -> str:
|
||||
"""Get User-Agent string identifying the Blender connector to prevent Cloudflare blocking."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
# Get the extension directory
|
||||
addon_dir = Path(__file__).parent.parent.parent
|
||||
|
||||
# Try to read version from blender_manifest.toml
|
||||
manifest_path = addon_dir / "blender_manifest.toml"
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("version = "):
|
||||
version = line.split("=")[1].strip().strip('"')
|
||||
break
|
||||
else:
|
||||
version = "3.0.0"
|
||||
else:
|
||||
version = "3.0.0"
|
||||
except Exception:
|
||||
# Fallback if we can't determine version
|
||||
version = "3.0.0"
|
||||
|
||||
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
return f"Speckle-Blender-Connector/{version} (Python/{python_version})"
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def generate_challenge() -> str:
|
||||
"""Generate a random 12-character alphanumeric challenge string."""
|
||||
chars = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(chars) for _ in range(12))
|
||||
|
||||
|
||||
class ThreadSafeAuthServer(HTTPServer):
|
||||
"""Thread-safe HTTP server for Speckle authentication with locked state management."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._lock = threading.Lock()
|
||||
self._server_url: Optional[str] = None
|
||||
self._challenge: Optional[str] = None
|
||||
self._auth_complete: bool = False
|
||||
self._auth_success: bool = False
|
||||
self._error_message: Optional[str] = None
|
||||
self._request_count: int = 0
|
||||
|
||||
@property
|
||||
def server_url(self) -> Optional[str]:
|
||||
with self._lock:
|
||||
return self._server_url
|
||||
|
||||
@server_url.setter
|
||||
def server_url(self, value: str) -> None:
|
||||
with self._lock:
|
||||
self._server_url = value
|
||||
|
||||
@property
|
||||
def challenge(self) -> Optional[str]:
|
||||
with self._lock:
|
||||
return self._challenge
|
||||
|
||||
@challenge.setter
|
||||
def challenge(self, value: str) -> None:
|
||||
with self._lock:
|
||||
self._challenge = value
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
with self._lock:
|
||||
return self._auth_complete
|
||||
|
||||
@property
|
||||
def is_successful(self) -> bool:
|
||||
with self._lock:
|
||||
return self._auth_success
|
||||
|
||||
@property
|
||||
def error_message(self) -> Optional[str]:
|
||||
with self._lock:
|
||||
return self._error_message
|
||||
|
||||
def set_auth_success(self) -> None:
|
||||
"""Mark authentication as successful (sets auth_complete LAST for atomicity)."""
|
||||
with self._lock:
|
||||
self._auth_success = True
|
||||
self._error_message = None
|
||||
self._auth_complete = True # Set LAST to prevent partial reads
|
||||
|
||||
def set_auth_failure(self, error_message: str) -> None:
|
||||
"""Mark authentication as failed (sets auth_complete LAST for atomicity)."""
|
||||
with self._lock:
|
||||
self._auth_success = False
|
||||
self._error_message = error_message
|
||||
self._auth_complete = True # Set LAST to prevent partial reads
|
||||
|
||||
def increment_request_count(self) -> int:
|
||||
"""Increment and return request count."""
|
||||
with self._lock:
|
||||
self._request_count += 1
|
||||
return self._request_count
|
||||
|
||||
@property
|
||||
def request_count(self) -> int:
|
||||
with self._lock:
|
||||
return self._request_count
|
||||
|
||||
|
||||
class SpeckleAuthHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP request handler for Speckle authentication flow with /auth/add-account and callback routes."""
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[Auth Server] {format % args}")
|
||||
|
||||
def do_GET(self):
|
||||
self.server.increment_request_count()
|
||||
|
||||
parsed_path = urlparse(self.path)
|
||||
query_params = parse_qs(parsed_path.query)
|
||||
|
||||
if parsed_path.path == "/auth/add-account":
|
||||
self._handle_add_account(query_params)
|
||||
elif parsed_path.path == "/":
|
||||
self._handle_callback(query_params)
|
||||
else:
|
||||
self._send_error_response(404, "Not Found")
|
||||
|
||||
def _handle_add_account(self, query_params: Dict[str, list]):
|
||||
"""Handle initial add-account request, generate challenge and redirect to Speckle server."""
|
||||
# Get server URL from query params
|
||||
server_url = query_params.get("serverUrl", ["https://app.speckle.systems"])[0]
|
||||
self.server.server_url = server_url.rstrip("/")
|
||||
|
||||
# Generate challenge
|
||||
self.server.challenge = generate_challenge()
|
||||
|
||||
# Construct redirect URL
|
||||
auth_url = f"{self.server.server_url}/authn/verify/{SPECKLE_APP_ID}/{self.server.challenge}"
|
||||
|
||||
print(f"[Auth Server] Redirecting to: {auth_url}")
|
||||
|
||||
# Send redirect response
|
||||
self.send_response(302)
|
||||
self.send_header("Location", auth_url)
|
||||
self.end_headers()
|
||||
|
||||
def _handle_callback(self, query_params: Dict[str, list]):
|
||||
"""Handle callback from Speckle server, exchange access code for tokens and save account."""
|
||||
# Get access code from query params
|
||||
access_code_list = query_params.get("access_code", [])
|
||||
|
||||
if not access_code_list:
|
||||
self._redirect_to_failure("fail-no-access-code")
|
||||
return
|
||||
|
||||
access_code = access_code_list[0]
|
||||
|
||||
try:
|
||||
# Exchange access code for tokens
|
||||
tokens = exchange_access_code_for_tokens(
|
||||
access_code, self.server.challenge, self.server.server_url
|
||||
)
|
||||
|
||||
# Get user and server info
|
||||
user_info, server_info = get_user_and_server_info(
|
||||
tokens["token"], self.server.server_url
|
||||
)
|
||||
|
||||
# Save account
|
||||
save_account_to_storage(
|
||||
tokens["token"], tokens["refreshToken"], user_info, server_info
|
||||
)
|
||||
|
||||
# Mark as successful (sets auth_complete LAST atomically)
|
||||
self.server.set_auth_success()
|
||||
|
||||
# Redirect to success page
|
||||
self._redirect_to_success()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Auth Server] Error during authentication: {e}")
|
||||
# Mark as failed (sets auth_complete LAST atomically)
|
||||
self.server.set_auth_failure(str(e))
|
||||
self._redirect_to_failure("fail")
|
||||
|
||||
def _redirect_to_success(self):
|
||||
self.send_response(302)
|
||||
self.send_header(
|
||||
"Location", "https://www.speckle.systems/connector-auth/success"
|
||||
)
|
||||
self.end_headers()
|
||||
|
||||
def _redirect_to_failure(self, reason: str):
|
||||
self.send_response(302)
|
||||
self.send_header(
|
||||
"Location", f"https://www.speckle.systems/connector-auth/{reason}"
|
||||
)
|
||||
self.end_headers()
|
||||
|
||||
def _send_error_response(self, code: int, message: str):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
f"<html><body><h1>{code} {message}</h1></body></html>".encode()
|
||||
)
|
||||
|
||||
|
||||
def _handle_auth_error(e: AuthenticationError) -> None:
|
||||
"""Re-raise AuthenticationError with user-friendly message for network errors."""
|
||||
error_str = str(e)
|
||||
if "Network error" in error_str or "URLError" in error_str:
|
||||
raise AuthenticationError(
|
||||
"Network error while authenticating. Please check your internet connection."
|
||||
) from e
|
||||
raise
|
||||
|
||||
|
||||
def _post_json(
|
||||
url: str,
|
||||
body: Dict[str, Any],
|
||||
auth_token: Optional[str] = None,
|
||||
error_context: str = "Request",
|
||||
) -> Dict[str, Any]:
|
||||
"""Make POST request with JSON body and optional Bearer token."""
|
||||
# Encode body as JSON
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
|
||||
# Build headers
|
||||
headers = {"Content-Type": "application/json", "User-Agent": get_user_agent()}
|
||||
|
||||
# Add Authorization header if token provided
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
|
||||
try:
|
||||
request = Request(url, data=data, headers=headers)
|
||||
with urlopen(request, timeout=30) as response:
|
||||
response_data = json.loads(response.read().decode("utf-8"))
|
||||
return response_data
|
||||
|
||||
except HTTPError as e:
|
||||
error_body = e.read().decode("utf-8") if e.fp else "No error details"
|
||||
raise AuthenticationError(f"{error_context} failed: {e.code} {error_body}")
|
||||
except URLError as e:
|
||||
raise AuthenticationError(f"Network error during {error_context}: {e.reason}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise AuthenticationError(f"Invalid JSON response from {error_context}: {e}")
|
||||
|
||||
|
||||
def exchange_access_code_for_tokens(
|
||||
access_code: str, challenge: str, server_url: str
|
||||
) -> Dict[str, str]:
|
||||
"""Exchange access code and challenge for authentication tokens."""
|
||||
if not challenge:
|
||||
raise AuthenticationError("No challenge available")
|
||||
|
||||
# Prepare request body
|
||||
body = {
|
||||
"appId": SPECKLE_APP_ID,
|
||||
"appSecret": SPECKLE_APP_ID,
|
||||
"accessCode": access_code,
|
||||
"challenge": challenge,
|
||||
}
|
||||
|
||||
# Make POST request
|
||||
url = f"{server_url}/auth/token"
|
||||
try:
|
||||
response_data = _post_json(url, body, error_context="token exchange")
|
||||
except AuthenticationError as e:
|
||||
_handle_auth_error(e)
|
||||
|
||||
# Validate response
|
||||
if "token" not in response_data or "refreshToken" not in response_data:
|
||||
raise AuthenticationError("Invalid response from token endpoint")
|
||||
|
||||
return {
|
||||
"token": response_data["token"],
|
||||
"refreshToken": response_data["refreshToken"],
|
||||
}
|
||||
|
||||
|
||||
def get_user_and_server_info(
|
||||
token: str, server_url: str
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Get user and server information using GraphQL query with auth token."""
|
||||
# Prepare GraphQL query
|
||||
query = """
|
||||
query {
|
||||
activeUser {
|
||||
id
|
||||
name
|
||||
email
|
||||
company
|
||||
avatar
|
||||
}
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
adminContact
|
||||
description
|
||||
version
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
body = {"query": query}
|
||||
|
||||
# Make POST request
|
||||
url = f"{server_url}/graphql"
|
||||
try:
|
||||
response_data = _post_json(
|
||||
url, body, auth_token=token, error_context="user info request"
|
||||
)
|
||||
except AuthenticationError as e:
|
||||
_handle_auth_error(e)
|
||||
|
||||
# Validate response
|
||||
if "data" not in response_data:
|
||||
raise AuthenticationError("Invalid GraphQL response")
|
||||
|
||||
data = response_data["data"]
|
||||
|
||||
if "activeUser" not in data or "serverInfo" not in data:
|
||||
raise AuthenticationError("Missing user or server info in response")
|
||||
|
||||
user_info = data["activeUser"]
|
||||
server_info = data["serverInfo"]
|
||||
|
||||
# Ensure server URL is set correctly
|
||||
server_info["url"] = server_url.rstrip("/")
|
||||
|
||||
return user_info, server_info
|
||||
|
||||
|
||||
def save_account_to_storage(
|
||||
token: str,
|
||||
refresh_token: str,
|
||||
user_info: Dict[str, Any],
|
||||
server_info: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Save account to Accounts.db SQLite database for compatibility with specklepy."""
|
||||
try:
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import os
|
||||
from specklepy.core.api.credentials import speckle_path_provider
|
||||
|
||||
# Generate account ID (hash of email + server URL)
|
||||
account_id_string = f"{user_info['email']}-{server_info['url']}"
|
||||
account_id = hashlib.md5(account_id_string.encode()).hexdigest().upper()
|
||||
|
||||
# Construct account object matching the expected format
|
||||
account_data = {
|
||||
"id": account_id,
|
||||
"token": token,
|
||||
"refreshToken": refresh_token,
|
||||
"isDefault": True,
|
||||
"isOnline": True,
|
||||
"serverInfo": {
|
||||
"name": server_info["name"],
|
||||
"company": server_info.get("company"),
|
||||
"version": server_info.get("version"),
|
||||
"description": server_info.get("description"),
|
||||
"url": server_info["url"],
|
||||
},
|
||||
"userInfo": {
|
||||
"id": user_info["id"],
|
||||
"name": user_info["name"],
|
||||
"email": user_info["email"],
|
||||
"company": user_info.get("company"),
|
||||
"avatar": user_info.get("avatar"),
|
||||
},
|
||||
}
|
||||
|
||||
# Get database path
|
||||
speckle_folder = speckle_path_provider.user_speckle_folder_path()
|
||||
db_path = os.path.join(speckle_folder, "Accounts.db")
|
||||
|
||||
# Ensure the Speckle folder exists
|
||||
os.makedirs(speckle_folder, exist_ok=True)
|
||||
|
||||
# Connect to database and save account
|
||||
# Use IMMEDIATE isolation level to acquire write lock immediately,
|
||||
# preventing race conditions in concurrent account additions
|
||||
conn = sqlite3.connect(db_path, isolation_level="IMMEDIATE")
|
||||
try:
|
||||
with conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create table if it doesn't exist
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
hash TEXT PRIMARY KEY,
|
||||
content TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# If setting as default, remove default flag from other accounts
|
||||
# Use batch update to make the operation more atomic
|
||||
if account_data["isDefault"]:
|
||||
cursor.execute("SELECT hash, content FROM objects")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Build list of updates to execute in batch
|
||||
updates = []
|
||||
for existing_id, existing_content in rows:
|
||||
try:
|
||||
existing_account = json.loads(existing_content)
|
||||
if existing_account.get("isDefault", False):
|
||||
existing_account["isDefault"] = False
|
||||
updates.append(
|
||||
(json.dumps(existing_account), existing_id)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# Skip malformed accounts
|
||||
continue
|
||||
|
||||
# Execute all updates in batch for better atomicity
|
||||
if updates:
|
||||
cursor.executemany(
|
||||
"UPDATE objects SET content = ? WHERE hash = ?", updates
|
||||
)
|
||||
|
||||
# Insert or replace the account
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO objects (hash, content) VALUES (?, ?)",
|
||||
(account_id, json.dumps(account_data)),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(
|
||||
f"[Auth] Successfully saved account: {user_info['email']} @ {server_info['url']} (ID: {account_id})"
|
||||
)
|
||||
|
||||
# Track account creation event
|
||||
try:
|
||||
from specklepy.logging import metrics
|
||||
|
||||
metrics.track(metrics.HOST_APP, "connector", "account", {"action": "add"})
|
||||
except Exception as e:
|
||||
# Don't fail if metrics tracking fails
|
||||
print(f"[Auth] Failed to track metrics: {e}")
|
||||
|
||||
except Exception as e:
|
||||
raise AuthenticationError(f"Failed to save account: {e}")
|
||||
|
||||
|
||||
class AuthenticationServer:
|
||||
"""Manages local HTTP server for Speckle authentication in a background thread."""
|
||||
|
||||
def __init__(self, port: int = SPECKLE_AUTH_PORT):
|
||||
self.port = port
|
||||
self.server: Optional[ThreadSafeAuthServer] = None
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.shutdown_event = threading.Event()
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start HTTP server in background thread."""
|
||||
try:
|
||||
# Create thread-safe server (state initialized in constructor)
|
||||
self.server = ThreadSafeAuthServer(
|
||||
("127.0.0.1", self.port), SpeckleAuthHandler
|
||||
)
|
||||
|
||||
# Start server in background thread
|
||||
self.thread = threading.Thread(target=self._run_server, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
print(f"[Auth Server] Started on http://127.0.0.1:{self.port}")
|
||||
return True
|
||||
|
||||
except OSError as e:
|
||||
if e.errno in (errno.EADDRINUSE, 10048): # Address already in use
|
||||
print(f"[Auth Server] Port {self.port} is already in use.")
|
||||
else:
|
||||
print(f"[Auth Server] Failed to start server: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[Auth Server] Unexpected error starting server: {e}")
|
||||
return False
|
||||
|
||||
def _run_server(self):
|
||||
try:
|
||||
# Set a timeout so handle_request doesn't block forever
|
||||
self.server.timeout = 0.5
|
||||
|
||||
# Server should handle a maximum of 3 requests:
|
||||
# 1. /auth/add-account (redirect to Speckle)
|
||||
# 2. / callback (from Speckle with access_code)
|
||||
# 3. Maybe a favicon or other browser request
|
||||
# After that or when shutdown is signaled, stop
|
||||
max_requests = 5 # Allow a few extra for browser quirks
|
||||
|
||||
while (
|
||||
not self.shutdown_event.is_set()
|
||||
and self.server.request_count < max_requests
|
||||
):
|
||||
self.server.handle_request()
|
||||
|
||||
# If auth is complete, we can stop serving
|
||||
if self.server.is_complete:
|
||||
print("[Auth Server] Authentication complete, stopping server")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Auth Server] Error in server thread: {e}")
|
||||
self.server.set_auth_failure(f"Server thread crashed: {e}")
|
||||
|
||||
def shutdown(self):
|
||||
if self.server:
|
||||
self.shutdown_event.set()
|
||||
try:
|
||||
# Give the server thread a moment to see the shutdown event
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2.0)
|
||||
|
||||
self.server.server_close()
|
||||
except Exception as e:
|
||||
print(f"[Auth Server] Error during shutdown: {e}")
|
||||
|
||||
self.server = None
|
||||
self.thread = None
|
||||
print("[Auth Server] Shutdown complete")
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
return self.server.is_complete if self.server else False
|
||||
|
||||
def is_successful(self) -> bool:
|
||||
return self.server.is_successful if self.server else False
|
||||
|
||||
def get_error_message(self) -> Optional[str]:
|
||||
return self.server.error_message if self.server else None
|
||||
|
||||
def open_auth_url(self, server_url: str = "https://app.speckle.systems"):
|
||||
"""Open authentication URL in browser to initiate auth flow."""
|
||||
# Trigger the add-account endpoint
|
||||
url = f"http://127.0.0.1:{self.port}/auth/add-account?serverUrl={server_url}"
|
||||
webbrowser.open(url)
|
||||
print("[Auth Server] Opening browser to initiate authentication...")
|
||||
+1
-1
@@ -30,7 +30,7 @@ def patch_manifest(simple_version: str):
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith("version ="):
|
||||
lines[index] = f'version = "{version[0]}.{version[1]}.{version[2]}",\n'
|
||||
lines[index] = f'version = "{version[0]}.{version[1]}.{version[2]}"\n'
|
||||
print(f"Patched connector version number in {FILE_PATH}")
|
||||
break
|
||||
|
||||
|
||||
Reference in New Issue
Block a user