RTL: add device login

This commit is contained in:
Nirvana
2026-08-14 19:28:42 +02:00
parent 182b6c386a
commit bbb07387f9
4 changed files with 384 additions and 35 deletions
@@ -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
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
@@ -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"
@@ -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:
@@ -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")