Simpli - authenticate

This commit is contained in:
Nirvana
2026-10-04 14:15:07 +02:00
parent 6328d82e8c
commit c0c358d216
3 changed files with 189 additions and 10 deletions
@@ -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
@@ -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"))
@@ -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,