182 lines
6.9 KiB
Python
182 lines
6.9 KiB
Python
"""Fleet Ops demo bearer token: mint and verify.
|
|
|
|
Docs: docs/superpowers/specs/2026-08-05-mobilityops-demo-token-design.md
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from packages.hub_runtime import read_secret_file
|
|
from services.control_api.auth.errors import InvalidToken
|
|
from services.control_api.auth.models import AuthenticatedPrincipal, PrincipalKind
|
|
from services.control_api.persistence.demo_client import (
|
|
DEMO_CLIENT_ID,
|
|
DEMO_CLIENT_ISSUER,
|
|
DEMO_CLIENT_SUBJECT,
|
|
)
|
|
|
|
TOKEN_PREFIX = "demo_" # noqa: S105
|
|
MIN_TTL_HOURS = 1
|
|
MAX_TTL_HOURS = 72
|
|
|
|
|
|
def _b64url_encode(data: bytes) -> str:
|
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def _b64url_decode(value: str) -> bytes:
|
|
padding = "=" * (-len(value) % 4)
|
|
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
|
|
|
|
|
def load_demo_token_signing_key(path: Path | None = None) -> bytes:
|
|
"""Load the demo token HMAC key.
|
|
|
|
With no argument this keeps the container contract exactly as before:
|
|
DEMO_TOKEN_KEY_FILE (default /run/secrets/demo_token_signing_key), read
|
|
under the /run/secrets containment check. An explicit path is rooted at
|
|
that file's own parent instead, so an operator running the mint/revoke
|
|
scripts directly on the host -- where the secret lives under
|
|
APPDATA_ROOT/secrets, not /run/secrets -- can name the real file.
|
|
"""
|
|
|
|
if path is None:
|
|
path = Path(os.environ.get("DEMO_TOKEN_KEY_FILE", "/run/secrets/demo_token_signing_key"))
|
|
key = read_secret_file(path).encode("utf-8")
|
|
else:
|
|
key = read_secret_file(path, root=path.parent).encode("utf-8")
|
|
if len(key) < 32:
|
|
raise ValueError("demo token signing key must contain at least 32 bytes")
|
|
return key
|
|
|
|
|
|
def mint_demo_token(key: bytes, *, ttl_hours: int, now: int | None = None) -> tuple[str, str]:
|
|
if not MIN_TTL_HOURS <= ttl_hours <= MAX_TTL_HOURS:
|
|
raise ValueError(f"ttl_hours must be between {MIN_TTL_HOURS} and {MAX_TTL_HOURS}")
|
|
issued_at = int(time.time()) if now is None else now
|
|
jti = secrets.token_hex(16)
|
|
payload = {"jti": jti, "iat": issued_at, "exp": issued_at + ttl_hours * 3600}
|
|
payload_json = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
payload_b64 = _b64url_encode(payload_json)
|
|
signature = hmac.new(key, payload_b64.encode("ascii"), hashlib.sha256).digest()
|
|
token = f"{TOKEN_PREFIX}{payload_b64}.{_b64url_encode(signature)}"
|
|
return token, jti
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DemoTokenClaims:
|
|
jti: str
|
|
issued_at: int
|
|
expires_at: int
|
|
|
|
|
|
def verify_demo_token(token: str, key: bytes, *, now: int | None = None) -> DemoTokenClaims:
|
|
reference = int(time.time()) if now is None else now
|
|
if not token.startswith(TOKEN_PREFIX):
|
|
raise InvalidToken()
|
|
body = token[len(TOKEN_PREFIX) :]
|
|
parts = body.split(".")
|
|
if len(parts) != 2:
|
|
raise InvalidToken()
|
|
payload_b64, signature_b64 = parts
|
|
try:
|
|
expected_signature = hmac.new(key, payload_b64.encode("ascii"), hashlib.sha256).digest()
|
|
actual_signature = _b64url_decode(signature_b64)
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
raise InvalidToken() from exc
|
|
if not hmac.compare_digest(expected_signature, actual_signature):
|
|
raise InvalidToken()
|
|
try:
|
|
payload = json.loads(_b64url_decode(payload_b64))
|
|
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise InvalidToken() from exc
|
|
if not isinstance(payload, dict):
|
|
raise InvalidToken()
|
|
jti = payload.get("jti")
|
|
issued_at = payload.get("iat")
|
|
expires_at = payload.get("exp")
|
|
if (
|
|
not isinstance(jti, str)
|
|
or not 1 <= len(jti) <= 64
|
|
or not isinstance(issued_at, int)
|
|
or not isinstance(expires_at, int)
|
|
):
|
|
raise InvalidToken()
|
|
if expires_at <= reference:
|
|
raise InvalidToken()
|
|
return DemoTokenClaims(jti=jti, issued_at=issued_at, expires_at=expires_at)
|
|
|
|
|
|
def load_demo_revocations(path: Path) -> frozenset[str]:
|
|
# A genuinely absent file means "nothing revoked" and is fine. A path that
|
|
# exists but is not a regular file is a misconfigured mount -- Docker
|
|
# silently creates a DIRECTORY at a bind-mount target whose host source is
|
|
# missing -- and must fail closed rather than read as an empty revocation
|
|
# list that silently un-revokes every revoked token.
|
|
if path.exists() and not path.is_file():
|
|
raise ValueError("demo token revocation path is not a regular file (misconfigured mount?)")
|
|
if not path.is_file():
|
|
return frozenset()
|
|
try:
|
|
document = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
raise ValueError("demo token revocation file is invalid") from exc
|
|
if not isinstance(document, dict) or not isinstance(document.get("revokedJtis"), list):
|
|
raise ValueError("demo token revocation file is invalid")
|
|
return frozenset(str(item) for item in document["revokedJtis"])
|
|
|
|
|
|
class DemoTokenAuthenticator:
|
|
"""Alternate PrincipalLike-producing authenticator for the Fleet Ops demo token.
|
|
|
|
Always resolves to the single fixed demo client identity provisioned by
|
|
services.control_api.persistence.demo_client.provision_demo_client — the
|
|
token payload is never trusted for project or client identity, only for
|
|
jti/iat/exp.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
key: bytes | None,
|
|
revocations_path: Path,
|
|
*,
|
|
key_loader: Callable[[], bytes] | None = None,
|
|
) -> None:
|
|
if (key is None) == (key_loader is None):
|
|
raise ValueError("provide exactly one of key or key_loader")
|
|
self.key = key
|
|
self.key_loader = key_loader
|
|
self.revocations_path = revocations_path
|
|
|
|
def __call__(self, authorization_header: str | None) -> AuthenticatedPrincipal:
|
|
if authorization_header is None or not authorization_header.startswith("Bearer "):
|
|
raise InvalidToken()
|
|
token = authorization_header[len("Bearer ") :].strip()
|
|
key = self.key_loader() if self.key_loader is not None else self.key
|
|
if key is None:
|
|
raise InvalidToken()
|
|
claims = verify_demo_token(token, key)
|
|
if claims.jti in load_demo_revocations(self.revocations_path):
|
|
raise InvalidToken()
|
|
return AuthenticatedPrincipal(
|
|
subject=DEMO_CLIENT_SUBJECT,
|
|
issuer=DEMO_CLIENT_ISSUER,
|
|
audience=(),
|
|
kind=PrincipalKind.MACHINE_CLIENT,
|
|
client_id=DEMO_CLIENT_ID,
|
|
machine_subject=DEMO_CLIENT_SUBJECT,
|
|
expires_at=datetime.fromtimestamp(claims.expires_at, tz=UTC),
|
|
issued_at=datetime.fromtimestamp(claims.issued_at, tz=UTC),
|
|
)
|