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