Files
SciMesh/scimesh/worker/auth.py
T
Efremenko Arhip 3a1461315f feat: self-service worker enrollment bound to a user account
Let a signed-in user turn their own machine into a worker without the
shared token. The coordinator already binds a JWT-authenticated
registration to owner_id as untrusted; this adds the missing pieces.

userservice: long-lived worker keys (scimesh_wk_live_*, hash-at-rest)
with create/list/revoke and a public /worker-tokens/exchange that trades
a key for a short-lived JWT carrying the owner current role/verified.

python worker: SCIMESH_WORKER_KEY + SCIMESH_USERSERVICE_URL; a token
provider exchanges the key and refreshes the JWT proactively and on 401,
so a long-running worker survives token expiry. Static bearer token path
is unchanged.

coordinator UI: an "add your machine" page that mints a key and shows a
ready-to-run command, proxying key management to the userservice; the
dashboard gains an owner-scoped "my machines" section.

docs: how to run a worker from your account, plus the untrusted/quorum/
verified trust model.
2026-07-27 16:11:07 +03:00

129 lines
4.5 KiB
Python

"""Bearer-token strategies for the worker's coordinator calls.
A worker authenticates in one of two ways:
* a *static* token — the shared service token or a directly supplied JWT, fixed
for the life of the process; or
* a *worker key* — a long-lived per-user credential the worker trades for a
short-lived JWT at the userservice, refreshing before that JWT expires.
Both are exposed through the small ``TokenProvider`` protocol so the HTTP
clients neither know nor care which one is in play.
"""
from __future__ import annotations
import json
import time
from typing import Callable, Protocol
from urllib.error import HTTPError, URLError
from urllib.request import Request, build_opener
from .transport import NoRedirectHandler
class TokenExchangeError(RuntimeError):
"""The userservice refused or failed to exchange a worker key."""
class TokenProvider(Protocol):
def token(self) -> str | None:
"""Return the current bearer token, refreshing it if necessary."""
def refresh(self) -> None:
"""Force the next token to be re-fetched (e.g. after a 401)."""
class StaticTokenProvider:
"""Serves a fixed token forever. ``None`` means "send no Authorization"."""
def __init__(self, token: str | None) -> None:
self._token = token
def token(self) -> str | None:
return self._token
def refresh(self) -> None: # noqa: D401 - nothing to refresh
return None
class WorkerKeyTokenProvider:
"""Exchanges a long-lived worker key for short-lived JWTs and refreshes them.
The token is cached until roughly ``1 - refresh_leeway`` of its lifetime has
elapsed, so the worker renews ahead of expiry instead of waiting for a 401.
A monotonic clock is injectable to keep tests deterministic.
"""
def __init__(
self,
userservice_url: str,
worker_key: str,
timeout: float,
*,
refresh_leeway: float = 0.2,
now: Callable[[], float] = time.monotonic,
) -> None:
self._url = userservice_url.rstrip("/")
self._key = worker_key
self._timeout = timeout
self._leeway = refresh_leeway
self._now = now
self._token: str | None = None
self._refresh_at: float = 0.0
self._opener = build_opener(NoRedirectHandler())
def token(self) -> str:
if self._token is None or self._now() >= self._refresh_at:
self._exchange()
assert self._token is not None # _exchange sets it or raises
return self._token
def refresh(self) -> None:
self._exchange()
def _exchange(self) -> None:
request = Request(
f"{self._url}/worker-tokens/exchange",
data=json.dumps({"key": self._key}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with self._opener.open(request, timeout=self._timeout) as response:
raw = response.read()
data = json.loads(raw) if raw else {}
except HTTPError as error:
# A revoked or unknown key is a permanent 401; there is nothing the
# worker can do but stop, so surface it rather than retry forever.
raise TokenExchangeError(
f"worker key exchange rejected with status {error.code}"
) from error
except (URLError, TimeoutError, json.JSONDecodeError) as error:
raise TokenExchangeError("worker key exchange request failed") from error
token = data.get("token")
if not isinstance(token, str) or not token:
raise TokenExchangeError("worker key exchange response is missing a token")
expires_in = data.get("expires_in")
ttl = float(expires_in) if isinstance(expires_in, (int, float)) and expires_in > 0 else 0.0
self._token = token
# Renew once ~(1 - leeway) of the lifetime is gone. An unknown TTL falls
# back to re-exchanging on the next call — correct, just chattier.
self._refresh_at = self._now() + ttl * (1.0 - self._leeway)
def provider_from_config(
*,
worker_key: str | None,
userservice_url: str | None,
bearer_token: str | None,
request_timeout: float,
) -> TokenProvider:
"""Pick the token strategy: a worker key (exchange mode) wins over a static
bearer token, which in turn wins over no credential at all."""
if worker_key and userservice_url:
return WorkerKeyTokenProvider(userservice_url, worker_key, request_timeout)
return StaticTokenProvider(bearer_token)