mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +01:00
Every request went through the module-level requests.get/put/post/delete helpers, so each one paid a fresh DNS+TCP+TLS handshake. Measured today against the real backends that is 95ms/req to api.github.com and 52ms to Realtime Database -- on a 27-request diet-guard tick, most of the wall clock. Each transport module now binds a pooling requests.Session to the name `requests`. A Session is a drop-in for those helpers, so all 9 call sites and all 16 tests that patch `<module>.requests.<verb>` are untouched; the real module stays available as `_requests` for the RequestException/Response references. Sessions are per-module rather than shared because the tests use sequence-consuming side_effect lists, where one module could otherwise eat another's queued response. Transient failures now retry, restricted to GET: a PUT here carries a Contents-API sha, and replaying one whose response was merely lost would write against a stale precondition on a remote the phone also reads. Also thread timeout_seconds through firebase_client_for, which accepted none, so a caller's tighter budget silently reached the GitHub backend only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
413 lines
15 KiB
Python
413 lines
15 KiB
Python
"""Firebase Authentication over its REST API, with no SDK dependency.
|
|
|
|
The Firebase Admin SDK authenticates with a service-account key, which
|
|
bypasses security rules entirely and would have to be distributed to every
|
|
device. These are single-user personal apps, so devices instead sign in as
|
|
one ordinary user and hold a refresh token -- the same path the Flutter side
|
|
takes, so both platforms behave identically.
|
|
|
|
Mirrors ``crdt_sync_dart``'s ``lib/src/firebase_auth_rest.dart``; keep the
|
|
two in step.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import datetime as dt
|
|
import json
|
|
from typing import TYPE_CHECKING, Protocol
|
|
|
|
import requests as _requests
|
|
|
|
from crdt_sync._http import new_session
|
|
from crdt_sync._remote import RemoteSyncError
|
|
|
|
# Bound to the name ``requests`` so the call sites below -- and the tests
|
|
# that patch ``<module>.requests.<verb>`` -- are unchanged by pooling.
|
|
requests = new_session()
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_SIGN_IN_BASE = "https://identitytoolkit.googleapis.com/v1"
|
|
_REFRESH_BASE = "https://securetoken.googleapis.com/v1"
|
|
_DEFAULT_TIMEOUT_SECONDS = 15
|
|
|
|
# Refresh this long before the token actually expires. An ID token lives ~1h;
|
|
# refreshing early means a tick that starts just under the wire still finishes
|
|
# with a valid token rather than 401ing halfway through a multi-file push.
|
|
_REFRESH_SKEW = dt.timedelta(minutes=5)
|
|
|
|
|
|
class FirebaseAuthError(RemoteSyncError):
|
|
"""Raised for an authentication failure the caller must not ignore.
|
|
|
|
A :class:`crdt_sync.RemoteSyncError` so callers that only care about
|
|
"sync is broken" can catch one type, while a settings screen can single
|
|
this out to say "your password is wrong" rather than "the network is
|
|
down".
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FirebaseCredentials:
|
|
"""One device's session: a short-lived ID token plus its refresh token.
|
|
|
|
Attributes:
|
|
----------
|
|
id_token:
|
|
The bearer credential for Realtime Database requests. Expires fast.
|
|
refresh_token:
|
|
The long-lived credential. **This is the secret worth protecting.**
|
|
expires_at:
|
|
When ``id_token`` stops being accepted, in UTC.
|
|
"""
|
|
|
|
id_token: str
|
|
refresh_token: str
|
|
expires_at: dt.datetime
|
|
|
|
def is_expired_at(self, now: dt.datetime) -> bool:
|
|
"""Return whether the token is expired, or close enough to it."""
|
|
return now + _REFRESH_SKEW >= self.expires_at
|
|
|
|
def to_json(self) -> dict[str, str]:
|
|
"""Return a JSON-serializable form."""
|
|
return {
|
|
"id_token": self.id_token,
|
|
"refresh_token": self.refresh_token,
|
|
"expires_at": self.expires_at.isoformat(),
|
|
}
|
|
|
|
@classmethod
|
|
def from_json(cls, data: dict[str, str]) -> FirebaseCredentials:
|
|
"""Rebuild credentials from :meth:`to_json` output."""
|
|
return cls(
|
|
id_token=data["id_token"],
|
|
refresh_token=data["refresh_token"],
|
|
expires_at=dt.datetime.fromisoformat(data["expires_at"]),
|
|
)
|
|
|
|
|
|
class CredentialStore(Protocol):
|
|
"""Where a device keeps its credentials between runs."""
|
|
|
|
def load(self) -> FirebaseCredentials | None:
|
|
"""Return the stored credentials, or ``None`` if not signed in."""
|
|
|
|
def save(self, credentials: FirebaseCredentials) -> None:
|
|
"""Persist ``credentials``."""
|
|
|
|
def clear(self) -> None:
|
|
"""Forget any stored credentials."""
|
|
|
|
|
|
class MemoryCredentialStore:
|
|
"""A :class:`CredentialStore` for tests and one-shot scripts."""
|
|
|
|
def __init__(self, credentials: FirebaseCredentials | None = None) -> None:
|
|
"""Start holding ``credentials`` (or nothing)."""
|
|
self._credentials = credentials
|
|
|
|
def load(self) -> FirebaseCredentials | None:
|
|
"""Return the held credentials."""
|
|
return self._credentials
|
|
|
|
def save(self, credentials: FirebaseCredentials) -> None:
|
|
"""Replace the held credentials."""
|
|
self._credentials = credentials
|
|
|
|
def clear(self) -> None:
|
|
"""Drop the held credentials."""
|
|
self._credentials = None
|
|
|
|
|
|
class FileCredentialStore:
|
|
"""A :class:`CredentialStore` backed by a ``0600`` JSON file.
|
|
|
|
Persistence is not an optimisation here: ``wake_alarm``'s PC side is a
|
|
fresh process every minute and ``diet_guard``'s every 15 minutes, so an
|
|
in-memory store would re-authenticate thousands of times a day.
|
|
"""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
"""Store credentials at ``path``, creating parent dirs as needed."""
|
|
self._path = path
|
|
|
|
def load(self) -> FirebaseCredentials | None:
|
|
"""Return the stored credentials, or ``None`` if the file is absent.
|
|
|
|
A corrupt or truncated file (an interrupted write) reads as "not
|
|
signed in" rather than raising: the caller's next step is to sign in
|
|
again, which repairs it.
|
|
"""
|
|
try:
|
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return None
|
|
try:
|
|
return FirebaseCredentials.from_json(data)
|
|
except (KeyError, TypeError, ValueError):
|
|
return None
|
|
|
|
def save(self, credentials: FirebaseCredentials) -> None:
|
|
"""Write ``credentials`` atomically, readable only by this user."""
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
temp = self._path.with_suffix(f"{self._path.suffix}.tmp")
|
|
# Create with 0600 from the outset -- writing then chmod'ing would
|
|
# leave the refresh token world-readable for the gap in between.
|
|
temp.touch(mode=0o600)
|
|
temp.write_text(json.dumps(credentials.to_json()), encoding="utf-8")
|
|
temp.replace(self._path)
|
|
|
|
def clear(self) -> None:
|
|
"""Delete the credentials file if it exists."""
|
|
self._path.unlink(missing_ok=True)
|
|
|
|
|
|
def _utcnow() -> dt.datetime:
|
|
return dt.datetime.now(dt.timezone.utc)
|
|
|
|
|
|
#: Reasons that mean the refresh token is permanently dead. Only these clear
|
|
#: the session: a network error or a 5xx must not, because a device that signs
|
|
#: itself out whenever the wifi drops needs a manual sign-in to recover, which
|
|
#: is a worse failure than the stale banner this guards against.
|
|
_TERMINAL_AUTH_REASONS = (
|
|
"TOKEN_EXPIRED",
|
|
"USER_DISABLED",
|
|
"USER_NOT_FOUND",
|
|
"INVALID_REFRESH_TOKEN",
|
|
"INVALID_GRANT_TYPE",
|
|
"MISSING_REFRESH_TOKEN",
|
|
)
|
|
|
|
|
|
def _is_revoked(error: FirebaseAuthError) -> bool:
|
|
"""Return whether ``error`` means the refresh token is permanently dead."""
|
|
message = str(error)
|
|
return any(reason in message for reason in _TERMINAL_AUTH_REASONS)
|
|
|
|
|
|
class FirebaseTokenProvider:
|
|
"""Signs in and keeps a valid ID token available, refreshing as needed.
|
|
|
|
``api_key`` is the project's public Web API key -- not a secret, and safe
|
|
to ship inside an APK. The actual credential is the refresh token held by
|
|
the store.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: str,
|
|
store: CredentialStore,
|
|
*,
|
|
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
|
|
clock: object = None,
|
|
) -> None:
|
|
"""Create a provider for ``api_key`` persisting through ``store``.
|
|
|
|
Args:
|
|
api_key: The project's public Web API key.
|
|
store: Where the refresh token lives between runs.
|
|
timeout_seconds: Per-request network timeout.
|
|
clock: A zero-arg callable returning "now" (tests inject one).
|
|
"""
|
|
self._api_key = api_key
|
|
self._store = store
|
|
self._timeout_seconds = timeout_seconds
|
|
self._clock = clock if callable(clock) else _utcnow
|
|
self._cached: FirebaseCredentials | None = None
|
|
|
|
def sign_in(self, email: str, password: str) -> None:
|
|
"""Exchange an email/password for a session and persist it.
|
|
|
|
Called once per device from a setup command; every later run reuses
|
|
the stored refresh token.
|
|
|
|
Raises:
|
|
FirebaseAuthError: If the credentials are rejected.
|
|
"""
|
|
body = self._post(
|
|
f"{_SIGN_IN_BASE}/accounts:signInWithPassword?key={self._api_key}",
|
|
{"email": email, "password": password, "returnSecureToken": True},
|
|
"sign in",
|
|
)
|
|
self._adopt(
|
|
id_token=body["idToken"],
|
|
refresh_token=body["refreshToken"],
|
|
expires_in_seconds=body["expiresIn"],
|
|
)
|
|
|
|
def sign_in_with_google(self, id_token: str, expected_uid: str = "") -> str:
|
|
"""Exchange a Google ID token for a session and persist it.
|
|
|
|
Mirrors ``crdt_sync_dart``'s ``signInWithGoogle``. Used to reseed a
|
|
machine's session after the sync account moved to Google-only sign-in:
|
|
the refresh token this stores is what keeps a headless job working
|
|
between runs, so the browser flow is needed once per machine, not once
|
|
per tick.
|
|
|
|
Deliberately *not* a service-account key. That would authenticate as
|
|
an admin and bypass the security rules entirely, so the pinned-uid
|
|
rule that protects this data would stop applying to every PC write.
|
|
|
|
Args:
|
|
id_token: A Google ID token whose audience is this project.
|
|
expected_uid: When given, asserted against the uid Firebase
|
|
returns. A mismatch raises without storing anything -- an
|
|
unlinked Google identity is *signed up* rather than refused,
|
|
and that session would then be denied every read and write.
|
|
|
|
Returns:
|
|
The email Firebase reports for the account.
|
|
|
|
Raises:
|
|
FirebaseAuthError: If the token is rejected, or resolves to a uid
|
|
other than ``expected_uid``.
|
|
"""
|
|
body = self._post(
|
|
f"{_SIGN_IN_BASE}/accounts:signInWithIdp?key={self._api_key}",
|
|
{
|
|
# The IdP credential travels form-encoded, not as JSON -- an
|
|
# identitytoolkit quirk. Sending it as a field returns
|
|
# INVALID_IDP_RESPONSE, which reads like a bad token.
|
|
"postBody": f"id_token={id_token}&providerId=google.com",
|
|
"requestUri": "http://localhost",
|
|
"returnSecureToken": True,
|
|
},
|
|
"sign in with Google",
|
|
)
|
|
uid = body.get("localId", "")
|
|
if expected_uid and uid != expected_uid:
|
|
msg = (
|
|
f"signed in as the wrong account: Google resolved to uid "
|
|
f"{uid!r}, but this data belongs to {expected_uid!r}"
|
|
)
|
|
raise FirebaseAuthError(msg)
|
|
self._adopt(
|
|
id_token=body["idToken"],
|
|
refresh_token=body["refreshToken"],
|
|
expires_in_seconds=body["expiresIn"],
|
|
)
|
|
return str(body.get("email", ""))
|
|
|
|
def id_token(self) -> str:
|
|
"""Return a currently-valid ID token, refreshing if needed.
|
|
|
|
Never returns a stale token and never silently no-ops: a sync that
|
|
quietly stops syncing is the failure mode this design exists to
|
|
prevent.
|
|
|
|
Raises:
|
|
FirebaseAuthError: If no session is stored, or the refresh token
|
|
has been revoked.
|
|
"""
|
|
credentials = self._load()
|
|
if credentials is None:
|
|
msg = "not signed in: no stored refresh token for this device"
|
|
raise FirebaseAuthError(msg)
|
|
if not credentials.is_expired_at(self._clock()):
|
|
return credentials.id_token
|
|
return self._refresh(credentials.refresh_token)
|
|
|
|
def has_session(self) -> bool:
|
|
"""Return whether this device has a stored session at all.
|
|
|
|
Distinguishes "sync is not configured" -- a normal state for
|
|
``screen-locker`` -- from "sync is configured and broken", an error.
|
|
"""
|
|
return self._load() is not None
|
|
|
|
def sign_out(self) -> None:
|
|
"""Forget the stored session, so the next token request fails."""
|
|
self._cached = None
|
|
self._store.clear()
|
|
|
|
def _load(self) -> FirebaseCredentials | None:
|
|
if self._cached is None:
|
|
self._cached = self._store.load()
|
|
return self._cached
|
|
|
|
def _refresh(self, refresh_token: str) -> str:
|
|
try:
|
|
body = self._post(
|
|
f"{_REFRESH_BASE}/token?key={self._api_key}",
|
|
{"grant_type": "refresh_token", "refresh_token": refresh_token},
|
|
"refresh the session",
|
|
)
|
|
except FirebaseAuthError as error:
|
|
# A revoked refresh token never becomes valid again, so keeping it
|
|
# is what let a dead device report "connected" and then fail every
|
|
# sync with TOKEN_EXPIRED. Drop it so has_session() is honest.
|
|
if _is_revoked(error):
|
|
self.sign_out()
|
|
raise
|
|
return self._adopt(
|
|
id_token=body["id_token"],
|
|
# A refresh may hand back a rotated token; keep the new one.
|
|
refresh_token=body["refresh_token"],
|
|
expires_in_seconds=body["expires_in"],
|
|
)
|
|
|
|
def _adopt(
|
|
self,
|
|
*,
|
|
id_token: str,
|
|
refresh_token: str,
|
|
expires_in_seconds: str,
|
|
) -> str:
|
|
credentials = FirebaseCredentials(
|
|
id_token=id_token,
|
|
refresh_token=refresh_token,
|
|
expires_at=self._clock() + dt.timedelta(seconds=int(expires_in_seconds)),
|
|
)
|
|
self._cached = credentials
|
|
self._store.save(credentials)
|
|
return id_token
|
|
|
|
def _post(
|
|
self,
|
|
url: str,
|
|
payload: dict[str, object],
|
|
what: str,
|
|
) -> dict[str, str]:
|
|
try:
|
|
response = requests.post(
|
|
url,
|
|
json=payload,
|
|
timeout=self._timeout_seconds,
|
|
)
|
|
except _requests.RequestException as exc:
|
|
msg = f"network error trying to {what}"
|
|
raise FirebaseAuthError(msg) from exc
|
|
if not response.ok:
|
|
reason = _reason(response)
|
|
msg = f"failed to {what}: HTTP {response.status_code}{reason}"
|
|
raise FirebaseAuthError(msg)
|
|
return response.json()
|
|
|
|
|
|
def _reason(response: _requests.Response) -> str:
|
|
"""Return Google's machine-readable error reason, in parentheses.
|
|
|
|
Pulls ``INVALID_PASSWORD`` / ``TOKEN_EXPIRED`` / ``USER_DISABLED`` out of
|
|
the body so the raised message says what actually went wrong rather than
|
|
just reporting a status code.
|
|
"""
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
# A non-JSON body (a proxy error page, say): the status code is all
|
|
# the detail there is.
|
|
return ""
|
|
if not isinstance(data, dict):
|
|
return ""
|
|
error = data.get("error")
|
|
if isinstance(error, dict):
|
|
return f" ({error.get('message')})"
|
|
if isinstance(error, str):
|
|
return f" ({error})"
|
|
return ""
|