From c0c358d2164652ce051eda07d65bad1dc72db1c5 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sun, 4 Oct 2026 14:15:07 +0200 Subject: [PATCH] Simpli - authenticate --- .../providers/_template/README.md | 125 ++++++++++++++++++ .../providers/simpli/auth.py | 54 +++++++- .../providers/simpli/constants.py | 20 ++- 3 files changed, 189 insertions(+), 10 deletions(-) diff --git a/lib/streaming_providers/providers/_template/README.md b/lib/streaming_providers/providers/_template/README.md index 14934ac..b5db447 100644 --- a/lib/streaming_providers/providers/_template/README.md +++ b/lib/streaming_providers/providers/_template/README.md @@ -542,6 +542,16 @@ Minimal no-auth stub: def invalidate(self): pass + # Credential methods are no-ops for a no-auth provider. + def has_credentials(self): + return True + + def set_credentials(self, username, password): + return False + + def clear_credentials(self): + return False + Wire it as `self.auth = YourNoAuth()` in `_build_auth()`. The manager ABCs' isinstance check passes (all three required methods are present), and `build_headers()` returns whatever static headers the provider @@ -596,6 +606,121 @@ from this check means the `auth` object is missing one of the three required methods; it is not fatal, but it usually means a wiring mistake. +### Credentials — how they get loaded, saved, and re-checked + +The `AuthProtocol` names the three *token* methods. It does not name the +*credential* methods, because credentials only exist for providers that +have them. But every provider that has credentials must implement the +credential surface correctly, or the auth class works only when the +caller passes credentials directly at construction — which never happens +in the real runtime. This is the failure mode most likely to slip +through a naive implementation: the provider instantiates cleanly, +`_build_auth` returns an object, and then the first `get_access_token()` +raises `CredentialsError` because nothing ever populated the +credentials. + +The three credential methods: + + has_credentials() -> bool + Return True if this auth can authenticate right now — i.e. + credentials are available from the constructor argument, the + settings manager, or a fallback that always succeeds. + Called by the UI and the registry to decide whether the + provider is usable. + + set_credentials(username, password) -> bool + Persist credentials via + settings_manager.save_provider_credentials(provider_name, + UserPasswordCredentials(username, password), country). + Called by the settings UI when the user enters credentials. + Return True on success. + + clear_credentials() -> bool + Clear stored credentials via + settings_manager.clear_provider_credentials(...) or + credential_manager.delete_credentials(provider_name, country). + Also call self.invalidate() to drop the cached token. + Return True on success. + +**Credentials source priority, checked in this order:** + +1. **Constructor argument.** The provider's `_build_auth()` passes + `credentials=self._credentials`, which is non-None only when the + caller constructed the provider with explicit credentials. This is + the case for CLI tools and tests, not for the runtime UI flow. +2. **Settings manager.** `settings_manager.get_provider_credentials( + provider_name, country)` reads the stored credentials that the + settings UI wrote via `set_credentials`. **This is the path the + runtime actually uses.** If your auth class doesn't call this, the + provider can never authenticate from the UI. +3. **Fallback.** Providers with anonymous or free access return a + fallback credentials object from `get_fallback_credentials()` (as + `BaseAuthenticator` does). The fallback is what lets the auth class + succeed even when the user hasn't configured anything — useful for + providers that offer a limited anonymous tier. + +**Re-read credentials on every authenticate, not just at construction.** +A user can store credentials through the UI at any time after the +provider was constructed. If your auth class caches +`self._credentials = None` at construction and never re-reads, the +provider stays broken until the app restarts. The pattern from +`BaseAuthenticator` is: + + def _ensure_credentials(self) -> bool: + # 1. If current credentials are valid, keep them. + if self._credentials and self._credentials.validate(): + return True + # 2. Otherwise try the settings manager, in case the user just + # stored them. + fresh = self._load_credentials_from_manager() + if fresh and fresh.validate(): + self._credentials = fresh + return True + # 3. Otherwise try the fallback. + self._credentials = self.get_fallback_credentials() + return self._credentials is not None and self._credentials.validate() + +Call `_ensure_credentials()` at the start of `_perform_authentication()`, +before building the login payload. This makes the auth class work +whether credentials were supplied at construction, stored by the UI +before first use, or stored by the UI after the provider was already +running. + +**Reference.** The full pattern lives in +`base/auth/base_auth.py`: +`_load_credentials_from_manager`, `_ensure_credentials`, +`save_credentials`, `clear_stored_credentials`, `has_stored_credentials`. +Read them before writing your auth class. They are the contract even +though they are not part of the protocol. + +**Providers with no user credentials** (anonymous-only, static-key, +free): + +* `has_credentials()` returns `True` unconditionally. +* `set_credentials()` and `clear_credentials()` are no-ops returning + `False` (there is nothing to store). +* `_ensure_credentials()` in `_perform_authentication` is not needed — + the login flow doesn't depend on stored credentials. + +### Per-authenticate credential flow + +A correct `_perform_authentication()` looks like this: + + def _perform_authentication(self): + if not self._ensure_credentials(): + raise CredentialsError( + f"no credentials available for {self.provider_name}" + ) + payload = self._build_login_payload(self._credentials) + resp = self.http_manager.post( + self._login_url(), json=payload, headers=self.build_headers() + ) + return self._create_token_from_response(resp.json()) + +The critical piece is `_ensure_credentials()`. Without it, the auth +class silently depends on the caller having passed credentials — which +the registry never does. + ## DRM DRM is optional. Providers with no DRM leave `_build_drm()` returning diff --git a/lib/streaming_providers/providers/simpli/auth.py b/lib/streaming_providers/providers/simpli/auth.py index 3942002..3d31b31 100644 --- a/lib/streaming_providers/providers/simpli/auth.py +++ b/lib/streaming_providers/providers/simpli/auth.py @@ -27,8 +27,7 @@ On first use: authoritative check (the RegisterDevice response shape is not verified). -The key is cached in-process only. The settings_manager accessor names -are host-specific and are not guessed. A process restart therefore hits +The key is cached in-process only. A process restart therefore hits GetDevices again, which returns the already-registered device. Error handling: network / JSON failures in the device calls are wrapped @@ -49,8 +48,20 @@ operation. CredentialManager.load_credentials handles both the country-nested format ({"simpli": {"at": {...}}}) and the flat format ({"simpli": {...}}), so an existing flat credentials.json works unchanged. + +Authenticate request shape +-------------------------- +The browser sends the Authenticate body as JSON text but with +Content-Type: text/plain (NOT application/json). The server returns a +different, token-less response when it sees application/json. The body +is therefore sent as a raw string (data=...) with Content-Type +text/plain set explicitly for this call, matching the browser capture. + +Everything else on the API uses Content-Type: application/json, so +this override applies only to Authenticate. """ +import json import secrets import threading import time @@ -268,6 +279,15 @@ class SimpliTVAuth: # ------------------------------------------------------------------ def _perform_authentication(self) -> BaseAuthToken: + """ + Log in and return a BaseAuthToken. + + IMPORTANT: the request body is sent as a raw JSON *string* with + Content-Type: text/plain, matching the browser capture. When the + same body is sent as application/json, the server responds 200 + with a short token-less body. Do not switch this to json=... + without re-verifying against the live endpoint. + """ creds = self._resolve_credentials() logger.debug(f"simpliTV[{self.country}]: logging in") @@ -278,15 +298,39 @@ class SimpliTVAuth: "login": creds["username"], "password": creds["password"], } + + headers = self.build_headers() + # Override Content-Type for this call only. The API accepts the + # JSON body as text/plain; application/json yields a different, + # token-less response. + headers["Content-Type"] = "text/plain" + + body = json.dumps(payload) + logger.debug( + f"simpliTV[{self.country}]: Authenticate request " + f"(Content-Type=text/plain, {len(body)} bytes)" + ) + resp = self.http_manager.post( self.config.authenticate_url(), - json=payload, - headers=self.build_headers(), + data=body, + headers=headers, ) data = resp.json() + token_value = data.get("token") if not token_value: - raise AuthError("simpliTV: no token in authenticate response") + # Log the response shape on failure so the cause is visible + # without another round trip. Never log the token itself. + keys = list(data.keys()) if isinstance(data, dict) else type(data).__name__ + logger.error( + f"simpliTV[{self.country}]: no token in authenticate " + f"response (top-level keys: {keys!r})" + ) + raise AuthError( + f"simpliTV: no token in authenticate response " + f"(keys={keys!r})" + ) expires_in = ( _parse_expiry(data.get("tokenExpirationTime")) diff --git a/lib/streaming_providers/providers/simpli/constants.py b/lib/streaming_providers/providers/simpli/constants.py index f5782b5..f60c7f9 100644 --- a/lib/streaming_providers/providers/simpli/constants.py +++ b/lib/streaming_providers/providers/simpli/constants.py @@ -14,10 +14,10 @@ class SimpliTVDefaults: PROVIDER_LOGO = "https://files.app.simplitv.at/files/orf1-hd-bunt.png" # --- Endpoints / hosts --------------------------------------------- - # Confirmed against the browser capture: the API host is - # api.app.austrostream.at (multi-tenant; simpliTV is the - # X-Tenant-Codename: simpli tenant). The web app itself lives on - # streaming.simpli.at, NOT streaming.simpli.at. + # The API host is api.app.austrostream.at (multi-tenant). The + # browser capture sends X-Tenant-Codename: simplitv -- note the "tv", + # which differs from PROVIDER_NAME. The web app lives on + # streaming.simpli.at. BASE_URL = "https://api.app.austrostream.at" WEBSITE = "https://streaming.simpli.at" @@ -64,7 +64,12 @@ class SimpliTVDefaults: # --- Platform / device identity ------------------------------------ PLATFORM_CODENAME = "www" - TENANT_CODENAME = "simpli" + + # NOTE: the tenant codename is "simplitv" (with the "tv"), matching + # X-Tenant-Codename in the browser capture. It is NOT the same as + # PROVIDER_NAME ("simpli"), which is the internal registry key and + # the credentials.json key. + TENANT_CODENAME = "simplitv" RESOURCE_LANGUAGE_CONTEXT = "de" DEVICE_NAME = "Firefox" @@ -172,6 +177,11 @@ class SimpliTVConfig: The simpliTV token is NOT a header (see auth.py): it is passed as a URL query parameter or body field. These are the base headers the browser sends alongside every API call. + + NOTE: Content-Type here is application/json;charset=utf-8, + which is correct for most endpoints. Authenticate is the + exception: it needs Content-Type: text/plain even though the + body is JSON. That override lives in auth.py. """ return { "User-Agent": self.user_agent,