From b6d30301481db46320a8e1c17cb3ae681079f8ab Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sat, 15 Aug 2026 03:56:22 +0200 Subject: [PATCH] Joyn --- .../providers/joyn/auth.py | 317 ++++++++---------- .../providers/joyn/constants.py | 33 +- 2 files changed, 145 insertions(+), 205 deletions(-) diff --git a/lib/streaming_providers/providers/joyn/auth.py b/lib/streaming_providers/providers/joyn/auth.py index 012e5e2..8ad80ee 100644 --- a/lib/streaming_providers/providers/joyn/auth.py +++ b/lib/streaming_providers/providers/joyn/auth.py @@ -122,6 +122,8 @@ class JoynAuthenticator(BaseOAuth2Authenticator): self._cmp_uc_id = None self._cmp_uc_instance = None self._auth_base_path = None + self._web_login_url = None + self._extracted_client_id = None # Initialize base class super().__init__( @@ -214,12 +216,14 @@ class JoynAuthenticator(BaseOAuth2Authenticator): return get_oauth_redirect_uri(self.country) def _discover_sso_endpoints(self) -> Dict[str, str]: - """Discover Joyn's SSO endpoints, but IGNORE any client_id from the response""" + """Discover Joyn's SSO endpoints and keep the server-issued web-login URL and client_id as-is.""" if self._sso_endpoints_cache and self._sso_endpoints_timestamp: if (time.time() - self._sso_endpoints_timestamp) < self._sso_cache_ttl: return self._sso_endpoints_cache try: + # Use the locally-generated per-install device id for discovery, matching the + # reference client's `client_ids['client_id']` (NOT the fixed DEVICE_IDS constant). url = f"https://auth.joyn.de/sso/endpoints?client_id={self._device_id}&client_name={self.platform}" headers = self._get_joyn_auth_headers() @@ -233,33 +237,37 @@ class JoynAuthenticator(BaseOAuth2Authenticator): endpoints = response.json() - # Get the full auth endpoint from discovery + # Keep the full, unmodified web-login URL. The server embeds its own client_id + # and tracking params (cmpUcId, cmpUcInstance) in this URL; the reference client + # GETs this URL verbatim rather than reconstructing an authorize URL by hand. auth_endpoint_full = endpoints.get("web-login", "") + self._web_login_url = auth_endpoint_full - # CRITICAL: Extract ONLY the base path, ignore all query parameters parsed_auth = urlparse(auth_endpoint_full) self._auth_base_path = urlunparse(( parsed_auth.scheme, parsed_auth.netloc, parsed_auth.path, - "", # params - "", # query - DISCARD any existing query params - "" # fragment + "", "", "" )) - # Extract cmpUcId and cmpUcInstance for tracking (but NOT client_id) + # Extract the server-issued client_id and tracking params for reuse in + # consent-accept and redeem-token calls later in the flow. params = parse_qs(parsed_auth.query) + self._extracted_client_id = params.get("client_id", [None])[0] self._cmp_uc_id = params.get("cmpUcId", [None])[0] self._cmp_uc_instance = params.get("cmpUcInstance", [None])[0] self._sso_endpoints_cache = { "authorization_base_path": self._auth_base_path, + "web_login_url": self._web_login_url, + "client_id": self._extracted_client_id, "token_endpoint": endpoints.get("redeem-token", "https://auth.joyn.de/auth/7pass/token"), } self._sso_endpoints_timestamp = time.time() - logger.debug(f"Discovered auth base path: {self._auth_base_path}") - logger.debug(f"cmpUcId: {self._cmp_uc_id}, cmpUcInstance: {self._cmp_uc_instance}") + logger.debug(f"Discovered web-login URL: {self._web_login_url}") + logger.debug(f"Extracted client_id: {self._extracted_client_id}") return self._sso_endpoints_cache @@ -348,56 +356,44 @@ class JoynAuthenticator(BaseOAuth2Authenticator): return None def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]: - """Complete Joyn login flow with CORRECT client_id""" + """Complete Joyn login flow matching the exact sequence observed from working traffic. + + Joyn does not implement real PKCE (code_verifier is always sent empty in the + redeem-token call) and does not use a verification-srv/initiate + device-fingerprint + mechanism. The client_id used for consent-accept and redeem-token is the one the + server itself embeds in the web-login redirect URL, not a fixed platform constant. + """ try: logger.debug("Starting Joyn login flow") - # Discover endpoints (to get base path and cmp params) + # Discover endpoints - gives us the literal web-login URL and its embedded client_id self._discover_sso_endpoints() - if not self._auth_base_path: - raise Exception("Failed to get authorization endpoint") - - # Generate PKCE codes - state = self.generate_oauth_state() - code_verifier = self.generate_pkce_verifier() - code_challenge = self.generate_pkce_challenge(code_verifier) + if not self._web_login_url: + raise Exception("Failed to get web-login URL from SSO discovery") + client_id = self._extracted_client_id or self.oauth_client_id cd1 = self._device_id - cmp_uc_id = self._cmp_uc_id or str(uuid.uuid4()) - cmp_uc_instance = self._cmp_uc_instance or 'WEB' - - # Build URL with OUR client_id, NOT the one from discovery - # cd9 and cd10 are required by the server - auth_params = { - "response_type": "code", - "scope": self.oauth_scope, - "view_type": "login", - "cd1": cd1, - "client_id": self.oauth_client_id, - "prompt": "consent", - "response_mode": "query", - "cmpUcId": cmp_uc_id, - "cmpUcInstance": cmp_uc_instance, - "redirect_uri": self.oauth_redirect_uri, - "state": state, - "cd9": "", - "cd10": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]), - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - - auth_url = f"{self._auth_base_path}?{urlencode(auth_params)}" - logger.debug(f"Auth URL built with client_id={self.oauth_client_id}") session = self._create_oauth_session() + # SessionAwareHTTPManager pre-loads Origin/Referer/User-Agent for the Joyn + # website and applies them AFTER our per-call headers, silently overriding + # them. We set explicit, per-step headers below (different Origin for + # auth.7pass.de vs auth.joyn.de), so clear the defaults here. + session.headers.clear() def _request(method, url, **kwargs): headers = kwargs.pop("headers", {}).copy() - clean_headers = { - k: v for k, v in headers.items() - if not k.lower().startswith('joyn-') - } + # joyn-* headers don't belong on calls to the 7pass.de auth domain; + # they're only sent on calls to Joyn's own auth.joyn.de endpoints + # (SSO discovery, redeem-token), matching the reference client. + if "auth.7pass.de" in url: + clean_headers = { + k: v for k, v in headers.items() + if not k.lower().startswith('joyn-') + } + else: + clean_headers = dict(headers) clean_headers.setdefault("User-Agent", JOYN_USER_AGENT) clean_headers.setdefault("Accept", "*/*") # Browser-realistic headers to prevent Cloudflare managed challenges. @@ -438,35 +434,32 @@ class JoynAuthenticator(BaseOAuth2Authenticator): if "Just a moment" in response.text or "challenge-platform" in response.text: raise WafBlockedException("Cloudflare managed challenge detected") - response = _request("GET", auth_url, allow_redirects=True) + def _raise_if_cf_error(e): + """http_manager raises HTTPError before we see the response, so 403/429 + Cloudflare challenges must be detected from the exception's response body.""" + resp = getattr(e, "response", None) + raw = getattr(resp, "text", str(e)) + if "Just a moment" in raw or "challenge-platform" in raw or "captcha" in raw.lower(): + raise WafBlockedException(f"Cloudflare/CAPTCHA block: {e}") + if resp is not None and resp.status_code in (403, 429): + raise WafBlockedException(f"Joyn login blocked by WAF ({resp.status_code}): {e}") - if response.status_code in (403, 429) or "captcha" in response.text.lower(): - raise WafBlockedException("Joyn login blocked by WAF/CAPTCHA") + # 1. GET the literal web-login URL as issued by the server (do not rebuild it) + try: + response = _request("GET", self._web_login_url, allow_redirects=True) + except WafBlockedException: + raise + except Exception as e: + _raise_if_cf_error(e) + raise _check_cf(response) - response.raise_for_status() final_url = response.url - signin_url = final_url # signin.7pass.de page — used as Referer for subsequent POSTs if "error.html" in final_url or "error_code" in final_url: error_match = re.search(r'error_code=(\d+)', final_url) error_code = error_match.group(1) if error_match else "unknown" - error_desc = re.search(r'error_description=([^&]+)', final_url) - error_desc = error_desc.group(1) if error_desc else "unknown" - raise Exception(f"Authorization failed: error_code={error_code}, description={error_desc}") - - if self.oauth_redirect_uri in final_url: - parsed = urlparse(final_url) - query = parse_qs(parsed.query) - auth_code = query.get("code", [None])[0] - if auth_code: - logger.info("Already authenticated, extracting code") - return self._exchange_authorization_code_for_token( - authorization_code=auth_code, - code_verifier=code_verifier, - state=state, - cd1=cd1, - ) + raise Exception(f"Authorization failed: error_code={error_code}") parsed_url = urlparse(final_url) query_params = parse_qs(parsed_url.query) @@ -482,148 +475,98 @@ class JoynAuthenticator(BaseOAuth2Authenticator): logger.debug(f"Extracted request_id: {request_id}") - # Public endpoint — non-fatal + # 2. Language/registration-setup check — non-fatal try: - _request("GET", f"https://auth.7pass.de/public-srv/public/{request_id}") + _request( + "GET", + f"https://auth.7pass.de/registration-setup-srv/public/list?acceptlanguage=undefined&requestId={request_id}", + ) except Exception as e: - logger.debug(f"Public endpoint failed (non-fatal): {e}") + logger.debug(f"registration-setup failed (non-fatal): {e}") - # User exists check — non-fatal, requestId in path only + # 3. Check whether the email exists — non-fatal try: _request( "POST", f"https://auth.7pass.de/users-srv/user/checkexists/{request_id}", - json={"email": username}, + json={"email": username, "requestId": request_id}, content_type="application/json", ) except Exception as e: - logger.debug(f"User check failed (non-fatal): {e}") + logger.debug(f"checkexists failed (non-fatal): {e}") - # Step 1a: Register device fingerprint — required before PASSWORD initiation + # 4. Configured verification methods list — non-fatal try: - import hashlib - _fingerprint_input = f"{request_id}{self._device_id}{JOYN_USER_AGENT}" - _fingerprint = hashlib.sha256(_fingerprint_input.encode()).hexdigest() _request( "POST", - "https://auth.7pass.de/device-srv/deviceinfo", - json={ - "fingerprint": _fingerprint, - "userAgent": "", - }, + "https://auth.7pass.de/verification-srv/v2/setup/public/configured/list", + json={"email": username, "request_id": request_id}, content_type="application/json", - headers={ - "Referer": signin_url, - "Origin": "https://signin.7pass.de", - }, ) except Exception as e: - logger.debug(f"Device info registration failed (non-fatal): {e}") + logger.debug(f"verification-srv failed (non-fatal): {e}") - # Step 1b: Initiate PASSWORD verification - # Wrapped in try/except so HTTPError from raise_for_status inside the - # HTTP manager doesn't bypass our Cloudflare detection. - try: - initiate_response = _request( - "POST", - "https://auth.7pass.de/verification-srv/v2/authenticate/initiate/PASSWORD", - json={ - "request_id": request_id, - "email": username, - "medium_id": "PASSWORD", - "usage_type": "PASSWORDLESS_AUTHENTICATION", - "type": "PASSWORD", - }, - content_type="application/json", - headers={ - "Referer": signin_url, - "Origin": "https://signin.7pass.de", - }, - ) - except Exception as e: - raw = getattr(getattr(e, "response", None), "text", str(e)) - if "Just a moment" in raw or "challenge-platform" in raw: - raise WafBlockedException("Cloudflare challenge on initiate POST") - raise - - _check_cf(initiate_response) - initiate_response.raise_for_status() - initiate_data = initiate_response.json() - - exchange_id = initiate_data.get("exchange_id") - status_id = initiate_data.get("status_id") - sub = initiate_data.get("sub", "") - if not exchange_id or not status_id: - raise Exception(f"Missing exchange_id/status_id from initiate: {initiate_data}") - - logger.debug(f"Initiate successful: exchange_id={exchange_id}, sub={sub}") - - # Step 2: Submit password + # 5. Submit username/password directly (form-encoded) try: login_response = _request( "POST", - "https://auth.7pass.de/login-srv/verification/login", + "https://auth.7pass.de/login-srv/login", data=urlencode({ - "sub": sub, - "status_id": status_id, - "verificationType": "PASSWORD", - "requestId": request_id, - "remember_me": "true", - "exchange_id": exchange_id, - "pass_code": password, + "username": username, "password": password, + "requestId": request_id, }).encode(), content_type="application/x-www-form-urlencoded", - headers={ - "Referer": signin_url, - "Origin": "https://signin.7pass.de", - }, allow_redirects=True, ) + except WafBlockedException: + raise except Exception as e: - raw = getattr(getattr(e, "response", None), "text", str(e)) - if "Just a moment" in raw or "challenge-platform" in raw: - raise WafBlockedException("Cloudflare challenge on login POST") + _raise_if_cf_error(e) raise _check_cf(login_response) - login_response.raise_for_status() final_url = login_response.url - - if "error.html" in final_url or "error_code" in final_url: - error_match = re.search(r'error_code=(\d+)', final_url) - error_code = error_match.group(1) if error_match else "unknown" - raise Exception(f"Login failed with error_code={error_code}") - parsed = urlparse(final_url) params = parse_qs(parsed.query) - # Handle consent if needed + # 6. Handle consent if the server didn't return a code directly if params.get("code") is None: sub = params.get("sub", [None])[0] track_id = params.get("track_id", [None])[0] if sub and track_id: logger.debug(f"Accepting consent for sub={sub}") - _request( - "POST", - "https://auth.7pass.de/login-srv/consent/accept", - json={ - "sub": sub, - "client_id": self.oauth_client_id, - "scopes": [{"offline_access": "denied"}], - }, - content_type="application/json", - ) + try: + _request( + "POST", + "https://auth.7pass.de/consent-management-srv/consent/scope/accept", + json={ + "sub": sub, + "client_id": client_id, + "scopes": [{"offline_access": "denied"}], + }, + content_type="application/json", + ) + except WafBlockedException: + raise + except Exception as e: + _raise_if_cf_error(e) + raise - continue_response = _request( - "POST", - f"https://auth.7pass.de/precheck/continue/{track_id}", - data=b"", - content_type="application/x-www-form-urlencoded", - allow_redirects=True, - ) - continue_response.raise_for_status() + try: + continue_response = _request( + "POST", + f"https://auth.7pass.de/login-srv/precheck/continue/{track_id}", + data=b"", + content_type="application/x-www-form-urlencoded", + allow_redirects=True, + ) + except WafBlockedException: + raise + except Exception as e: + _raise_if_cf_error(e) + raise final_url = continue_response.url parsed = urlparse(final_url) params = parse_qs(parsed.query) @@ -634,12 +577,38 @@ class JoynAuthenticator(BaseOAuth2Authenticator): logger.debug("Authorization code obtained") - return self._exchange_authorization_code_for_token( - authorization_code=auth_code, - code_verifier=code_verifier, - state=state, - cd1=cd1, - ) + # 7. Redeem the code directly. Joyn does not implement real PKCE — the working + # client always sends code_verifier as an empty string here. + cd1_value = params.get("cd1", [None])[0] or cd1 + redeem_data = { + "client_id": client_id, + "code": auth_code, + "code_verifier": "", + "redirect_uri": self.oauth_redirect_uri, + "tracking_id": cd1_value, + "tracking_name": self.platform, + } + + try: + redeem_response = _request( + "POST", + self.oauth_token_endpoint, + json=redeem_data, + content_type="application/json", + headers=self._get_joyn_auth_headers(), + allow_redirects=False, + ) + except WafBlockedException: + raise + except Exception as e: + _raise_if_cf_error(e) + raise + + _check_cf(redeem_response) + token_data = redeem_response.json() + + logger.info("Joyn login flow successful") + return token_data except WafBlockedException: raise diff --git a/lib/streaming_providers/providers/joyn/constants.py b/lib/streaming_providers/providers/joyn/constants.py index 3dea12c..ef02711 100644 --- a/lib/streaming_providers/providers/joyn/constants.py +++ b/lib/streaming_providers/providers/joyn/constants.py @@ -14,28 +14,8 @@ JOYN_LOGO = "https://upload.wikimedia.org/wikipedia/de/thumb/7/74/Joyn_%28Stream # Authentication - 7pass OIDC # ============================================================================ -# 7pass base URL (OIDC provider) JOYN_7PASS_BASE_URL = "https://auth.7pass.de" -# 7pass OIDC endpoints (discovered via OIDC discovery) -JOYN_7PASS_ENDPOINTS = { - "AUTHORIZE": f"{JOYN_7PASS_BASE_URL}/authz-srv/authz", # correct path - "TOKEN": f"{JOYN_7PASS_BASE_URL}/token", - "LOGIN": f"{JOYN_7PASS_BASE_URL}/login-srv/verification/login", # correct login path - "CONSENT_ACCEPT": f"{JOYN_7PASS_BASE_URL}/login-srv/consent/accept", # correct consent path - "PRECHECK_CONTINUE": f"{JOYN_7PASS_BASE_URL}/precheck/continue", # correct precheck path - "USER_CHECK_EXISTS": f"{JOYN_7PASS_BASE_URL}/users-srv/user/checkexists", - "REGISTRATION_SETUP": f"{JOYN_7PASS_BASE_URL}/registration-setup-srv/public/list", - "VERIFICATION_CONFIGURED": f"{JOYN_7PASS_BASE_URL}/verification-srv/v2/setup/public/configured/list", - "VERIFICATION_INITIATE": f"{JOYN_7PASS_BASE_URL}/verification-srv/v2/authenticate/initiate/PASSWORD", # NEW - "DEVICE_INFO": f"{JOYN_7PASS_BASE_URL}/device-srv/deviceinfo", # Required before PASSWORD initiation -} - -# Joyn auth endpoints (non-OIDC) -JOYN_AUTH_ENDPOINTS = { - "REFRESH": "https://auth.joyn.de/auth/refresh", # Token refresh endpoint -} - # OAuth2 Configuration JOYN_OAUTH_SCOPE = "openid email profile offline_access" @@ -54,16 +34,14 @@ JOYN_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 JOYN_CLIENT_VERSION = "5.1344.1" DEFAULT_PLATFORM = "web" -# Base authentication headers (without dynamic values) JOYN_AUTH_HEADERS_BASE = { "User-Agent": JOYN_USER_AGENT, "Accept": "application/json", "Content-Type": "application/json", - "Origin": "https://www.joyn.de", # Base origin, overridden per country + "Origin": "https://www.joyn.de", "joyn-client-version": JOYN_CLIENT_VERSION, } -# Base API headers (without auth token) JOYN_API_BASE_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", @@ -76,19 +54,16 @@ JOYN_API_BASE_HEADERS = { JOYN_GRAPHQL_BASE_URL = "https://api.joyn.de/graphql" -# GraphQL persisted query hashes GRAPHQL_QUERY_HASHES = { "LIVE_PLAYER": "52b37a3cf5bc75e56026aed7b0d234874eeabd2eccd369d0cd3d3a6ea15ef566", "LIVE_CHANNELS": "b7703103ddd0516be6b49ed66186092a6c6f6d815ccc502a9f50800a8cc18dd2", } -# GraphQL endpoints with full URLs JOYN_GRAPHQL_ENDPOINTS = { "LIVE_PLAYER": f'{JOYN_GRAPHQL_BASE_URL}?operationName=PageLivePlayerClientSide&enable_user_location=true&watch_assistant_variant=true&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22{GRAPHQL_QUERY_HASHES["LIVE_PLAYER"]}%22%7D%7D', "LIVE_CHANNELS": f"{JOYN_GRAPHQL_BASE_URL}?operationName=LiveChannelsAndEpg&enable_user_location=true&watch_assistant_variant=true", } -# Base GraphQL headers JOYN_GRAPHQL_BASE_HEADERS = { "X-Api-Key": "4f0fd9f18abbe3cf0e87fdb556bc39c8", "Accept": "application/json", @@ -96,7 +71,6 @@ JOYN_GRAPHQL_BASE_HEADERS = { "User-Agent": JOYN_USER_AGENT, } -# GraphQL query defaults GRAPHQL_PERSISTED_QUERY_VERSION = 1 GRAPHQL_LIVE_CHANNELS_FILTER = "DEFAULT" GRAPHQL_MAX_RESULTS = 5000 @@ -111,7 +85,6 @@ JOYN_STREAMING_ENDPOINTS = { "PLAYLIST": "https://api.vod-prd.s.joyn.de/v1/channel/{channel_id}/playlist", } -# Default video configuration for playlist requests DEFAULT_VIDEO_CONFIG = { "enableDolbyAtmos": True, "enableSubtitles": True, @@ -126,7 +99,6 @@ DEFAULT_VIDEO_CONFIG = { "maxSecurityLevel": 5, } -# Signature secret key (base64 encoded) SIGNATURE_SECRET_KEY = "MzU0MzM3MzgzMzM4MzMzNjM1NDMzNzM4MzYzNDM2MzYzNTQzMzczODM2MzYzMzM4MzIzNjM1NDMzNzM4MzMzMDM2MzQzNTM5MzU0MzM3MzgzMzM5MzMzNTMyMzQzNTQzMzczODM2MzUzMzM5MzU0MzM3MzgzMzM4MzMzMjMzNDYzNTQzMzczODM2MzYzMzMzMzM0NDMzNDIzNTQzMzczODMzMzgzNjM2MzMzNQ==" # ============================================================================ @@ -179,8 +151,7 @@ JOYN_DOMAINS = { def get_oauth_redirect_uri(country: str) -> str: """Get country-specific OAuth redirect URI""" - # Joyn uses same redirect URI for all countries - return "https://www.joyn.de/oauth" + return f"https://www.joyn.{country}/oauth" # ============================================================================ # DRM Configuration