Files
script.service.ultimate/lib/streaming_providers/providers/_template/auth.py
T
2026-10-04 16:41:49 +02:00

386 lines
15 KiB
Python

# streaming_providers/providers/_template/auth.py
"""
{TODO: Provider name} authentication.
Implements the three shared Auth methods (get_access_token, build_headers,
invalidate), the credential surface (has_credentials, set_credentials,
clear_credentials), and any optional extensions the provider needs.
Auth is a protocol, not an ABC. See base/protocols.py for the runtime
shape; see ../_template/README.md ("The Auth protocol") for the contract.
Constructor contract (recommended, not enforced):
__init__(*, http_manager, country, settings_manager=None,
credentials=None, **provider_opts)
Credentials source priority (README, "Credentials"):
1. constructor argument (CLI, tests)
2. injected settings_manager (may be None -- the registry usually
constructs providers WITHOUT one)
3. CredentialManager (direct credentials.json read) -- the path that
actually works in the normal runtime
4. fallback credentials (anonymous/free tier), if any
Credentials are re-read on every authenticate, so a user who stores them
after the provider was constructed does not need an app restart.
Deviations to document HERE (module docstring) if your provider has them:
* Token NOT in a header (query param / body field): build_headers()
returns base headers only; callers attach the token via
with_token(url, param=...) / auth_body(). Put the param names in
constants.py (they can differ per endpoint) and add a one-line
comment at every manager call site that uses build_headers().
* Content-Type quirks (e.g. a login endpoint that needs text/plain with
a JSON body): send data=json.dumps(payload) with the header set for
THAT call only, and comment why, or someone will "fix" it.
Thread safety: the host is multi-threaded. get_access_token() and the
other stateful accessors hold an RLock so two threads on a cold cache do
not run the (multi-step) login twice. Providers whose login is a single
HTTP call can drop the lock.
"""
import threading
from typing import Any, Dict, Optional
from ...base.auth.credential_manager import CredentialManager
from ...base.auth.credentials import UserPasswordCredentials
from ...base.errors import CredentialsError
from ...base.utils.logger import logger
from .constants import YourDefaults
from .models import YourAuthToken
class YourProviderAuth:
"""
Authenticator for {TODO: provider name}.
Not a subclass of any base class -- matches AuthProtocol by shape.
"""
def __init__(
self,
*,
http_manager,
country: str,
settings_manager=None,
credentials=None,
config=None,
**provider_opts,
):
"""
Args:
http_manager: Shared HTTPManager instance (owned by provider).
country: Two-letter country code.
settings_manager: Base settings manager. May be None; the auth
class must work without it.
credentials: Pre-supplied credentials (source #1).
config: The provider's YourConfig (URLs, base headers).
**provider_opts: Provider-specific state (device_id,
client_version, platform, ...). Document what
you use; the base ignores everything here.
"""
self.http_manager = http_manager
self.country = country
self.settings_manager = settings_manager
self.config = config
self._credentials = credentials
self._lock = threading.RLock()
# TODO: store provider_opts you need, e.g.:
# self.device_id = provider_opts.get("device_id") or self._load_device_id()
# Optional persistence: restore a stored token (no network I/O).
self._cached_token = self._load_session()
# ------------------------------------------------------------------
# The three shared methods -- every provider implements these
# ------------------------------------------------------------------
def get_access_token(self, force_refresh: bool = False) -> str:
"""
Return the raw token string (no scheme prefix).
If a cached token exists and is not near expiry, return it.
Otherwise authenticate, cache, and return.
"""
with self._lock:
if (
not force_refresh
and self._cached_token
and not self._cached_token.is_expired
):
return self._cached_token.access_token
token = self._perform_authentication()
self._cached_token = token
self._save_session(token)
return token.access_token
def build_headers(
self, token: Optional[str] = None, **opts
) -> Dict[str, str]:
"""
Return request-ready headers.
If token is None, fetch it via get_access_token(). Providers add
their own non-auth headers (device id, client version, session
state, origin, referer) here.
"""
if token is None:
token = self.get_access_token()
headers = (
self.config.get_base_headers()
if self.config is not None
else {"Accept": "application/json"}
)
# TODO: pick the auth scheme your provider uses:
# MoveTV "X-Auth-Token": token
# Magenta "Bff_token": token
# RTL+ "Authorization": f"Bearer {token}"
# HRTi "authorization": f"Client {token}"
# Discovery "Authorization": f"Bearer {token}" + session headers
headers["Authorization"] = f"Bearer {token}"
# TODO: add non-auth headers the API requires. Examples:
# "X-Device-Id": self.device_id
# "X-Client-Version": self.client_version
# "Origin": ..., "Referer": ...
# Discovery-style session state, Magenta-style guest ids, etc. also
# go here.
return headers
def invalidate(self) -> None:
"""
Drop cached token and session state. Called after 401s.
The next get_access_token() call must perform full
re-authentication. Callers: on a 401, call invalidate() and retry
the request once -- never in a loop.
"""
with self._lock:
self._cached_token = None
self._clear_session()
# TODO: clear provider-specific session state, e.g.:
# self._session_state = None
# self._cookies.clear()
# ------------------------------------------------------------------
# Credential surface (providers with user credentials)
#
# Providers WITHOUT user credentials: has_credentials() returns True,
# set_/clear_credentials() are no-ops returning False, and
# _ensure_credentials() is not needed in _perform_authentication().
# ------------------------------------------------------------------
def has_credentials(self) -> bool:
"""True if this auth can authenticate right now."""
if self._credentials and self._credentials.validate():
return True
fresh = self._load_stored_credentials()
if fresh and fresh.validate():
return True
fallback = self.get_fallback_credentials()
return bool(fallback and fallback.validate())
def set_credentials(self, username: str, password: str) -> bool:
"""Persist credentials (called by the settings UI)."""
if not self.settings_manager:
return False
try:
self.settings_manager.save_provider_credentials(
YourDefaults.PROVIDER_NAME,
UserPasswordCredentials(username, password),
self.country,
)
except Exception as e:
logger.warning(f"Could not store credentials: {e}")
return False
with self._lock:
self._credentials = None # force a re-read on next login
self.invalidate()
return True
def clear_credentials(self) -> bool:
"""Clear stored credentials and drop the cached token."""
try:
if self.settings_manager:
self.settings_manager.clear_provider_credentials(
YourDefaults.PROVIDER_NAME, self.country
)
else:
CredentialManager().delete_credentials(
YourDefaults.PROVIDER_NAME, self.country
)
except Exception as e:
logger.warning(f"Could not clear credentials: {e}")
return False
with self._lock:
self._credentials = None
self.invalidate()
return True
def get_fallback_credentials(self):
"""
Credentials for an anonymous / limited free tier, or None.
Override for providers that work without user configuration.
"""
return None
def _load_stored_credentials(self):
"""Sources #2 and #3: settings_manager first, then CredentialManager."""
if self.settings_manager and hasattr(
self.settings_manager, "get_provider_credentials"
):
try:
creds = self.settings_manager.get_provider_credentials(
YourDefaults.PROVIDER_NAME, self.country
)
if creds:
return creds
except Exception as e:
logger.debug(f"settings_manager credentials failed: {e}")
try:
# Covers both the country-nested and flat storage layouts.
return CredentialManager().load_credentials(
YourDefaults.PROVIDER_NAME, self.country
)
except Exception as e:
logger.debug(f"CredentialManager load failed: {e}")
return None
def _ensure_credentials(self) -> bool:
"""
Make self._credentials valid, re-reading storage if needed.
Call at the START of _perform_authentication(). Without it the
auth class silently depends on the caller having passed
credentials at construction -- which the registry never does.
"""
if self._credentials and self._credentials.validate():
return True
fresh = self._load_stored_credentials()
if fresh and fresh.validate():
self._credentials = fresh
return True
self._credentials = self.get_fallback_credentials()
return self._credentials is not None and self._credentials.validate()
# ------------------------------------------------------------------
# Provider-specific implementation
# ------------------------------------------------------------------
def _perform_authentication(self) -> YourAuthToken:
"""
Do the actual login HTTP call and return a YourAuthToken.
(A concrete AuthToken subclass is mandatory: BaseAuthToken is an
ABC. See models.py.)
"""
if not self._ensure_credentials():
raise CredentialsError(
f"no credentials available for {YourDefaults.PROVIDER_NAME}"
)
payload = self._build_login_payload(self._credentials)
resp = self.http_manager.post(
self._login_url(), json=payload, headers=self._login_headers()
)
return self._create_token_from_response(resp.json())
def _login_url(self) -> str:
return self.config.login_url()
def _login_headers(self) -> Dict[str, str]:
# Base headers only: build_headers() would try to fetch a token.
return self.config.get_base_headers()
def _build_login_payload(self, credentials) -> Dict[str, Any]:
# Custom credentials classes provide to_auth_payload().
# TODO: adapt to your provider's login payload.
return {
"username": credentials.username,
"password": credentials.password,
}
def _create_token_from_response(self, data: Dict[str, Any]) -> YourAuthToken:
# TODO: parse the login response. Check base/auth/base_auth.py for
# any additional required BaseAuthToken fields.
raise NotImplementedError(
"YourProviderAuth._create_token_from_response"
)
# ------------------------------------------------------------------
# Session persistence (OPTIONAL)
#
# Skip it for providers with cheap re-auth (opaque token, no refresh
# flow -- simpliTV does). Keep it for expensive flows (multi-step,
# rate-limited, device codes). If you skip it, delete _load_session /
# _save_session / _clear_session and the call in __init__.
# ------------------------------------------------------------------
def _load_session(self) -> Optional[YourAuthToken]:
if not self.settings_manager:
return None
try:
stored = self.settings_manager.load_token_data(
YourDefaults.PROVIDER_NAME, self.country
)
return YourAuthToken.from_dict(stored) if stored else None
except Exception as e:
logger.debug(f"Could not restore stored token: {e}")
return None
def _save_session(self, token) -> None:
if not self.settings_manager:
return
try:
self.settings_manager.save_token_data(
YourDefaults.PROVIDER_NAME, token.to_dict(), self.country
)
except Exception as e:
logger.debug(f"Could not persist token: {e}")
def _clear_session(self) -> None:
if not self.settings_manager:
return
try:
self.settings_manager.clear_token(
YourDefaults.PROVIDER_NAME, self.country
)
except Exception as e:
logger.debug(f"Could not clear stored token: {e}")
# ------------------------------------------------------------------
# Optional extensions -- uncomment and implement only if needed
# ------------------------------------------------------------------
# def get_scoped_token(self, scope: str, **opts) -> Optional[str]:
# """Secondary token for the given scope (RTL+: bedrock / upfront)."""
# return None
# def get_session_context(self) -> Optional[Dict[str, Any]]:
# """
# Opaque session state needed by build_headers. Magenta returns
# {"device_id": ..., "session_id": ...}; Discovery the current
# session headers.
# """
# return None
# def authorize_playback(
# self, content_id: str, **opts
# ) -> Dict[str, Any]:
# """
# Provider-specific pre-playback step (HRTi AuthorizeSession,
# MoveTV live-source fetch, Discovery playbackInfo POST, RTL+
# upfront token, Magenta persona JWT). No fixed interface; the
# name is a convention, the shape is provider-specific.
# """
# return {}
# def with_token(self, url: str, param: Optional[str] = None) -> str:
# """Token-in-URL providers: append the token. Take the parameter
# name from constants.py (YourDefaults.TOKEN_PARAM), never hardcode."""
# ...