Source code for aioafero.v1.conclave.access
"""Mint a short-lived Conclave access token via REST.
The Hubspace app calls ``POST .../accounts/{accountId}/conclaveAccess`` before
opening the Conclave TLS socket. The OAuth bearer is **only used to mint this
short-lived token** — the socket itself authenticates with ``tokens[0].token``
from this response, not the JWT (using the JWT was the historic cause of
"hello works, login rejected").
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from securelogging import add_secret
from aioafero.errors import AferoError
from aioafero.v1 import v1_const
if TYPE_CHECKING: # pragma: no cover
from aioafero.v1 import AferoBridgeV1
CONCLAVE_ACCESS_ENDPOINT = "/v1/accounts/{}/conclaveAccess"
[docs]
@dataclass(frozen=True)
class ConclaveAccess:
"""Parsed response from ``POST .../conclaveAccess``.
:param host: TLS host for the Conclave socket
(e.g. ``conclave-stream1.afero.net``).
:param port: TLS port for the Conclave socket.
:param ssl: ``True`` when the server advertises TLS (always ``True``
in observed captures).
:param compression: ``True`` when the server announces zlib compression
on the wire (the initial ``hello`` is sent as a single zlib blob).
:param token: Short-lived single-use Conclave socket access token. **Not**
the OAuth bearer — pass this as ``login.accessToken``.
:param channel_id: Conclave channel ID (the account UUID).
:param expires_timestamp: Optional epoch milliseconds at which ``token``
expires; observed tokens may also be single-use, so callers should
always mint a fresh token on reconnect rather than trust this clock.
"""
host: str
port: int
ssl: bool
compression: bool
token: str
channel_id: str
expires_timestamp: int | None = None
[docs]
async def request_conclave_access(
bridge: AferoBridgeV1,
*,
user: bool = True,
soft_hub: bool = False,
) -> ConclaveAccess:
"""Mint a fresh Conclave access token.
Always call this on first connect and on every reconnect — observed
tokens are short-lived and may be single-use.
:param bridge: Initialized :class:`~aioafero.v1.AferoBridgeV1`; the bearer
token and account ID are read from the bridge.
:param user: Set ``True`` for end-user clients (the only mode observed in
captures). Always ``True`` for HA / app-style consumers.
:param soft_hub: Set ``True`` only for soft-hub clients. Token shape and
channel routing for ``softHub=true`` are not yet captured — leave
``False`` unless you have verified the response shape.
:returns: Parsed :class:`ConclaveAccess`.
:raises AferoError: If the response is missing the conclave host or
an account-scoped token.
"""
endpoint = CONCLAVE_ACCESS_ENDPOINT.format(bridge.account_id)
url = bridge.generate_api_url(endpoint)
headers = {
"host": v1_const.AFERO_CLIENTS[bridge.afero_client]["API_HOST"],
"content-type": "application/json; charset=utf-8",
}
payload = {"user": user, "softHub": soft_hub}
res = await bridge.request("POST", url, headers=headers, json=payload)
res.raise_for_status()
data = await res.json()
return _parse_conclave_access(data)
def _parse_conclave_access(data: object) -> ConclaveAccess:
"""Validate and parse the ``conclaveAccess`` JSON payload."""
if not isinstance(data, dict):
raise AferoError("conclaveAccess response is not a JSON object")
conclave = data.get("conclave")
if not isinstance(conclave, dict):
raise AferoError("conclaveAccess response is missing 'conclave' block")
host = _require_non_empty_str(
conclave.get("host"),
"conclaveAccess response is missing host/port",
)
port = _require_positive_port(
conclave.get("port"),
"conclaveAccess response is missing host/port",
)
token_entry = _select_account_token(data.get("tokens"))
if token_entry is None:
raise AferoError("conclaveAccess response is missing an account token")
token = _require_non_empty_str(
token_entry.get("token"),
"conclaveAccess account token is missing token/channelId",
)
channel_id = _require_non_empty_str(
token_entry.get("channelId"),
"conclaveAccess account token is missing token/channelId",
)
add_secret(token)
return ConclaveAccess(
host=host,
port=port,
ssl=bool(conclave.get("ssl", True)),
compression=bool(conclave.get("compression", True)),
token=token,
channel_id=channel_id,
expires_timestamp=_coerce_int(token_entry.get("expiresTimestamp")),
)
def _require_non_empty_str(value: object, message: str) -> str:
"""Return ``value`` when it is a non-empty string; otherwise raise."""
if not isinstance(value, str) or not value.strip():
raise AferoError(message)
return value
def _require_positive_port(value: object, message: str) -> int:
"""Return a positive TCP port from ``value``; otherwise raise."""
try:
port = int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
raise AferoError(message) from None
if port <= 0:
raise AferoError(message)
return port
def _select_account_token(raw_tokens: object) -> dict | None:
"""Pick the account-scoped token from ``tokens[]``.
Captures always return ``type: "account"`` first; fall back to the first
entry if a future deployment reshuffles ordering.
"""
tokens = raw_tokens if isinstance(raw_tokens, list) else []
for entry in tokens:
if isinstance(entry, dict) and entry.get("type") == "account":
return entry
first = tokens[0] if tokens else None
return first if isinstance(first, dict) else None
def _coerce_int(value: object) -> int | None:
"""Best-effort int coercion for optional timestamp fields."""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None