184 lines
6.8 KiB
Python
184 lines
6.8 KiB
Python
"""
|
|
Voicebox Cloud device login — the "Log in with browser" flow.
|
|
|
|
The desktop opens the browser to ``{web}/connect``; the user authorizes while
|
|
signed in; the cloud redirects a single-use code back to this backend's loopback
|
|
callback. We exchange that code (server-to-server, over TLS) for a ``voicebox_…``
|
|
API key, verify the key against the API, and store it locally. The key never
|
|
travels through a browser URL, and an unfinished flow leaves nothing behind.
|
|
|
|
The ``state`` we mint and round-trip prevents login-CSRF: a callback whose state
|
|
we didn't issue (e.g. an attacker tricking the user into hitting the loopback
|
|
callback with their own code) is rejected.
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
import time
|
|
import webbrowser
|
|
from urllib.parse import urlencode
|
|
|
|
import httpx
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import config
|
|
from ..database import CloudSettings as DBCloudSettings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SINGLETON_ID = 1
|
|
PENDING_TTL_SECONDS = 600 # the whole browser flow must finish within 10 min
|
|
|
|
# state -> expiry epoch. In-memory: a single backend process owns the flow, and a
|
|
# dropped pairing should simply be restarted.
|
|
_pending: dict[str, float] = {}
|
|
|
|
|
|
def _prune() -> None:
|
|
now = time.time()
|
|
for state, expiry in list(_pending.items()):
|
|
if expiry < now:
|
|
_pending.pop(state, None)
|
|
|
|
|
|
def _json_dict(response: httpx.Response) -> dict | None:
|
|
"""Parsed JSON body, or None when it isn't a JSON object."""
|
|
try:
|
|
payload = response.json()
|
|
except ValueError:
|
|
return None
|
|
return payload if isinstance(payload, dict) else None
|
|
|
|
|
|
def _consume_state(state: str) -> bool:
|
|
"""Validate and single-use-consume a pending state."""
|
|
_prune()
|
|
expiry = _pending.pop(state, None)
|
|
return expiry is not None and expiry >= time.time()
|
|
|
|
|
|
def start_login(callback_url: str, device_name: str) -> str:
|
|
"""Mint a state, build the authorize URL, and open the browser.
|
|
|
|
Returns the authorize URL (also opened here) so the caller can surface it as
|
|
a fallback if the browser didn't open.
|
|
"""
|
|
state = secrets.token_urlsafe(24)
|
|
_prune()
|
|
_pending[state] = time.time() + PENDING_TTL_SECONDS
|
|
|
|
params = urlencode({"redirect_uri": callback_url, "state": state, "name": device_name})
|
|
authorize_url = f"{config.get_cloud_web_url()}/connect?{params}"
|
|
|
|
try:
|
|
webbrowser.open(authorize_url)
|
|
except Exception: # pragma: no cover - platform dependent
|
|
logger.exception("failed to open browser for cloud login")
|
|
|
|
return authorize_url
|
|
|
|
|
|
async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str]:
|
|
"""Exchange the code for an API key and store it. Returns (ok, message)."""
|
|
if not _consume_state(state):
|
|
return False, "This sign-in link is invalid or has expired. Start again from the app."
|
|
if not code:
|
|
return False, "Missing authorization code."
|
|
|
|
web = config.get_cloud_web_url()
|
|
api = config.get_cloud_api_url()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
exchanged = await client.post(f"{web}/api/connect/exchange", json={"code": code})
|
|
if exchanged.status_code != 200:
|
|
logger.warning("cloud exchange rejected code: %s", exchanged.status_code)
|
|
return False, "Could not complete sign-in — the code was rejected."
|
|
payload = _json_dict(exchanged)
|
|
if payload is None:
|
|
logger.warning("cloud exchange returned a non-JSON payload")
|
|
return False, "Voicebox Cloud returned an unexpected response."
|
|
api_key = payload.get("key")
|
|
device_name = payload.get("label")
|
|
if not api_key:
|
|
return False, "Voicebox Cloud did not return a key."
|
|
|
|
# Confirm the freshly minted key actually authenticates the API.
|
|
me = await client.get(
|
|
f"{api}/v1/account/me",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
)
|
|
if me.status_code != 200:
|
|
logger.warning("minted key failed verification: %s", me.status_code)
|
|
return False, "Sign-in succeeded but the key could not be verified."
|
|
# The 200 above proves the key works; the user id is best-effort.
|
|
data = (_json_dict(me) or {}).get("data")
|
|
account_user_id = data.get("userId") if isinstance(data, dict) else None
|
|
except httpx.HTTPError:
|
|
logger.exception("network error during cloud exchange")
|
|
return False, "Could not reach Voicebox Cloud. Check your connection and try again."
|
|
|
|
_store_key(db, api_key=api_key, device_name=device_name, account_user_id=account_user_id)
|
|
logger.info("connected to Voicebox Cloud as device %r", device_name)
|
|
return True, "Connected"
|
|
|
|
|
|
def _get_or_create_row(db: Session) -> DBCloudSettings:
|
|
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).first()
|
|
if row is None:
|
|
row = DBCloudSettings(id=SINGLETON_ID)
|
|
db.add(row)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
# Another request created the singleton concurrently.
|
|
db.rollback()
|
|
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).one()
|
|
else:
|
|
db.refresh(row)
|
|
return row
|
|
|
|
|
|
def _store_key(db: Session, *, api_key: str, device_name: str | None, account_user_id: str | None):
|
|
from datetime import datetime
|
|
|
|
row = _get_or_create_row(db)
|
|
row.api_key = api_key
|
|
row.device_name = device_name
|
|
row.account_user_id = account_user_id
|
|
row.connected_at = datetime.utcnow()
|
|
db.commit()
|
|
|
|
|
|
def get_status(db: Session) -> dict:
|
|
"""Local view of the cloud link — never returns the full key."""
|
|
row = _get_or_create_row(db)
|
|
connected = bool(row.api_key)
|
|
# Prefix only: "voicebox_" (9) + 8 chars, matching the cloud's key_prefix.
|
|
key_prefix = row.api_key[:17] if row.api_key else None
|
|
return {
|
|
"connected": connected,
|
|
"device_name": row.device_name if connected else None,
|
|
"account_user_id": row.account_user_id if connected else None,
|
|
"key_prefix": key_prefix,
|
|
"connected_at": row.connected_at if connected else None,
|
|
"dashboard_url": f"{config.get_cloud_web_url()}/account",
|
|
}
|
|
|
|
|
|
def disconnect(db: Session) -> None:
|
|
"""Forget the local credential. The key remains valid on the server until
|
|
revoked from the account dashboard — surface that in the UI."""
|
|
row = _get_or_create_row(db)
|
|
row.api_key = None
|
|
row.device_name = None
|
|
row.account_user_id = None
|
|
row.connected_at = None
|
|
db.commit()
|
|
|
|
|
|
def get_api_key(db: Session) -> str | None:
|
|
"""The stored bearer key, for the (future) sync client. None if not linked."""
|
|
row = _get_or_create_row(db)
|
|
return row.api_key
|