mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
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:
+24
-16
@@ -1,20 +1,28 @@
|
|||||||
# Companion bridge (read-only)
|
# Companion bridge
|
||||||
|
|
||||||
A thin, additive layer so a LAN client can discover what an Odysseus server
|
A thin, additive layer so a LAN client (e.g. a phone) can discover what an
|
||||||
offers, without duplicating any LLM logic. Reachable with either a logged-in
|
Odysseus server offers and pair to it, without duplicating any LLM logic.
|
||||||
cookie session or a Bearer `ody_` API token (auth is enforced globally by
|
|
||||||
`AuthMiddleware`).
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Auth | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/companion/ping` | cheap, auth-validated health check |
|
| GET | `/api/companion/ping` | session or token | cheap, auth-validated health check |
|
||||||
| GET | `/api/companion/info` | server identity + capability flags |
|
| GET | `/api/companion/info` | session or token | server identity + capability flags |
|
||||||
| GET | `/api/companion/models` | the **caller's own** model endpoints |
|
| 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
|
`/models` scopes to the caller's real owner plus legacy null-owner shared rows
|
||||||
callers) plus legacy null-owner shared rows, the same rule as `owner_filter`. It
|
(same rule as `owner_filter`) and never returns API-key material.
|
||||||
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`.
|
|
||||||
|
|
||||||
This module is intentionally read-only. Pairing/token-minting, token-owner
|
## Pairing CSRF posture
|
||||||
session attribution, and any mutation endpoints are proposed in separate PRs.
|
|
||||||
|
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`.
|
||||||
|
|||||||
@@ -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
|
Read endpoints (/api/companion/ping, /info, owner-scoped /models) so a LAN
|
||||||
can discover what a server offers. No new LLM logic; auth is enforced by the
|
client can discover what a server offers, plus admin-only pairing
|
||||||
existing AuthMiddleware. See companion/README.md.
|
(/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
|
from companion.routes import setup_companion_routes
|
||||||
|
|||||||
@@ -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
|
||||||
+118
-7
@@ -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
|
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
|
offers and pair to it, without duplicating any LLM logic.
|
||||||
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.
|
|
||||||
|
|
||||||
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
|
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_`
|
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 import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
|
||||||
|
from companion import pairing as _pairing
|
||||||
|
|
||||||
|
|
||||||
def token_owner(request: Request) -> str | None:
|
def token_owner(request: Request) -> str | None:
|
||||||
"""The real owner to attribute a request to, for read-scoping.
|
"""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
|
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:
|
def setup_companion_routes() -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/companion", tags=["companion"])
|
router = APIRouter(prefix="/api/companion", tags=["companion"])
|
||||||
|
|
||||||
@@ -93,8 +118,6 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
if owner:
|
if owner:
|
||||||
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
||||||
for ep in q.all():
|
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):
|
if not owner_can_see(ep.owner, owner):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -121,4 +144,92 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
db.close()
|
db.close()
|
||||||
return {"endpoints": out}
|
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 → 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 → 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
|
return router
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user