Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,181 @@
|
||||
"""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),
|
||||
)
|
||||
Reference in new issue
Block a user