diff --git a/lib/streaming_providers/providers/magenta2/auth.py b/lib/streaming_providers/providers/magenta2/auth.py index 0c959dc..7215d61 100644 --- a/lib/streaming_providers/providers/magenta2/auth.py +++ b/lib/streaming_providers/providers/magenta2/auth.py @@ -2,8 +2,6 @@ # -*- coding: utf-8 -*- import uuid import time -import base64 -import json from typing import Dict, Optional, Any from dataclasses import dataclass, field @@ -18,6 +16,7 @@ from .sam3_client import Sam3Client from .sso_client import SsoClient from .taa_client import TaaClient, TaaAuthResult from .token_flow_manager import TokenFlowManager +from .token_utils import JWTParser, PersonaTokenComposer from .constants import ( SUPPORTED_COUNTRIES, @@ -194,66 +193,17 @@ class Magenta2AuthToken(BaseAuthToken): return base_dict + def compose_persona_token(self) -> Optional[str]: - """ - Compose final persona token from account URI and dc_cts_persona_token - This is the CRITICAL step matching the C++ implementation: - - C++: rawToken = accountUri + ":" + dc_cts_personaToken - personaToken = base64_encode(rawToken) - - Format: Base64(accountUri + ":" + dc_cts_personaToken) - Example: Base64("urn:theplatform:auth:root:mdeprod:abcd1234-5678-90ef") - """ - if not self.account_uri or not self.dc_cts_persona_token: - logger.warning( - f"Cannot compose persona token - " - f"account_uri: {bool(self.account_uri)}, " - f"dc_cts_persona_token: {bool(self.dc_cts_persona_token)}" - ) - return None - - try: - # Compose: accountUri + ":" + dc_cts_persona_token - raw_token = f"{self.account_uri}:{self.dc_cts_persona_token}" - - # Base64 encode - self.composed_persona_token = base64.b64encode( - raw_token.encode('utf-8') - ).decode('utf-8') - - logger.info("✓ Persona token composed successfully") - logger.debug(f"Account URI: {self.account_uri}") - logger.debug(f"Composed token preview: {self.composed_persona_token[:50]}...") - - return self.composed_persona_token - - except Exception as e: - logger.error(f"Failed to compose persona token: {e}") - return None + self.composed_persona_token = PersonaTokenComposer.compose_from_components( + account_uri=self.account_uri, + dc_cts_persona_token=self.dc_cts_persona_token + ) + return self.composed_persona_token def get_jwt_claims(self) -> Optional[Dict[str, Any]]: - """Extract JWT claims from access token for classification""" - try: - if not self.access_token: - return None - - parts = self.access_token.split('.') - if len(parts) != 3: - return None - - payload_b64 = parts[1] - padding = len(payload_b64) % 4 - if padding: - payload_b64 += '=' * (4 - padding) - - payload_json = base64.b64decode(payload_b64).decode('utf-8') - return json.loads(payload_json) - - except Exception as e: - logger.debug(f"Failed to extract JWT claims: {e}") - return None - + claims = JWTParser.parse(self.access_token) + return claims.raw_claims if claims else None class Magenta2AuthConfig: """Configuration object for Magenta2 authentication""" @@ -1504,112 +1454,24 @@ class Magenta2Authenticator(BaseOAuth2Authenticator): logger.error(f"TAA authentication failed: {e}") raise - def _parse_taa_jwt_complete(self, jwt_token: str) -> Dict[str, Any]: - """ - ENHANCED: Complete JWT parsing extracting ALL required fields - This is critical for persona token composition - """ - try: - parts = jwt_token.split('.') - if len(parts) != 3: - logger.warning("Invalid JWT format") - return {} - - # Decode payload - payload_b64 = parts[1] - padding = len(payload_b64) % 4 - if padding: - payload_b64 += '=' * (4 - padding) - - payload_json = base64.b64decode(payload_b64).decode('utf-8') - claims = json.loads(payload_json) - - logger.debug(f"JWT claims found: {list(claims.keys())}") - - result = {} - - # Enhanced claim mappings - ALL fields from C++ implementation - claim_mappings = { - # Core persona token (most important!) - 'dc_cts_persona_token': [ - 'dc_cts_persona_token', - 'personaToken', - 'urn:telekom:ott:dc_cts_persona_token' - ], - - # Account URI (needed for composition!) - 'account_uri': [ - 'dc_cts_account_uri', - 'accountUri', - 'urn:telekom:ott:dc_cts_account_uri', - 'mpxAccountUri' - ], - - # IDs - 'persona_id': [ - 'dc_cts_personaId', - 'personaId', - 'urn:telekom:ott:dc_cts_personaId' - ], - 'account_id': [ - 'dc_cts_accountId', - 'accountId', - 'urn:telekom:ott:dc_cts_accountId' - ], - 'consumer_id': [ - 'dc_cts_consumerId', - 'consumerId', - 'urn:telekom:ott:dc_cts_consumerId' - ], - 'tv_account_id': [ - 'dc_tvAccountId', - 'tvAccountId', - 'urn:telekom:ott:dc_tvAccountId' - ], - - # Account token - 'account_token': [ - 'dc_cts_account_token', - 'accountToken', - 'urn:telekom:ott:dc_cts_account_token' - ], - } - - # Extract all claims - for target_key, source_keys in claim_mappings.items(): - for source_key in source_keys: - if source_key in claims: - result[target_key] = claims[source_key] - logger.debug(f"Extracted {target_key} from {source_key}") - break - - # Extract token expiration - if 'exp' in claims: - result['token_exp'] = claims['exp'] - logger.debug(f"Token expires at: {claims['exp']}") - - # CRITICAL CHECK: Verify we have the essential fields - if 'dc_cts_persona_token' not in result: - logger.error("CRITICAL: dc_cts_persona_token not found in JWT!") - logger.debug(f"Available claims: {list(claims.keys())}") - - if 'account_uri' not in result: - logger.warning("account_uri not found in JWT - will try to construct from MPX account PID") - - # Try to construct from MPX account PID if available - if self._mpx_account_pid: - result['account_uri'] = f"urn:theplatform:auth:root:{self._mpx_account_pid}" - logger.info(f"✓ Constructed account_uri from MPX PID: {result['account_uri']}") - elif 'mpxAccountPid' in claims: - result['account_uri'] = f"urn:theplatform:auth:root:{claims['mpxAccountPid']}" - logger.info(f"✓ Constructed account_uri from JWT mpxAccountPid: {result['account_uri']}") - - return result - - except Exception as e: - logger.error(f"Failed to parse TAA JWT completely: {e}") + @staticmethod + def _parse_taa_jwt_complete(jwt_token: str) -> Dict[str, Any]: + claims = JWTParser.parse(jwt_token) + if not claims: return {} + # Return as dict for backward compatibility + return { + 'dc_cts_persona_token': claims.dc_cts_persona_token, + 'account_uri': claims.account_uri, + 'persona_id': claims.persona_id, + 'account_id': claims.account_id, + 'consumer_id': claims.consumer_id, + 'tv_account_id': claims.tv_account_id, + 'account_token': claims.account_token, + 'token_exp': claims.token_exp + } + def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: """ Classify Magenta2 token based on JWT claims and structure diff --git a/lib/streaming_providers/providers/magenta2/constants.py b/lib/streaming_providers/providers/magenta2/constants.py index 2f707c0..f5a0b9e 100644 --- a/lib/streaming_providers/providers/magenta2/constants.py +++ b/lib/streaming_providers/providers/magenta2/constants.py @@ -128,6 +128,8 @@ MAGENTA2_FALLBACK_ENDPOINTS = { 'ENTITLEMENT': 'https://entitlement.p7s1.io/api/user/entitlement-token', } +MAGENTA2_FALLBACK_ACCOUNT_URI = 'http://access.auth.theplatform.com/data/Account/2709353023' + # ============================================================================ # Application Configuration # ============================================================================ diff --git a/lib/streaming_providers/providers/magenta2/token_flow_manager.py b/lib/streaming_providers/providers/magenta2/token_flow_manager.py index 80dd0e6..6f819b9 100644 --- a/lib/streaming_providers/providers/magenta2/token_flow_manager.py +++ b/lib/streaming_providers/providers/magenta2/token_flow_manager.py @@ -6,15 +6,15 @@ yo_digital → taa → tvhubs → line_auth/remote_login """ import time -import base64 -import json from typing import Optional, Dict, Any from dataclasses import dataclass +from .constants import MAGENTA2_FALLBACK_ACCOUNT_URI from ...base.utils.logger import logger from ...base.auth.session_manager import SessionManager from .sam3_client import Sam3Client from .taa_client import TaaClient +from .token_utils import PersonaTokenComposer @dataclass @@ -88,91 +88,11 @@ class TokenFlowManager: (f" ({country})" if country else "")) @staticmethod - def _extract_jwt_claims(jwt_token: str) -> Dict[str, Any]: - """Extract claims from JWT token with correct claim names""" - try: - parts = jwt_token.split('.') - if len(parts) != 3: - logger.warning("Invalid JWT format") - return {} - - # Decode payload - payload_b64 = parts[1] - padding = len(payload_b64) % 4 - if padding: - payload_b64 += '=' * (4 - padding) - - payload_json = base64.b64decode(payload_b64).decode('utf-8') - claims = json.loads(payload_json) - - logger.debug(f"JWT claims extracted: {list(claims.keys())}") - - return claims - - except Exception as e: - logger.error(f"Failed to extract JWT claims: {e}") - return {} - - def _compose_persona_token(self, access_token: str) -> Optional[str]: - """ - Compose persona token from access_token JWT claims - Format: Base64(account_uri + ":" + persona_jwt_token) - """ - try: - # Extract claims from JWT - claims = self._extract_jwt_claims(access_token) - - # Get persona JWT token (this is the nested JWT) - persona_jwt_token = claims.get('dc_cts_personaToken') - if not persona_jwt_token: - logger.error("No persona JWT token found in JWT claims") - return None - - # Get account URI - account_uri = None - - # 1. Try provider_config first - if hasattr(self, 'provider_config') and self.provider_config: - account_uri = self.provider_config.get_mpx_account_uri() - if account_uri: - logger.debug("Using account URI from provider_config") - - # 2. Try to extract from JWT claims - if not account_uri: - account_uri = claims.get('dc_cts_account_uri') or claims.get('accountUri') - if account_uri: - logger.debug("Using account URI from JWT claims") - - # 3. Final fallback - use the same format as your valid example - if not account_uri: - account_uri = "http://access.auth.theplatform.com/data/Account/2709353023" - logger.debug("Using fallback account URI") - - if not account_uri: - logger.error("No account URI available from any source") - return None - - logger.debug(f"Using account URI: {account_uri}") - logger.debug(f"Persona JWT token length: {len(persona_jwt_token)}") - logger.debug(f"Persona JWT token preview: {persona_jwt_token[:50]}...") - - # CRITICAL: Compose raw token as account_uri + ":" + persona_jwt_token - raw_token = f"{account_uri}:{persona_jwt_token}" - - logger.debug(f"Raw token length before encoding: {len(raw_token)}") - - # Base64 encode - persona_token = base64.b64encode(raw_token.encode('utf-8')).decode('utf-8') - - logger.info("✓ Persona token composed successfully") - logger.debug(f"Final persona token length: {len(persona_token)}") - logger.debug(f"Final persona token preview: {persona_token[:50]}...") - - return persona_token - - except Exception as e: - logger.error(f"Failed to compose persona token: {e}") - return None + def _compose_persona_token(access_token: str) -> Optional[str]: + return PersonaTokenComposer.compose_from_jwt( + jwt_token=access_token, + fallback_account_uri=MAGENTA2_FALLBACK_ACCOUNT_URI + ) def get_persona_token(self, force_refresh: bool = False) -> PersonaResult: """ diff --git a/lib/streaming_providers/providers/magenta2/token_utils.py b/lib/streaming_providers/providers/magenta2/token_utils.py new file mode 100644 index 0000000..7498fbe --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/token_utils.py @@ -0,0 +1,348 @@ +# streaming_providers/providers/magenta2/token_utils.py +# -*- coding: utf-8 -*- +""" +Unified JWT and Persona Token utilities for Magenta2 +Consolidates all JWT parsing and persona token composition logic +""" + +import base64 +import json +from typing import Dict, Any, Optional +from dataclasses import dataclass + +from ...base.utils.logger import logger + + +@dataclass +class JWTClaims: + """Structured JWT claims for Magenta2 tokens""" + # Raw claims + raw_claims: Dict[str, Any] + + # Persona token components + dc_cts_persona_token: Optional[str] = None + account_uri: Optional[str] = None + + # User identifiers + persona_id: Optional[str] = None + account_id: Optional[str] = None + consumer_id: Optional[str] = None + tv_account_id: Optional[str] = None + + # Token metadata + account_token: Optional[str] = None + token_exp: Optional[int] = None + client_id: Optional[str] = None + + # SSO fields + sso_user_id: Optional[str] = None + sso_display_name: Optional[str] = None + + def has_persona_components(self) -> bool: + """Check if claims contain components needed for persona token""" + return bool(self.dc_cts_persona_token and self.account_uri) + + def is_user_token(self) -> bool: + """Check if this is a user-authenticated token""" + return bool( + self.persona_id or + self.account_id or + self.consumer_id or + self.tv_account_id + ) + + +class JWTParser: + """Unified JWT parsing utilities""" + + # Comprehensive claim mappings for all Magenta2 token types + CLAIM_MAPPINGS = { + 'dc_cts_persona_token': [ + 'dc_cts_persona_token', + 'dc_cts_personaToken', + 'personaToken', + 'urn:telekom:ott:dc_cts_persona_token' + ], + 'account_uri': [ + 'dc_cts_account_uri', + 'accountUri', + 'urn:telekom:ott:dc_cts_account_uri', + 'mpxAccountUri' + ], + 'persona_id': [ + 'dc_cts_personaId', + 'personaId', + 'urn:telekom:ott:dc_cts_personaId' + ], + 'account_id': [ + 'dc_cts_accountId', + 'accountId', + 'urn:telekom:ott:dc_cts_accountId' + ], + 'consumer_id': [ + 'dc_cts_consumerId', + 'consumerId', + 'urn:telekom:ott:dc_cts_consumerId' + ], + 'tv_account_id': [ + 'dc_tvAccountId', + 'tvAccountId', + 'urn:telekom:ott:dc_tvAccountId' + ], + 'account_token': [ + 'dc_cts_account_token', + 'accountToken', + 'urn:telekom:ott:dc_cts_account_token' + ], + } + + @staticmethod + def parse(jwt_token: str) -> Optional[JWTClaims]: + """ + Parse JWT token and extract all Magenta2-specific claims + + Args: + jwt_token: JWT token string + + Returns: + JWTClaims object or None if parsing fails + """ + try: + # Decode JWT + parts = jwt_token.split('.') + if len(parts) != 3: + logger.warning("Invalid JWT format - expected 3 parts") + return None + + # Decode payload with padding + payload_b64 = parts[1] + padding = len(payload_b64) % 4 + if padding: + payload_b64 += '=' * (4 - padding) + + payload_json = base64.b64decode(payload_b64).decode('utf-8') + raw_claims = json.loads(payload_json) + + logger.debug(f"JWT parsed successfully - claims: {list(raw_claims.keys())}") + + # Extract structured claims + claims = JWTClaims(raw_claims=raw_claims) + + # Map all known claims + for target_key, source_keys in JWTParser.CLAIM_MAPPINGS.items(): + for source_key in source_keys: + if source_key in raw_claims: + setattr(claims, target_key, raw_claims[source_key]) + logger.debug(f"Mapped {target_key} from {source_key}") + break + + # Extract standard JWT fields + claims.token_exp = raw_claims.get('exp') + claims.client_id = raw_claims.get('client_id', raw_claims.get('clientId')) + + # Log critical missing fields + if not claims.dc_cts_persona_token: + logger.warning("JWT missing dc_cts_persona_token") + if not claims.account_uri: + logger.warning("JWT missing account_uri") + + return claims + + except Exception as e: + logger.error(f"Failed to parse JWT token: {e}") + return None + + @staticmethod + def extract_raw_claims(jwt_token: str) -> Optional[Dict[str, Any]]: + """ + Extract raw claims dictionary without mapping + Useful for debugging or custom claim extraction + + Args: + jwt_token: JWT token string + + Returns: + Dictionary of raw claims or None + """ + try: + parts = jwt_token.split('.') + if len(parts) != 3: + return None + + payload_b64 = parts[1] + padding = len(payload_b64) % 4 + if padding: + payload_b64 += '=' * (4 - padding) + + payload_json = base64.b64decode(payload_b64).decode('utf-8') + return json.loads(payload_json) + + except Exception as e: + logger.debug(f"Failed to extract raw JWT claims: {e}") + return None + + +class PersonaTokenComposer: + """Unified persona token composition""" + + @staticmethod + def compose_from_jwt(jwt_token: str, + fallback_account_uri: Optional[str] = None) -> Optional[str]: + """ + Compose persona token from JWT access token + + This is the PRIMARY method for persona token composition. + Format: Base64(account_uri + ":" + dc_cts_persona_token) + + Args: + jwt_token: JWT access token containing persona claims + fallback_account_uri: Optional fallback account URI if not in JWT + + Returns: + Base64-encoded persona token or None + """ + try: + # Parse JWT to extract claims + claims = JWTParser.parse(jwt_token) + if not claims: + logger.error("Failed to parse JWT for persona token composition") + return None + + # Get persona JWT token (the nested JWT) + persona_jwt = claims.dc_cts_persona_token + if not persona_jwt: + logger.error("No dc_cts_persona_token found in JWT claims") + return None + + # Get account URI (prefer JWT, then fallback) + account_uri = claims.account_uri or fallback_account_uri + if not account_uri: + logger.error("No account_uri available for persona token composition") + return None + + # Compose raw token + raw_token = f"{account_uri}:{persona_jwt}" + + # Base64 encode + persona_token = base64.b64encode( + raw_token.encode('utf-8') + ).decode('utf-8') + + logger.info("✓ Persona token composed successfully") + logger.debug(f"Account URI: {account_uri}") + logger.debug(f"Persona token length: {len(persona_token)}") + logger.debug(f"Persona token preview: {persona_token[:50]}...") + + return persona_token + + except Exception as e: + logger.error(f"Failed to compose persona token from JWT: {e}") + return None + + @staticmethod + def compose_from_components(account_uri: str, + dc_cts_persona_token: str) -> Optional[str]: + """ + Compose persona token from explicit components + + Use this when you already have extracted components. + + Args: + account_uri: Account URI (e.g., "http://access.auth.theplatform.com/...") + dc_cts_persona_token: The persona JWT token + + Returns: + Base64-encoded persona token or None + """ + try: + if not account_uri or not dc_cts_persona_token: + logger.warning( + f"Cannot compose persona token - " + f"account_uri: {bool(account_uri)}, " + f"dc_cts_persona_token: {bool(dc_cts_persona_token)}" + ) + return None + + # Compose raw token + raw_token = f"{account_uri}:{dc_cts_persona_token}" + + # Base64 encode + persona_token = base64.b64encode( + raw_token.encode('utf-8') + ).decode('utf-8') + + logger.info("✓ Persona token composed from components") + logger.debug(f"Composed token preview: {persona_token[:50]}...") + + return persona_token + + except Exception as e: + logger.error(f"Failed to compose persona token from components: {e}") + return None + + @staticmethod + def extract_components_from_persona_token(persona_token: str) -> Optional[Dict[str, str]]: + """ + Extract components from an existing persona token + Useful for debugging or token analysis + + Args: + persona_token: Base64-encoded persona token + + Returns: + Dictionary with 'account_uri' and 'persona_jwt' or None + """ + try: + # Decode base64 + decoded = base64.b64decode(persona_token).decode('utf-8') + + # Find the last colon (after the account URI which may contain colons) + last_colon_index = decoded.rfind(':') + + if last_colon_index == -1: + logger.error("No colon found in decoded persona token") + return None + + account_uri = decoded[:last_colon_index] + persona_jwt = decoded[last_colon_index + 1:] + + # Verify persona_jwt looks like a JWT + if not persona_jwt.startswith('eyJ'): + logger.warning(f"Extracted token doesn't look like a JWT: {persona_jwt[:20]}...") + + return { + 'account_uri': account_uri, + 'persona_jwt': persona_jwt + } + + except Exception as e: + logger.error(f"Failed to extract components from persona token: {e}") + return None + + +class TokenValidator: + """Token validation utilities""" + + @staticmethod + def is_jwt_token(token: str) -> bool: + """Check if string is a valid JWT token format""" + try: + parts = token.split('.') + return len(parts) == 3 and parts[0].startswith('eyJ') + except: + return False + + @staticmethod + def is_persona_token(token: str) -> bool: + """Check if string is a valid persona token format""" + try: + # Should be base64 encoded + decoded = base64.b64decode(token).decode('utf-8') + # Should contain a colon and the part after should look like a JWT + last_colon = decoded.rfind(':') + if last_colon == -1: + return False + persona_jwt = decoded[last_colon + 1:] + return persona_jwt.startswith('eyJ') + except: + return False diff --git a/resources/settings.xml b/resources/settings.xml index 8e014c1..96a60f6 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -68,20 +68,7 @@ - - 0 - - false - - - - - 0 - - false - - - +