mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 22:30:40 -07:00
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.
This commit is contained in:
@@ -16,6 +16,8 @@ from .models import (
|
||||
GenerationSettings,
|
||||
GenerationVersion,
|
||||
MCPClientBinding,
|
||||
PairedDevice,
|
||||
PairingToken,
|
||||
ProfileChannelMapping,
|
||||
ProfileSample,
|
||||
Project,
|
||||
@@ -37,6 +39,8 @@ __all__ = [
|
||||
"GenerationSettings",
|
||||
"GenerationVersion",
|
||||
"MCPClientBinding",
|
||||
"PairedDevice",
|
||||
"PairingToken",
|
||||
"ProfileChannelMapping",
|
||||
"ProfileSample",
|
||||
"Project",
|
||||
|
||||
@@ -279,3 +279,37 @@ class Capture(Base):
|
||||
llm_model = Column(String, nullable=True)
|
||||
refinement_flags = Column(Text, nullable=True) # JSON blob
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PairedDevice(Base):
|
||||
"""A mobile device paired with this Voicebox install (V0 pair flow).
|
||||
|
||||
Stores only the SHA-256 of the bearer token; the bearer plaintext is
|
||||
returned to the device once at pairing time and never persisted
|
||||
server-side. If the device loses its bearer the user must re-pair.
|
||||
"""
|
||||
|
||||
__tablename__ = "paired_devices"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
bearer_hash = Column(String, nullable=False, unique=True, index=True)
|
||||
revoked = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_seen_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class PairingToken(Base):
|
||||
"""One-time token used to complete a device pairing.
|
||||
|
||||
Minted by the desktop UI via /pair/init, redeemed by the mobile
|
||||
device via /pair/complete in exchange for a long-lived bearer.
|
||||
Single-use; expires after ~5 minutes.
|
||||
"""
|
||||
|
||||
__tablename__ = "pairing_tokens"
|
||||
|
||||
token = Column(String, primary_key=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -793,3 +793,61 @@ class AvailableEffectsResponse(BaseModel):
|
||||
"""Response listing all available effect types."""
|
||||
|
||||
effects: List[AvailableEffect]
|
||||
|
||||
|
||||
# --- Mobile pairing (V0) -----------------------------------------------------
|
||||
|
||||
class HostCandidate(BaseModel):
|
||||
"""A reachable address the desktop can embed in the pair QR."""
|
||||
|
||||
address: str # ``host:port``, e.g. "192.168.1.5:17493"
|
||||
label: str # human-friendly name shown in the desktop host picker
|
||||
kind: str # "lan" | "tailscale" | "loopback"
|
||||
|
||||
|
||||
class PairInitResponse(BaseModel):
|
||||
"""Response for POST /pair/init — desktop renders ``pairing_url`` as a QR."""
|
||||
|
||||
token: str
|
||||
expires_at: datetime
|
||||
pairing_url: str # full voicebox://pair?host=…&token=… URL
|
||||
|
||||
|
||||
class PairCompleteRequest(BaseModel):
|
||||
"""Mobile-side request body for POST /pair/complete."""
|
||||
|
||||
token: str = Field(..., min_length=1, max_length=128)
|
||||
device_name: str = Field(..., min_length=1, max_length=80)
|
||||
|
||||
|
||||
class PairCompleteResponse(BaseModel):
|
||||
"""One-time response after successful pairing.
|
||||
|
||||
``bearer`` is returned in plaintext exactly once and is never persisted
|
||||
server-side; the device must save it (e.g. iOS SecureStore) immediately.
|
||||
"""
|
||||
|
||||
device_id: str
|
||||
bearer: str
|
||||
device_name: str
|
||||
|
||||
|
||||
class PairedDeviceResponse(BaseModel):
|
||||
"""A row in the desktop's Settings → Mobile device list."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
revoked: bool
|
||||
created_at: datetime
|
||||
last_seen_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
"""Identity of the bearer-authenticated caller."""
|
||||
|
||||
device_id: str
|
||||
device_name: str
|
||||
last_seen_at: Optional[datetime] = None
|
||||
|
||||
+42
-19
@@ -1,6 +1,23 @@
|
||||
"""Route registration for the voicebox API."""
|
||||
"""Route registration for the voicebox API.
|
||||
|
||||
from fastapi import FastAPI
|
||||
Authentication model
|
||||
--------------------
|
||||
Two router groups:
|
||||
|
||||
* **Open** — ``health`` (status checks anyone on the LAN may probe) and
|
||||
``pairing`` (pre-pair endpoints + admin endpoints with their own
|
||||
loopback-only or token-only gates).
|
||||
|
||||
* **Protected** — everything else, gated by ``require_bearer_or_loopback``:
|
||||
loopback callers (the desktop app over 127.0.0.1) pass without auth as
|
||||
before; LAN/Tailscale callers must present a valid paired-device bearer.
|
||||
This is what lets ``just dev`` bind to 0.0.0.0 without exposing user
|
||||
data to anyone on the same network.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
from ..utils.auth import require_bearer_or_loopback
|
||||
|
||||
|
||||
def register_routers(app: FastAPI) -> None:
|
||||
@@ -23,22 +40,28 @@ def register_routers(app: FastAPI) -> None:
|
||||
from .speak import router as speak_router
|
||||
from .mcp_bindings import router as mcp_bindings_router
|
||||
from .events import router as events_router
|
||||
from .pairing import router as pairing_router
|
||||
|
||||
# Open — health probes and the pre-pair / admin pairing endpoints.
|
||||
app.include_router(health_router)
|
||||
app.include_router(profiles_router)
|
||||
app.include_router(channels_router)
|
||||
app.include_router(generations_router)
|
||||
app.include_router(history_router)
|
||||
app.include_router(transcription_router)
|
||||
app.include_router(llm_router)
|
||||
app.include_router(captures_router)
|
||||
app.include_router(stories_router)
|
||||
app.include_router(effects_router)
|
||||
app.include_router(audio_router)
|
||||
app.include_router(models_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(cuda_router)
|
||||
app.include_router(speak_router)
|
||||
app.include_router(mcp_bindings_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(pairing_router)
|
||||
|
||||
# Protected — loopback callers pass through; LAN callers need a paired bearer.
|
||||
protected = [Depends(require_bearer_or_loopback)]
|
||||
app.include_router(profiles_router, dependencies=protected)
|
||||
app.include_router(channels_router, dependencies=protected)
|
||||
app.include_router(generations_router, dependencies=protected)
|
||||
app.include_router(history_router, dependencies=protected)
|
||||
app.include_router(transcription_router, dependencies=protected)
|
||||
app.include_router(llm_router, dependencies=protected)
|
||||
app.include_router(captures_router, dependencies=protected)
|
||||
app.include_router(stories_router, dependencies=protected)
|
||||
app.include_router(effects_router, dependencies=protected)
|
||||
app.include_router(audio_router, dependencies=protected)
|
||||
app.include_router(models_router, dependencies=protected)
|
||||
app.include_router(settings_router, dependencies=protected)
|
||||
app.include_router(tasks_router, dependencies=protected)
|
||||
app.include_router(cuda_router, dependencies=protected)
|
||||
app.include_router(speak_router, dependencies=protected)
|
||||
app.include_router(mcp_bindings_router, dependencies=protected)
|
||||
app.include_router(events_router, dependencies=protected)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Mobile device pairing endpoints (V0 — bearer auth)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..database import PairedDevice, get_db
|
||||
from ..services import pairing
|
||||
from ..utils.auth import require_loopback, require_paired_device
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/pair/init",
|
||||
response_model=models.PairInitResponse,
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
async def pair_init(request: Request, db: Session = Depends(get_db)):
|
||||
"""Mint a one-time pairing token (loopback callers only).
|
||||
|
||||
Optional ``?host=`` query param overrides what's embedded in the QR's
|
||||
pairing URL. The desktop UI should pass whatever address is reachable
|
||||
from the mobile device — LAN IP, Tailscale 100.x address, or MagicDNS
|
||||
name. Defaults to the request Host header for curl-driven local testing.
|
||||
"""
|
||||
host = request.query_params.get("host") or (
|
||||
request.headers.get("host") or "127.0.0.1:17493"
|
||||
)
|
||||
return pairing.init_pairing_token(db, host=host)
|
||||
|
||||
|
||||
@router.post("/pair/complete", response_model=models.PairCompleteResponse)
|
||||
async def pair_complete(
|
||||
body: models.PairCompleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Exchange a pairing token for a long-lived bearer.
|
||||
|
||||
Open endpoint — possession of the (one-time, short-TTL) token is
|
||||
itself the proof of authorization.
|
||||
"""
|
||||
try:
|
||||
return pairing.complete_pairing(db, body.token, body.device_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/devices",
|
||||
response_model=list[models.PairedDeviceResponse],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
async def list_paired_devices(db: Session = Depends(get_db)):
|
||||
"""List paired devices for the desktop Settings → Mobile pane."""
|
||||
return pairing.list_devices(db)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/devices/{device_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
async def revoke_paired_device(device_id: str, db: Session = Depends(get_db)):
|
||||
"""Revoke a paired device's bearer."""
|
||||
try:
|
||||
pairing.revoke_device(db, device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/me", response_model=models.MeResponse)
|
||||
async def me(device: PairedDevice = Depends(require_paired_device)):
|
||||
"""Identity of the bearer-authenticated caller — used by mobile to
|
||||
confirm pairing succeeded and the bearer round-trips.
|
||||
"""
|
||||
return models.MeResponse(
|
||||
device_id=device.id,
|
||||
device_name=device.name,
|
||||
last_seen_at=device.last_seen_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pair/host-candidates",
|
||||
response_model=list[models.HostCandidate],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
async def host_candidates(request: Request):
|
||||
"""Suggested host strings (LAN IP, Tailscale, loopback) for the QR."""
|
||||
port = request.url.port or 17493
|
||||
return pairing.list_host_candidates(port=port)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Mobile device pairing service (V0 — bearer auth, no E2E payload encryption yet).
|
||||
|
||||
Flow:
|
||||
1. Desktop UI (loopback) calls POST /pair/init → mints a single-use token
|
||||
with a 5-minute TTL and returns a ``voicebox://pair?...`` URL.
|
||||
2. Mobile scans the QR (or pastes the URL) and POSTs /pair/complete with
|
||||
the token + a human-readable device name.
|
||||
3. Server validates the token, mints a long-lived bearer, and stores
|
||||
only SHA-256(bearer). The bearer plaintext is returned exactly once.
|
||||
4. Mobile saves the bearer in SecureStore. Subsequent calls carry
|
||||
``Authorization: Bearer <bearer>``.
|
||||
|
||||
The bearer is returned exactly once. Server has no path to recover it; if
|
||||
the device loses its key the user must re-pair (and revoke the old device
|
||||
from Settings → Mobile if they want to be tidy).
|
||||
|
||||
Phase 2 will layer XChaCha20-Poly1305 payload encryption + HKDF session
|
||||
keys on top — see ``mobile/PLAN.md`` § Pairing & transport. The bearer
|
||||
established here is the foundation either way.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import PairedDevice, PairingToken
|
||||
from ..models import (
|
||||
HostCandidate,
|
||||
PairCompleteResponse,
|
||||
PairInitResponse,
|
||||
PairedDeviceResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAIRING_TOKEN_TTL = timedelta(minutes=5)
|
||||
TOKEN_BYTES = 32 # urlsafe-b64 encoded → ~44 chars
|
||||
|
||||
|
||||
def _hash_bearer(bearer: str) -> str:
|
||||
return hashlib.sha256(bearer.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _build_pairing_url(token: str, host: str) -> str:
|
||||
# ``host`` should be reachable from the mobile device — LAN IP, Tailscale
|
||||
# 100.x address, MagicDNS hostname (e.g. ``mac.tail-xxxx.ts.net:17493``).
|
||||
# The desktop UI is responsible for picking the right host; loopback is
|
||||
# only useful for curl-driven local testing.
|
||||
return f"voicebox://pair?host={host}&token={token}"
|
||||
|
||||
|
||||
def init_pairing_token(db: Session, host: str) -> PairInitResponse:
|
||||
"""Mint a one-time pairing token. Caller must already be authorized as loopback."""
|
||||
token = secrets.token_urlsafe(TOKEN_BYTES)
|
||||
expires_at = datetime.utcnow() + PAIRING_TOKEN_TTL
|
||||
db.add(PairingToken(token=token, expires_at=expires_at))
|
||||
db.commit()
|
||||
return PairInitResponse(
|
||||
token=token,
|
||||
expires_at=expires_at,
|
||||
pairing_url=_build_pairing_url(token, host),
|
||||
)
|
||||
|
||||
|
||||
def complete_pairing(db: Session, token: str, device_name: str) -> PairCompleteResponse:
|
||||
"""Exchange a pairing token for a long-lived device bearer.
|
||||
|
||||
Raises ValueError on invalid / expired / already-used token.
|
||||
"""
|
||||
row = db.query(PairingToken).filter(PairingToken.token == token).first()
|
||||
if row is None:
|
||||
raise ValueError("Invalid pairing token")
|
||||
if row.used_at is not None:
|
||||
raise ValueError("Pairing token already used")
|
||||
if row.expires_at < datetime.utcnow():
|
||||
raise ValueError("Pairing token expired")
|
||||
|
||||
row.used_at = datetime.utcnow()
|
||||
|
||||
bearer = secrets.token_urlsafe(TOKEN_BYTES)
|
||||
device = PairedDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
name=device_name.strip(),
|
||||
bearer_hash=_hash_bearer(bearer),
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
|
||||
logger.info("Paired new device id=%s name=%s", device.id, device.name)
|
||||
|
||||
return PairCompleteResponse(
|
||||
device_id=device.id,
|
||||
bearer=bearer,
|
||||
device_name=device.name,
|
||||
)
|
||||
|
||||
|
||||
def authenticate_bearer(db: Session, bearer: str) -> Optional[PairedDevice]:
|
||||
"""Look up a paired device by bearer. Bumps last_seen_at on success."""
|
||||
if not bearer:
|
||||
return None
|
||||
bearer_hash = _hash_bearer(bearer)
|
||||
device = (
|
||||
db.query(PairedDevice)
|
||||
.filter(PairedDevice.bearer_hash == bearer_hash, PairedDevice.revoked.is_(False))
|
||||
.first()
|
||||
)
|
||||
if device is not None:
|
||||
device.last_seen_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return device
|
||||
|
||||
|
||||
def list_devices(db: Session) -> list[PairedDeviceResponse]:
|
||||
"""Return all paired devices (revoked included) for the desktop UI."""
|
||||
rows = db.query(PairedDevice).order_by(PairedDevice.created_at.desc()).all()
|
||||
return [PairedDeviceResponse.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
def revoke_device(db: Session, device_id: str) -> None:
|
||||
"""Mark a device as revoked. Idempotent on repeat calls."""
|
||||
device = db.query(PairedDevice).filter(PairedDevice.id == device_id).first()
|
||||
if device is None:
|
||||
raise ValueError("Device not found")
|
||||
device.revoked = True
|
||||
db.commit()
|
||||
logger.info("Revoked device id=%s name=%s", device.id, device.name)
|
||||
|
||||
|
||||
# --- Host discovery ---------------------------------------------------------
|
||||
|
||||
def _get_lan_ip() -> Optional[str]:
|
||||
"""Best-effort outbound IPv4 of the host. Uses the UDP-connect trick —
|
||||
no packets are actually sent; the kernel just picks the source IP it
|
||||
would use to reach 8.8.8.8.
|
||||
"""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_tailscale_ip() -> Optional[str]:
|
||||
"""Return the host's Tailscale 100.x address if Tailscale is installed
|
||||
and reports one. Returns ``None`` on any failure — Tailscale is optional.
|
||||
|
||||
Tries ``tailscale`` on PATH first, then falls back to the Mac App Store
|
||||
install location (the macOS App bundle doesn't symlink onto PATH by
|
||||
default, only into the user's shell init via an alias subprocess can't see).
|
||||
"""
|
||||
candidate_binaries = [
|
||||
"tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
]
|
||||
for binary in candidate_binaries:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[binary, "ip", "--4"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
if result.returncode != 0:
|
||||
continue
|
||||
ip = result.stdout.strip().splitlines()[0].strip() if result.stdout else ""
|
||||
if ip:
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
def list_host_candidates(port: int) -> list[HostCandidate]:
|
||||
"""Return suggested host strings the desktop can embed in the QR.
|
||||
|
||||
Order matters — the UI defaults to the first non-loopback entry.
|
||||
"""
|
||||
candidates: list[HostCandidate] = []
|
||||
lan_ip = _get_lan_ip()
|
||||
if lan_ip and not lan_ip.startswith("127."):
|
||||
candidates.append(
|
||||
HostCandidate(address=f"{lan_ip}:{port}", label="Local network", kind="lan")
|
||||
)
|
||||
tailscale_ip = _get_tailscale_ip()
|
||||
if tailscale_ip and tailscale_ip != lan_ip:
|
||||
candidates.append(
|
||||
HostCandidate(address=f"{tailscale_ip}:{port}", label="Tailscale", kind="tailscale")
|
||||
)
|
||||
candidates.append(
|
||||
HostCandidate(address=f"127.0.0.1:{port}", label="Loopback (testing only)", kind="loopback")
|
||||
)
|
||||
return candidates
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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"},
|
||||
)
|
||||
Reference in New Issue
Block a user