Files
script.service.ultimate/lib/streaming_providers/providers/magenta2/sam3_client.py
T
2026-01-26 16:32:30 +01:00

770 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# streaming_providers/providers/magenta2/sam3_client.py
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from urllib.parse import parse_qs, urlparse
from ...base.network import HTTPManager
from ...base.utils.logger import logger
from .constants import DEFAULT_REQUEST_TIMEOUT, GRANT_TYPES, SSO_USER_AGENT
@dataclass
class Sam3AuthMethod:
"""SAM3 authentication method"""
name: str
enabled: bool = False
@dataclass
class Sam3AuthMethods:
"""Available SAM3 authentication methods"""
password: bool = False
code: bool = False
line: bool = False
@dataclass
class Sam3FormField:
"""HTML form field extracted from SAM3 login pages"""
name: str
value: str
type: str = "hidden"
class Sam3Client:
"""
SAM3 authentication client implementing the complete login flow from C++ code
"""
def __init__(
self,
http_manager: HTTPManager,
session_id: str,
device_id: str,
sam3_client_id: str,
issuer_url: str = None,
oauth_token_endpoint: str = None,
line_auth_endpoint: str = None,
backchannel_start_url: str = None,
qr_code_url_template: str = None,
):
"""
Initialize SAM3 client with all required endpoints
"""
self.http_manager = http_manager
self.session_id = session_id
self.device_id = device_id
self.sam3_client_id = sam3_client_id
# STORE ALL ENDPOINTS FOR DIFFERENT AUTH FLOWS
self.issuer_url = issuer_url # For user auth flow (username/password)
self.oauth_token_endpoint = oauth_token_endpoint # For OAuth flows
self.line_auth_endpoint = line_auth_endpoint # For device line auth
self.token_endpoint = line_auth_endpoint # Backwards compatibility
# Remote login endpoints
self.backchannel_start_url = backchannel_start_url
self.qr_code_url_template = qr_code_url_template
# Other SAM3 endpoints (will be updated from OpenID config)
self.authorization_endpoint: Optional[str] = None
self.userinfo_endpoint: Optional[str] = None
# Authentication state
self.auth_methods = Sam3AuthMethods()
self.form_fields: List[Sam3FormField] = []
self.refresh_token: Optional[str] = None
self.access_tokens: Dict[str, str] = {} # scope -> token
self.__last_line_auth_response: Optional[Dict[str, Any]] = None
# Remote login handler (lazy initialized)
self._remote_login_handler: Optional["RemoteLoginHandler"] = None
logger.debug(
f"SAM3 client initialized - "
f"Issuer: {issuer_url}, "
f"OAuth: {oauth_token_endpoint}, "
f"Line: {line_auth_endpoint}, "
f"Backchannel: {backchannel_start_url}"
)
# ========================================================================
# Endpoint Resolution Helpers
# ========================================================================
def _get_token_endpoint(self) -> str:
"""Get the appropriate token endpoint with fallback logic"""
return self.oauth_token_endpoint or self.token_endpoint or self.line_auth_endpoint
def _get_line_auth_endpoint(self) -> str:
"""Get line auth endpoint with fallback"""
return self.line_auth_endpoint or self.token_endpoint
# ========================================================================
# Common Token Request Method
# ========================================================================
def _make_token_request(
self, operation: str, payload: Dict[str, Any], endpoint_override: str = None
) -> Dict[str, Any]:
"""
Common method for all token requests
Args:
operation: Operation name for logging
payload: Request payload (will be form-encoded)
endpoint_override: Optional endpoint override
Returns:
Token response data
"""
endpoint = endpoint_override or self._get_token_endpoint()
if not endpoint:
raise Exception("No token endpoint available")
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": SSO_USER_AGENT,
}
# ✅ LOG REFRESH TOKEN DETAILS BEFORE REQUEST
if "refresh_token" in payload:
rt = payload["refresh_token"]
logger.debug(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
logger.debug(f"🔄 REFRESH TOKEN DETAILS FOR {operation}")
logger.debug(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
logger.debug(f" Full refresh_token: {rt}")
logger.debug(f" Length: {len(rt)} characters")
logger.debug(f" First 30 chars: {rt[:30]}...")
logger.debug(f" Format check: {'RT2:' if rt.startswith('RT2:') else 'Other format'}")
if ":" in rt:
parts = rt.split(":")
logger.debug(f" Parts (split by ':'): {len(parts)} parts")
for i, part in enumerate(parts):
logger.debug(f" Part {i}: {part}")
logger.debug(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
response = self.http_manager.post(
endpoint,
operation=operation,
headers=headers,
data=payload,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
# ✅ ENHANCED 400 ERROR LOGGING
if response.status_code == 400:
logger.error(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
logger.error(f"❌ HTTP 400 Error for {operation}")
logger.error(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
logger.error(f"Request endpoint: {endpoint}")
logger.error(f"Request payload:")
for key, value in payload.items():
if key == "refresh_token":
# Show full refresh token in error case
logger.error(f" {key}: {value}")
logger.error(f" Length: {len(value)} chars")
elif key in ["token", "access_token"]:
logger.error(f" {key}: {value[:20]}... ({len(value)} chars)")
else:
logger.error(f" {key}: {value}")
logger.error(f"Response status: {response.status_code}")
logger.error(f"Response size: {len(response.text)} bytes")
logger.error(f"Response headers:")
for header, value in response.headers.items():
logger.error(f" {header}: {value}")
logger.error(f"Response body (raw):")
logger.error(f" '{response.text}'")
# Try to parse as JSON for structured error
try:
error_json = response.json()
logger.error(f"Response JSON (parsed):")
import json
logger.error(json.dumps(error_json, indent=2))
# Highlight specific error fields
if "error" in error_json:
logger.error(f"❌ Error type: {error_json['error']}")
if "error_description" in error_json:
logger.error(f"❌ Error description: {error_json['error_description']}")
if "message" in error_json:
logger.error(f"❌ Error message: {error_json['message']}")
if "error_uri" in error_json:
logger.error(f"❌ Error URI: {error_json['error_uri']}")
except Exception as parse_err:
logger.error(f"⚠️ Could not parse response as JSON: {parse_err}")
logger.error(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
response.raise_for_status()
data = response.json()
# Store refresh token if provided
if "refresh_token" in data:
new_rt = data["refresh_token"]
# logger.debug(f"✅ Refresh token received from {operation}")
# logger.debug(f" New refresh_token: {new_rt}")
# logger.debug(f" Length: {len(new_rt)} chars")
# Compare with old refresh token if we had one
# if hasattr(self, 'refresh_token') and self.refresh_token:
# if self.refresh_token == new_rt:
# logger.debug(f" ℹ️ Same as previous refresh_token")
# else:
# logger.debug(f" 🔄 Different from previous refresh_token")
# logger.debug(f" Old: {self.refresh_token[:30]}...")
# logger.debug(f" New: {new_rt[:30]}...")
self.refresh_token = new_rt
return data
# ========================================================================
# Authentication Methods Discovery
# ========================================================================
def discover_auth_methods(self, line_auth_url: str) -> bool:
"""
Discover available authentication methods
Matching C++ GetAuthMethods()
"""
try:
logger.debug("Discovering SAM3 authentication methods")
headers = {"User-Agent": SSO_USER_AGENT, "Accept": "application/json"}
response = self.http_manager.get(
line_auth_url,
operation="auth_methods",
headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
content = data.get("content", {})
if "supportedAuthenticationKinds" in content:
auth_kinds = content["supportedAuthenticationKinds"]
for method in auth_kinds:
if method == GRANT_TYPES["PASSWORD"]:
self.auth_methods.password = True
elif method == GRANT_TYPES["AUTH_CODE"]:
self.auth_methods.code = True
elif method == GRANT_TYPES["LINE_AUTH"]:
self.auth_methods.line = True
logger.info(
f"SAM3 auth methods: password={self.auth_methods.password}, "
f"code={self.auth_methods.code}, line={self.auth_methods.line}"
)
return True
except Exception as e:
logger.error(f"Failed to discover SAM3 auth methods: {e}")
return False
# ========================================================================
# User Authentication Flow (Username/Password)
# ========================================================================
def sam3_login(self, username: str, password: str) -> Dict[str, str]:
"""
Complete SAM3 login flow matching C++ Sam3Client::Sam3Login()
"""
try:
logger.info("Starting SAM3 login flow")
# Step 1: Get initial authorization page
auth_url = self._build_authorization_url()
logger.debug(f"Step 1: Getting authorization page: {auth_url}")
initial_response = self.http_manager.get(
auth_url,
operation="sam3_auth",
headers=self._get_sso_headers(),
timeout=DEFAULT_REQUEST_TIMEOUT,
allow_redirects=False,
)
# Step 2: Parse HTML and extract form fields
self._parse_html_form_fields(initial_response.text)
logger.debug(f"Step 2: Extracted {len(self.form_fields)} form fields")
# Step 3: Submit username to factorx endpoint
factorx_url = (
f"{self.issuer_url}/factorx"
if self.issuer_url
else "https://login.telekom-dienste.de/factorx"
)
username_data = self._build_username_payload(username)
logger.debug(f"Step 3: Submitting username to {factorx_url}")
username_response = self.http_manager.post(
factorx_url,
operation="sam3_username",
headers=self._get_sso_headers(),
data=username_data,
timeout=DEFAULT_REQUEST_TIMEOUT,
allow_redirects=False,
)
# Step 4: Parse response and extract updated form fields
self._parse_html_form_fields(username_response.text)
logger.debug(f"Step 4: Updated form fields: {len(self.form_fields)}")
# Step 5: Submit password to factorx endpoint
password_data = self._build_password_payload(username, password)
logger.debug(f"Step 5: Submitting password to {factorx_url}")
password_response = self.http_manager.post(
factorx_url,
operation="sam3_password",
headers=self._get_sso_headers(),
data=password_data,
timeout=DEFAULT_REQUEST_TIMEOUT,
allow_redirects=False,
)
# Step 6: Extract authorization code from redirect
redirect_url = password_response.headers.get("Location", "")
logger.debug(f"Step 6: Redirect URL: {redirect_url}")
if not redirect_url:
# Follow redirects manually if needed
final_response = self.http_manager.get(
factorx_url,
operation="sam3_final",
headers=self._get_sso_headers(),
timeout=DEFAULT_REQUEST_TIMEOUT,
allow_redirects=True,
)
redirect_url = final_response.url
# Step 7: Extract code and state from redirect URL
code, state = self._extract_code_and_state(redirect_url)
if not code or not state:
raise Exception("Could not extract authorization code and state from redirect")
logger.info("SAM3 login completed successfully")
return {"code": code, "state": state, "redirect_url": redirect_url}
except Exception as e:
logger.error(f"SAM3 login failed: {e}")
raise Exception(f"SAM3 login failed: {e}")
# ========================================================================
# Line Authentication (Device Token)
# ========================================================================
def get_last_line_auth_response(self) -> Optional[Dict[str, Any]]:
"""Get the last line auth response data"""
return self.__last_line_auth_response
def line_auth(self, device_token: str) -> bool:
"""
Line authentication using device token
"""
try:
endpoint = self._get_line_auth_endpoint()
if not endpoint:
raise Exception("No line auth endpoint available")
logger.debug("Performing line authentication with device token")
payload = {
"grant_type": GRANT_TYPES["LINE_AUTH"],
"client_id": self.sam3_client_id,
"token": device_token,
"scope": "tvhubs offline_access",
}
# Use common token request method
data = self._make_token_request("line_auth", payload, endpoint)
# Store the actual response data
self.__last_line_auth_response = data
if "refresh_token" in data:
logger.info("Line authentication successful, refresh token obtained")
return True
logger.warning("Line authentication succeeded but no refresh token received")
return False
except Exception as e:
logger.error(f"Line authentication failed: {e}")
return False
# ========================================================================
# Remote Login (Backchannel Authentication)
# ========================================================================
def update_sam3_client_id(self, new_client_id: str) -> None:
"""Update SAM3 client ID and recreate remote login handler"""
old_client_id = self.sam3_client_id
self.sam3_client_id = new_client_id
# Invalidate the existing remote login handler so it gets recreated with new client ID
if self._remote_login_handler:
logger.debug(
f"Invalidating remote login handler due to client ID change: {old_client_id} -> {new_client_id}"
)
self._remote_login_handler = None
logger.info(f"✓ Updated SAM3 client ID to: {new_client_id}")
def _get_remote_login_handler(self) -> Optional["RemoteLoginHandler"]:
"""
Get or create remote login handler
"""
# Return existing handler if available
if self._remote_login_handler:
return self._remote_login_handler
# Check if we have required endpoints
has_backchannel = bool(self.backchannel_start_url)
has_token_endpoint = bool(self._get_token_endpoint())
has_qr_template = bool(self.qr_code_url_template)
if not all([has_backchannel, has_token_endpoint, has_qr_template]):
# Detailed debug logging
logger.debug("🚫 Remote login not available - missing endpoints:")
logger.debug(
f" • backchannel_start_url: {has_backchannel} -> {self.backchannel_start_url}"
)
logger.debug(
f" • token_endpoint: {has_token_endpoint} -> {self._get_token_endpoint()}"
)
logger.debug(
f" • qr_code_url_template: {has_qr_template} -> {self.qr_code_url_template}"
)
return None
# LAZY IMPORT to avoid circular dependency
try:
from .remote_login_handler import RemoteLoginHandler
except ImportError as e:
logger.error(f"Failed to import RemoteLoginHandler: {e}")
return None
# Create handler with CURRENT client ID
self._remote_login_handler = RemoteLoginHandler(
http_manager=self.http_manager,
sam3_client_id=self.sam3_client_id, # Use current value
backchannel_start_url=self.backchannel_start_url,
token_endpoint=self._get_token_endpoint(),
qr_code_url_template=self.qr_code_url_template,
)
logger.info(
f"✓ Remote login handler initialized with client_id: {self.sam3_client_id[:8]}..."
)
return self._remote_login_handler
def can_use_remote_login(self) -> bool:
"""Check if remote login is available"""
return self._get_remote_login_handler() is not None
def remote_login(self, scope: str = "tvhubs offline_access") -> Optional[Dict[str, Any]]:
"""
Perform complete remote login (backchannel auth) flow
Args:
scope: OAuth scopes to request
Returns:
Token data dict if successful, None if failed/timeout/cancelled
"""
handler = self._get_remote_login_handler()
if not handler:
logger.error("Remote login not available")
return None
# Perform complete flow (notifier is already set in handler)
token_data = handler.perform_complete_flow(scope)
# Store refresh token if we got one
if token_data and "refresh_token" in token_data:
self.refresh_token = token_data["refresh_token"]
logger.info("✓ Remote login successful, refresh token obtained")
return token_data
def get_remote_login_status(self) -> Optional[Dict[str, Any]]:
"""Get current remote login session status"""
handler = self._get_remote_login_handler()
if not handler:
return None
return handler.get_session_status()
# ========================================================================
# Generic Token Operations
# ========================================================================
def get_token(
self, grant_type: str, scope: str, credential1: str = "", credential2: str = ""
) -> str:
"""
Generic token acquisition
"""
try:
logger.debug(f"Getting token with grant_type: {grant_type}, scope: {scope}")
payload = {"grant_type": grant_type, "client_id": self.sam3_client_id}
# Add credentials based on grant type
if grant_type == GRANT_TYPES["REFRESH_TOKEN"]:
payload["refresh_token"] = credential1
payload["scope"] = f"{scope} offline_access"
elif grant_type == GRANT_TYPES["REMOTE_LOGIN"]:
payload["auth_req_id"] = credential1
payload["auth_req_sec"] = credential2
# Use common token request method
data = self._make_token_request("get_token", payload)
access_token = data.get("access_token")
if not access_token:
raise Exception("No access token in token response")
# Store token by scope
self.access_tokens[scope] = access_token
logger.debug(f"Token obtained for scope: {scope}")
return access_token
except Exception as e:
logger.error(f"Token acquisition failed: {e}")
raise
def refresh_access_token(self, scope: str) -> str:
"""
Refresh access token
Matching C++ Sam3Client::RefreshToken()
"""
if not self.refresh_token:
raise Exception("No refresh token available")
return self.get_token(GRANT_TYPES["REFRESH_TOKEN"], scope, self.refresh_token)
def get_access_token(self, scope: str) -> str:
"""
Get access token for specific scope with line auth fallback
"""
# Return cached token if available
if scope in self.access_tokens:
return self.access_tokens[scope]
# Try to use line auth established session first
if self.refresh_token:
try:
token = self.refresh_access_token(scope)
if token:
return token
except Exception as e:
logger.warning(f"Token refresh failed for scope {scope}: {e}")
# If no refresh token available, we can't get an access token
logger.warning(f"No access token available for scope {scope} - line auth may be needed")
return ""
def get_token_endpoint(self) -> str:
"""Public method to get token endpoint"""
return self._get_token_endpoint()
def get_client_status(self) -> Dict[str, Any]:
"""
Public method to get SAM3 client status for debugging
"""
return {
"initialized": True,
"has_backchannel_url": bool(self.backchannel_start_url),
"has_token_endpoint": bool(self._get_token_endpoint()),
"has_qr_code_url": bool(self.qr_code_url_template),
"backchannel_url": self.backchannel_start_url,
"token_endpoint": self._get_token_endpoint(),
"qr_code_url": self.qr_code_url_template,
}
def set_qr_code_url(self, qr_code_url: str) -> None:
"""Public method to set QR code URL"""
self.qr_code_url_template = qr_code_url
logger.debug(f"QR code URL set: {qr_code_url}")
# ========================================================================
# HTML Form Parsing Helpers
# ========================================================================
def _build_authorization_url(self) -> str:
"""Build authorization URL matching C++ implementation"""
if not self.authorization_endpoint:
# Fallback to standard URL
return "https://login.telekom-dienste.de/oauth2/auth"
params = {
"client_id": self.sam3_client_id,
"redirect_uri": "https://web2.magentatv.de/authn/idm",
"response_type": "code",
"scope": "openid offline_access",
}
param_string = "&".join([f"{k}={v}" for k, v in params.items()])
return f"{self.authorization_endpoint}?{param_string}"
def _parse_html_form_fields(self, html_content: str) -> None:
"""
Parse HTML form fields matching C++ ParseHtml()
"""
self.form_fields.clear()
# Look for form with id="login" or similar
form_start = html_content.find('form id="login"')
if form_start == -1:
form_start = html_content.find("<form")
if form_start == -1:
logger.warning("No form found in HTML content")
return
form_end = html_content.find("</form>", form_start)
if form_end == -1:
logger.warning("No form end tag found")
return
form_html = html_content[form_start:form_end]
# Find all hidden input fields
pattern = r'<input[^>]*type="hidden"[^>]*name="([^"]*)"[^>]*value="([^"]*)"[^>]*>'
matches = re.findall(pattern, form_html, re.IGNORECASE)
for name, value in matches:
self.form_fields.append(Sam3FormField(name=name, value=value))
logger.debug(f"Found form field: {name} = {value}")
# Also look for input fields without explicit type="hidden"
pattern_all = r'<input[^>]*name="([^"]*)"[^>]*value="([^"]*)"[^>]*>'
all_matches = re.findall(pattern_all, form_html, re.IGNORECASE)
for name, value in all_matches:
# Skip duplicates
if not any(field.name == name for field in self.form_fields):
self.form_fields.append(Sam3FormField(name=name, value=value))
logger.debug(f"Found additional form field: {name} = {value}")
def _build_username_payload(self, username: str) -> str:
"""Build username submission payload"""
payload_parts = []
# Add all hidden fields
for field in self.form_fields:
payload_parts.append(f"{field.name}={field.value}")
# Add username field
payload_parts.append(f"pw_usr={self._url_encode(username)}")
payload_parts.append("pw_submit=")
payload_parts.append("hidden_pwd=")
return "&".join(payload_parts)
def _build_password_payload(self, username: str, password: str) -> str:
"""Build password submission payload"""
payload_parts = []
# Add all hidden fields (they might have changed)
for field in self.form_fields:
payload_parts.append(f"{field.name}={field.value}")
# Add password fields
payload_parts.append(f"hidden_usr={self._url_encode(username)}")
payload_parts.append(f"pw_pwd={self._url_encode(password)}")
payload_parts.append("pw_submit=")
return "&".join(payload_parts)
@staticmethod
def _extract_code_and_state(redirect_url: str) -> tuple[str, str]:
"""Extract code and state from redirect URL"""
try:
parsed = urlparse(redirect_url)
query_params = parse_qs(parsed.query)
code = query_params.get("code", [None])[0]
state = query_params.get("state", [None])[0]
if code and state:
logger.debug(f"Extracted code: {code[:8]}..., state: {state}")
return code, state
# Also check fragment (#) for SPA redirects
if "#" in redirect_url:
fragment = redirect_url.split("#")[1]
fragment_params = parse_qs(fragment)
code = fragment_params.get("code", [None])[0]
state = fragment_params.get("state", [None])[0]
return code, state
except Exception as e:
logger.error(f"Failed to extract code and state from URL: {e}")
return None, None
@staticmethod
def _get_sso_headers() -> Dict[str, str]:
"""Get headers for SSO requests"""
return {
"User-Agent": SSO_USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Origin": "https://web2.magentatv.de",
"Referer": "https://web2.magentatv.de/",
}
@staticmethod
def _url_encode(value: str) -> str:
"""URL encode a string"""
from urllib.parse import quote
return quote(value)
# ========================================================================
# Configuration Updates
# ========================================================================
def update_endpoints(self, openid_config: Dict[str, Any]) -> None:
"""Update endpoints from OpenID configuration"""
# Update issuer URL from OpenID config
if "issuer" in openid_config:
self.issuer_url = openid_config["issuer"]
# Update other endpoints for user authentication flows
self.authorization_endpoint = openid_config.get("authorization_endpoint")
self.userinfo_endpoint = openid_config.get("userinfo_endpoint")
# Get backchannel auth start endpoint
if "backchannel_auth_start" in openid_config:
self.backchannel_start_url = openid_config["backchannel_auth_start"]
logger.debug(f"Backchannel auth endpoint from OpenID: {self.backchannel_start_url}")
# OAuth token endpoint might be different from line auth endpoint
if "token_endpoint" in openid_config:
self.oauth_token_endpoint = openid_config["token_endpoint"]
logger.debug(
f"Updated SAM3 endpoints: "
f"issuer={self.issuer_url}, "
f"auth={bool(self.authorization_endpoint)}, "
f"oauth_token={bool(self.oauth_token_endpoint)}, "
f"line_auth={bool(self.line_auth_endpoint)}, "
f"backchannel={bool(self.backchannel_start_url)}"
)