Files
2026-10-16 21:51:41 +02:00

914 lines
38 KiB
Python

# streaming_providers/providers/hrti/auth.py
"""
HRTiAuthenticator -- stateful login flow (Variant B raw authenticator).
Managers never call this class; they go through HRTiSession (the AuthProtocol
adapter). The provider keeps both objects: ``self.authenticator`` for the
legacy auth mixin, ``self.auth`` (the session) for the managers.
Device identity
---------------
The device id (a UUID) is CLIENT identity, not account state: it survives
invalidate() / refresh() and is deliberately NOT cleared with the token. It
is loaded from the settings manager on startup (flat keys, country=None) and
persisted whenever a fresh one is generated or the ErrorCode-4 recovery
re-registers it (register_device -> _persist_device_id). The server-side
registration of that id can expire while the stored id lives on -- that is
ErrorCode 4, recovered reactively by api.api_post (ONE re-registration + ONE
retry, then AuthError).
"""
import json
import time
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel
from ...base.auth.credentials import UserPasswordCredentials
from ...base.errors import AuthError, ProviderError, ServerError
from ...base.utils.logger import logger
from .api import DEVICE_SESSION_INVALID_CODE, typed_error_for
from .constants import HRTiConfig, HRTiDefaults
from .models import HRTiAuthToken, HRTiCredentials
class HRTiAuthenticator(BaseAuthenticator):
def __init__(self, *, http_manager, config: HRTiConfig, credentials=None, config_dir=None):
"""
``config`` is the ONE HRTiConfig of the provider (shared with session
and managers; never build a second one here). Storage keys stay FLAT
(country=None): credentials, persisted session and device id of
existing installations are stored without a country.
"""
if http_manager is None:
raise ValueError("http_manager is required for HRTiAuthenticator")
self._config = config
self._http_manager = http_manager
# HRTi-specific state BEFORE the parent init (it loads the session)
self._ip_address = None
self._device_id = None
self._user_id = None
super().__init__(
provider_name=HRTiDefaults.PROVIDER_NAME,
credentials=credentials,
country=None,
config_dir=config_dir,
)
self._initialize_device()
@property
def config(self):
"""Safe access to config"""
return self._config
@property
def http_manager(self):
"""Safe access to http_manager - required by provider"""
if self._http_manager is None:
# This should never happen since we validate in __init__
raise ValueError(
"HTTP manager not available - this should have been set during initialization"
)
return self._http_manager
@http_manager.setter
def http_manager(self, value):
"""Allow setting http_manager"""
self._http_manager = value
@property
def auth_endpoint(self) -> str:
"""HRTi authentication endpoint"""
return self.config.endpoint("grant_access")
def _get_auth_headers(self) -> Dict[str, str]:
"""Get headers specifically for authentication endpoint"""
ip_address = self._get_ip_address() # cached; "0.0.0.0" on failure (never None)
device_id = self.get_device_id()
# Special headers for authentication endpoint with /signin referer
headers = {
"User-Agent": self.config.user_agent,
"Accept": "application/json",
"Content-Type": "application/json",
"deviceid": device_id,
"devicetypeid": self.config.device_reference_id,
"ipaddress": ip_address,
"operatorreferenceid": self.config.operator_reference_id,
"origin": self.config.base_website,
"referer": f"{self.config.base_website}/signin",
}
return headers
def _get_api_headers(
self, bearer_token: Optional[str] = None, referer: Optional[str] = None
) -> Dict[str, str]:
"""Get headers for regular API endpoints with proper session support"""
ip_address = self._get_ip_address() # cached; "0.0.0.0" on failure (never None)
device_id = self.get_device_id()
if referer is None:
referer = f"{self.config.base_website}/login"
headers = {
"connection": "keep-alive",
"User-Agent": self.config.user_agent,
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"deviceid": device_id,
"devicetypeid": self.config.device_reference_id,
"ipaddress": ip_address,
"operatorreferenceid": self.config.operator_reference_id,
"origin": self.config.base_website,
"referer": referer,
"accept-encoding": "gzip, deflate, br",
"accept-language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
}
# Add authorization header with Client prefix
if bearer_token:
headers["authorization"] = f"Client {bearer_token}"
logger.debug("Added authorization header to API headers")
return headers
def _build_auth_payload(self) -> Dict[str, Any]:
"""Build HRTi-specific authentication payload"""
# HRTi uses specific format: {"Username":"...","Password":"...","OperatorReferenceId":"hrt"}
if hasattr(self.credentials, "username") and hasattr(self.credentials, "password"):
return {
"Username": self.credentials.username,
"Password": self.credentials.password,
"OperatorReferenceId": self.config.operator_reference_id,
}
else:
# Fallback for any other credential type
return {
"Username": getattr(self.credentials, "username", ""),
"Password": getattr(self.credentials, "password", ""),
"OperatorReferenceId": self.config.operator_reference_id,
}
def _create_token_from_response(self, response_data: Dict[str, Any]) -> HRTiAuthToken:
"""Create HRTi-specific token from API response"""
result = response_data.get("Result") or {}
# Debug the response structure
logger.debug(f"GrantAccess response Result keys: {list(result.keys())}")
# Extract token
access_token = result.get("Token", "")
logger.debug(f"Extracted access_token: {'present' if access_token else 'MISSING'}")
# Store user ID if available
if "Customer" in result:
self._user_id = result["Customer"].get("CustomerId", "")
logger.debug(f"Extracted user_id: {self._user_id}")
else:
logger.warning("No Customer data in response")
token = HRTiAuthToken(
access_token=access_token,
token_type="Client",
expires_in=HRTiDefaults.TOKEN_LIFETIME_SECONDS, # GrantAccess carries no lifetime
issued_at=time.time(),
user_id=self._user_id or "", # Ensure user_id is never None
valid_from=result.get("ValidFrom", ""),
valid_to=result.get("ValidTo", ""),
)
# Classify the token immediately after creation
token.auth_level = self._classify_token(token)
logger.debug(
f"Created HRTiAuthToken - access_token: {bool(token.access_token)}, user_id: {token.user_id}"
)
return token
def get_fallback_credentials(self):
"""Get fallback credentials (anonymous access)"""
return HRTiCredentials(
username=HRTiDefaults.ANONYMOUS_USERNAME,
password=HRTiDefaults.ANONYMOUS_PASSWORD,
)
def _perform_authentication(self) -> HRTiAuthToken:
"""Perform HRTi custom authentication flow"""
logger.info("Starting HRTi authentication flow")
# Step 1: Get IP address
self._get_ip_address()
# Step 2: Get environment configuration
self._get_environment_config()
# Step 3: Perform grant access
token_data = self._perform_grant_access()
# Step 4: Create token and SET IT AS CURRENT TOKEN
token = self._create_token_from_response(token_data)
self._current_token = token # THIS IS THE CRITICAL MISSING LINE
# Debug: Verify token is available
logger.debug(f"Token created - access_token present: {bool(token.access_token)}")
if not token.access_token:
logger.warning("Token created but access_token is empty!")
# Step 5: Register device (now with token available in self._current_token)
self._register_device()
# Step 6: Get content rating and profiles
self._get_initial_data()
logger.info(
f"HRTi authentication successful - user_id: {token.user_id}, auth_level: {token.auth_level.value}"
)
# Save the session after successful authentication
self._save_session()
return token
def _get_ip_address(self) -> str:
"""Get public IP address - prefer configured IP from settings, fallback to API"""
if self._ip_address:
return self._ip_address
# Priority 1: Try to get configured IP from Kodi settings
try:
configured_ip = self._get_configured_ip_from_settings()
if configured_ip:
self._ip_address = configured_ip
logger.info(f"Using configured IP address from Kodi settings: {self._ip_address}")
return self._ip_address
except Exception as e:
logger.debug(f"Could not read configured IP from settings: {e}")
# Priority 2: ask the API (endpoint resolved lazily from the dynamic config)
try:
logger.debug("No configured IP found, fetching from API...")
response = self.http_manager.get(self.config.endpoint("get_ip"), operation="api")
response.raise_for_status()
self._ip_address = response.text.strip().strip('"') # Remove quotes if present
logger.info(f"Retrieved IP address from API: {self._ip_address}")
return self._ip_address
except Exception as e:
# Not cached: the next call may succeed
logger.error(f"Error getting IP address from API: {e}")
return "0.0.0.0"
def _get_configured_ip_from_settings(self) -> Optional[str]:
"""
Get configured IP address from Kodi settings via settings_manager
Returns:
Configured IP address or None if not available
"""
# Access settings_manager through parent class
if not hasattr(self, "settings_manager") or not self.settings_manager:
logger.debug("Settings manager not available for IP configuration")
return None
kodi_bridge = self.settings_manager.kodi_bridge
if not kodi_bridge or not kodi_bridge.is_kodi_environment():
logger.debug("Kodi bridge not available or not in Kodi environment")
return None
# Try to read IP from Kodi settings
# HRTi doesn't use country in this implementation, pass None
configured_ip = kodi_bridge.read_ip_address_from_kodi("hrti", None)
if configured_ip:
logger.debug(f"Found configured IP in Kodi settings: {configured_ip}")
return configured_ip
return None
def _get_environment_config(self):
"""Dynamic endpoints (env.json / config.production.json), loaded once by
the shared config -- also used by the managers, independent of login."""
self.config.ensure_loaded()
def _perform_grant_access(self) -> Dict[str, Any]:
"""Perform grant access authentication with proper headers and debugging"""
try:
device_id = self.get_device_id()
logger.debug(f"Performing grant access with username: {self.credentials.username}")
logger.debug(f"Using device ID: {device_id}")
logger.debug(f"Using IP address: {self._get_ip_address()}")
# Use auth-specific headers with /signin referer
headers = self._get_auth_headers()
payload = self._build_auth_payload()
# Log the request details (safely)
safe_headers = headers.copy()
if "deviceid" in safe_headers:
safe_headers["deviceid"] = f"{safe_headers['deviceid'][:8]}..."
logger.debug(f"HRTi Auth Headers: {safe_headers}")
safe_payload = payload.copy()
if "Password" in safe_payload:
safe_payload["Password"] = "***" if safe_payload["Password"] else "<empty>"
logger.debug(f"HRTi Auth Payload: {safe_payload}")
auth_endpoint = self.auth_endpoint # ConfigurationError when missing
response = self.http_manager.post(
auth_endpoint,
operation="auth",
headers=headers,
data=json.dumps(payload),
)
logger.debug(f"HRTi Auth Response - Status: {response.status_code}")
response.raise_for_status()
result = response.json()
if "Result" not in result:
raise AuthError(
"HRTi: GrantAccess response has no Result "
f"(ErrorCode={result.get('ErrorCode')}: {result.get('ErrorDescription')})"
)
customer_id = result.get("Result", {}).get("Customer", {}).get("CustomerId", "unknown")
logger.debug(f"Grant access successful - user: {customer_id}")
return result
except Exception as e:
logger.error(f"HRTi grant access failed: {e}")
# Only fallback to anonymous if we're not already using it
if (
not isinstance(self.credentials, HRTiCredentials)
or self.credentials.username != HRTiDefaults.ANONYMOUS_USERNAME
):
logger.info("Falling back to anonymous credentials")
self.credentials = self.get_fallback_credentials()
return self._perform_grant_access()
else:
if isinstance(e, ProviderError):
raise
raise typed_error_for(e, self.config.api_endpoints.get("grant_access")) from e
def _register_device(self, strict: bool = False) -> bool:
"""
Register the device with HRTi (RegisterDevice, HSAPI endpoint).
strict=False (login flow, step 5 of _perform_authentication): best
effort -- every failure is logged and swallowed, a missing token is
a warning; registration must never abort the login. Returns
True/False (the login flow ignores it).
strict=True (ErrorCode-4 recovery, called via register_device()):
failures RAISE typed errors -- AuthError when there is no token to
register with, ServerError for transport/HTTP/body-ErrorCode
failures, ConfigurationError when the endpoint is not configured.
The recovery ladder in api.api_post must be able to tell a FAILED
re-registration from a successful one; swallowing here would make
it retry the original request, get ErrorCode 4 again and raise a
misleading AuthError while the real cause is only a warning in
the log.
"""
url: Optional[str] = None
try:
bearer_token = self._current_token.access_token if self._current_token else ""
if not bearer_token:
if strict:
raise AuthError("HRTi: no token available for device registration")
logger.warning("No bearer token available for device registration")
return False
# ConfigurationError when missing (endpoint() raises, never returns
# a falsy URL -- the old `if not register_endpoint` check was dead)
url = self.config.endpoint("register_device")
# Use API headers with proper authorization and referer
headers = self._get_api_headers(bearer_token)
# Update the referer to root path for device registration
headers["referer"] = f"{self.config.base_website}/"
# Add cache-control and pragma headers to match working example
headers["cache-control"] = "no-cache"
headers["pragma"] = "no-cache"
payload = {
"DeviceSerial": self._device_id,
"DeviceReferenceId": self.config.device_reference_id,
"IpAddress": self._get_ip_address(),
"ConnectionType": self.config.connection_type,
"ApplicationVersion": self.config.application_version,
"DrmId": self._device_id,
"OsVersion": self.config.os_version,
"ClientType": self.config.client_type,
}
logger.debug(f"HRTi Device Registration Payload: {payload}")
response = self.http_manager.post(
url,
operation="api",
headers=headers,
data=json.dumps(payload),
)
logger.debug(f"Device registration response status: {response.status_code}")
response.raise_for_status()
result = response.json()
error_code = result.get("ErrorCode") if isinstance(result, dict) else None
if error_code not in (0, None):
if strict:
raise ServerError(
f"HRTi device registration failed: [{error_code}] "
f"{result.get('ErrorDescription')}",
url=url,
code=str(error_code),
)
logger.warning(
f"HRTi device registration error: [{error_code}] "
f"{result.get('ErrorDescription')}"
)
return False
logger.debug(f"HRTi device registration successful: {result}")
return True
except ProviderError as e:
# Typed error (incl. ConfigurationError from endpoint() and the
# ServerError/AuthError raised above): fatal in the recovery
# path, logged and swallowed in the login flow (v1 behaviour).
if strict:
raise
logger.warning(f"HRTi device registration failed: {e}")
return False
except Exception as e:
if strict:
raise typed_error_for(e, url) from e
logger.warning(f"HRTi device registration failed: {e}")
return False
def _get_initial_data(self):
"""Get initial content rating and profiles using API headers with proper authorization"""
try:
bearer_token = self._current_token.access_token if self._current_token else ""
headers = self._get_api_headers(bearer_token)
# Update the referer to root path for these API calls
headers["referer"] = f"{self.config.base_website}/"
# Get content ratings
content_endpoint = self.config.endpoint("content_ratings")
if content_endpoint:
content_response = self.http_manager.post(
content_endpoint,
operation="api",
headers=headers,
data=json.dumps({}),
)
content_response.raise_for_status()
# Get profiles
profiles_endpoint = self.config.endpoint("profiles")
if profiles_endpoint:
profiles_response = self.http_manager.post(
profiles_endpoint,
operation="api",
headers=headers,
data=json.dumps({}),
)
profiles_response.raise_for_status()
logger.debug("HRTi initial data loaded")
except Exception as e:
logger.debug(f"Error loading HRTi initial data: {e}")
def _initialize_device(self):
"""Initialize or load device ID - ensure it's a proper UUID"""
try:
self._device_id = self.settings_manager.get_device_id(self.provider_name, self.country)
if not self._device_id:
self._device_id = str(uuid.uuid4())
# A freshly generated id MUST survive the restart, otherwise
# the device identity churns on every start and the server
# sees a brand-new device each time. No-op + warning when the
# settings manager has no setter (see _persist_device_id).
self._persist_device_id()
logger.debug(f"Generated new device ID: {self._device_id}")
# Ensure it's a valid UUID format
if not self._validate_uuid(self._device_id):
logger.warning(f"Invalid device ID format, generating new one: {self._device_id}")
self._device_id = str(uuid.uuid4())
self._persist_device_id()
except Exception as e:
logger.error(f"Error initializing device ID: {e}")
self._device_id = str(uuid.uuid4())
self._persist_device_id()
@staticmethod
def _validate_uuid(uuid_string):
"""Validate UUID format"""
try:
uuid.UUID(uuid_string)
return True
except ValueError:
return False
def get_device_id(self) -> str:
"""Get device ID"""
return self._device_id
def get_ip_address(self) -> str:
"""Get IP address (cached; "0.0.0.0" fallback is not cached)."""
return self._get_ip_address()
def _load_session(self) -> None:
"""
Restore the persisted token. The stored dict is FLAT (``to_dict``), so
it is rebuilt with HRTiAuthToken.from_dict. It must NOT go through
_create_token_from_response (the base default), which expects a
GrantAccess response and used to produce a token with an EMPTY
access_token and a fresh issued_at -- "valid" for 24 h and never
refreshed.
"""
self._current_token = None
try:
token_data = self.settings_manager.load_token_data(self.provider_name, self.country)
if token_data and token_data.get("access_token"):
token = HRTiAuthToken.from_dict(token_data)
token.auth_level = self._classify_token(token)
self._current_token = token
self._user_id = token.user_id or self._user_id or ""
logger.info("Restored persisted HRTi session")
else:
logger.info("No existing HRTi session found")
except Exception as e:
logger.error(f"Error loading HRTi session: {e}")
self._current_token = None
self._user_id = self._user_id or ""
# ------------------------------------------------------------------
# State read by HRTiSession
# ------------------------------------------------------------------
def get_current_token(self) -> Optional[BaseAuthToken]:
return self._current_token
def get_user_id(self) -> str:
return self._user_id or ""
def _stored_credentials(self) -> Any:
"""Credentials from the settings manager (flat key, country=None)."""
try:
return self.settings_manager.get_provider_credentials(self.provider_name, self.country)
except TypeError: # older settings manager without the country parameter
return self.settings_manager.get_provider_credentials(self.provider_name)
def _has_valid_user_credentials(self) -> bool:
"""Stored OR current credentials are valid user (username/password) credentials."""
return any(
isinstance(creds, UserPasswordCredentials) and creds.validate()
for creds in (self._stored_credentials(), self.credentials)
)
def has_user_credentials(self) -> bool:
"""True when real (non-anonymous) user credentials are stored or set."""
stored = self._stored_credentials()
for creds in (stored, self.credentials):
if (
isinstance(creds, UserPasswordCredentials)
and creds.validate()
and creds.username != HRTiDefaults.ANONYMOUS_USERNAME
):
return True
return False
def authorize_session(
self,
content_type: str,
content_ref_id: str,
content_drm_id: Optional[str] = None,
video_store_ids: Optional[List[str]] = None,
channel_id: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> Dict[str, Any]:
"""Authorize a playback session. Raises typed errors (AuthError on 401,
ServerError otherwise); returns the raw ``Result`` dict -- the caller
checks ``Authorized``."""
bearer_token = self._current_token.access_token if self._current_token else ""
headers = self._get_api_headers(bearer_token)
# Set referer based on content type
if channel_id is None:
headers["referer"] = f"{self.config.base_website}/videostore"
elif content_type == "tlive":
headers["referer"] = f"{self.config.base_website}/live/tv?channel={channel_id}"
elif content_type == "rlive":
headers["referer"] = f"{self.config.base_website}/live/radio"
else:
headers["referer"] = f"{self.config.base_website}/live/"
payload = {
"ContentType": content_type,
"ContentReferenceId": content_ref_id,
"ContentDrmId": content_drm_id or f"{content_ref_id}_drm",
"VideostoreReferenceIds": video_store_ids,
"ChannelReferenceId": channel_id,
"StartTime": start_time,
"EndTime": end_time,
}
logger.debug(
f"Authorizing session - type: {content_type}, ref: {content_ref_id}, drm: {content_drm_id}"
)
auth_endpoint = self.config.endpoint("authorize_session")
try:
response = self.http_manager.post(
auth_endpoint, operation="api", headers=headers, data=json.dumps(payload)
)
response.raise_for_status()
result = response.json()
except ProviderError:
raise
except Exception as exc:
raise typed_error_for(exc, auth_endpoint) from exc
session_result = result.get("Result")
if not session_result:
error_code = result.get("ErrorCode")
if error_code == DEVICE_SESSION_INVALID_CODE:
# Stale device session: AuthError (NOT ServerError) so that
# HRTiSession.authorize_session's existing recovery ladder
# (refresh = invalidate + forced login, which re-registers the
# device inside _perform_authentication) heals it with
# exactly one retry. Assumption to verify on device:
# ErrorCode 4 means the same here as on the data endpoints.
raise AuthError(
"HRTi: AuthorizeSession rejected the device session "
f"(ErrorCode {error_code}: {result.get('ErrorDescription')})"
)
raise ServerError(
"HRTi: no result in AuthorizeSession response "
f"(ErrorCode={error_code}: {result.get('ErrorDescription')})",
url=auth_endpoint,
code=str(error_code),
)
logger.debug(
f"Session authorization result - authorized: {session_result.get('Authorized', False)}, "
f"session_id: {session_result.get('SessionId') or session_result.get('DrmId')}"
)
return session_result
def report_session_event(self, session_id: str, channel_id: Optional[str] = None) -> bool:
"""Report session event (like play start)"""
try:
bearer_token = self._current_token.access_token if self._current_token else ""
headers = self._get_api_headers(bearer_token)
# Set referer based on channel
if channel_id is None:
headers["referer"] = f"{self.config.base_website}/videostore"
else:
headers["referer"] = f"{self.config.base_website}/live/tv?channel={channel_id}"
payload = {"SessionEventId": 1, "SessionId": session_id} # 1 = play start
report_endpoint = self.config.endpoint("report_session")
if not report_endpoint:
logger.warning("No report_session endpoint configured")
return False
response = self.http_manager.post(
report_endpoint,
operation="api",
headers=headers,
data=json.dumps(payload),
)
response.raise_for_status()
logger.debug(f"Session event reported for session {session_id}")
return True
except Exception as e:
logger.warning(f"Session event reporting failed: {e}")
return False
@staticmethod
def get_time_offset(hours_offset: int) -> int:
"""Get timestamp with offset in milliseconds"""
target_time = datetime.now() + timedelta(hours=hours_offset)
return int(target_time.timestamp() * 1000)
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
"""Classify HRTi token authentication level"""
if not token or not token.access_token:
return TokenAuthLevel.ANONYMOUS
# Check if it's an HRTiAuthToken and has user_id attribute
if isinstance(token, HRTiAuthToken):
# Check if it's an anonymous token by user_id
if token.user_id == HRTiDefaults.ANONYMOUS_USERNAME:
return TokenAuthLevel.ANONYMOUS
# If we have user credentials (stored or current) and the token has a real
# user_id, it's user authenticated
if (
self._has_valid_user_credentials()
and token.user_id
and token.user_id != HRTiDefaults.ANONYMOUS_USERNAME
):
return TokenAuthLevel.USER_AUTHENTICATED
# Check if using anonymous credentials
if hasattr(self.credentials, "username") and self.credentials.username == HRTiDefaults.ANONYMOUS_USERNAME:
return TokenAuthLevel.ANONYMOUS
# If we have user credentials, consider it user authenticated
if (
hasattr(self.credentials, "username")
and self.credentials.username
and self.credentials.username != HRTiDefaults.ANONYMOUS_USERNAME
):
return TokenAuthLevel.USER_AUTHENTICATED
return TokenAuthLevel.CLIENT_CREDENTIALS
def should_upgrade_token(self, token: BaseAuthToken) -> bool:
"""
Determine if token should be upgraded from anonymous to user credentials
"""
if not token:
return False
# Classify token if not already classified
if token.auth_level == TokenAuthLevel.UNKNOWN:
token.auth_level = self._classify_token(token)
# Check if token is anonymous and we have user credentials
if token.auth_level == TokenAuthLevel.ANONYMOUS:
# Upgrade if we have any user credentials (stored or current)
return self._has_valid_user_credentials()
return False
def get_bearer_token(self, force_refresh: bool = False, force_upgrade: bool = False) -> str:
"""
Get bearer token with automatic upgrade from anonymous to user credentials
"""
logger.debug(
f"HRTi get_bearer_token: force_refresh={force_refresh}, force_upgrade={force_upgrade}"
)
# Get current token (authenticate if needed)
current_token = self.authenticate(force_refresh=force_refresh)
# Check if upgrade is needed/requested
upgrade_wanted = self.should_upgrade_token(current_token)
should_upgrade = force_upgrade or upgrade_wanted
if should_upgrade:
logger.info(
f"HRTi token upgrade triggered (force={force_upgrade}, should_upgrade={upgrade_wanted})"
)
# Store original credentials at the beginning
original_credentials = self.credentials
try:
# Get user credentials from settings manager
user_creds = self._stored_credentials()
if not user_creds or not user_creds.validate():
logger.debug("No valid user credentials available for upgrade")
return current_token.bearer_token
# Switch to user credentials
self.credentials = user_creds
# Perform authentication with user credentials
logger.info("Performing authentication with user credentials for upgrade")
user_token = self._perform_authentication()
if user_token and not user_token.is_expired:
# Verify it's actually a user token (not anonymous)
user_token.auth_level = self._classify_token(user_token)
if user_token.auth_level == TokenAuthLevel.USER_AUTHENTICATED:
logger.info("Successfully upgraded to user token")
self._current_token = user_token
self._save_session()
return user_token.bearer_token
else:
logger.warning(
"Authentication succeeded but token is still anonymous level"
)
self.credentials = original_credentials
return current_token.bearer_token
else:
logger.warning("User authentication failed during upgrade")
self.credentials = original_credentials
return current_token.bearer_token
except Exception as e:
logger.error(f"Token upgrade failed: {e}")
# Restore original credentials on failure
self.credentials = original_credentials
return current_token.bearer_token
return current_token.bearer_token if current_token else ""
def _refresh_token(self) -> Optional[BaseAuthToken]:
"""Refresh HRTi token - reauthenticate since it's custom auth"""
logger.info("Refreshing HRTi token via reauthentication")
try:
return self._perform_authentication()
except Exception as e:
logger.error(f"HRTi token refresh failed: {e}")
return None
# ------------------------------------------------------------------
# ErrorCode-4 recovery (called through HRTiSession.reregister_device)
# ------------------------------------------------------------------
def register_device(self, force: bool = False) -> None:
"""
Public, targeted device re-registration -- the ErrorCode-4 recovery
path. Called ONLY through HRTiSession.reregister_device (which locks
and dedupes concurrent callers); managers and api.py never call this
directly.
Unlike the best-effort registration inside the login flow
(_register_device(strict=False) swallows every failure), this RAISES
typed errors (AuthError / ServerError / ConfigurationError) -- see
_register_device(strict=True).
The device id is NOT rotated: RegisterDevice (re-)creates the
server-side record for the DeviceSerial supplied, so the SAME id is
sent; generating a fresh one would churn the device identity and any
server-side per-device state. ``force`` is therefore accepted for the
session contract but has no shortcut to skip today -- registration
in the login flow is unconditional anyway (the web client
re-registers on every login too).
Persists the device id after success (write-through, see
_persist_device_id): the original bug was a stored id whose
server-side registration had expired; the reactive recovery fixes
the session, the write-through keeps memory and storage consistent.
"""
logger.debug(f"HRTi: targeted device re-registration (force={force})")
if self._register_device(strict=True):
self._persist_device_id()
def _persist_device_id(self) -> None:
"""
Write the CURRENT device id back to the storage it is loaded from on
startup (settings_manager.get_device_id(provider_name, country)).
Defensive about the save API (its name is not pinned anywhere in
this file): tries the natural setter names, each with the signature
MIRRORING the getter -- (provider_name, country, device_id), country
being None for the flat HRTi keys. No other signature is guessed: a
2-arg call against a setter with an optional country parameter would
silently bind the device id to the country slot.
Never raises. If no setter exists, logs ONE warning naming the
expected API -- then the id lives only in memory for this process
and the reactive ErrorCode-4 recovery in api.api_post self-heals
after every restart instead of never. If get_device_id turns out to
be get-or-create, this whole method is a harmless no-op except in
the freshly-generated branches of _initialize_device.
"""
device_id = self._device_id
if not device_id:
return
settings_manager = getattr(self, "settings_manager", None)
if settings_manager is None:
return
for name in ("set_device_id", "save_device_id", "store_device_id"):
setter = getattr(settings_manager, name, None)
if not callable(setter):
continue
try:
setter(self.provider_name, self.country, device_id)
logger.debug(f"HRTi: device id persisted via {name}")
return
except TypeError:
continue # wrong name/signature -- try the next candidate
except Exception as exc:
logger.warning(f"HRTi: persisting the device id failed ({name}): {exc!r}")
return
logger.warning(
"HRTi: settings manager exposes no device-id setter "
"(tried set_device_id/save_device_id/store_device_id with the "
"get_device_id-mirroring signature (provider, country, device_id)) "
"-- the current device id is not persisted; ErrorCode-4 recovery "
"stays reactive. Add the setter to make it stick across restarts."
)