Files
ITWorx_MCP_Hub-Public/services/runtime/demo_token.py
T

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),
)