feat(service): add wait_for_approval and return the cache for a login with no prompt

A pair code shown in a log line never answers a prompt, so the server read that login as anonymous and kept the session cache.
This commit is contained in:
imSp4rky
2026-10-07 09:03:18 -06:00
parent 3fd69e8039
commit 5b0369e63a
12 changed files with 97 additions and 36 deletions
+18
View File
@@ -256,6 +256,24 @@ REST download job shows it as `input_prompt`. Locally it routes through the
shared Rich console (`prompt_user`) so the prompt renders correctly alongside shared Rich console (`prompt_user`) so the prompt renders correctly alongside
progress and log output. progress and log output.
For a login that the user approves on another device (a pair code, a QR code),
use `self.wait_for_approval(message, check, timeout)`, never a hand-written
loop. It shows the message, then calls `check` at each interval until `check`
returns a value. `check` returns `None` while the approval is pending.
```python
token = self.wait_for_approval(
f"Open {self.config['endpoints']['pair']} and enter code {code}",
check=lambda: self.poll_pair_token(code),
timeout=expires_in,
)
```
It raises `TimeoutError` after `timeout` seconds. Under `serve` mode it also stops
when the client deletes the remote session, so the loop does not continue on the
server with no client. The message goes out as a log line, so a remote
session shows it, but a REST download job does not relay it as `input_prompt`.
!!! warning "Make each token cache entry unique" !!! warning "Make each token cache entry unique"
The cache above uses `tokens_{device}_{profile}` on purpose. A token cache The cache above uses `tokens_{device}_{profile}` on purpose. A token cache
must include every dimension that changes the token (device, profile, or must include every dimension that changes the token (device, profile, or
+1 -1
View File
@@ -957,7 +957,7 @@ The `proxy` value must be a full proxy URI, unless the operator gives your API k
} }
``` ```
`server_account` is `true` when the server authenticated with one of its own accounts (`serve.server_accounts`). Otherwise the server uses only what the client sent, and never falls back to its own credentials. Only a client that sent its own cookies, credentials, or cache files gets cookies, auth headers, and the remote session cache back. A client that answered a login prompt (a device code or an OTP) gets them too. This applies only when the login then succeeds and the remote session does not use a server account. For any other remote session (a server account, or an anonymous login through the server's proxy) the tracks and segments responses carry no cookies and no auth headers. The server also drops secret-looking keys from track data, and the cache stays on the server. A server-account service also refuses a client proxy URI, because the server's login would pass through it. `server_account` is `true` when the server authenticated with one of its own accounts (`serve.server_accounts`). Otherwise the server uses only what the client sent, and never falls back to its own credentials. When the login succeeds and the remote session does not use a server account, the login belongs to the client, and the client gets cookies, auth headers, and the remote session cache back. A remote session on the server's device is the exception: the client gets its cookies back, but no auth headers and no cache. For a server-account remote session the tracks and segments responses carry no cookies and no auth headers. The server also drops secret-looking keys from track data, and the cache stays on the server. A server-account service also refuses a client proxy URI, because the server's login would pass through it.
| Status | Error code | Meaning | | Status | Error code | Meaning |
| --- | --- | --- | | --- | --- | --- |
+3 -3
View File
@@ -213,9 +213,9 @@ hood the client walks a remote session through its lifecycle.
deletes the remote session. If the login belongs to the client, the `DELETE` deletes the remote session. If the login belongs to the client, the `DELETE`
request returns the updated cache files (for example a refreshed token) and the request returns the updated cache files (for example a refreshed token) and the
client saves them locally, so the **next** remote session can skip interactive client saves them locally, so the **next** remote session can skip interactive
authentication. The login belongs to the client when the client sent authentication. The login belongs to the client when it succeeds and the
cookies, credentials, or cache files, or answered a login prompt (a device code remote session does not use a server account. This includes a pair code that
or an OTP) that led to a successful login. A server-account login never does, the user approves on another device. A server-account login never does,
and neither does a remote session on the server's device (see below). and neither does a remote session on the server's device (see below).
The client saves only the returned files that pass the same profile check it The client saves only the returned files that pass the same profile check it
applies before it sends them, so a file for another profile cannot overwrite applies before it sends them, so a file for another profile cannot overwrite
+3
View File
@@ -89,6 +89,9 @@ These are abstract. A service will not load unless it defines all three:
`get_clearkey_license` (returns `None` by default). See [DRM hooks](#drm-hooks) below. `get_clearkey_license` (returns `None` by default). See [DRM hooks](#drm-hooks) below.
- **`request_input(prompt)`**: prompts the user. It goes through the remote-dl input - **`request_input(prompt)`**: prompts the user. It goes through the remote-dl input
bridge, the REST download job relay, or the rich console. bridge, the REST download job relay, or the rich console.
- **`wait_for_approval(message, check, timeout, interval=3.0)`**: shows a message, then
calls `check` until it returns a value. Use it for a login that the user approves on
another device, such as a pair code.
- **Event callbacks**: `on_segment_downloaded`, `on_track_downloaded`, - **Event callbacks**: `on_segment_downloaded`, `on_track_downloaded`,
`on_track_decrypted`, `on_track_repacked`, `on_track_multiplex` let a service react to `on_track_decrypted`, `on_track_repacked`, `on_track_multiplex` let a service react to
pipeline events (for example, MonaLisa decrypts each segment in `on_segment_downloaded`). pipeline events (for example, MonaLisa decrypts each segment in `on_segment_downloaded`).
@@ -0,0 +1,34 @@
import logging
from types import SimpleNamespace
import pytest
from unshackle.core.api.input_bridge import InputBridge
from unshackle.core.service import Service
def _service(bridge=None):
return SimpleNamespace(log=logging.getLogger("test"), _input_bridge=bridge)
def test_returns_the_first_value_check_gives():
answers = iter([None, None, "token"])
assert Service.wait_for_approval(_service(), "enter code", lambda: next(answers), timeout=5, interval=0) == "token"
def test_times_out_when_check_never_gives_a_value():
with pytest.raises(TimeoutError):
Service.wait_for_approval(_service(), "enter code", lambda: None, timeout=0.05, interval=0.01)
def test_stops_when_the_remote_session_is_cancelled():
bridge = InputBridge()
calls = []
def check():
calls.append(1)
bridge.cancel()
with pytest.raises(RuntimeError):
Service.wait_for_approval(_service(bridge), "enter code", check, timeout=5, interval=0)
assert len(calls) == 1
+1 -2
View File
@@ -101,7 +101,7 @@ def test_the_client_refuses_a_challenge_for_the_other_drm_system() -> None:
assert "not a playready device" in answer["error"] assert "not a playready device" in answer["error"]
def test_a_cdm_call_after_authentication_leaves_the_status_and_client_auth_alone() -> None: def test_a_cdm_call_after_authentication_leaves_the_status_alone() -> None:
bridge = InputBridge() bridge = InputBridge()
bridge.status = AuthStatus.AUTHENTICATED bridge.status = AuthStatus.AUTHENTICATED
result: list[dict] = [] result: list[dict] = []
@@ -118,7 +118,6 @@ def test_a_cdm_call_after_authentication_leaves_the_status_and_client_auth_alone
assert result == [{"session": "s", "challenge": "Yw=="}] assert result == [{"session": "s", "challenge": "Yw=="}]
assert bridge.status is AuthStatus.AUTHENTICATED assert bridge.status is AuthStatus.AUTHENTICATED
assert bridge.answered is False
assert bridge.get_pending_cdm_call() is None assert bridge.get_pending_cdm_call() is None
-13
View File
@@ -96,19 +96,6 @@ def test_status_setter() -> None:
assert bridge.status is AuthStatus.AUTHENTICATED assert bridge.status is AuthStatus.AUTHENTICATED
def test_answered_only_after_an_accepted_response() -> None:
bridge = InputBridge()
assert bridge.submit_response("early") is False
assert bridge.answered is False
worker = threading.Thread(target=bridge.request_input, args=("code?",))
worker.start()
while bridge.status != AuthStatus.PENDING_INPUT:
time.sleep(0.01)
assert bridge.submit_response("CODE") is True
worker.join(timeout=2)
assert bridge.answered is True
def test_request_input_after_authentication_fails_fast() -> None: def test_request_input_after_authentication_fails_fast() -> None:
"""The client stops polling for prompts once authenticated, so a later prompt must not wait for an answer.""" """The client stops polling for prompts once authenticated, so a later prompt must not wait for an answer."""
bridge = InputBridge() bridge = InputBridge()
+2 -1
View File
@@ -221,11 +221,12 @@ def _run_session(monkeypatch, tmp_path, data, answer, server_account=False, fail
@pytest.mark.parametrize( @pytest.mark.parametrize(
"data, answer, server_account, fail, expected", "data, answer, server_account, fail, expected",
[ [
({}, None, False, False, False), # anonymous login through the server: stays the server's ({}, None, False, False, True), # no prompt: a pair code shown in the log, approved on another device
({}, "CODE", False, False, True), # device code the client approved ({}, "CODE", False, False, True), # device code the client approved
({}, "CODE", False, True, False), # answered, but the login failed ({}, "CODE", False, True, False), # answered, but the login failed
({}, "CODE", True, False, False), # server account OTP answered by the client ({}, "CODE", True, False, False), # server account OTP answered by the client
({"cache": "tokens"}, None, False, False, True), # client uploaded its own earlier login ({"cache": "tokens"}, None, False, False, True), # client uploaded its own earlier login
({}, None, True, False, False), # server account
], ],
) )
def test_client_auth_follows_login_provenance(monkeypatch, tmp_path, data, answer, server_account, fail, expected): def test_client_auth_follows_login_provenance(monkeypatch, tmp_path, data, answer, server_account, fail, expected):
+5 -6
View File
@@ -356,7 +356,7 @@ class RequestCache:
Such a server keeps nothing that a client sends, so the request runs on a cache directory Such a server keeps nothing that a client sends, so the request runs on a cache directory
of its own that the handler removes when the request ends, on success and on error. Before of its own that the handler removes when the request ends, on success and on error. Before
that, a client that logged in with its own cookies, credentials or cache gets the updated that, a client that logged in with its own cookies, credentials or cache gets the updated
files back, the same as when a remote session ends. files back.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@@ -3559,7 +3559,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
async def run_auth() -> None: async def run_auth() -> None:
try: try:
await asyncio.to_thread(service_instance.authenticate, cookies, credential) await asyncio.to_thread(service_instance.authenticate, cookies, credential)
if bridge.answered and not server_account: if not server_account:
session.client_auth = True session.client_auth = True
session.auth_status = AuthStatus.AUTHENTICATED session.auth_status = AuthStatus.AUTHENTICATED
bridge.status = AuthStatus.AUTHENTICATED bridge.status = AuthStatus.AUTHENTICATED
@@ -3647,10 +3647,9 @@ async def session_titles_handler(session_id: str, request: Optional[web.Request]
def client_session_auth(session: Any) -> tuple[Dict[str, str], Dict[str, str]]: def client_session_auth(session: Any) -> tuple[Dict[str, str], Dict[str, str]]:
"""The service session headers and cookies a remote client may receive. """The service session headers and cookies a remote client may receive.
Only a client that sent its own cookies or credentials gets them back. Otherwise (a server A client whose own login succeeded gets them back. For a server account the cookie jar
account, or an anonymous login through the server's proxy) the cookie jar stays on the stays on the server and the server drops the auth-bearing headers, so the client cannot
server and the server drops the auth-bearing headers, so the client cannot reuse a login reuse a login the server paid for. A remote session on the server's lent device keeps its cookies (the client's account)
the server paid for. A remote session on the server's lent device keeps its cookies (the client's account)
but drops those headers too. but drops those headers too.
""" """
svc_session = session.service_instance.session svc_session = session.service_instance.session
+3 -6
View File
@@ -49,7 +49,6 @@ class InputBridge:
_response: Optional[str] = field(default=None, init=False, repr=False) _response: Optional[str] = field(default=None, init=False, repr=False)
_status: AuthStatus = field(default=AuthStatus.AUTHENTICATING, init=False) _status: AuthStatus = field(default=AuthStatus.AUTHENTICATING, init=False)
_cancelled: bool = field(default=False, init=False, repr=False) _cancelled: bool = field(default=False, init=False, repr=False)
_answered: bool = field(default=False, init=False, repr=False)
_cdm_call: Optional[dict[str, Any]] = field(default=None, init=False, repr=False) _cdm_call: Optional[dict[str, Any]] = field(default=None, init=False, repr=False)
_response_ready: threading.Event = field(default_factory=threading.Event, init=False, repr=False) _response_ready: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
_picked_up: threading.Event = field(default_factory=threading.Event, init=False, repr=False) _picked_up: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
@@ -166,8 +165,6 @@ class InputBridge:
if not self._awaiting_answer(): if not self._awaiting_answer():
return False return False
self._response = response self._response = response
if self._cdm_call is None:
self._answered = True
self._picked_up.set() self._picked_up.set()
self._response_ready.set() self._response_ready.set()
return True return True
@@ -189,10 +186,10 @@ class InputBridge:
self._response_ready.set() self._response_ready.set()
@property @property
def answered(self) -> bool: def cancelled(self) -> bool:
"""``True`` once the bridge has accepted a response from the client.""" """``True`` once the server cancelled the bridge, so a login loop must stop."""
with self._lock: with self._lock:
return self._answered return self._cancelled
@property @property
def status(self) -> AuthStatus: def status(self) -> AuthStatus:
+1 -3
View File
@@ -50,9 +50,7 @@ class SessionEntry:
server_cdm_max_height: Optional[int] = None server_cdm_max_height: Optional[int] = None
server_vault: bool = False # a session the client's device licenses may still take keys from the server vault server_vault: bool = False # a session the client's device licenses may still take keys from the server vault
server_device: bool = False # its service runs on the server's lent device, so keys come only from a vault server_device: bool = False # its service runs on the server's lent device, so keys come only from a vault
client_auth: bool = ( client_auth: bool = False # the login is the client's: it succeeded without a server account
False # the client sent its own cookies, credentials, or cache files, or answered a login prompt
)
input_bridge: Optional[InputBridge] = None input_bridge: Optional[InputBridge] = None
log_buffer: Optional[Any] = None # SessionLogBuffer mirroring the service's self.log log_buffer: Optional[Any] = None # SessionLogBuffer mirroring the service's self.log
auth_status: AuthStatus = AuthStatus.AUTHENTICATED auth_status: AuthStatus = AuthStatus.AUTHENTICATED
+26 -1
View File
@@ -1,10 +1,11 @@
import logging import logging
import time
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from collections.abc import Callable, Generator from collections.abc import Callable, Generator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from http.cookiejar import CookieJar from http.cookiejar import CookieJar
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Union from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
if TYPE_CHECKING: if TYPE_CHECKING:
from unshackle.core.api.input_bridge import InputBridge from unshackle.core.api.input_bridge import InputBridge
@@ -140,6 +141,9 @@ def sanitize_proxy_for_log(uri: Optional[str], mask_host: bool = False) -> Optio
return mask_proxy(uri, mask_host=mask_host, allow_debug=False) return mask_proxy(uri, mask_host=mask_host, allow_debug=False)
T = TypeVar("T")
class Service(metaclass=ABCMeta): class Service(metaclass=ABCMeta):
"""The Service Base Class. """The Service Base Class.
@@ -479,6 +483,27 @@ class Service(metaclass=ABCMeta):
return self._input_bridge.request_input(prompt) return self._input_bridge.request_input(prompt)
return prompt_user(prompt) return prompt_user(prompt)
def wait_for_approval(
self, message: str, check: Callable[[], Optional[T]], timeout: float, interval: float = 3.0
) -> T:
"""Show ``message``, then call ``check`` until it returns a value.
Use it for a login that the user approves on another device, such as a pair code.
``check`` returns None while the approval is pending. Raises TimeoutError after
``timeout`` seconds, and RuntimeError when the remote session no longer exists.
"""
self.log.info(message)
deadline = time.monotonic() + timeout
while True:
if self._input_bridge is not None and self._input_bridge.cancelled:
raise RuntimeError("Session was cancelled")
result = check()
if result is not None:
return result
if time.monotonic() + interval >= deadline:
raise TimeoutError(f"No approval within {timeout:.0f}s")
time.sleep(interval)
def search(self) -> Generator[SearchResult, None, None]: def search(self) -> Generator[SearchResult, None, None]:
""" """
Find titles from the Service by query. Find titles from the Service by query.