fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
519 lines
18 KiB
Python
519 lines
18 KiB
Python
"""Pairing flow (RFC 8628 device authorization grant)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from flask import jsonify, make_response, request
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from application.api.devices.auth import fingerprint_pubkey, hash_session_token
|
|
from application.cache import get_redis_instance
|
|
from application.core.settings import settings
|
|
from application.storage.db.repositories.devices import DevicesRepository
|
|
from application.storage.db.repositories.user_tools import UserToolsRepository
|
|
from application.storage.db.session import db_session
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
_USER_CODE_LEN = 8 # ABCD-WXYZ (8 chars, displayed with a single dash)
|
|
_PAIRING_REDIS_PREFIX = "docsgpt:device_pairing:"
|
|
_ALLOWED_APPROVAL_MODES = {"ask", "full"}
|
|
# Attempts to find a non-colliding device name before releasing the claim.
|
|
_DEVICE_NAME_RETRIES = 5
|
|
|
|
# Atomic redeem claim: read the pairing JSON, and only if its ``status`` is
|
|
# ``pending`` flip it to ``redeemed`` and write it back (TTL preserved).
|
|
# Returns 1 if this caller won the claim, 0 otherwise (missing/not pending).
|
|
# Run server-side so two concurrent redeems of one code can't both win.
|
|
_CLAIM_PAIRING_LUA = """
|
|
local raw = redis.call('GET', KEYS[1])
|
|
if not raw then
|
|
return 0
|
|
end
|
|
local ok, state = pcall(cjson.decode, raw)
|
|
if not ok then
|
|
return 0
|
|
end
|
|
if state['status'] ~= 'pending' then
|
|
return 0
|
|
end
|
|
state['status'] = 'redeemed'
|
|
local ttl = redis.call('TTL', KEYS[1])
|
|
local encoded = cjson.encode(state)
|
|
if ttl and ttl > 0 then
|
|
redis.call('SET', KEYS[1], encoded, 'EX', ttl)
|
|
else
|
|
redis.call('SET', KEYS[1], encoded)
|
|
end
|
|
return 1
|
|
"""
|
|
|
|
|
|
def _redis():
|
|
return get_redis_instance()
|
|
|
|
|
|
def _generate_user_code() -> str:
|
|
return "".join(secrets.choice(_USER_CODE_ALPHABET) for _ in range(_USER_CODE_LEN))
|
|
|
|
|
|
def _format_user_code(code: str) -> str:
|
|
return f"{code[:4]}-{code[4:]}"
|
|
|
|
|
|
def _normalize_user_code(code: str) -> str:
|
|
return code.replace("-", "").replace(" ", "").upper()
|
|
|
|
|
|
def _user_code_index_key(user_code: str) -> str:
|
|
return f"docsgpt:device_pairing_code:{user_code}"
|
|
|
|
|
|
def create_pairing() -> tuple:
|
|
"""Create a new pairing code for the calling user.
|
|
|
|
Optional body fields ``name``, ``description``, and ``approval_mode``
|
|
are stashed in Redis alongside the pairing state and consumed by
|
|
``redeem_pairing`` when the device row is created. Unknown values
|
|
fall back to: name -> CLI hostname; description -> empty; approval_mode
|
|
-> ``ask``.
|
|
"""
|
|
decoded = getattr(request, "decoded_token", None)
|
|
user_id = decoded.get("sub") if isinstance(decoded, dict) else None
|
|
if not user_id:
|
|
return _error("authentication_required", 401)
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
raw_name = body.get("name")
|
|
if raw_name is not None and not isinstance(raw_name, str):
|
|
return _error("invalid_name", 400)
|
|
requested_name = (raw_name or "").strip() or None
|
|
requested_description = body.get("description")
|
|
if isinstance(requested_description, str):
|
|
requested_description = requested_description.strip() or None
|
|
else:
|
|
requested_description = None
|
|
requested_mode = body.get("approval_mode")
|
|
if requested_mode is not None and requested_mode not in _ALLOWED_APPROVAL_MODES:
|
|
return _error("invalid_approval_mode", 400)
|
|
|
|
redis_client = _redis()
|
|
if redis_client is None:
|
|
return _error("redis_unavailable", 503)
|
|
|
|
device_code = f"dc_{uuid.uuid4().hex}"
|
|
user_code = _generate_user_code()
|
|
ttl = int(settings.REMOTE_DEVICE_PAIRING_TTL_SECONDS)
|
|
|
|
state = {
|
|
"device_code": device_code,
|
|
"user_code": _format_user_code(user_code),
|
|
"user_code_raw": user_code,
|
|
"user_id": user_id,
|
|
"status": "pending",
|
|
"created_at": int(time.time()),
|
|
"requested_name": requested_name,
|
|
"requested_description": requested_description,
|
|
"requested_approval_mode": requested_mode,
|
|
}
|
|
try:
|
|
redis_client.setex(
|
|
_PAIRING_REDIS_PREFIX + device_code, ttl, json.dumps(state)
|
|
)
|
|
redis_client.setex(_user_code_index_key(user_code), ttl, device_code)
|
|
except Exception:
|
|
logger.exception("redis setex failed during pairing create")
|
|
return _error("redis_unavailable", 503)
|
|
|
|
base_url = settings.API_URL or ""
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"device_code": device_code,
|
|
"user_code": _format_user_code(user_code),
|
|
"verification_uri": base_url,
|
|
"expires_in": ttl,
|
|
"interval": 3,
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
def get_pairing(device_code: str) -> tuple:
|
|
"""Poll pairing status (UI side)."""
|
|
decoded = getattr(request, "decoded_token", None)
|
|
user_id = decoded.get("sub") if isinstance(decoded, dict) else None
|
|
if not user_id:
|
|
return _error("authentication_required", 401)
|
|
|
|
state = _load_pairing(device_code)
|
|
if state is None:
|
|
return _error("pairing_not_found", 404)
|
|
if state.get("user_id") != user_id:
|
|
return _error("pairing_not_found", 404)
|
|
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"device_code": state["device_code"],
|
|
"user_code": state.get("user_code"),
|
|
"status": state.get("status", "pending"),
|
|
"device_id": state.get("device_id"),
|
|
"device_name": state.get("device_name"),
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
def delete_pairing(device_code: str) -> tuple:
|
|
decoded = getattr(request, "decoded_token", None)
|
|
user_id = decoded.get("sub") if isinstance(decoded, dict) else None
|
|
if not user_id:
|
|
return _error("authentication_required", 401)
|
|
state = _load_pairing(device_code)
|
|
if state is None:
|
|
return _error("pairing_not_found", 404)
|
|
if state.get("user_id") != user_id:
|
|
return _error("pairing_not_found", 404)
|
|
redis_client = _redis()
|
|
if redis_client is None:
|
|
return _error("redis_unavailable", 503)
|
|
try:
|
|
redis_client.delete(_PAIRING_REDIS_PREFIX + device_code)
|
|
if state.get("user_code_raw"):
|
|
redis_client.delete(_user_code_index_key(state["user_code_raw"]))
|
|
except Exception:
|
|
logger.exception("redis delete failed during pairing cancel")
|
|
return make_response(jsonify({"success": True}), 200)
|
|
|
|
|
|
def redeem_pairing() -> tuple:
|
|
"""CLI submits the user_code and machine info to claim a device row.
|
|
|
|
Returns ``device_id`` + opaque ``session_token`` (shown once). The
|
|
token is stored only as ``token_hash`` so a Redis snapshot leak +
|
|
DB snapshot leak still can't reconstruct it.
|
|
"""
|
|
body = request.get_json(silent=True) or {}
|
|
user_code_raw = _normalize_user_code(str(body.get("user_code", "")))
|
|
if not user_code_raw or len(user_code_raw) != _USER_CODE_LEN:
|
|
return _error("invalid_user_code", 400)
|
|
hostname = body.get("hostname") or "unknown"
|
|
os_name = body.get("os") or ""
|
|
arch = body.get("arch") or ""
|
|
cli_version = body.get("cli_version") or ""
|
|
pubkey_b64 = body.get("machine_pubkey")
|
|
if not pubkey_b64:
|
|
return _error("missing_machine_pubkey", 400)
|
|
|
|
redis_client = _redis()
|
|
if redis_client is None:
|
|
return _error("redis_unavailable", 503)
|
|
try:
|
|
device_code = redis_client.get(_user_code_index_key(user_code_raw))
|
|
if device_code is None:
|
|
return _error("pairing_not_found", 404)
|
|
if isinstance(device_code, (bytes, bytearray)):
|
|
device_code = device_code.decode("utf-8")
|
|
except Exception:
|
|
logger.exception("redis get failed during pairing redeem")
|
|
return _error("redis_unavailable", 503)
|
|
|
|
state = _load_pairing(device_code)
|
|
if state is None:
|
|
return _error("pairing_not_found", 404)
|
|
if state.get("status") != "pending":
|
|
return _error("pairing_already_redeemed", 409)
|
|
|
|
# Atomically claim the pairing before doing any work. Two concurrent
|
|
# redeems of the same code can both pass the check above; only the one
|
|
# that flips ``pending`` -> ``redeemed`` here proceeds to create a device.
|
|
claimed = _claim_pairing(redis_client, device_code)
|
|
if claimed is None:
|
|
return _error("redis_unavailable", 503)
|
|
if not claimed:
|
|
return _error("pairing_already_redeemed", 409)
|
|
|
|
user_id = state["user_id"]
|
|
|
|
try:
|
|
fingerprint = fingerprint_pubkey(pubkey_b64)
|
|
except Exception:
|
|
return _error("invalid_machine_pubkey", 400)
|
|
session_token = "tok_" + secrets.token_urlsafe(32)
|
|
token_hash = hash_session_token(session_token)
|
|
device_id = "dev_" + uuid.uuid4().hex
|
|
|
|
# Apply UI-side overrides stashed at pairing create time, with
|
|
# sensible fallbacks.
|
|
requested_name = state.get("requested_name") or None
|
|
description = state.get("requested_description") or None
|
|
approval_mode = state.get("requested_approval_mode") or "ask"
|
|
if approval_mode not in _ALLOWED_APPROVAL_MODES:
|
|
approval_mode = "ask"
|
|
|
|
name_source = requested_name or hostname
|
|
# The claim has already been consumed, so a failed device insert would
|
|
# strand a one-time code. ``_next_device_name`` only adds a 16-bit
|
|
# suffix, so a ``UNIQUE (user_id, name)`` collision is possible; retry
|
|
# with a fresh name a few times, and release the claim if every attempt
|
|
# collides so the user can redeem the code again.
|
|
name = None
|
|
created = False
|
|
for _attempt in range(_DEVICE_NAME_RETRIES):
|
|
candidate = _next_device_name(user_id, name_source)
|
|
try:
|
|
with db_session() as conn:
|
|
DevicesRepository(conn).create(
|
|
device_id=device_id,
|
|
user_id=user_id,
|
|
name=candidate,
|
|
machine_pubkey_fingerprint=fingerprint,
|
|
token_hash=token_hash,
|
|
hostname=hostname,
|
|
os=os_name,
|
|
arch=arch,
|
|
cli_version=cli_version,
|
|
approval_mode=approval_mode,
|
|
description=description,
|
|
)
|
|
_upsert_remote_device_user_tool(
|
|
conn, user_id=user_id, device_id=device_id,
|
|
device_name=candidate, description=description,
|
|
)
|
|
name = candidate
|
|
created = True
|
|
break
|
|
except IntegrityError:
|
|
# Name collision on this attempt; the transaction rolled back.
|
|
# Loop to try a freshly-generated name.
|
|
logger.warning(
|
|
"device name collision on redeem (attempt %d); retrying",
|
|
_attempt + 1,
|
|
)
|
|
continue
|
|
except Exception:
|
|
logger.exception("failed to create device row during redeem")
|
|
_release_claim(redis_client, device_code, state, user_code_raw)
|
|
return _error("internal_error", 500)
|
|
|
|
if not created:
|
|
logger.error(
|
|
"device create exhausted name retries during redeem; "
|
|
"releasing claim",
|
|
)
|
|
_release_claim(redis_client, device_code, state, user_code_raw)
|
|
return _error("internal_error", 500)
|
|
|
|
state["status"] = "redeemed"
|
|
state["device_id"] = device_id
|
|
state["device_name"] = name
|
|
try:
|
|
redis_client.setex(
|
|
_PAIRING_REDIS_PREFIX + device_code, 300, json.dumps(state)
|
|
)
|
|
if state.get("user_code_raw"):
|
|
redis_client.delete(_user_code_index_key(state["user_code_raw"]))
|
|
except Exception:
|
|
logger.exception("redis update failed after redeem (non-fatal)")
|
|
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"device_id": device_id,
|
|
"session_token": session_token,
|
|
"name": name,
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
def _claim_pairing(redis_client, device_code: str) -> Optional[bool]:
|
|
"""Atomically transition a pairing from ``pending`` to ``redeemed``.
|
|
|
|
Returns ``True`` if this caller won the claim, ``False`` if the pairing
|
|
is already non-pending or gone, and ``None`` if the atomic op itself
|
|
failed (caller should treat that as Redis-unavailable rather than
|
|
proceeding, to avoid a non-atomic double-redeem).
|
|
"""
|
|
key = _PAIRING_REDIS_PREFIX + device_code
|
|
try:
|
|
result = redis_client.eval(_CLAIM_PAIRING_LUA, 1, key)
|
|
except Exception:
|
|
logger.exception("redis eval failed during pairing claim")
|
|
return None
|
|
return bool(result)
|
|
|
|
|
|
def _release_claim(
|
|
redis_client, device_code: str, state: dict, user_code_raw: str,
|
|
) -> None:
|
|
"""Revert a consumed claim so the one-time code is redeemable again.
|
|
|
|
Called when the device row can't be created after the atomic claim
|
|
already flipped the pairing to ``redeemed``. Restores ``pending`` and
|
|
re-points the user-code index at the device code, preserving the
|
|
remaining TTL where possible. Best-effort: failures are logged, not
|
|
raised, since the caller is already returning an error.
|
|
"""
|
|
key = _PAIRING_REDIS_PREFIX + device_code
|
|
try:
|
|
ttl = redis_client.ttl(key)
|
|
revert = dict(state)
|
|
revert["status"] = "pending"
|
|
revert.pop("device_id", None)
|
|
revert.pop("device_name", None)
|
|
encoded = json.dumps(revert)
|
|
if isinstance(ttl, int) and ttl > 0:
|
|
redis_client.setex(key, ttl, encoded)
|
|
if user_code_raw:
|
|
redis_client.setex(
|
|
_user_code_index_key(user_code_raw), ttl, device_code
|
|
)
|
|
else:
|
|
redis_client.set(key, encoded)
|
|
if user_code_raw:
|
|
redis_client.set(_user_code_index_key(user_code_raw), device_code)
|
|
except Exception:
|
|
logger.exception("failed to release pairing claim for %s", device_code)
|
|
|
|
|
|
def _load_pairing(device_code: str) -> Optional[dict]:
|
|
redis_client = _redis()
|
|
if redis_client is None:
|
|
return None
|
|
try:
|
|
raw = redis_client.get(_PAIRING_REDIS_PREFIX + device_code)
|
|
except Exception:
|
|
return None
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, (bytes, bytearray)):
|
|
raw = raw.decode("utf-8")
|
|
try:
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _next_device_name(user_id: str, hostname: str) -> str:
|
|
"""Return a candidate name for the new device row.
|
|
|
|
Adds a random 16-bit suffix to reduce collisions on the DB
|
|
``UNIQUE (user_id, name)`` constraint. Collisions are still possible,
|
|
so ``redeem_pairing`` retries with a fresh candidate on conflict.
|
|
"""
|
|
base = hostname or "device"
|
|
base = base.strip() or "device"
|
|
return f"{base}-{os.urandom(2).hex()}"
|
|
|
|
|
|
def _upsert_remote_device_user_tool(
|
|
conn, *, user_id: str, device_id: str,
|
|
device_name: str, description: Optional[str],
|
|
) -> None:
|
|
"""Create or update the ``user_tools`` row that fronts this device."""
|
|
repo = UserToolsRepository(conn)
|
|
existing = _find_remote_device_tool_row(conn, user_id, device_id)
|
|
actions = [
|
|
{
|
|
"name": "run_command",
|
|
"description": (
|
|
f"Execute a shell command on the remote device '{device_name}'. "
|
|
f"{description or ''}".strip()
|
|
),
|
|
"active": True,
|
|
# ``tool_executor`` consults ``RemoteDeviceTool.preview_requires_approval``
|
|
# live for ``remote_device`` calls, so the static snapshot must
|
|
# stay neutral. The tool's own ``execute_action`` still yields
|
|
# the awaiting-approval pause when the live decision says so.
|
|
"require_approval": False,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {
|
|
"type": "string",
|
|
"description": "Shell command to run.",
|
|
"filled_by_llm": True,
|
|
"value": "",
|
|
},
|
|
"working_directory": {
|
|
"type": "string",
|
|
"description": "Working directory on the remote.",
|
|
"filled_by_llm": True,
|
|
"value": "",
|
|
},
|
|
"timeout_ms": {
|
|
"type": "integer",
|
|
"description": "Timeout in milliseconds (max 600000).",
|
|
"filled_by_llm": True,
|
|
"value": "",
|
|
},
|
|
},
|
|
"required": ["command"],
|
|
},
|
|
}
|
|
]
|
|
config = {"device_id": device_id}
|
|
display_name = f"Remote device — {device_name}"
|
|
if existing:
|
|
repo.update(
|
|
str(existing["id"]),
|
|
user_id,
|
|
{
|
|
"display_name": display_name,
|
|
"description": description or "",
|
|
"config": config,
|
|
"actions": actions,
|
|
"status": True,
|
|
},
|
|
)
|
|
else:
|
|
repo.create(
|
|
user_id=user_id,
|
|
name="remote_device",
|
|
display_name=display_name,
|
|
description=description or "",
|
|
config=config,
|
|
actions=actions,
|
|
status=True,
|
|
)
|
|
|
|
|
|
def _find_remote_device_tool_row(conn, user_id: str, device_id: str):
|
|
"""Locate the user_tools row whose config.device_id matches."""
|
|
from sqlalchemy import text
|
|
row = conn.execute(
|
|
text(
|
|
"""
|
|
SELECT *
|
|
FROM user_tools
|
|
WHERE user_id = :user_id
|
|
AND name = 'remote_device'
|
|
AND config ->> 'device_id' = :device_id
|
|
LIMIT 1
|
|
"""
|
|
),
|
|
{"user_id": user_id, "device_id": device_id},
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return dict(row._mapping)
|
|
|
|
|
|
def _error(code: str, status: int) -> tuple:
|
|
return make_response(jsonify({"success": False, "error": code}), status)
|