fix provider eon login

This commit is contained in:
Nirvana
2026-10-20 08:01:04 +02:00
parent 91e153173d
commit 47c626d0eb
3 changed files with 76 additions and 47 deletions
+8 -28
View File
@@ -4,8 +4,9 @@ Eon authentication -- the auth class itself satisfies AuthProtocol
(get_access_token / build_headers / invalidate); managers get it directly.
Three identities are involved (see PVREon / HttpClient):
* client token -- OAuth client_credentials at the broker; only used for
device registration. Re-fetched on 401.
* client token -- OAuth client_credentials at the broker; shared with
EonEndpoints (brand / CDN discovery) and used for device
registration. Owned by EonClientToken, re-fetched on 401.
* device -- serial (client-generated), deviceId / deviceNumber
(server-minted). CLIENT IDENTITY: persisted, never
cleared with the session.
@@ -59,7 +60,7 @@ from ...base.errors import AuthError, ProviderError, ServerError, error_from_exc
from ...base.utils.logger import logger
from ...base.utils.transport import transport_errors
from .constants import EonConfig, EonDefaults
from .bootstrap import EonEndpoints
from .bootstrap import EonClientToken, EonEndpoints
from .errors import EonLoginRejected, EonOtpRequiredError
from .models import EonDeviceRegistration, EonTokenResponse
@@ -118,6 +119,7 @@ class EonAuth:
http_manager: Any,
config: EonConfig,
endpoints: EonEndpoints,
client_token: EonClientToken,
store: Optional[EonStore] = None,
credentials: Any = None,
credentials_loader: Optional[Callable[[], Any]] = None,
@@ -126,6 +128,7 @@ class EonAuth:
self._http = http_manager
self._config = config
self._endpoints = endpoints
self._client_token = client_token
self._store: EonStore = store if store is not None else MemoryStore()
self._credentials = credentials # object with .username / .password
self._credentials_loader = credentials_loader # lazy: no disk/network in __init__
@@ -133,10 +136,8 @@ class EonAuth:
self._stream_user: Optional[str] = None
self._access_token: Optional[str] = None
self._expires_at: Optional[float] = None
self._generic_token: Optional[str] = None
self._pending_otp: Optional[str] = None
self._lock = threading.RLock()
self._generic_lock = threading.RLock()
self._clock = clock
self.last_error: Optional[Exception] = None
self._permanent: Optional[Exception] = None
@@ -385,29 +386,8 @@ class EonAuth:
raise AuthError(f"{_PROVIDER}: the one-time code was not confirmed or has expired") from exc
# ------------------------------------------------------------------
# Client token + device registration
# Generic client-token calls + device registration
# ------------------------------------------------------------------
def _client_token(self, *, force: bool = False) -> str:
with self._generic_lock:
if self._generic_token and not force:
return self._generic_token
with transport_errors("client token", _PROVIDER):
try:
resp = self._http.post(
EonDefaults.BROKER_URL + EonDefaults.PATH_OAUTH_TOKEN,
params={"grant_type": "client_credentials"},
json={},
headers=self._config.basic_headers(),
operation="auth",
)
token = resp.json().get("access_token")
except HTTPError as exc:
raise error_from_exception(exc) from exc
if not token:
raise ServerError(f"{_PROVIDER}: client token response has no access_token")
self._generic_token = token
return token
def _generic_call(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
body: Optional[Dict[str, Any]] = None) -> Any:
"""Call with the client token; one retry after refreshing it on 401.
@@ -417,7 +397,7 @@ class EonAuth:
raise NotImplementedError(f"http_manager has no {method}()")
url = self._endpoints.url(path)
for attempt in (1, 2):
headers = self._config.bearer_headers(self._client_token(force=attempt == 2))
headers = self._config.bearer_headers(self._client_token.get(force=attempt == 2))
kwargs: Dict[str, Any] = {"headers": headers, "operation": "api", "raise_for_status": False}
if params:
kwargs["params"] = params
@@ -2,12 +2,15 @@
"""
Lazy bootstrap data. Nothing here runs in __init__ (no network there).
EonEndpoints -- resolves the brand's API host via the broker (v2/brands ->
cdnIdentifier, v1/cdninfo -> domains.baseApi[selector]).
Needs NO login: broker calls carry the client credentials.
EonCatalog -- rendering profiles, streaming servers and the service-provider
id. These need a logged-in session, so they sit behind EonApi
and are cached with a TTL.
EonClientToken -- OAuth client_credentials token at the broker. Needed by the
broker calls (brand / CDN discovery) and by the device
registration; no user, no device involved.
EonEndpoints -- resolves the brand's API host via the broker (v2/brands ->
cdnIdentifier, v1/cdninfo -> domains.baseApi[selector]).
Needs the CLIENT TOKEN, no user login.
EonCatalog -- rendering profiles, streaming servers and the service-provider
id. These need a logged-in session, so they sit behind EonApi
and are cached with a TTL.
Brand resolution is by IDENTIFIER, never by list position: the broker's list
has changed before. An unknown brand falls back to the CDN the broker marks
@@ -18,7 +21,9 @@ import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from ...base.errors import ConfigurationError, ServerError
from requests.exceptions import HTTPError
from ...base.errors import ConfigurationError, ServerError, error_from_exception
from ...base.utils.logger import logger
from ...base.utils.transport import transport_errors
from .constants import EonConfig, EonDefaults
@@ -27,11 +32,43 @@ from .models import EonRenderingProfile, EonServer
_PROVIDER = EonDefaults.PROVIDER_LABEL
class EonEndpoints:
class EonClientToken:
"""OAuth client_credentials token at the broker. No user, no device."""
def __init__(self, *, http_manager: Any, config: EonConfig):
self._http = http_manager
self._config = config
self._lock = threading.RLock()
self._token: Optional[str] = None
def get(self, *, force: bool = False) -> str:
with self._lock:
if self._token and not force:
return self._token
with transport_errors("client token", _PROVIDER):
try:
resp = self._http.post(
EonDefaults.BROKER_URL + EonDefaults.PATH_OAUTH_TOKEN,
params={"grant_type": "client_credentials"},
json={},
headers=self._config.basic_headers(),
operation="auth",
)
token = resp.json().get("access_token")
except HTTPError as exc:
raise error_from_exception(exc) from exc
if not token:
raise ServerError(f"{_PROVIDER}: client token response has no access_token")
self._token = token
return token
class EonEndpoints:
def __init__(self, *, http_manager: Any, config: EonConfig, client_token: EonClientToken):
self._http = http_manager
self._config = config
self._client_token = client_token
self._lock = threading.RLock()
self._api_base: Optional[str] = None
self._images_base: Optional[str] = None
@@ -87,12 +124,18 @@ class EonEndpoints:
def _broker_get(self, path: str, what: str) -> Any:
with transport_errors(what, _PROVIDER):
resp = self._http.get(
EonDefaults.BROKER_URL + path,
headers=self._config.basic_headers(),
operation="api",
)
return resp.json()
for attempt in (1, 2):
token = self._client_token.get(force=attempt == 2)
resp = self._http.get(
EonDefaults.BROKER_URL + path,
headers=self._config.bearer_headers(token),
operation="api",
raise_for_status=False,
)
if resp.status_code != 401 or attempt == 2:
resp.raise_for_status()
return resp.json()
raise ServerError(f"{_PROVIDER}: unreachable") # pragma: no cover
def _brand_cdn_identifier(self) -> str:
brands = self._broker_get(EonDefaults.PATH_BRANDS, "brands")
@@ -1,10 +1,11 @@
# streaming_providers/providers/eon/provider.py
# streaming_providers/providers/eon/provider.py
"""
Eon provider -- orchestrator only.
The provider owns the SHARED resources (config, http_manager, endpoints,
auth, api, catalog, caches) and BUILDS the managers. Everything identical
across providers is inherited from ManagedProvider.
The provider owns the SHARED resources (config, http_manager, client_token,
endpoints, auth, api, catalog, caches) and BUILDS the managers. Everything
identical across providers is inherited from ManagedProvider.
Eon is a white-label platform: `config["brand"]` (vivacom, nova, sbb,
telemach, telemach-hr, telemach-si) picks the operator, `config["platform"]`
@@ -19,7 +20,7 @@ from ...base.managed_provider import ManagedProvider
from ...base.models.proxy_models import ProxyConfig
from .api import EonApi
from .auth import EonAuth, EonStore
from .bootstrap import EonCatalog, EonEndpoints
from .bootstrap import EonCatalog, EonClientToken, EonEndpoints
from .catchup_manager import EonCatchupManager
from .channel_manager import EonChannelManager
from .constants import EonConfig, EonDefaults
@@ -80,12 +81,17 @@ class EonProvider(ManagedProvider):
)
# 4. Lazy collaborators: no network in __init__.
self.endpoints = EonEndpoints(
self.client_token = EonClientToken(
http_manager=self.http_manager, config=self.provider_config
)
self.endpoints = EonEndpoints(
http_manager=self.http_manager, config=self.provider_config,
client_token=self.client_token,
)
self.auth = EonAuth(
http_manager=self.http_manager, config=self.provider_config,
endpoints=self.endpoints,
client_token=self.client_token,
store=store if store is not None else default_store,
credentials_loader=credentials_loader,
)