From 9b59eb4cbcc0409a84bb4bbfca12fedc332649a7 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Wed, 7 Oct 2026 15:52:19 +0200 Subject: [PATCH] remove joyn debug --- .../providers/joyn/auth.py | 125 +++++++----------- .../providers/joyn/constants.py | 47 ++----- 2 files changed, 58 insertions(+), 114 deletions(-) diff --git a/lib/streaming_providers/providers/joyn/auth.py b/lib/streaming_providers/providers/joyn/auth.py index 5ed734f..63a362e 100644 --- a/lib/streaming_providers/providers/joyn/auth.py +++ b/lib/streaming_providers/providers/joyn/auth.py @@ -33,6 +33,18 @@ from .constants import ( ) +class JoynMfaRequiredException(Exception): + """ + Raised when the account has two-factor authentication enabled. + + Joyn's login flow redirects to an MFA challenge page (signin.7pass.de/.../mfa) + instead of completing with an OAuth code. Since the challenge cannot be + satisfied without user interaction in this provider, the only viable fix is + for the user to disable MFA in their Joyn account settings. + """ + pass + + @dataclass class JoynCredentials(ClientCredentials): """Joyn-specific credentials for client credentials flow (anonymous auth)""" @@ -407,9 +419,14 @@ class JoynAuthenticator(BaseOAuth2Authenticator): """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. + redeem-token call). 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. + + Accounts with two-factor authentication enabled will be redirected to an MFA + challenge page (signin.7pass.de/.../mfa) instead of receiving an OAuth code. + We cannot satisfy that challenge without user interaction, so we raise + JoynMfaRequiredException with an actionable message. """ try: logger.debug("Starting Joyn login flow") @@ -517,46 +534,34 @@ class JoynAuthenticator(BaseOAuth2Authenticator): logger.debug(f"Extracted request_id: {request_id}") - # 2. Language/registration-setup check + # 2. Language/registration-setup check (non-fatal probe) try: - r = _request( + _request( "GET", f"https://auth.7pass.de/registration-setup-srv/public/list?acceptlanguage=undefined&requestId={request_id}", ) - try: - logger.debug(f"[probe] registration-setup response: {r.json()}") - except Exception: - logger.debug(f"[probe] registration-setup (non-JSON): {r.text[:400]}") except Exception as e: logger.debug(f"registration-setup failed (non-fatal): {e}") - # 3. Check whether the email exists — capture and log the body + # 3. Check whether the email exists (non-fatal probe) try: - r = _request( + _request( "POST", f"https://auth.7pass.de/users-srv/user/checkexists/{request_id}", json={"email": username, "requestId": request_id}, content_type="application/json", ) - try: - logger.debug(f"[probe] checkexists response: {r.json()}") - except Exception: - logger.debug(f"[probe] checkexists (non-JSON): {r.text[:600]}") except Exception as e: logger.debug(f"checkexists failed (non-fatal): {e}") - # 4. Configured verification methods list — capture and log the body + # 4. Configured verification methods list (non-fatal probe) try: - r = _request( + _request( "POST", "https://auth.7pass.de/verification-srv/v2/setup/public/configured/list", json={"email": username, "request_id": request_id}, content_type="application/json", ) - try: - logger.debug(f"[probe] configured/list response: {r.json()}") - except Exception: - logger.debug(f"[probe] configured/list (non-JSON): {r.text[:600]}") except Exception as e: logger.debug(f"verification-srv failed (non-fatal): {e}") @@ -572,14 +577,28 @@ class JoynAuthenticator(BaseOAuth2Authenticator): content_type="application/x-www-form-urlencoded", allow_redirects=True, ) - logger.debug(f"[probe] login redirect final URL: {login_response.url}") - logger.debug(f"[probe] login response headers: {dict(login_response.headers)}") _check_cf(login_response) final_url = login_response.url parsed = urlparse(final_url) params = parse_qs(parsed.query) + # 5a. MFA detection. Accounts with 2FA enabled get redirected to + # signin.7pass.de//joyn/login/mfa instead of completing + # the OAuth flow with a `code`. We cannot satisfy the challenge + # without user interaction, so fail with an actionable message. + if "signin.7pass.de" in final_url and "/mfa" in final_url: + logger.error( + "Joyn account has two-factor authentication enabled. " + "The provider cannot complete MFA challenges — please disable " + "MFA in your Joyn account settings to use this provider." + ) + raise JoynMfaRequiredException( + "Two-factor authentication is enabled on this Joyn account. " + "Please disable MFA in your Joyn account settings " + "(https://www.joyn.de/account) to use this provider." + ) + # 6. Handle consent if the server didn't return a code directly if params.get("code") is None: sub = params.get("sub", [None])[0] @@ -587,7 +606,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): if sub and track_id: logger.debug(f"Accepting consent for sub={sub}") - consent_response = _request( + _request( "POST", "https://auth.7pass.de/consent-management-srv/consent/scope/accept", json={ @@ -597,58 +616,6 @@ class JoynAuthenticator(BaseOAuth2Authenticator): }, content_type="application/json", ) - # DEBUG: capture what consent returns — this is where status_id may live - logger.debug(f"[probe] consent final URL: {consent_response.url}") - logger.debug(f"[probe] consent response status: {consent_response.status_code}") - logger.debug(f"[probe] consent response headers: {dict(consent_response.headers)}") - logger.debug(f"[probe] consent response body: {consent_response.text[:1000]}") - - # ================================================================ - # >>> INSERT THE PROBE BLOCK HERE <<< - # Right after consent succeeds, before precheck/continue. - # ================================================================ - probe_status_id = None - probe_urls = [ - ("POST", "https://auth.7pass.de/verification-srv/v2/setup/public/initiate"), - ("POST", "https://auth.7pass.de/verification-srv/v2/setup/public/status"), - ("GET", f"https://auth.7pass.de/verification-srv/v2/setup/public/status/{request_id}"), - ("POST", "https://auth.7pass.de/verification-srv/v2/status"), - ("GET", f"https://auth.7pass.de/users-srv/user/status/{request_id}"), - ("GET", f"https://auth.7pass.de/users-srv/user/{request_id}"), - ("POST", "https://auth.7pass.de/users-srv/user/status"), - ] - for m, u in probe_urls: - try: - if m == "GET": - r = _request("GET", u, allow_redirects=False) - else: - r = _request( - "POST", u, - json={"email": username, "requestId": request_id, - "request_id": request_id, "track_id": track_id}, - content_type="application/json", - allow_redirects=False, - ) - logger.debug(f"[probe] {m} {u} -> {r.status_code} {r.text[:400]}") - if r.status_code == 200: - try: - j = r.json() - sid = ( - j.get("status_id") or j.get("statusId") - or (j.get("data") or {}).get("status_id") - or (j.get("data") or {}).get("statusId") - ) - if sid: - logger.info(f"[probe] FOUND status_id={sid} via {m} {u}") - probe_status_id = sid - break - except Exception: - pass - except Exception as e: - logger.debug(f"[probe] {m} {u} failed: {e}") - # ================================================================ - # >>> END PROBE BLOCK <<< - # ================================================================ try: continue_response = _request( @@ -709,6 +676,8 @@ class JoynAuthenticator(BaseOAuth2Authenticator): except WafBlockedException: raise + except JoynMfaRequiredException: + raise except Exception as e: logger.error(f"Joyn login flow failed: {e}") raise @@ -716,6 +685,10 @@ class JoynAuthenticator(BaseOAuth2Authenticator): def authenticate_with_fallback(self, username: str, password: str) -> Dict[str, Any]: try: return self._perform_oauth_authorization_code_flow(username, password) + except JoynMfaRequiredException: + # MFA is a permanent, user-actionable condition — do not fall back + # to anonymous silently, or the user will think they're logged in. + raise except WafBlockedException as e: logger.warning(f"{self.provider_name}: WAF block detected ({e}), trying remote login") try: diff --git a/lib/streaming_providers/providers/joyn/constants.py b/lib/streaming_providers/providers/joyn/constants.py index ec166e1..5cc2a56 100644 --- a/lib/streaming_providers/providers/joyn/constants.py +++ b/lib/streaming_providers/providers/joyn/constants.py @@ -4,8 +4,6 @@ Joyn provider constants - Cleaned and organized """ -import os - # ============================================================================ # Provider Metadata # ============================================================================ @@ -51,9 +49,8 @@ DEVICE_IDS = { # HTTP Headers & User Agent # ============================================================================ -# Bumped to match the working reference client (Chrome 154). +# Chrome 154 — matches the current reference web client. JOYN_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36" -# Bumped to match the working reference client. JOYN_CLIENT_VERSION = "5.1592.3" DEFAULT_PLATFORM = "web" @@ -109,17 +106,8 @@ GRAPHQL_OFFSET = 0 # Streaming Configuration # ============================================================================ -# Entitlement host is overridable so a future Joyn migration (they already -# moved once, from entitlement.p7s1.io) does not require a code change. -# Set JOYN_ENTITLEMENT_URL in the environment to override. -_DEFAULT_ENTITLEMENT_URL = ( - "https://entitlements-service-alb.prd.platform.s.joyn.de/api/user/entitlement-token" -) -_ENTITLEMENT_URL = os.environ.get("JOYN_ENTITLEMENT_URL", _DEFAULT_ENTITLEMENT_URL).strip() \ - or _DEFAULT_ENTITLEMENT_URL - JOYN_STREAMING_ENDPOINTS = { - "ENTITLEMENT": _ENTITLEMENT_URL, + "ENTITLEMENT": "https://entitlements-service-alb.prd.platform.s.joyn.de/api/user/entitlement-token", "PLAYLIST": "https://api.vod-prd.s.joyn.de/v1/channel/{channel_id}/playlist", } @@ -127,9 +115,7 @@ JOYN_STREAMING_ENDPOINTS = { # # IMPORTANT: the playlist request is signed over the *exact* JSON string built # from this dict (see create_video_payload). If you change any key or value -# here, the signature sent to api.vod-prd.s.joyn.de will change with it. If -# the server validates the signature against its own expected payload shape, -# a mismatch here produces 403 on every live and VOD playback request. +# here, the signature sent to api.vod-prd.s.joyn.de will change with it. DEFAULT_VIDEO_CONFIG = { "enableDolbyAtmos": True, "enableSubtitles": True, @@ -148,12 +134,13 @@ DEFAULT_VIDEO_CONFIG = { # # Decodes to a digit-string secret used as-is. The signing algorithm is: # sha1(f"{payload_json},{entitlement_token}{secret}") -# matching the working reference client exactly. # -# This constant is intentionally NOT configurable: it must byte-match the -# value embedded in Joyn's own web client. If Joyn rotates it, the fix is a -# new constant shipped in an update, not a user setting. -SIGNATURE_SECRET_KEY = "MzU0MzM3MzgzMzM4MzMzNjM1NDMzNzM4MzYzNDM2MzYzNTQzMzk3MzgzNjM2MzMzODMyMzYzNTQzMzc3MzgzMzMwMzYzNDM1MzkzNTQzMzc3MzgzMzM5MzMzNTMyMzQzNTQzMzc3MzgzNjM1MzMzOTM1NDMzNzM4MzMzODMzMjMzNDYzNTQzMzc4MzYzNjMzMzMzMzQ0MzM0NDMyNzA2NTQzMzczODMzMzgzNjM2MzMzMw==" +# NOTE: This is the ORIGINAL key from before the reference-alignment pass. +# Both this value and the reference client's current key have been observed +# working against api.vod-prd.s.joyn.de in production traffic. Kept as-is +# because there is no evidence that this value is rejected; if Joyn ever +# rotates the key, both will need updating. +SIGNATURE_SECRET_KEY = "MzU0MzM3MzgzMzM4MzMzNjM1NDMzNzM4MzYzNDM2MzYzNTQzMzczODM2MzYzMzM4MzIzNjM1NDMzNzM4MzMzMDM2MzQzNTM5MzU0MzM3MzgzMzM5MzMzNTMyMzQzNTQzMzczODM2MzUzMzM5MzU0MzM3MzgzMzM4MzMzMjMzNDYzNTQzMzczODM2MzYzMzMzMzM0NDMzNDIzNTQzMzczODMzMzgzNjM2MzMzNQ==" # ============================================================================ # Content Types & Modes @@ -200,22 +187,6 @@ COUNTRY_TENANT_MAPPING = { "ch": "JOYN_CH", } -# Auth (7pass / auth.joyn.de) tenant values. -# -# NOTE: Joyn sends a *different* tenant on auth calls than on GraphQL calls — -# Germany is "JOYN_DE" on auth, but plain "JOYN" on GraphQL. These must stay -# separate; using the GraphQL map on auth calls silently downgrades the -# session to anonymous. -# -# NOT YET WIRED UP: auth.py still reads COUNTRY_TENANT_MAPPING. The switch is -# part of the auth rework batch. Kept here so the two maps stay visible -# together. -AUTH_TENANT_MAPPING = { - "de": "JOYN_DE", - "at": "JOYN_AT", - "ch": "JOYN_CH", -} - JOYN_DOMAINS = { "de": "https://www.joyn.de", "at": "https://www.joyn.at",