screen-locker/screen_locker/_stream_auth.py
2026-10-09 19:05:51 +02:00

94 lines
3.0 KiB
Python

"""The ID token and URL for the Firebase session stream.
Split out of :mod:`screen_locker._session_stream` for the 250-line cap.
Reuses crdt_sync's own token provider and the screen_locker credential cache
(``~/.config/screen_locker/firebase_auth.json``); it never signs in with the
password, which is stale -- only the cached refresh token authenticates. A
missing session is healed the way the sync heals it,
:func:`screen_locker._sync_client.try_recover_firebase_session`.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Final
from crdt_sync import (
ConfigError,
FirebaseAuthError,
FirebaseConfig,
FirebaseTokenProvider,
credential_store_for,
)
from crdt_sync._firebase import encode_path
from screen_locker._sync_client import try_recover_firebase_session
__all__ = ["StreamAuth", "StreamError", "stream_auth"]
_APP: Final = "screen_locker"
# Inside crdt_sync's 5-minute refresh skew, so the reconnect mints a new token
# instead of being handed the dying one back.
_RENEW_BEFORE_EXPIRY: Final = timedelta(minutes=4)
_ASSUMED_LIFETIME: Final = timedelta(hours=1)
class StreamError(Exception):
"""One stream connection failed in a way that warrants backing off."""
@dataclass(frozen=True)
class StreamAuth:
"""Where to stream from, with which token, and when to renew it."""
url: str
token: str
renew_at: datetime
def _provider(config: FirebaseConfig) -> FirebaseTokenProvider:
"""A provider over the cached session, recovering one if there is none.
Raises:
StreamError: No cached session, and recovery failed too.
"""
store = credential_store_for(_APP)
auth = FirebaseTokenProvider(config.api_key, store)
if auth.has_session():
return auth
recovery = try_recover_firebase_session()
if not recovery.recovered:
msg = f"no cached Firebase session and recovery failed: {recovery.reason}"
raise StreamError(msg)
return FirebaseTokenProvider(config.api_key, store)
def stream_auth(path: str) -> StreamAuth:
"""A fresh token for streaming ``path`` (a logical RTDB path).
A new provider per call re-reads the cache, so a token the 15-min sync
refreshed in the meantime is picked up rather than refreshed again.
Raises:
StreamError: The config, the session or the token is unusable.
"""
try:
config = FirebaseConfig.load()
except ConfigError as exc:
msg = f"Firebase config unusable: {exc}"
raise StreamError(msg) from exc
auth = _provider(config)
try:
token = auth.id_token()
except FirebaseAuthError as exc:
msg = f"could not get an ID token: {exc}"
raise StreamError(msg) from exc
saved = credential_store_for(_APP).load()
expires = saved.expires_at if saved else datetime.now(UTC) + _ASSUMED_LIFETIME
return StreamAuth(
url=f"{config.database_url.rstrip('/')}/{encode_path(path)}.json",
token=token,
renew_at=expires - _RENEW_BEFORE_EXPIRY,
)