Files
voicebox/backend/utils/auth.py
T
James Pine f4d21504e3 Mobile companion app + paired-device backend
New iOS-first companion (Expo SDK 54 + NativeWind v4) with three tabs:
Captures (the hero — floating gold mic, live mic-meter waveform,
expand-row playback), Generate (profile picker + speak + autoplay +
recent), and Voices (searchable profile list).

Pairing (V0): backend mints a one-time token, mobile scans/pastes the
voicebox:// URL, server returns a long-lived bearer it stores only as a
SHA-256 hash. Bearer-or-loopback auth applied to every user-data router
so binding 0.0.0.0 doesn't leak existing endpoints. Loopback callers
(the desktop app) keep their friction-free access.

Desktop Settings → Mobile pane: live host picker (LAN / Tailscale auto-
detected via the App-bundle binary path on macOS), QR rendering,
5-minute expiry countdown, copyable URL fallback, paired-device list
with revoke. Auto-closes when a new device pairs.

just dev now binds the backend to 0.0.0.0 so paired phones can reach
it — and just setup-python pins mlx-audio==0.4.1 + mlx-lm so fresh
Apple Silicon worktrees get a working STT path on first install.
2026-04-25 17:09:32 -07:00

104 lines
3.6 KiB
Python

"""FastAPI dependencies for the V0 mobile-pair auth model.
Two dependencies are exposed:
* ``require_loopback`` — reject calls that don't originate from a loopback
address. Used to gate desktop-only admin endpoints (pair init, devices
list, revoke). Loopback callers stay unauthenticated everywhere else
too — the desktop app talks to its own backend over 127.0.0.1.
* ``require_paired_device`` — validate ``Authorization: Bearer <token>``
against the ``paired_devices`` table. Used to identify paired mobile
callers and bumps ``last_seen_at`` on success.
Phase 2 will layer XChaCha20-Poly1305 payload encryption on top of the
bearer (see ``mobile/PLAN.md``); the bearer stays the identity primitive.
"""
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..database import PairedDevice, get_db
from ..services import pairing as pairing_service
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def require_loopback(request: Request) -> None:
"""Reject calls from non-loopback addresses."""
client = request.client
host = client.host if client else None
if host not in LOOPBACK_HOSTS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Loopback only",
)
def _extract_bearer(request: Request) -> Optional[str]:
auth = request.headers.get("Authorization") or request.headers.get("authorization")
if not auth:
return None
parts = auth.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1].strip()
def require_paired_device(
request: Request,
db: Session = Depends(get_db),
) -> PairedDevice:
"""Resolve the PairedDevice authenticated by the request bearer."""
bearer = _extract_bearer(request)
if not bearer:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
device = pairing_service.authenticate_bearer(db, bearer)
if device is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
return device
def require_bearer_or_loopback(
request: Request,
db: Session = Depends(get_db),
) -> None:
"""Loopback callers pass without auth; everyone else needs a paired bearer.
Applied as a router-level dependency on user-data endpoints so the
desktop UI (which talks over 127.0.0.1) keeps its current friction-free
access while LAN-reachable callers must be a paired mobile device.
The pre-pair endpoints (``POST /pair/complete``) and the desktop-only
admin endpoints (``POST /pair/init``, ``GET /devices``) intentionally
stay outside this gate — they have their own dependencies.
"""
client = request.client
host = client.host if client else None
if host in LOOPBACK_HOSTS:
return
bearer = _extract_bearer(request)
if not bearer:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
device = pairing_service.authenticate_bearer(db, bearer)
if device is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked bearer token",
headers={"WWW-Authenticate": "Bearer"},
)