Files
script.service.ultimate/lib/streaming_providers/providers/discovery/auth.py
T
2026-05-04 16:52:50 +02:00

1470 lines
58 KiB
Python

# streaming_providers/providers/discovery/auth.py
"""
Discovery+ Authentication
Handles authentication for Discovery+ including anonymous and user credentials.
Supports two-step token negotiation with session headers.
"""
import base64
import hashlib
import json
import time
import uuid
import requests
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel
from ...base.auth.credentials import BaseCredentials, UserPasswordCredentials
from ...base.models.proxy_models import ProxyConfig
from ...base.utils.logger import logger
from .constants import (
DEFAULT_DEVICE_ID,
DEFAULT_ENV,
DEFAULT_PLATFORM_OS,
DEFAULT_REALM,
DEFAULT_TENANT,
DISCOVERY_ARKOSE_DATA_PAYLOAD,
DISCOVERY_ARKOSE_DATA_URL,
DISCOVERY_ARKOSE_FC_URL,
DISCOVERY_ARKOSE_SITEKEY,
DISCOVERY_AUTH_ORIGIN,
DISCOVERY_AUTH_REFERER,
DISCOVERY_BOOTSTRAP_URL,
DISCOVERY_CLIENT_ID_PREFIX,
DISCOVERY_DEVICE_CONSENT,
DISCOVERY_DEFAULT_TIMEZONE,
DISCOVERY_DISCO_PARAMS,
DISCOVERY_FEATURE_FLAGS_PAYLOAD,
DISCOVERY_FEATURE_FLAGS_URL,
DISCOVERY_HMAC_KEY,
HOME_MARKET_MAPPING,
PlatformOS,
AuthProvider,
get_device_info_template,
get_disco_client,
get_user_agent,
)
from .exceptions import (
InvalidCredentialsError,
UnsupportedCredentialTypeError,
)
@dataclass
class DiscoveryAnonymousCredentials(BaseCredentials):
"""Discovery+ anonymous credentials - no client_id/secret needed"""
realm: str = DEFAULT_REALM
def validate(self) -> bool:
"""Validate anonymous credentials"""
return bool(self.realm)
@property
def credential_type(self) -> str:
return "discovery_anonymous"
def to_auth_payload(self) -> Dict[str, Any]:
"""
Convert to authentication payload.
For anonymous auth, Discovery+ uses query parameters, not a payload.
Return empty dict as this method is required by BaseCredentials.
"""
return {}
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for storage"""
return {
"type": self.credential_type,
"realm": self.realm,
}
@dataclass
class DiscoveryUserCredentials(UserPasswordCredentials):
"""Discovery+ user credentials"""
provider: str = AuthProvider.USERNAME_PASSWORD.value
def to_login_payload(self) -> Dict[str, Any]:
"""
Convert to login payload for API request.
Returns:
Dictionary formatted for Discovery+ login API
"""
return {
"credentials": {
"password": self.password,
"username": self.username,
"provider": self.provider,
}
}
@property
def credential_type(self) -> str:
return "discovery_user"
@dataclass
class DiscoveryAuthToken(BaseAuthToken):
"""Discovery+ authentication token"""
refresh_token: Optional[str] = field(default=None)
realm: Optional[str] = field(default=None)
anonymous: bool = field(default=True)
token_id: Optional[str] = field(default=None)
st_cookie: Optional[str] = field(default=None) # long-lived session cookie
@classmethod
def from_token_response(
cls,
response_data: Dict[str, Any],
response=None,
) -> "DiscoveryAuthToken":
"""
Create token from API response.
Args:
response_data: Parsed JSON body from /token or /login response
response: Optional raw requests.Response — used to extract the
st= session cookie which is the true session credential.
Returns:
Initialized DiscoveryAuthToken
"""
data = response_data.get("data", {})
attributes = data.get("attributes", {})
# Extract st= cookie from the response if available.
# This is the long-lived session cookie that allows re-deriving
# the access token on subsequent /token calls without re-login.
st_cookie = None
if response is not None:
st_cookie = response.cookies.get("st")
token = cls(
access_token=attributes.get("token", ""),
token_type="Bearer",
expires_in=31536000, # Default 1 year
issued_at=time.time(),
realm=attributes.get("realm"),
anonymous=attributes.get("anonymous", True),
token_id=data.get("id"),
st_cookie=st_cookie,
)
# Parse JWT for more accurate expiration
token._parse_jwt_expiration()
return token
def _parse_jwt_expiration(self) -> None:
"""
Extract expiration from JWT token.
Updates expires_in based on JWT exp claim if present.
"""
try:
parts = self.access_token.split(".")
if len(parts) == 3:
import base64
import json
payload_b64 = parts[1]
# Add padding if needed
padding = len(payload_b64) % 4
if padding:
payload_b64 += "=" * (4 - padding)
payload = json.loads(base64.b64decode(payload_b64))
if "exp" in payload:
self.expires_in = payload["exp"] - self.issued_at
except (ValueError, IndexError, KeyError):
# If JWT parsing fails, keep default expiration
pass
def to_dict(self) -> Dict[str, Any]:
"""
Convert token to dictionary.
Returns:
Dictionary representation of the token
"""
return {
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"token_type": self.token_type,
"expires_in": self.expires_in,
"issued_at": self.issued_at,
"realm": self.realm,
"anonymous": self.anonymous,
"token_id": self.token_id,
"st_cookie": self.st_cookie,
}
class DiscoveryAuthenticator(BaseAuthenticator):
"""
Discovery+ authenticator with dynamic endpoint discovery.
Supports:
- Anonymous authentication (two-step token negotiation)
- User credentials (username/password upgrade from anonymous token)
- Dynamic endpoint discovery from bootstrap
- Session header persistence (x-disco-id, x-wbd-session-state, x-wbd-ace)
Auth flows:
Anonymous : GET /token (no headers) → 400 + session headers
→ GET /token (with session headers) → 200 + anon token
→ GET /bootstrap (with session headers + Bearer anon token)
→ 200 + full endpoint/routing map
User : [same two-step /token negotiation]
→ POST /login (Authorization: Bearer <anon_token>) → 200 + user token
→ GET /bootstrap (with session headers + Bearer user token)
→ 200 + full endpoint/routing map
Bootstrap is always called AFTER a token is obtained so it receives proper
session headers and an Authorization header, allowing it to return the
correct country-specific endpoint routing rather than a generic/empty 400.
Note: Discovery+ uses simple token-based auth, not OAuth2.
"""
@staticmethod
def _promote_credentials(credentials):
"""
Ensure credentials are Discovery-specific types rather than bare base classes.
The credential manager deserialises stored credentials as plain
UserPasswordCredentials. Promoting them here means every downstream
isinstance(..., DiscoveryUserCredentials) check works correctly without
requiring changes to shared infrastructure.
"""
from ...base.auth.credentials import UserPasswordCredentials as BaseUPC
if isinstance(credentials, BaseUPC) and not isinstance(credentials, DiscoveryUserCredentials):
return DiscoveryUserCredentials(
username=credentials.username,
password=credentials.password,
)
return credentials
def __init__(
self,
country: str = "de",
settings_manager=None,
credentials=None,
config_dir: Optional[str] = None,
http_manager=None,
proxy_config: Optional[ProxyConfig] = None,
platform_os: Optional[PlatformOS] = None,
device_id: Optional[str] = None,
):
self.country = country
self.home_market = HOME_MARKET_MAPPING.get(country, "emea")
self.tenant = DEFAULT_TENANT
self.env = DEFAULT_ENV
# OS platform — controls User-Agent, x-disco-client, x-device-info
self.platform_os: PlatformOS = platform_os if platform_os is not None else DEFAULT_PLATFORM_OS
# Device ID — prefer injected value (from provider via settings_manager),
# fall back to _load_or_create_device_id() for standalone usage.
# Note: set to sentinel now; resolved after super().__init__() below
# so that self.settings_manager is available for the fallback path.
self._injected_device_id = device_id
# Session headers storage (from /token 400 response)
self._session_state: Optional[str] = None
self._disco_id: Optional[str] = None
self._wbd_ace: Optional[str] = None
# GI SDK client ID — fetched from feature flags, session-specific
self._gisdk_client_id: Optional[str] = None
# Store http_manager and proxy_config BEFORE calling super().__init__
self._http_manager = http_manager
self._proxy_config = proxy_config
self._endpoints: Dict[str, str] = {}
self._api_groups: Dict[str, Any] = {}
self._routing: Dict[str, Any] = {}
# Promote credentials to Discovery-specific type before passing to super,
# so that isinstance checks throughout the auth flow work correctly.
# The credential manager returns bare UserPasswordCredentials; wrapping
# here ensures DiscoveryUserCredentials is used consistently.
promoted_credentials = self._promote_credentials(credentials) if credentials else None
logger.debug(
f"Discovery __init__: original={type(credentials).__name__}, "
f"promoted={type(promoted_credentials).__name__}"
)
# Call parent __init__ (BaseAuthenticator signature)
super().__init__(
provider_name="discovery",
settings_manager=settings_manager,
credentials=promoted_credentials,
country=country,
config_dir=config_dir,
enable_kodi_integration=True,
)
logger.debug(f"Discovery __init__ post-super: self.credentials={type(self.credentials).__name__}")
# Resolve device_id now that super().__init__() has run and
# self.settings_manager (set by BaseAuthenticator) is available.
if self._injected_device_id:
self.device_id = self._injected_device_id
logger.debug(f"Using injected device_id: {self.device_id}")
else:
self.device_id = self._load_or_create_device_id()
del self._injected_device_id # clean up sentinel
# If no credentials were provided at construction time, attempt to load
# from storage. Promote here too — the credential manager always returns
# bare base-class instances.
if not self.credentials:
raw = self._load_credentials_from_manager()
if raw:
self.credentials = self._promote_credentials(raw)
logger.info(f"Loaded stored credentials for discovery ({country})")
@property
def http_manager(self):
"""
Get or create HTTP manager.
Returns:
HTTP manager instance
Raises:
RuntimeError: If HTTP manager cannot be created
"""
if self._http_manager is not None:
return self._http_manager
logger.warning(
f"No HTTP manager available for {self.provider_name}, creating one"
)
try:
from ...base.network import HTTPManagerFactory
self._http_manager = HTTPManagerFactory.create_for_provider(
self.provider_name,
proxy_config=self._proxy_config,
user_agent=get_user_agent(self.platform_os),
timeout=30,
)
except Exception as e:
logger.error(f"Error creating HTTP manager: {e}")
raise RuntimeError(
f"Cannot create HTTP manager for {self.provider_name}: {e}"
)
return self._http_manager
def _load_or_create_device_id(self) -> str:
"""
Return a stable device ID for this installation.
Intended to be called after the settings_manager is available.
In normal operation the provider injects the device_id directly via
the constructor, so this is only used as a fallback when the
authenticator is constructed standalone (e.g. in tests).
Falls back to DEFAULT_DEVICE_ID if no settings manager is available.
Returns:
UUID string (with hyphens) to use as device ID
"""
# BaseAuthenticator stores settings_manager as self.settings_manager
# (no underscore prefix) when passed via super().__init__()
sm = getattr(self, "settings_manager", None)
try:
if sm:
device_id = sm.get_device_id("discovery", self.country)
if device_id:
logger.debug(f"Loaded device_id from settings: {device_id}")
return device_id
except Exception as e:
logger.debug(f"Could not load device_id from settings manager: {e}")
logger.debug("No settings manager available, using DEFAULT_DEVICE_ID")
return DEFAULT_DEVICE_ID
def _discover_endpoints(self, auth_headers: Dict[str, str]) -> None:
"""
Discover API endpoints from bootstrap using an authenticated session.
Bootstrap is a POST with an empty JSON body {}. It requires:
- Authorization: Bearer <token>
- Full session headers (x-disco-id, x-wbd-session-state, x-wbd-ace)
populated from the /token negotiation.
Must be called AFTER a token has been obtained so all of the above
are available.
Args:
auth_headers: Headers including Authorization and all session headers,
as built by _build_authenticated_headers().
"""
try:
logger.debug(f"Discovering endpoints for Discovery+ ({self.country})")
# Bootstrap is a POST with empty body — not a GET.
# http_manager.post() calls raise_for_status() internally, so we
# catch HTTPError and inspect the response ourselves to avoid
# treating a non-200 as a hard failure.
try:
response = self.http_manager.post(
DISCOVERY_BOOTSTRAP_URL,
operation="bootstrap",
headers=auth_headers,
json_data={},
timeout=30,
)
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else "unknown"
body = ""
if e.response is not None:
try:
body = f" — body: {e.response.json()}"
except ValueError:
body = f" — body: {e.response.text[:200]}"
logger.warning(
f"Bootstrap returned {status}{body} — falling back to hardcoded endpoints"
)
return
try:
data = response.json()
except Exception as parse_err:
logger.warning(
f"Could not parse bootstrap response: {parse_err}. "
"Falling back to hardcoded endpoints."
)
return
# Store routing info
self._routing = data.get("routing", {})
self._api_groups = data.get("apiGroups", {})
# Build endpoint map
self._endpoints = {}
for endpoint in data.get("endpoints", []):
path = endpoint.get("path")
api_group = endpoint.get("apiGroup")
if path and api_group and api_group in self._api_groups:
base_url = self._build_api_url(api_group)
if base_url:
self._endpoints[path] = f"{base_url}{path}"
logger.debug(
f"Discovered {len(self._endpoints)} endpoints from bootstrap"
)
except Exception as e:
logger.warning(f"Failed to discover endpoints: {e}")
def _build_api_url(self, api_group: str) -> Optional[str]:
"""
Build API URL from apiGroup template.
Args:
api_group: API group identifier
Returns:
Constructed API URL or None if template not found
"""
if not self._api_groups or api_group not in self._api_groups:
return None
template = self._api_groups[api_group].get("baseUrl", "")
if not template:
return None
# Replace placeholders
replacements = {
"{tenant}": self.tenant,
"{homeMarket}": self.home_market,
"{env}": self.env,
"{domain}": "api.discoveryplus.com",
}
url = template
for key, value in replacements.items():
url = url.replace(key, value)
return url
def get_endpoint(self, path: str) -> Optional[str]:
"""
Get full URL for an endpoint path discovered from bootstrap.
Args:
path: Endpoint path (e.g., '/token')
Returns:
Full endpoint URL or None if not yet discovered (fallbacks apply)
"""
return self._endpoints.get(path)
@property
def auth_endpoint(self) -> str:
"""
Get authentication endpoint URL (required by BaseAuthenticator).
For Discovery+, this returns the login endpoint for user auth
or token endpoint for anonymous auth.
Returns:
Authentication endpoint URL
"""
return self.login_endpoint
@property
def token_endpoint(self) -> str:
"""Get token endpoint URL"""
endpoint = self.get_endpoint("/token")
if endpoint:
return endpoint
return f"https://default.any-any.{self.env}.api.discoveryplus.com/token"
@property
def login_endpoint(self) -> str:
"""Get login endpoint URL"""
endpoint = self.get_endpoint("/login")
if endpoint:
return endpoint
bolt_any = self._build_api_url("bolt-any-homemarket")
return (
f"{bolt_any}/login" if bolt_any
else "https://default.any-emea.prd.api.discoveryplus.com/login"
)
@property
def playback_endpoint(self) -> str:
"""Get playback endpoint URL"""
endpoint = self.get_endpoint("/any/playback/v1/playbackInfo")
if endpoint:
return endpoint
bolt_any = self._build_api_url("bolt-any-homemarket")
return (
f"{bolt_any}/any/playback/v1/playbackInfo" if bolt_any
else "https://default.any-any.prd.api.discoveryplus.com/any/playback/v1/playbackInfo"
)
@property
def cms_home_endpoint(self) -> str:
"""Get CMS home endpoint URL"""
endpoint = self.get_endpoint("/cms/routes/home")
if endpoint:
return endpoint
bolt_any = self._build_api_url("bolt-any-homemarket")
return (
f"{bolt_any}/cms/routes/home" if bolt_any
else "https://default.any-any.prd.api.discoveryplus.com/cms/routes/home"
)
@property
def cms_collections_endpoint(self) -> str:
"""Get CMS collections endpoint URL"""
endpoint = self.get_endpoint("/cms/collections")
if endpoint:
return endpoint
bolt_any = self._build_api_url("bolt-any-homemarket")
return (
f"{bolt_any}/cms/collections" if bolt_any
else "https://default.any-any.prd.api.discoveryplus.com/cms/collections"
)
def _fetch_feature_flags(self, bearer_token: str) -> None:
"""
Fetch feature flags to obtain session-specific GI SDK client ID.
Must be called AFTER obtaining an anonymous token since the endpoint
requires Authorization: Bearer <anon_token>.
Populates self._gisdk_client_id.
Args:
bearer_token: Anonymous access token from /token
"""
try:
# Create a proper token object with all required fields
# Use current time for issued_at
import time
temp_token = DiscoveryAuthToken(
access_token=bearer_token,
token_type="Bearer",
expires_in=3600, # Temporary - won't be used
issued_at=time.time(),
)
headers = self._build_authenticated_headers(temp_token)
# Ensure Content-Type is set for this request
headers["Content-Type"] = "application/json"
response = self.http_manager.post(
DISCOVERY_FEATURE_FLAGS_URL,
operation="auth",
headers=headers,
json_data=DISCOVERY_FEATURE_FLAGS_PAYLOAD,
)
response.raise_for_status()
data = response.json()
# Extract gisdk clientId from oauth config (session-specific)
oauth_cfg = data.get("oauth", {}).get("config", {})
client_id = oauth_cfg.get("webToMobileAuth", {}).get("clientId")
if client_id:
self._gisdk_client_id = client_id
logger.debug(f"Fetched GI SDK client ID from feature flags: {client_id}")
else:
logger.warning("No GI SDK client ID in feature flags response")
except Exception as e:
logger.warning(f"Feature flags fetch failed, x-gisdk will be omitted: {e}")
@staticmethod
def _build_bda(user_agent: str) -> str:
"""
Build an Arkose browser data (bda) fingerprint payload.
Arkose requires a bda field containing AES-encrypted JSON fingerprint
data. The encryption key is: userAgent + str(floor(now / 21600) * 21600)
— i.e. the UA concatenated with the current 6-hour window start.
The fingerprint mirrors a real Chrome/Linux browser session on
discoveryplus.com so Arkose validates it as a legitimate client.
Args:
user_agent: Browser User-Agent string (must match FC request header)
Returns:
Base64-encoded encrypted bda string
"""
import os as _os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding as crypto_padding
ts_ms = int(time.time() * 1000)
ts_s = ts_ms // 1000
n_value = base64.b64encode(str(ts_s).encode()).decode()
f_value = hashlib.md5(user_agent.encode()).hexdigest()
enhanced_fp = [
{"key": "user_agent_data_brands", "value": "Not(A:Brand,Chromium,Google Chrome"},
{"key": "user_agent_data_mobile", "value": False},
{"key": "navigator_connection_downlink", "value": 10},
{"key": "navigator_connection_downlink_max", "value": None},
{"key": "network_info_rtt", "value": 50},
{"key": "network_info_save_data", "value": False},
{"key": "network_info_rtt_type", "value": None},
{"key": "screen_pixel_depth", "value": 24},
{"key": "navigator_device_memory", "value": 8},
{"key": "navigator_languages", "value": "de-DE"},
{"key": "window_inner_width", "value": 0},
{"key": "window_inner_height", "value": 0},
{"key": "window_outer_width", "value": 1920},
{"key": "window_outer_height", "value": 1040},
{"key": "browser_detection_firefox", "value": False},
{"key": "browser_detection_brave", "value": False},
{"key": "f58835f", "value": hashlib.md5((user_agent + "f58835f").encode()).hexdigest()},
{"key": "browser_object_checks", "value": hashlib.md5((user_agent + "boc").encode()).hexdigest()},
{"key": "29s83ih9", "value": hashlib.md5(b"").hexdigest()},
{"key": "audio_codecs", "value": "{\"ogg\":\"probably\",\"mp3\":\"probably\",\"wav\":\"probably\",\"m4a\":\"maybe\",\"aac\":\"probably\"}"},
{"key": "audio_codecs_extended_hash", "value": hashlib.md5(b"audio_codecs_extended").hexdigest()},
{"key": "video_codecs", "value": "{\"ogg\":\"\",\"h264\":\"probably\",\"webm\":\"probably\",\"mpeg4v\":\"\",\"mpeg4a\":\"\",\"theora\":\"\"}"},
{"key": "video_codecs_extended_hash", "value": hashlib.md5(b"video_codecs_extended").hexdigest()},
{"key": "media_query_dark_mode", "value": False},
{"key": "f9bf2db", "value": "{\"pc\":\"no-preference\",\"ah\":\"hover\",\"ap\":\"fine\",\"p\":\"fine\",\"h\":\"hover\",\"u\":\"fast\",\"prm\":\"no-preference\",\"prt\":\"no-preference\",\"s\":\"enabled\",\"fc\":\"none\"}"},
{"key": "headless_browser_phantom", "value": False},
{"key": "headless_browser_selenium", "value": False},
{"key": "headless_browser_nightmare_js", "value": False},
{"key": "862f2c1", "value": 4},
{"key": "1l2l5234ar2", "value": str(ts_ms)},
{"key": "document__referrer", "value": ""},
{"key": "window__ancestor_origins", "value": ["https://auth.discoveryplus.com"]},
{"key": "window__tree_index", "value": [3]},
{"key": "window__tree_structure", "value": "[[[]],[[]],[],[]]"},
{"key": "window__location_href", "value": "about:srcdoc"},
{"key": "client_config__sitedata_location_href", "value": "about:srcdoc"},
{"key": "client_config__language", "value": "de-DE"},
{"key": "client_config__surl", "value": "https://a4gds3vfh.discoveryplus.com"},
{"key": "c8480e29a", "value": hashlib.md5((user_agent + "c8480e29a").encode()).hexdigest()},
{"key": "client_config__triggered_inline", "value": False},
{"key": "mobile_sdk__is_sdk", "value": False},
{"key": "audio_fingerprint", "value": "124.04347527516074"},
{"key": "navigator_battery_charging", "value": False},
{"key": "7541c2s", "value": None},
{"key": "1f220c9", "value": hashlib.md5((user_agent + "1f220c9").encode()).hexdigest()},
{"key": "math_fingerprint", "value": hashlib.md5(b"math").hexdigest()},
{"key": "supported_math_functions", "value": hashlib.md5(b"supported_math").hexdigest()},
{"key": "3f76dd27", "value": "landscape-primary"},
{"key": "5dd48ca0", "value": 5},
{"key": "4b4b269e68", "value": "0d8cf6a2-ec3e-48fb-80bf-5f1c3a09af7f"},
{"key": "6a62b2a558", "value": hashlib.md5((user_agent + "6a62b2a558").encode()).hexdigest()},
{"key": "is_keyless", "value": False},
{"key": "wait_for_settings", "value": True},
{"key": "c2d2015", "value": hashlib.md5((user_agent + "c2d2015").encode()).hexdigest()},
{"key": "43f2d94", "value": []},
{"key": "20c15922", "value": False},
{"key": "4f59ca8", "value": None},
{"key": "3ea7194", "value": {"supported": True, "formats": ["HDR10", "HLG"], "isHDR": False}},
{"key": "05d3d24", "value": hashlib.md5((user_agent + "05d3d24").encode()).hexdigest()},
{"key": "speech_default_voice", "value": "Google Deutsch || de-DE"},
{"key": "speech_voices_hash", "value": hashlib.md5(b"speech_voices").hexdigest()},
{"key": "83eb055", "value": hashlib.md5((user_agent + "83eb055").encode()).hexdigest()},
{"key": "4ca87df3d1", "value": "Ow=="},
{"key": "867e25e5d4", "value": "Ow=="},
{"key": "d4a306884c", "value": "Ow=="},
]
fe_list = [
"DNT:unknown", "L:de-DE", "D:24", "PR:1", "S:1920,1080",
"AS:1920,1040", "TO:-60", "SS:true", "LS:true", "IDB:true",
"B:false", "ODB:false", "CPUC:unknown", "PK:Linux x86_64",
"CFP:false", "FR:false", "FOS:false", "FB:false",
"JSF:Arial,Courier,Courier New,Helvetica,Times,Times New Roman",
"P:Chrome PDF Viewer,Chromium PDF Viewer,Microsoft Edge PDF Viewer,PDF Viewer,WebKit built-in PDF",
"T:0,false,false", "H:4", "SWF:false",
]
fingerprint = [
{"key": "api_type", "value": "js"},
{"key": "f", "value": f_value},
{"key": "n", "value": n_value},
{"key": "wh", "value": f"{hashlib.md5(user_agent.encode()).hexdigest()}|{hashlib.md5((user_agent + 'wh').encode()).hexdigest()}"},
{"key": "enhanced_fp", "value": enhanced_fp},
{"key": "fe", "value": fe_list},
{"key": "ife_hash", "value": hashlib.md5(json.dumps(fe_list[:5]).encode()).hexdigest()},
{"key": "jsbd", "value": "{\"HL\":5,\"NCE\":true,\"DT\":\"Authentication\",\"NWD\":\"false\",\"DMTO\":1,\"DOTO\":1}"},
]
plaintext = json.dumps(fingerprint, separators=(',', ':'))
key_time = round(ts_s - (ts_s % 21600))
key_str = user_agent + str(key_time)
# Encrypt: derive AES-256 key via MD5 chaining (same as Arkose JS client)
salt = _os.urandom(8)
dk = key_str.encode() + salt
arr = [hashlib.md5(dk).hexdigest()]
for x in range(1, 3):
arr.append(hashlib.md5(bytes.fromhex(arr[x - 1]) + dk).hexdigest())
result = ''.join(arr)
aes_key = bytes.fromhex(result[:64])
iv = _os.urandom(16)
padder = crypto_padding.PKCS7(128).padder()
padded = padder.update(plaintext.encode()) + padder.finalize()
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv), backend=default_backend())
enc = cipher.encryptor()
ct = enc.update(padded) + enc.finalize()
payload = {"ct": base64.b64encode(ct).decode(), "s": salt.hex(), "iv": iv.hex()}
return base64.b64encode(json.dumps(payload).encode()).decode()
@staticmethod
def _sign_request(method: str, path: str, body: Optional[str] = None) -> str:
"""
Build x-disco-client-id header value for a specific request.
Discovery+ signs each request with HMAC-SHA256. The message format
(from the GI SDK JS bundle) is:
"{timestamp}:{METHOD}:{relativePath}:{body}"
where body is the raw JSON string (or "" if no body).
The key is the base64-decoded hmacKeys.web.key from feature flags.
The header value is: "{keyId}:{timestamp}:{hex_signature}"
Args:
method: HTTP method in uppercase (e.g. "POST", "GET")
path: Relative path only, e.g. "/login"
body: Raw request body string, or None/empty for no body
Returns:
x-disco-client-id header value
"""
import hashlib
import hmac as hmac_lib
timestamp = str(int(time.time()))
body_str = body if body else ""
message = f"{timestamp}:{method.upper()}:{path}:{body_str}"
signature = hmac_lib.new(
DISCOVERY_HMAC_KEY.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
client_id = f"{DISCOVERY_CLIENT_ID_PREFIX}:{timestamp}:{signature}"
logger.debug(f"Signed request {method} {path} -> client-id prefix: {DISCOVERY_CLIENT_ID_PREFIX}:{timestamp}")
return client_id
def _build_device_info(self) -> str:
"""
Build x-device-info header.
Format varies by platform:
Linux: dplus/<version> (desktop/desktop; Linux/x86_64; device-id/session-id)
Windows: dplus/<version> (desktop/desktop; Windows/NT 10.0; device-id/session-id)
"""
device_id = self.device_id or DEFAULT_DEVICE_ID
session_id = str(uuid.uuid4())
return get_device_info_template(self.platform_os).format(
device_id=device_id, session_id=session_id
)
def _build_base_headers(self) -> Dict[str, str]:
"""
Build base headers for all requests, including session headers if available.
Returns:
Dictionary of HTTP headers
"""
headers = {
"User-Agent": get_user_agent(self.platform_os),
"x-device-info": self._build_device_info(),
"x-disco-client": get_disco_client(self.platform_os),
"x-disco-params": DISCOVERY_DISCO_PARAMS,
"x-gisdk": f"clientId={self._gisdk_client_id}" if self._gisdk_client_id else None,
"x-wbd-device-consent": DISCOVERY_DEVICE_CONSENT,
"x-wbd-preferred-language": f"{self.country.lower()}-DE",
"x-wbd-time-zone": DISCOVERY_DEFAULT_TIMEZONE,
}
# Add session headers if we have them (from /token 400 response)
if self._disco_id:
headers["x-disco-id"] = self._disco_id
if self._session_state:
headers["x-wbd-session-state"] = self._session_state
if self._wbd_ace:
headers["x-wbd-ace"] = self._wbd_ace
# Remove any None values (e.g. x-gisdk before feature flags are fetched)
return {k: v for k, v in headers.items() if v is not None}
def _get_auth_headers(self) -> Dict[str, str]:
"""
Get headers for authentication request.
Returns:
Dictionary of HTTP headers
"""
return self._build_base_headers()
def _build_authenticated_headers(self, token: BaseAuthToken) -> Dict[str, str]:
"""
Build headers for post-auth requests (bootstrap, CMS, playback).
Combines session headers (populated during /token negotiation) with:
- Authorization: Bearer <token>
- Content-Type: application/json
- Origin / Referer (required by bootstrap)
- x-wbd-session-state updated to include the token segment, which
the server expects after authentication completes. The session
state is a semicolon-separated list of named JWT segments; we
append/replace the "token:" segment with the current access token.
Args:
token: Successfully obtained authentication token
Returns:
Headers dict ready for bootstrap, CMS, and playback requests
"""
headers = self._build_base_headers() # picks up _disco_id etc.
headers["Authorization"] = f"Bearer {token.access_token}"
headers["Content-Type"] = "application/json"
headers["Origin"] = DISCOVERY_AUTH_ORIGIN
headers["Referer"] = DISCOVERY_AUTH_REFERER
# NOTE: x-wbd-session-state is a server-issued encrypted blob returned
# in the /token 400 response headers. It cannot be constructed from the
# access_token. On a stored-token path _session_state is None and
# _build_base_headers() will not add this header — that is correct.
# The Authorization: Bearer header is sufficient for bootstrap.
return headers
def _build_auth_payload(self) -> Dict[str, Any]:
"""
Build authentication payload based on credential type.
Returns:
Authentication payload dictionary
Raises:
InvalidCredentialsError: If no credentials available
UnsupportedCredentialTypeError: If credential type not supported
"""
if not self.credentials:
raise InvalidCredentialsError("No credentials available")
if isinstance(self.credentials, DiscoveryAnonymousCredentials):
return {}
elif isinstance(self.credentials, DiscoveryUserCredentials):
return self.credentials.to_login_payload()
else:
raise UnsupportedCredentialTypeError(type(self.credentials).__name__)
def _create_token_from_response(
self,
response_data: Dict[str, Any],
response=None,
) -> BaseAuthToken:
"""
Create token object from API response.
Args:
response_data: Parsed JSON body from the auth response
response: Raw requests.Response for cookie extraction
Returns:
Initialized token with auth level classified
"""
token = DiscoveryAuthToken.from_token_response(response_data, response=response)
token.auth_level = self._classify_token(token)
return token
def get_fallback_credentials(self) -> DiscoveryAnonymousCredentials:
"""
Get fallback credentials (anonymous).
Returns:
Anonymous credentials instance
"""
return DiscoveryAnonymousCredentials(realm=DEFAULT_REALM)
def _perform_authentication(self) -> BaseAuthToken:
"""
Perform authentication using appropriate flow based on credential type.
Returns:
Authenticated token
Raises:
InvalidCredentialsError: If no credentials available
UnsupportedCredentialTypeError: If credential type not supported
"""
if isinstance(self.credentials, DiscoveryAnonymousCredentials):
return self._perform_anonymous_auth()
elif isinstance(self.credentials, DiscoveryUserCredentials):
return self._perform_user_auth()
else:
raise UnsupportedCredentialTypeError(type(self.credentials).__name__)
def _perform_anonymous_auth(self) -> BaseAuthToken:
"""
Perform anonymous authentication with two-step header negotiation.
Step 1: GET /token with no session headers → 400 response whose
headers contain x-disco-id, x-wbd-session-state, x-wbd-ace.
Step 2: GET /token again with those three headers → 200 with token.
Returns:
Anonymous authentication token
"""
logger.info(
f"Performing two-step anonymous authentication for {self.provider_name}"
)
# Step 1 headers: base headers only, no session headers yet
base_headers = {k: v for k, v in {
"User-Agent": get_user_agent(self.platform_os),
"x-device-info": self._build_device_info(),
"x-disco-client": get_disco_client(self.platform_os),
"x-disco-params": DISCOVERY_DISCO_PARAMS,
"x-gisdk": f"clientId={self._gisdk_client_id}" if self._gisdk_client_id else None,
"x-wbd-device-consent": DISCOVERY_DEVICE_CONSENT,
"x-wbd-preferred-language": f"{self.country.lower()}-DE",
"x-wbd-time-zone": DISCOVERY_DEFAULT_TIMEZONE,
}.items() if v is not None}
params = {"realm": "bolt"}
# Step 1: Initial request — we expect 400 + session headers in response
logger.debug("Step 1: Making initial token request (expecting 400)")
response = self.http_manager.get(
self.token_endpoint,
operation="auth",
headers=base_headers,
params=params,
allow_redirects=False,
)
if response.status_code == 400:
# Extract required session headers from the 400 response
disco_id = response.headers.get("x-disco-id")
session_state = response.headers.get("x-wbd-session-state")
wbd_ace = response.headers.get("x-wbd-ace")
if not all([disco_id, session_state, wbd_ace]):
missing = [
h for h, v in [
("x-disco-id", disco_id),
("x-wbd-session-state", session_state),
("x-wbd-ace", wbd_ace),
] if not v
]
logger.error(
f"Missing required headers in 400 response: {missing}"
)
logger.debug(f"Response headers: {dict(response.headers)}")
response.raise_for_status()
logger.debug(f"Received x-disco-id: {disco_id}")
logger.debug(f"Received x-wbd-ace: {wbd_ace[:50]}...")
logger.debug(f"Received x-wbd-session-state: {session_state[:100]}...")
# Persist session headers for future requests (CMS, playback, etc.)
self._disco_id = disco_id
self._session_state = session_state
self._wbd_ace = wbd_ace
# Step 2: Repeat the request with all three session headers
logger.debug("Step 2: Making second token request with session headers")
second_headers = base_headers.copy()
second_headers["x-disco-id"] = disco_id
second_headers["x-wbd-session-state"] = session_state
second_headers["x-wbd-ace"] = wbd_ace
response = self.http_manager.get(
self.token_endpoint,
operation="auth",
headers=second_headers,
params=params,
)
response.raise_for_status()
token_data = response.json()
logger.info("Anonymous authentication successful")
token = self._create_token_from_response(token_data, response=response)
assert isinstance(token, DiscoveryAuthToken)
if token.st_cookie:
logger.debug("Captured st= session cookie from /token response")
else:
logger.warning("No st= cookie in /token response — session may not persist")
# Bootstrap with authenticated session to discover country-specific
# endpoints. Must happen after token is obtained.
self._discover_endpoints(self._build_authenticated_headers(token))
return token
elif response.status_code == 200:
# The server returned 200 directly, skipping the 400 header-negotiation
# step. This happens when the st= session cookie in the jar is still
# valid — the server recognises the existing session and returns the
# current token (which may already be a user-level token, not anonymous).
# The caller (_perform_user_auth) MUST check anon_token.anonymous before
# proceeding to POST /login, otherwise the server will reject it with
# 400 "Log in attempt with already logged in token".
logger.debug("Received 200 directly without header negotiation")
token_data = response.json()
token = self._create_token_from_response(token_data, response=response)
assert isinstance(token, DiscoveryAuthToken)
self._discover_endpoints(self._build_authenticated_headers(token))
return token # may be user-level — _perform_user_auth handles this
else:
logger.error(f"Unexpected response status: {response.status_code}")
response.raise_for_status()
raise RuntimeError(f"Unexpected status {response.status_code} from /token")
def _solve_arkose(self, blob: str) -> str:
"""
Exchange the Arkose blob for a solved FunCaptcha token using
Discovery+'s own hosted Arkose endpoint.
Flow:
POST https://a4gds3vfh.discoveryplus.com/fc/gt2/public_key/<sitekey>
Content-Type: application/x-www-form-urlencoded
Body (form-encoded):
public_key=<sitekey>
...
data[blob]=<blob from /users/arkose/data>
The x-ark-esync-value header is the current Unix timestamp rounded
down to the nearest hour, which is the sync value Arkose expects.
Args:
blob: Arkose data blob from /users/arkose/data
Returns:
Solved Arkose token string
Raises:
Exception: If the token exchange fails
"""
import math
# Build browser data fingerprint encrypted with UA + 6hr window key
bda = self._build_bda(get_user_agent(self.platform_os))
# rnd = random float in [0, 1) — mirrors what the browser JS sends
import random
rnd = str(random.random())
# esync value = current time rounded down to the nearest hour
esync_value = str(int(math.floor(time.time() / 3600) * 3600))
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": DISCOVERY_AUTH_ORIGIN,
"Referer": DISCOVERY_AUTH_REFERER + "/",
"User-Agent": get_user_agent(self.platform_os),
"x-ark-esync-value": esync_value,
}
# Full form payload mirrors what the browser sends
payload = {
"public_key": DISCOVERY_ARKOSE_SITEKEY,
"capi_version": "3.7.8",
"capi_mode": "lightbox",
"style_theme": "dplus",
"rnd": rnd,
"bda": bda,
"site": "null",
"userbrowser": get_user_agent(self.platform_os),
"language": "de-DE",
"data[blob]": blob,
}
logger.debug("Exchanging Arkose blob for FC token")
response = self.http_manager.post(
DISCOVERY_ARKOSE_FC_URL,
operation="auth",
data=payload,
headers=headers,
)
response.raise_for_status()
data = response.json()
token = data.get("token")
if not token:
raise Exception(f"No token in Arkose FC response: {data}")
logger.debug("Arkose FC token obtained successfully")
return token
def _fetch_arkose_blob(self) -> str:
"""
Fetch the Arkose data blob required before POST /login.
Discovery+ requires a solved Arkose (FunCaptcha) token on every /login
request. The blob returned here is passed to your Arkose solver which
returns the final token to include in x-disco-arkose-token.
Returns:
Arkose blob string
Raises:
Exception: If the blob cannot be fetched
"""
headers = self._build_base_headers()
headers["Authorization"] = f"Bearer {self._current_anon_token}"
headers["Content-Type"] = "application/json"
headers["Origin"] = DISCOVERY_AUTH_ORIGIN
headers["Referer"] = DISCOVERY_AUTH_REFERER
response = self.http_manager.post(
DISCOVERY_ARKOSE_DATA_URL,
operation="auth",
headers=headers,
json_data=DISCOVERY_ARKOSE_DATA_PAYLOAD,
)
response.raise_for_status()
data = response.json()
blob = data["data"]["attributes"]["blob"]
logger.debug(f"Fetched Arkose blob (length={len(blob)})")
return blob
def _perform_user_auth(self) -> BaseAuthToken:
"""
Perform user authentication by upgrading an anonymous token.
"""
if not isinstance(self.credentials, DiscoveryUserCredentials):
raise InvalidCredentialsError(
"User credentials required for user authentication"
)
logger.info(
f"Performing user authentication for {self.provider_name} "
f"(user: {self.credentials.username})"
)
# Step 1+2: Obtain anonymous token
logger.debug("Obtaining anonymous base token for user auth upgrade")
anon_token = self._perform_anonymous_auth()
assert isinstance(anon_token, DiscoveryAuthToken)
# Short-circuit: if /token returned a 200 directly (because the st= session
# cookie is still alive), the token is already a fully authenticated user
# token — NOT an anonymous one. Posting it to /login would trigger:
# 400 "Log in attempt with already logged in token on deviceId: [...]"
# In this case we skip the upgrade entirely and return the existing session.
if not anon_token.anonymous:
logger.info(
f"Anonymous auth returned an existing user session for "
f"{self.credentials.username} — skipping /login upgrade"
)
self._current_token = anon_token
self._save_session()
return anon_token
# Step 3: Fetch feature flags with anon token (optional)
logger.debug("Fetching feature flags for GI SDK client ID")
try:
self._fetch_feature_flags(anon_token.access_token)
except Exception as e:
logger.debug(f"Feature flags fetch failed (non-critical): {e}")
# Step 4: Fetch Arkose blob and solve the challenge.
logger.debug("Fetching Arkose blob for login challenge")
self._current_anon_token = anon_token.access_token
arkose_blob = self._fetch_arkose_blob()
arkose_token = self._solve_arkose(arkose_blob)
logger.debug("Arkose challenge solved")
# Step 5: Upgrade anonymous session to user session
logger.debug(
f"Upgrading anonymous token to user token for "
f"{self.credentials.username}"
)
payload = self.credentials.to_login_payload()
payload_str = json.dumps(payload, separators=(',', ':'))
# Build headers: session headers + Bearer anon token + Origin/Referer + Arkose
upgrade_headers = self._build_base_headers()
upgrade_headers["Authorization"] = f"Bearer {anon_token.access_token}"
upgrade_headers["Content-Type"] = "application/json"
upgrade_headers["Origin"] = DISCOVERY_AUTH_ORIGIN
upgrade_headers["Referer"] = DISCOVERY_AUTH_REFERER
upgrade_headers["x-disco-arkose-sitekey"] = DISCOVERY_ARKOSE_SITEKEY
upgrade_headers["x-disco-arkose-token"] = arkose_token
upgrade_headers["x-disco-client-id"] = self._sign_request("POST", "/login", payload_str)
response = self.http_manager.post(
self.login_endpoint,
operation="auth",
headers=upgrade_headers,
data=payload_str,
)
# Handle the "already logged in" error.
# Root cause: the st= session cookie in the jar still identifies a live
# session for this device, so /token returns a user token directly (200)
# instead of a fresh anon token. The short-circuit above handles the
# normal case; this branch is a last-resort safety net for edge cases
# where the token reports anonymous=True but the device is already bound.
if response.status_code == 400:
try:
error_data = response.json()
errors = error_data.get('errors', [])
for error in errors:
if error.get('code') == 'invalid.token' and 'already logged in' in error.get('detail', ''):
logger.warning(
"Login rejected — device session already active. "
"Clearing st= cookie and session state, then retrying."
)
# Clear the stale st= cookie — this is the real culprit.
# Rotating the device ID alone does not help because the
# server uses the st= cookie to identify the session.
self.invalidate_token()
try:
self.http_manager._session.cookies.clear()
logger.debug("Cleared all cookies")
except Exception:
pass
# Retry the entire authentication process with a clean slate.
logger.info("Retrying authentication after clearing session cookie")
return self._perform_user_auth()
except Exception as parse_err:
logger.debug(f"Could not parse error response: {parse_err}")
if response.status_code == 401:
raise InvalidCredentialsError(
f"Invalid credentials for user {self.credentials.username}"
)
response.raise_for_status()
token_data = response.json()
user_token = self._create_token_from_response(token_data, response=response)
assert isinstance(user_token, DiscoveryAuthToken)
if user_token.st_cookie:
logger.debug("Captured st= session cookie from /login response")
else:
logger.warning("No st= cookie in /login response — user session may not persist")
if user_token.anonymous:
logger.warning(
f"Login succeeded for {self.credentials.username} but token "
"is still anonymous — account may lack an active subscription"
)
else:
logger.info(
f"User authentication successful for {self.credentials.username}"
)
self._discover_endpoints(self._build_authenticated_headers(user_token))
self._current_token = user_token
self._save_session()
return user_token
def _refresh_token(self) -> Optional[BaseAuthToken]:
"""
Token refresh — Discovery+ tokens have no refresh_token mechanism.
Returning None triggers a full re-authentication in the base class.
Returns:
None (triggers re-authentication)
"""
logger.debug(
f"No refresh token support for {self.provider_name}, "
"re-authenticating"
)
return None
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
"""
Classify Discovery+ token based on authentication level.
Args:
token: Token to classify
Returns:
Token authentication level
"""
if hasattr(token, "anonymous") and not token.anonymous:
return TokenAuthLevel.USER_AUTHENTICATED
return TokenAuthLevel.ANONYMOUS
def _establish_session(self) -> None:
"""
Establish a live session (session headers + endpoints) using whatever
credentials are available.
"""
if isinstance(self.credentials, DiscoveryUserCredentials):
logger.debug(
"User credentials available — running full user auth to "
"establish session"
)
token = self._perform_user_auth()
# Ensure the token is stored
self._current_token = token
else:
logger.debug(
"No user credentials — running anonymous /token negotiation "
"to establish session for bootstrap"
)
token = self._perform_anonymous_auth()
# Ensure the token is stored
self._current_token = token
def _restore_session_cookie(self, st_cookie: str) -> None:
"""
Inject a stored st= cookie back into the http_manager session.
The st= cookie is the true session credential for Discovery+. Without
it in the active cookie jar, /token returns a fresh anonymous token
regardless of what Authorization header we send.
Args:
st_cookie: The raw st= cookie value from storage
"""
jar = self.http_manager._session.cookies
if jar.get("st"):
return # Already present — nothing to do
jar.set("st", st_cookie, domain="api.discoveryplus.com", path="/")
logger.debug("Restored st= session cookie into http_manager cookie jar")
def get_bearer_token(self, force_refresh: bool = False) -> str:
"""
Get bearer token for API requests.
"""
token = self.authenticate(force_refresh=force_refresh)
# Restore the st= cookie into the http_manager session if we have one
if isinstance(token, DiscoveryAuthToken) and token.st_cookie:
self._restore_session_cookie(token.st_cookie)
token_info = self.get_token_info() or {}
is_anonymous_token = token_info.get("anonymous", True)
has_user_credentials = isinstance(self.credentials, DiscoveryUserCredentials)
logger.debug(
f"Token state: anonymous={is_anonymous_token}, "
f"has_user_credentials={has_user_credentials}, "
f"has_session_headers={bool(self._session_state)}" # Added this
)
if is_anonymous_token and has_user_credentials:
logger.info(
"Stored token is anonymous but user credentials are available "
"— upgrading to user token"
)
token = self._perform_user_auth()
elif not self._endpoints:
logger.debug(
"Endpoints not yet discovered — establishing session for bootstrap"
)
# CRITICAL FIX: Even if we have a token, we need session headers
# for CMS requests. The token alone isn't enough.
if not self._session_state:
# No live session headers — need to run auth flow to get them.
self._establish_session()
# After _establish_session, we need to get the updated token
token = self._current_token
else:
self._discover_endpoints(self._build_authenticated_headers(token))
# ADD THIS: Ensure session headers are populated even if endpoints exist
# but session headers are missing (happens on stored token restore)
elif not self._session_state:
logger.debug("Token exists but session headers missing - re-establishing session")
self._establish_session()
token = self._current_token
return token.bearer_token
def is_authenticated(self) -> bool:
"""
Check if currently authenticated with valid token.
Returns:
True if authenticated with valid token
"""
return (
self._current_token is not None and
not self._current_token.is_expired
)
def invalidate_token(self) -> None:
"""Invalidate current token and clear from storage"""
self._current_token = None
# Clear session headers so next auth starts fresh
self._disco_id = None
self._session_state = None
self._wbd_ace = None
# Clear the st= cookie from the session jar
try:
self.http_manager._session.cookies.clear(
domain="api.discoveryplus.com", path="/", name="st"
)
except (AttributeError, KeyError, ValueError):
pass
except (AttributeError, KeyError, IOError, OSError):
pass