diff --git a/lib/streaming_providers/providers/joyn/auth.py b/lib/streaming_providers/providers/joyn/auth.py index 8ad80ee..2f9b178 100644 --- a/lib/streaming_providers/providers/joyn/auth.py +++ b/lib/streaming_providers/providers/joyn/auth.py @@ -21,6 +21,7 @@ from .constants import ( DEFAULT_MAX_RETRIES, DEFAULT_PLATFORM, DEVICE_IDS, + JOYN_AUTH_ENDPOINTS, JOYN_AUTH_HEADERS_BASE, JOYN_CLIENT_VERSION, JOYN_DOMAINS, @@ -350,7 +351,31 @@ class JoynAuthenticator(BaseOAuth2Authenticator): logger.debug("Anonymous token cannot be refreshed") return None - return super()._refresh_oauth_token() + # Joyn uses a dedicated refresh endpoint, distinct from the + # authorization-code redeem-token endpoint (oauth_token_endpoint). + # grant_type is the token_type (e.g. "Bearer"), matching the + # working reference client, not a standard "refresh_token" value. + payload = { + "refresh_token": self._current_token.refresh_token, + "grant_type": self._current_token.token_type, + "client_id": self._device_id, # Must match the client_id used for login/discovery + "client_name": self.platform, + } + + response = self.http_manager.post( + JOYN_AUTH_ENDPOINTS["REFRESH"], + operation="auth", + headers=self._get_joyn_auth_headers(), + json_data=payload, + timeout=getattr(self.config, "timeout", 30), + ) + + self._check_oauth_error_response(response) + token_data = response.json() + + refreshed_token = self._create_token_from_response(token_data) + logger.info("Joyn token refresh successful") + return refreshed_token except Exception as e: logger.warning(f"Token refresh failed: {e}") return None @@ -372,7 +397,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): 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 + client_id = self._extracted_client_id or self._device_id cd1 = self._device_id session = self._create_oauth_session() @@ -635,8 +660,10 @@ class JoynAuthenticator(BaseOAuth2Authenticator): try: logger.info(f"Starting client credentials flow") + # Joyn's anonymous auth expects a client_id and anon_device_id. + # We use the persistent device_id for both to maintain consistency. payload = { - "client_id": self.oauth_client_id, + "client_id": self._device_id, "client_name": self.platform, "anon_device_id": self._device_id } diff --git a/lib/streaming_providers/providers/joyn/constants.py b/lib/streaming_providers/providers/joyn/constants.py index ef02711..6326553 100644 --- a/lib/streaming_providers/providers/joyn/constants.py +++ b/lib/streaming_providers/providers/joyn/constants.py @@ -16,6 +16,25 @@ JOYN_LOGO = "https://upload.wikimedia.org/wikipedia/de/thumb/7/74/Joyn_%28Stream JOYN_7PASS_BASE_URL = "https://auth.7pass.de" +# 7pass OIDC endpoints — reference only. auth.py's login flow hardcodes these same +# paths inline (verified against working traffic) rather than importing this dict; +# keep the two in sync if you change one. +JOYN_7PASS_ENDPOINTS = { + "AUTHORIZE": f"{JOYN_7PASS_BASE_URL}/authz-srv/authz", + "TOKEN": f"{JOYN_7PASS_BASE_URL}/token", + "LOGIN": f"{JOYN_7PASS_BASE_URL}/login-srv/login", + "CONSENT_ACCEPT": f"{JOYN_7PASS_BASE_URL}/consent-management-srv/consent/scope/accept", + "PRECHECK_CONTINUE": f"{JOYN_7PASS_BASE_URL}/login-srv/precheck/continue", + "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", +} + +# 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" @@ -34,14 +53,16 @@ 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", + "Origin": "https://www.joyn.de", # Base origin, overridden per country "joyn-client-version": JOYN_CLIENT_VERSION, } +# Base API headers (without auth token) JOYN_API_BASE_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", @@ -54,16 +75,19 @@ 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", @@ -71,6 +95,7 @@ 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 @@ -85,6 +110,7 @@ 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, @@ -99,6 +125,7 @@ DEFAULT_VIDEO_CONFIG = { "maxSecurityLevel": 5, } +# Signature secret key (base64 encoded) SIGNATURE_SECRET_KEY = "MzU0MzM3MzgzMzM4MzMzNjM1NDMzNzM4MzYzNDM2MzYzNTQzMzczODM2MzYzMzM4MzIzNjM1NDMzNzM4MzMzMDM2MzQzNTM5MzU0MzM3MzgzMzM5MzMzNTMyMzQzNTQzMzczODM2MzUzMzM5MzU0MzM3MzgzMzM4MzMzMjMzNDYzNTQzMzczODM2MzYzMzMzMzM0NDMzNDIzNTQzMzczODMzMzgzNjM2MzMzNQ==" # ============================================================================