Add admin-only companion pairing

Split 3/4 of the companion bridge (#863, #871 landed 1/4 and 2/4). Adds admin-only
device pairing to the companion router.

- GET  /api/companion/pair  -- renders a form; never mints (a GET must not mint a
  credential: SameSite=Lax session cookies ride top-level GET navigations, so
  GET-minting would be CSRF-triggerable via a link/<img>)
- POST /api/companion/pair  -- mints a one-time chat-scoped token. Admin-cookie
  only; CSRF-safe because a SameSite=Lax cookie is not sent on a cross-site POST,
  the same protection POST /api/tokens relies on. ?format=json returns the
  pairing payload for an in-app screen.

Minting invalidates the auth middleware's token cache so the code works on the
next request with no restart. companion/pairing.py holds the mint/LAN/QR helpers;
the token is shown once and stored only as a bcrypt hash + prefix
(mirrors routes/api_token_routes.py).

Tests (tests/test_companion_pairing.py):
- a bearer/'api' caller and a non-admin user are rejected by require_admin (403);
  an admin passes
- the token is returned once and persisted only as a hash
- minting invalidates the cache (works without restart)
- minting is exposed on POST, never GET (CSRF)
This commit is contained in:
Mahdi Salmanzade
2026-06-02 07:43:50 +04:00
committed by GitHub
parent 19a4f823a4
commit 05fb48e9d5
5 changed files with 424 additions and 27 deletions

View File

@@ -1,20 +1,28 @@
# Companion bridge (read-only)
# Companion bridge
A thin, additive layer so a LAN client can discover what an Odysseus server
offers, without duplicating any LLM logic. Reachable with either a logged-in
cookie session or a Bearer `ody_` API token (auth is enforced globally by
`AuthMiddleware`).
A thin, additive layer so a LAN client (e.g. a phone) can discover what an
Odysseus server offers and pair to it, without duplicating any LLM logic.
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/companion/ping` | cheap, auth-validated health check |
| GET | `/api/companion/info` | server identity + capability flags |
| GET | `/api/companion/models` | the **caller's own** model endpoints |
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/api/companion/ping` | session or token | cheap, auth-validated health check |
| GET | `/api/companion/info` | session or token | server identity + capability flags |
| GET | `/api/companion/models` | session or token | the **caller's own** model endpoints |
| GET | `/api/companion/pair` | **admin cookie** | pairing page (a form; never mints) |
| POST | `/api/companion/pair` | **admin cookie** | mint a one-time pairing token (`?format=json` for an in-app screen) |
`/models` scopes to the caller's real owner (the token's owner for bearer
callers) plus legacy null-owner shared rows, the same rule as `owner_filter`. It
never returns API-key material. The owner rule lives in two pure, tested helpers
(`token_owner`, `owner_can_see`) — see `tests/test_companion_readonly.py`.
`/models` scopes to the caller's real owner plus legacy null-owner shared rows
(same rule as `owner_filter`) and never returns API-key material.
This module is intentionally read-only. Pairing/token-minting, token-owner
session attribution, and any mutation endpoints are proposed in separate PRs.
## Pairing CSRF posture
Minting happens **only on POST**. The session cookie is `SameSite=Lax`
(`routes/auth_routes.py`), so a browser will not send it on a cross-site POST —
the same protection `POST /api/tokens` relies on. A `GET` would be unsafe (Lax
cookies ride top-level GET navigations), so `GET /pair` only renders a form.
Minting invalidates the auth middleware's token cache, so a freshly minted token
works on the next request without a restart.
The pairing/scoping rules live in small, tested units (`token_owner`,
`owner_can_see`, `mint_pairing_token`, `pairing.*`) — see
`tests/test_companion_readonly.py` and `tests/test_companion_pairing.py`.

View File

@@ -1,8 +1,9 @@
"""Odysseus companion bridge — additive, read-only LAN endpoints.
"""Odysseus companion bridge — additive LAN endpoints.
Exposes /api/companion/ping, /info, and an owner-scoped /models so a LAN client
can discover what a server offers. No new LLM logic; auth is enforced by the
existing AuthMiddleware. See companion/README.md.
Read endpoints (/api/companion/ping, /info, owner-scoped /models) so a LAN
client can discover what a server offers, plus admin-only pairing
(/api/companion/pair) that mints a one-time chat-scoped token on POST. No new LLM
logic; auth is enforced by the existing AuthMiddleware. See companion/README.md.
"""
from companion.routes import setup_companion_routes

121
companion/pairing.py Normal file
View File

@@ -0,0 +1,121 @@
"""Shared pairing helpers for the companion bridge.
Token minting + LAN discovery + QR rendering, kept here as small, importable
units so the route layer stays thin and the logic is directly testable.
"""
from __future__ import annotations
import json
import os
import secrets
import socket
import uuid
import bcrypt
PAIRING_VERSION = 1
COMPANION_SCOPE = "chat"
def default_port() -> int:
"""Best guess at the port the server is reachable on. Callers that know the
real request port should pass it explicitly."""
try:
return int(os.environ.get("APP_PORT", "7000"))
except ValueError:
return 7000
def lan_ip_candidates() -> list[str]:
"""Likely LAN IPv4 addresses for this host, best candidate first.
The UDP-connect trick reveals the egress interface the OS would use to reach
the default gateway -- i.e. the address a phone on the same Wi-Fi should
target. No packets are actually sent. Loopback is dropped.
"""
candidates: list[str] = []
def _add(ip):
if ip and ip not in candidates and not ip.startswith("127."):
candidates.append(ip)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
_add(s.getsockname()[0])
except OSError:
pass
finally:
s.close()
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
_add(info[4][0])
except OSError:
pass
return candidates
def find_admin_user() -> str | None:
"""Resolve an admin username from data/auth.json (schema uses is_admin),
falling back to the first user."""
auth_path = os.path.join("data", "auth.json")
try:
with open(auth_path, "r", encoding="utf-8") as f:
users = (json.load(f) or {}).get("users", {})
except (OSError, json.JSONDecodeError):
return None
for uname, udata in users.items():
if udata.get("is_admin") is True:
return uname
return next(iter(users), None)
def mint_token(owner: str, name: str = "companion") -> tuple[str, str]:
"""Create a chat-scoped API token row and return (token_id, raw_token).
The raw token is returned ONCE -- only its bcrypt hash + an 8-char prefix
are persisted. Mirrors routes/api_token_routes.py so cookie- and
companion-minted tokens are indistinguishable to the auth middleware.
"""
from core.database import get_db_session, ApiToken
raw_token = "ody_" + secrets.token_urlsafe(32)
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
token_id = str(uuid.uuid4())[:8]
with get_db_session() as db:
db.add(ApiToken(
id=token_id,
owner=owner,
name=name,
token_hash=token_hash,
token_prefix=raw_token[:8],
scopes=COMPANION_SCOPE,
is_active=True,
))
return token_id, raw_token
def pairing_payload(host: str, port: int, token: str) -> dict:
"""The exact JSON a client scans / accepts. Keep keys stable."""
return {"v": PAIRING_VERSION, "host": host, "port": port, "token": token}
def pairing_qr_png_data_uri(payload: dict) -> str | None:
"""Render the pairing payload as a QR `data:` URI for an <img>. Returns None
if the optional qrcode dep is unavailable."""
try:
import base64
import io
import qrcode
img = qrcode.make(json.dumps(payload, separators=(",", ":")))
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None

View File

@@ -1,19 +1,30 @@
"""Companion bridge — read-only endpoints (/api/companion/*).
"""Companion bridge — /api/companion/*.
A thin, additive layer so a LAN client (e.g. a phone) can discover what a server
offers without duplicating any LLM logic. This module is intentionally
read-only: it exposes a cheap health check, server identity, and the caller's
own model list. Pairing/token-minting and any mutation live in separate changes.
offers and pair to it, without duplicating any LLM logic.
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
means the caller is authenticated by either a cookie session or a Bearer `ody_`
API token.
API token. The read endpoints (ping/info/models) accept either; the pairing
endpoints are admin-cookie only.
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
cross-site POST, so an admin's cookie can't be used by a malicious page to mint
a token -- the same protection the existing POST /api/tokens relies on. Minting
on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
/pair only renders a form.
"""
import html
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from src.auth_helpers import get_current_user
from companion import pairing as _pairing
def token_owner(request: Request) -> str | None:
"""The real owner to attribute a request to, for read-scoping.
@@ -40,6 +51,20 @@ def owner_can_see(row_owner, owner) -> bool:
return row_owner is None or row_owner == owner
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
cache, so the new token is accepted on the very next request without a server
restart. Returns (token_id, raw_token); the raw token is shown once.
`invalidate` is the app's request.app.state.invalidate_token_cache callable
(passed in so this stays a pure, testable unit).
"""
token_id, raw_token = _pairing.mint_token(owner)
if callable(invalidate):
invalidate()
return token_id, raw_token
def setup_companion_routes() -> APIRouter:
router = APIRouter(prefix="/api/companion", tags=["companion"])
@@ -93,8 +118,6 @@ def setup_companion_routes() -> APIRouter:
if owner:
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
for ep in q.all():
# Defence in depth: never emit a row the owner rule rejects, even
# if the SQL filter above were ever loosened.
if not owner_can_see(ep.owner, owner):
continue
try:
@@ -121,4 +144,92 @@ def setup_companion_routes() -> APIRouter:
db.close()
return {"endpoints": out}
@router.get("/pair")
def pair_page(request: Request):
"""Admin-only pairing page. Renders a form that POSTs to mint a code.
A GET never mints a credential: SameSite=Lax session cookies ride
top-level GET navigations, so minting on GET would be triggerable by a
link or <img> (CSRF). The actual mint is the POST handler below.
"""
require_admin(request)
page = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pair a device</title>
<style>
body{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:48px auto;padding:0 20px;color:#e8e8e8;background:#16161a}
.card{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:28px;text-align:center}
button{background:#7c9cff;color:#0e0e12;border:none;border-radius:10px;padding:12px 20px;font-size:15px;font-weight:600;cursor:pointer}
</style></head>
<body><div class="card">
<h2>Pair a device</h2>
<p>Generate a one-time pairing code (a chat-scoped API token) for a LAN client.</p>
<form method="POST" action="/api/companion/pair">
<button type="submit">Generate pairing code</button>
</form>
<p style="color:#8a8a96;font-size:12px;margin-top:18px">Admin only. Each code mints a new token, shown once. Manage or revoke under Settings &rarr; API tokens.</p>
</div></body></html>"""
return HTMLResponse(page)
@router.post("/pair")
def pair_create(request: Request):
"""Mint a pairing code. Admin-cookie only; CSRF-safe because the
SameSite=Lax session cookie is not sent on a cross-site POST (same
protection as POST /api/tokens). Minting invalidates the token cache so
the code works immediately, no restart. `?format=json` returns the
payload for an in-app pairing screen."""
require_admin(request)
owner = get_current_user(request)
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
token_id, raw_token = mint_pairing_token(owner, invalidate)
hosts = _pairing.lan_ip_candidates()
host = hosts[0] if hosts else "127.0.0.1"
port = request.url.port or _pairing.default_port()
payload = _pairing.pairing_payload(host, port, raw_token)
qr = _pairing.pairing_qr_png_data_uri(payload)
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
if (request.query_params.get("format") or "").lower() == "json":
return {
"host": host,
"port": port,
"token": raw_token,
"token_id": token_id,
"hosts": hosts,
"payload": payload,
"qr": qr if qr_ok else None,
}
import json as _json
payload_json = _json.dumps(payload, separators=(",", ":"))
# Only ever emit a known PNG data-URI into the src; every other value is
# html.escaped.
qr_block = (
f'<img src="{html.escape(qr)}" alt="Pairing QR" width="260" height="260">'
if qr_ok else "<p><em>QR rendering unavailable -- enter the details manually.</em></p>"
)
page = f"""<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pairing code</title>
<style>
body{{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:40px auto;padding:0 20px;color:#e8e8e8;background:#16161a}}
.card{{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:24px;text-align:center}}
code{{background:#0e0e12;padding:2px 6px;border-radius:6px;word-break:break-all}}
.row{{text-align:left;margin:10px 0;font-size:14px;color:#bdbdc7}}
.warn{{color:#e0a85e;font-size:13px;margin-top:18px}}
</style></head>
<body><div class="card">
<h2>Pairing code</h2>
{qr_block}
<div class="row"><strong>Host:</strong> <code>{html.escape(host)}</code></div>
<div class="row"><strong>Port:</strong> <code>{html.escape(str(port))}</code></div>
<div class="row"><strong>Token:</strong> <code>{html.escape(raw_token)}</code></div>
<div class="row"><strong>Payload:</strong> <code>{html.escape(payload_json)}</code></div>
<p class="warn">Shown once. This grants chat access to your Odysseus; revoke it
in Settings &rarr; API tokens (id <code>{html.escape(token_id)}</code>). The
device must be on the same network, and the server must bind to your LAN.</p>
</div></body></html>"""
return HTMLResponse(page)
return router

View File

@@ -0,0 +1,156 @@
"""Tests for the companion pairing endpoints (split 3/4).
Covers what the review asked for:
- a non-admin / bearer caller cannot call /api/companion/pair (admin-only)
- the pairing token is minted once (hashed at rest) and the mint invalidates
the auth cache so it works immediately, no restart
- minting is a POST, never a GET (CSRF: a SameSite=Lax cookie rides a
top-level GET, so GET-minting would be triggerable by a link / <img>)
"""
import contextlib
import os
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Capture what mint_token would persist, via a stubbed core.database.
_CAPTURED = {}
class _ApiToken:
def __init__(self, **kw):
_CAPTURED.clear()
_CAPTURED.update(kw)
self.__dict__.update(kw)
@contextlib.contextmanager
def _get_db_session():
yield MagicMock()
# core/__init__ pulls in models/session_manager which import many ORM names from
# core.database; under conftest's sqlalchemy stubs the real module can't load.
# A __getattr__ module resolves ANY requested name to a MagicMock, while keeping
# our real get_db_session/ApiToken for the mint test.
class _DBStub(types.ModuleType):
def __getattr__(self, name): # noqa: D401
return MagicMock()
_db = _DBStub("core.database")
_db.get_db_session = _get_db_session
_db.ApiToken = _ApiToken
sys.modules["core.database"] = _db # overwrite any minimal stub from a sibling test
for _name, _attrs in {
"core.auth": {"AuthManager": MagicMock()},
"src.endpoint_resolver": {"build_chat_url": (lambda u: u)},
}.items():
if _name not in sys.modules:
_mm = types.ModuleType(_name)
for _k, _v in _attrs.items():
setattr(_mm, _k, _v)
sys.modules[_name] = _mm
from fastapi import HTTPException # noqa: E402
import companion.pairing as P # noqa: E402
from companion.routes import mint_pairing_token, setup_companion_routes # noqa: E402
from core.middleware import require_admin # noqa: E402
# --- token minting: shown once, hashed at rest -----------------------------
def test_mint_token_returns_raw_once_and_stores_only_a_hash():
token_id, raw = P.mint_token("alice")
assert raw.startswith("ody_")
# The persisted row stores a bcrypt hash + prefix, never the plaintext.
assert _CAPTURED["token_hash"] != raw
assert _CAPTURED["token_hash"].startswith("$2") # bcrypt
assert _CAPTURED["token_prefix"] == raw[:8]
assert _CAPTURED["owner"] == "alice"
assert _CAPTURED["scopes"] == "chat"
assert _CAPTURED["is_active"] is True
def test_mint_pairing_token_invalidates_cache(monkeypatch):
# The mint must flip the auth middleware's cache so the token works on the
# very next request, with no restart.
monkeypatch.setattr(P, "mint_token", lambda owner, name="companion": ("id1", "ody_demo"))
invalidate = MagicMock()
token_id, raw = mint_pairing_token("alice", invalidate)
assert (token_id, raw) == ("id1", "ody_demo")
invalidate.assert_called_once()
def test_mint_pairing_token_tolerates_no_invalidator(monkeypatch):
monkeypatch.setattr(P, "mint_token", lambda owner, name="companion": ("id1", "ody_demo"))
# Must not blow up if the app didn't expose an invalidator.
assert mint_pairing_token("alice", None) == ("id1", "ody_demo")
def test_pairing_payload_shape():
p = P.pairing_payload("192.168.1.9", 7000, "ody_x")
assert p == {"v": 1, "host": "192.168.1.9", "port": 7000, "token": "ody_x"}
# --- admin-only gate: a bearer/non-admin caller is rejected ----------------
def _admin_mgr(is_admin):
return SimpleNamespace(is_admin=lambda u: is_admin, is_configured=True)
def _req(current_user, *, api_token=False, is_admin=False):
return SimpleNamespace(
state=SimpleNamespace(current_user=current_user, api_token=api_token),
headers={},
app=SimpleNamespace(state=SimpleNamespace(auth_manager=_admin_mgr(is_admin))),
)
def test_bearer_token_caller_cannot_pair(monkeypatch):
# Bearer callers come through as the "api" pseudo-user, which is not admin.
monkeypatch.setenv("AUTH_ENABLED", "true")
with pytest.raises(HTTPException) as exc:
require_admin(_req("api", api_token=True, is_admin=False))
assert exc.value.status_code == 403
def test_non_admin_user_cannot_pair(monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "true")
with pytest.raises(HTTPException) as exc:
require_admin(_req("bob", is_admin=False))
assert exc.value.status_code == 403
def test_admin_user_passes_the_gate(monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "true")
# Should not raise.
require_admin(_req("alice", is_admin=True))
# --- CSRF: minting is POST, never GET --------------------------------------
def _pair_methods():
router = setup_companion_routes()
methods = set()
for r in router.routes:
path = getattr(r, "path", "")
if path.endswith("/pair"):
methods |= set(getattr(r, "methods", set()) or set())
return methods
def test_pair_is_minted_via_post_not_get():
methods = _pair_methods()
assert "POST" in methods, "pairing must accept POST (the mint)"
assert "GET" in methods, "GET should render the form page"
# The distinction is enforced in the handlers: GET renders a form and never
# mints; only POST calls mint_pairing_token.