# streaming_providers/providers/rtlplus/auth.py import base64 import json import time import hashlib from typing import Any, Dict, Optional, List from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator from ...base.models.proxy_models import ProxyConfig from ...base.utils.logger import logger from .constants import RTLPlusConfig, RTLPlusDefaults from .models import RTLPlusAuthToken, RTLPlusClientCredentials, RTLPlusUserCredentials class RTLPlusAuthenticator(BaseOAuth2Authenticator): def __init__( self, credentials=None, config_dir=None, client_version=None, device_id=None, proxy_config: Optional[ProxyConfig] = None, http_manager=None, ): config_dict = {} if client_version: config_dict["client_version"] = client_version if device_id: config_dict["device_id"] = device_id if proxy_config is None: from ...base.network import ProxyConfigManager proxy_mgr = ProxyConfigManager(config_dir) proxy_config = proxy_mgr.get_proxy_config("rtlplus") super().__init__( provider_name="rtlplus", credentials=credentials, config_dir=config_dir, proxy_config=proxy_config, http_manager=http_manager, ) # AFTER super().__init__ so the base class can't overwrite it self._config = RTLPlusConfig(config_dict) self._client_id = None self._bedrock_token: Optional[str] = None self._bedrock_token_expiry: float = 0 self._cached_user_id: Optional[str] = None self._selected_profile_id: Optional[str] = None self.enable_oidc_discovery( discovery_url=RTLPlusDefaults.AUTH_REALM_BASE, cache_ttl=86400 # Cache for 24 hours ) if self.credentials is None: self.credentials = self._get_default_credentials() @property def auth_endpoint(self) -> str: return self.config.auth_endpoint @property def oauth_client_id(self) -> str: # Dynamic: refresh_token grants must be replayed against the same # client_id the token was originally issued under. Web login uses # BEDROCK_CLIENT_ID; the device/QR flow uses DEVICE_CLIENT_ID. # _build_refresh_payload() (base class, base_oauth2_auth.py) reads # this property, so keeping it dynamic is what makes refresh work # for both flows without overriding _build_refresh_payload(). current = getattr(self, "_current_token", None) login_client = getattr(current, "login_client", None) return login_client or RTLPlusDefaults.BEDROCK_CLIENT_ID @property def oauth_scope(self) -> str: return "openid email profile" @property def oauth_redirect_uri(self) -> str: # Used in authorize step return "https://plus.rtl.de/tv-programm" @property def oauth_token_redirect_uri(self) -> str: # Used in the token exchange step return f"{self.config.base_website}silent-sso-iframe.html" @property def config(self) -> RTLPlusConfig: return self._config def _get_auth_headers(self) -> Dict[str, str]: return self.config.get_auth_headers() def _build_auth_payload(self) -> Dict[str, Any]: return self.credentials.to_auth_payload() def _get_default_credentials(self): try: config_creds = self._get_anonymous_credentials_from_config() if config_creds: return RTLPlusClientCredentials( client_id=config_creds.get("client_id", RTLPlusDefaults.ANONYMOUS_CLIENT_ID), client_secret=config_creds.get("client_secret", RTLPlusDefaults.ANONYMOUS_CLIENT_SECRET), ) except Exception as e: logger.warning(f"Could not get dynamic credentials: {e}") return RTLPlusClientCredentials() def _create_token_from_response(self, response_data: Dict[str, Any]) -> RTLPlusAuthToken: # Carry forward login_client so refresh keeps using the right # client_id. Normal OAuth token responses never include this key # (it's our own bookkeeping); if absent, fall back to whatever # client issued the token being replaced. login_client = response_data.get("login_client") if not login_client: current = getattr(self, "_current_token", None) login_client = getattr(current, "login_client", None) return RTLPlusAuthToken( access_token=response_data["access_token"], token_type=response_data.get("token_type", "Bearer"), expires_in=response_data.get("expires_in", 86400), issued_at=response_data.get("issued_at", time.time()), refresh_token=response_data.get("refresh_token"), refresh_expires_in=response_data.get("refresh_expires_in", 0), not_before_policy=response_data.get("not-before-policy"), scope=response_data.get("scope", ""), login_client=login_client, ) def get_current_token_level(self) -> TokenAuthLevel: for attr in ("token", "_token", "current_token", "_current_token", "_access_token"): tok = getattr(self, attr, None) if tok is not None: return self._classify_token(tok) return TokenAuthLevel.UNKNOWN def get_fallback_credentials(self): return self._get_default_credentials() def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: if not token or not token.access_token: return TokenAuthLevel.UNKNOWN try: parts = token.access_token.split(".") if len(parts) < 2: return TokenAuthLevel.UNKNOWN payload_segment = parts[1] padding = 4 - len(payload_segment) % 4 if padding != 4: payload_segment += "=" * padding payload_json = base64.b64decode(payload_segment) payload = json.loads(payload_json) client_id = payload.get("clientId") is_guest = payload.get("isGuest", False) preferred_username = payload.get("preferred_username") email = payload.get("email") if preferred_username or email: return TokenAuthLevel.USER_AUTHENTICATED if is_guest and client_id == "anonymous-user": return TokenAuthLevel.CLIENT_CREDENTIALS return TokenAuthLevel.UNKNOWN except Exception as e: logger.warning(f"RTL+ Error classifying token: {e}") return TokenAuthLevel.UNKNOWN def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]: import uuid return self._perform_generic_form_login( username=username, password=password, form_selector_pattern=r'