diff --git a/lib/streaming_providers/providers/rtlplus/auth.py b/lib/streaming_providers/providers/rtlplus/auth.py index bf54dc4..1048003 100644 --- a/lib/streaming_providers/providers/rtlplus/auth.py +++ b/lib/streaming_providers/providers/rtlplus/auth.py @@ -44,7 +44,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): http_manager=http_manager, ) - # ✅ AFTER super().__init__ so the base class can't overwrite it + # 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 @@ -66,7 +66,15 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): @property def oauth_client_id(self) -> str: - return RTLPlusDefaults.BEDROCK_CLIENT_ID # "bedrock-m6group_web" + # 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: @@ -106,6 +114,15 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): 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"), @@ -115,6 +132,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): 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: @@ -171,9 +189,9 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): extra_params={ "prompt": "login", "nonce": str(uuid.uuid4()), - "claim": "sub", # ← add this - "state": '{"redirectUrl":"#"}', # ← add this - "auth_flow_type": "login", # ← add this + "claim": "sub", + "state": '{"redirectUrl":"#"}', + "auth_flow_type": "login", }, additional_form_data={"credentialId": "", "rememberMe": "on"}, ) @@ -284,17 +302,10 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): Based on analysis, this appears to be a SHA-1 hash of device_id + timestamp. """ - # Convert timestamp to string ts_str = str(timestamp) - - # Try SHA-1 of device_id + timestamp (most likely) data = f"{device_id}{ts_str}" token = hashlib.sha1(data.encode()).hexdigest() - # Alternative: Try with colon separator - # data = f"{device_id}:{ts_str}" - # token = hashlib.sha1(data.encode()).hexdigest() - logger.debug(f"Generated auth token for device {device_id} at {timestamp}: {token}") return token @@ -310,29 +321,24 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): if not oauth_token: raise ValueError("No OAuth token available for Bedrock token request") - # Get user ID (Gigya UID) user_id = self.get_user_id_from_token() if not user_id: logger.warning("No user ID available for Bedrock token request") - # Continue without user_id - might still work for anonymous? - # Get timestamp and generate auth token timestamp = self._get_server_timestamp() auth_token = self._generate_auth_token(self.config.device_id, timestamp) logger.debug(f"Timestamp: {timestamp}, Auth token: {auth_token}") - # Get profile ID if available profile_id = self.get_selected_profile_id() logger.debug(f"Using profile_id: {profile_id}") - # Get headers with profile_id and user_id headers = self.config.get_bedrock_token_headers( oauth_token=oauth_token, auth_token=auth_token, timestamp=timestamp, profile_id=profile_id, - user_id=user_id, # Add user_id parameter + user_id=user_id, ) logger.debug( @@ -363,7 +369,6 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): f"Bedrock token obtained with profile: {decoded.get('profileid', 'none')}, expires: {self._bedrock_token_expiry}") except Exception as e: logger.debug(f"Could not decode Bedrock token expiry: {e}") - # Set default expiry (1 hour) self._bedrock_token_expiry = time.time() + 3600 logger.debug("RTL+ Bedrock token obtained successfully") @@ -375,10 +380,6 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): The upfront token is used as x-dt-auth-token when requesting licenses from DRMToday. - - Args: - content_id: DRM content ID (e.g., "dashcenc_rtlde_vox") - uid: User ID from OAuth token """ oauth_token = self.get_bearer_token() bedrock_token = self.get_bedrock_token() @@ -406,9 +407,6 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): def get_user_id_from_token(self) -> Optional[str]: """ Extract user ID (sub claim) from the current OAuth token. - - Returns: - User ID string or None if not available """ if self._cached_user_id: return self._cached_user_id @@ -430,11 +428,9 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): payload_json = base64.b64decode(payload_segment) payload = json.loads(payload_json) - # The user ID is in the 'sub' claim # Format: f:83a2e227-f27d-4d33-a811-33ad588170c4:1052940424 sub = payload.get("sub", "") - # Extract numeric ID from the end if present if ":" in sub: self._cached_user_id = sub.split(":")[-1] else: @@ -465,7 +461,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): logger.info("RTL+ user credentials saved successfully") self.invalidate_token() self.invalidate_bedrock_token() - self._cached_user_id = None # Clear cached user ID + self._cached_user_id = None else: logger.error("Failed to save RTL+ user credentials") @@ -540,7 +536,6 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): profiles = self.get_user_profiles(user_id) logger.debug(f"Fetched profiles: {profiles}") - # Select first adult profile (not kid profile) adult_profiles = [p for p in profiles if p.get("profile_type") == "adult"] if not adult_profiles: logger.error("No adult profiles found") @@ -553,15 +548,12 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): logger.error(f"Failed to fetch/select profile: {e}") return False - # Store the selected profile ID self._selected_profile_id = profile_id - # Save to credentials for persistence if hasattr(self.credentials, "profile_id"): self.credentials.profile_id = profile_id self.save_credentials(self.credentials) - # Invalidate Bedrock token so it gets re-issued with the profile ID self.invalidate_bedrock_token() logger.info(f"Profile selected successfully: {profile_id}") @@ -572,7 +564,6 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): if hasattr(self, "_selected_profile_id") and self._selected_profile_id: return self._selected_profile_id - # Try to load from stored credentials if hasattr(self.credentials, "profile_id") and self.credentials.profile_id: return self.credentials.profile_id @@ -586,4 +577,42 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): if self.has_user_credentials(): return self.select_profile() - return False \ No newline at end of file + return False + + # -------------------------------------------------------------------------- + # Remote Login (Device Code / QR) — overrides base's PKCE remote login, + # which uses a different protocol RTL+ does not support for this flow. + # See remote_login_handler.py in this package for the implementation. + # -------------------------------------------------------------------------- + + def _perform_remote_login_flow(self) -> Dict[str, Any]: + """ + Called automatically by the base class's authenticate_with_fallback() + when _perform_oauth_authorization_code_flow() raises WafBlockedException. + + RTL+'s remote login is a genuine OAuth2 Device Authorization Grant + (RFC 8628) against a dedicated TV/device client, not the PKCE + authorization_code + callback flow the base class implements by + default (BaseOAuth2Authenticator._perform_remote_login_flow / + RemoteLoginManager) — that flow's redirect_uri isn't reachable + from a phone completing the login, so it doesn't apply here. + """ + from .remote_login_handler import RTLRemoteLoginHandler + + handler = RTLRemoteLoginHandler( + http_manager=self.http_manager, + device_client_id=RTLPlusDefaults.DEVICE_CLIENT_ID, + device_auth_url=RTLPlusDefaults.DEVICE_AUTH_ENDPOINT, + token_endpoint=RTLPlusDefaults.AUTH_ENDPOINT, + provider_name="RTL+", + ) + + token_data = handler.perform_complete_flow() + + if not token_data: + raise Exception("RTL+ remote login failed") + + # Tag which client issued this token so oauth_client_id / + # _create_token_from_response can route refreshes correctly. + token_data.setdefault("login_client", RTLPlusDefaults.DEVICE_CLIENT_ID) + return token_data \ No newline at end of file diff --git a/lib/streaming_providers/providers/rtlplus/constants.py b/lib/streaming_providers/providers/rtlplus/constants.py index f4fbaeb..9c6ccc3 100644 --- a/lib/streaming_providers/providers/rtlplus/constants.py +++ b/lib/streaming_providers/providers/rtlplus/constants.py @@ -38,11 +38,21 @@ class RTLPlusDefaults: CLIENT_VERSION_FALLBACK = "2025.6.26.0" CLIENT_ID = f"rtlplus-{PLATFORM_DEFAULT}" BEDROCK_CLIENT_ID = "bedrock-m6group_web" + # Dedicated client for the OAuth2 Device Authorization Grant (RFC 8628). + # Must NOT be BEDROCK_CLIENT_ID -- /auth/device only accepts clients + # registered for the device-code flow. Confirmed against the working + # legacy addon (auth.py: DEVICE_CLIENT_ID = 'bedrock-androidtv'). + DEVICE_CLIENT_ID = "bedrock-androidtv" # API endpoints AUTH_REALM_BASE = "https://auth.rtl.de/auth/realms/rtlplus" AUTH_BASE_URL = f"{AUTH_REALM_BASE}/protocol/openid-connect" AUTH_ENDPOINT = f"{AUTH_BASE_URL}/token" + # OAuth2 Device Authorization Grant endpoint (RFC 8628). Confirmed + # correct against the working legacy addon -- it lives under + # AUTH_BASE_URL (.../protocol/openid-connect/auth/device), NOT under + # AUTH_REALM_BASE directly. + DEVICE_AUTH_ENDPOINT = f"{AUTH_BASE_URL}/auth/device" GRAPHQL_ENDPOINT = "https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql" BASE_WEBSITE = "https://plus.rtl.de/" CONFIG_ENDPOINT = "https://plus.rtl.de/assets/config/config.json" diff --git a/lib/streaming_providers/providers/rtlplus/models.py b/lib/streaming_providers/providers/rtlplus/models.py index e02527a..792e7d2 100644 --- a/lib/streaming_providers/providers/rtlplus/models.py +++ b/lib/streaming_providers/providers/rtlplus/models.py @@ -64,6 +64,7 @@ class RTLPlusAuthToken(BaseAuthToken): refresh_expires_in: int = 0, not_before_policy: Optional[int] = None, scope: str = "", + login_client: Optional[str] = None, ): super().__init__( access_token=access_token, @@ -76,6 +77,13 @@ class RTLPlusAuthToken(BaseAuthToken): self.refresh_expires_in = refresh_expires_in self.not_before_policy = not_before_policy self.scope = scope + # Which OAuth client_id this token (and its refresh_token) was + # issued under. RTL+ registers separate clients per flow + # (BEDROCK_CLIENT_ID for web login, DEVICE_CLIENT_ID for the + # device-code/QR flow) and refresh_token grants must be replayed + # against the SAME client_id or the auth server rejects them. + # Mirrors the legacy addon's `login_client` tracking. + self.login_client = login_client def to_dict(self) -> Dict[str, Any]: """Convert token to dictionary""" @@ -88,6 +96,7 @@ class RTLPlusAuthToken(BaseAuthToken): "refresh_expires_in": self.refresh_expires_in, "not_before_policy": self.not_before_policy, "scope": self.scope, + "login_client": self.login_client, } @classmethod @@ -102,6 +111,7 @@ class RTLPlusAuthToken(BaseAuthToken): refresh_expires_in=data.get("refresh_expires_in", 0), not_before_policy=data.get("not-before-policy"), scope=data.get("scope", ""), + login_client=data.get("login_client"), ) def is_valid(self) -> bool: diff --git a/lib/streaming_providers/providers/rtlplus/remote_login_handler.py b/lib/streaming_providers/providers/rtlplus/remote_login_handler.py new file mode 100644 index 0000000..d440d7c --- /dev/null +++ b/lib/streaming_providers/providers/rtlplus/remote_login_handler.py @@ -0,0 +1,300 @@ +# streaming_providers/providers/rtlplus/remote_login_handler.py +""" +Remote Login Handler for RTL+ — OAuth2 Device Authorization Grant (RFC 8628) + +Same architecture as providers/magenta2/remote_login_handler.py: a +provider-specific handler that owns the protocol details, and delegates +QR display / countdown / polling-thread lifecycle to the generic +NotificationFactory adapters. No third-party QR service is used — the +Kodi adapter renders the QR locally via qr_generator.generate_qr_code_png, +and the console adapter just prints the URL/code. + +Endpoint and client_id are confirmed against the working legacy Kodi +addon's _device_login() implementation: + - device auth: POST {AUTH_BASE_URL}/auth/device (NOT AUTH_REALM_BASE) + - client_id: 'bedrock-androidtv' (NOT the web client) + - token poll: POST {AUTH_ENDPOINT} with + grant_type=urn:ietf:params:oauth:grant-type:device_code +""" + +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ...base.network import HTTPManager +from ...base.ui import NotificationFactory, NotificationInterface, NotificationResult +from ...base.utils.logger import logger + + +@dataclass +class RTLDeviceLoginSession: + """RTL+ device-code login session data""" + + device_code: str + user_code: str + verification_uri: str + verification_uri_complete: str + expires_in: int + interval: int + started_at: float + + +class RTLRemoteLoginHandler: + """ + Handles the OAuth2 Device Authorization Grant for RTL+. + + Responsibilities: + - Start the device auth session (/auth/device) + - Poll the token endpoint until the user completes login on another device + - Coordinate with the generic notification system for QR/code display + """ + + def __init__( + self, + http_manager: HTTPManager, + device_client_id: str, + device_auth_url: str, + token_endpoint: str, + notifier: Optional[NotificationInterface] = None, + provider_name: str = "RTL+", + request_timeout: int = 15, + ): + self.http_manager = http_manager + self.device_client_id = device_client_id + self.device_auth_url = device_auth_url + self.token_endpoint = token_endpoint + self.provider_name = provider_name + self.request_timeout = request_timeout + + if notifier: + self._notifier = notifier + else: + self._notifier = NotificationFactory.create( + http_manager=http_manager, + provider_name=provider_name, + success_message=f"{provider_name} login successful", + failure_template=f"{provider_name} login failed: {{reason}}", + ) + + self._current_session: Optional[RTLDeviceLoginSession] = None + + logger.debug(f"RTLRemoteLoginHandler initialized with {self._notifier.__class__.__name__}") + + def set_notifier(self, notifier: NotificationInterface) -> None: + """Set custom notification interface""" + self._notifier = notifier + + # -------------------------------------------------------------------------- + # Device Authorization Grant + # -------------------------------------------------------------------------- + + def start_remote_login(self, scope: str = "openid") -> RTLDeviceLoginSession: + """ + Start the device authorization request. + + Raises: + Exception: If the device auth request fails or the response is + missing device_code/user_code. + """ + logger.info("Starting RTL+ remote login (Device Authorization Grant)") + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + payload = {"client_id": self.device_client_id, "scope": scope} + + try: + response = self.http_manager.post( + self.device_auth_url, + operation="device_auth", + headers=headers, + data=payload, + timeout=self.request_timeout, + ) + response.raise_for_status() + data = response.json() + except Exception as e: + logger.error(f"RTL+ device auth request failed: {e}") + raise Exception(f"RTL+ remote login start failed: {e}") + + device_code = data.get("device_code", "") + user_code = data.get("user_code", "") + verification_uri = data.get("verification_uri", "") + verification_uri_complete = data.get( + "verification_uri_complete", + f"{verification_uri}?user_code={user_code}" if verification_uri else "", + ) + expires_in = int(data.get("expires_in", 600)) + interval = max(int(data.get("interval", 5)), 5) + + if not device_code or not user_code: + raise Exception("RTL+ device auth response missing device_code/user_code") + + session = RTLDeviceLoginSession( + device_code=device_code, + user_code=user_code, + verification_uri=verification_uri, + verification_uri_complete=verification_uri_complete, + expires_in=expires_in, + interval=interval, + started_at=time.time(), + ) + self._current_session = session + + logger.info( + f"RTL+ device login started: user_code={user_code}, " + f"expires_in={expires_in}s, interval={interval}s" + ) + return session + + def poll_for_token(self, session: RTLDeviceLoginSession) -> Optional[Dict[str, Any]]: + """ + Poll the token endpoint until the device code is confirmed, expires, + or is denied. Intended for use as a poll_callback: returns token + data (dict) on success, None on timeout/denial/cancellation. + """ + headers = {"Content-Type": "application/x-www-form-urlencoded"} + payload = { + "client_id": self.device_client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": session.device_code, + } + + start_time = session.started_at + deadline = start_time + session.expires_in + interval = session.interval + + while True: + now = time.time() + if now >= deadline: + logger.warning("RTL+ device login: session expired") + return None + + if self._notifier.is_cancelled(): + logger.info("RTL+ device login: user cancelled") + return None + + try: + response = self.http_manager.post( + self.token_endpoint, + operation="device_poll", + headers=headers, + data=payload, + timeout=self.request_timeout, + ) + except Exception as e: + logger.debug(f"RTL+ device poll network error (will retry): {e}") + time.sleep(min(interval, max(0.5, deadline - now))) + continue + + if response.status_code == 200: + logger.info("RTL+ device login: authentication confirmed") + return response.json() + + if response.status_code == 400: + try: + error_data = response.json() + except Exception: + error_data = {} + error = error_data.get("error", "") + + if error == "authorization_pending": + pass + elif error == "slow_down": + interval = min(interval + 5, 30) + logger.debug(f"RTL+ device login: slow_down, new interval={interval}s") + elif error == "expired_token": + logger.info("RTL+ device login: code expired") + return None + elif error == "access_denied": + logger.info("RTL+ device login: user denied") + return None + else: + logger.warning(f"RTL+ device login: unexpected error '{error}'") + return None + else: + logger.warning( + f"RTL+ device login: unexpected status {response.status_code}: {response.text[:200]}" + ) + return None + + time.sleep(min(interval, max(0.5, deadline - time.time()))) + + def perform_complete_flow(self, scope: str = "openid") -> Optional[Dict[str, Any]]: + """ + Perform the complete device-code flow with QR display + polling. + + Mirrors providers/magenta2/remote_login_handler.py's + perform_complete_flow(): uses the notifier's integrated polling + support when available (Kodi — auto-closes the dialog on success), + otherwise falls back to displaying the code/URL once and polling + manually (console). + """ + try: + session = self.start_remote_login(scope) + + if hasattr(self._notifier, "show_remote_login_with_polling"): + logger.info("Using integrated polling (threaded)") + + def poll_callback(): + return self.poll_for_token(session) + + result = self._notifier.show_remote_login_with_polling( + login_code=session.user_code, + qr_target_url=session.verification_uri_complete, + expires_in=session.expires_in, + interval=session.interval, + poll_callback=poll_callback, + ) + + if result == NotificationResult.CONTINUE: + if hasattr(self._notifier, "get_token_data"): + token_data = self._notifier.get_token_data() + if token_data: + logger.info("✓ RTL+ remote login completed") + self._notifier.close(success=True) + return token_data + logger.warning("No token data available") + return None + logger.error("Notifier doesn't support get_token_data()") + return None + + logger.warning(f"RTL+ remote login result: {result}") + return None + + # Console adapter or other — manual polling + logger.info("Using manual polling") + + result = self._notifier.show_remote_login( + login_code=session.user_code, + qr_target_url=session.verification_uri_complete, + expires_in=session.expires_in, + interval=session.interval, + ) + + if result != NotificationResult.CONTINUE: + logger.warning(f"Failed to show notification: {result}") + return None + + token_data = self.poll_for_token(session) + + if token_data: + logger.info("✓ RTL+ remote login completed") + self._notifier.close(success=True) + else: + logger.warning("RTL+ remote login timed out or cancelled") + self._notifier.close(success=False, message="Timeout or cancelled") + + return token_data + + except Exception as e: + logger.error(f"RTL+ remote login flow failed: {e}") + self._notifier.close(success=False, message=str(e)) + return None + finally: + self._current_session = None + + def cancel(self) -> None: + """Cancel current session""" + if self._current_session: + logger.info("Cancelling RTL+ remote login session") + self._current_session = None + self._notifier.close(success=False, message="Cancelled") \ No newline at end of file