mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-12 16:10:59 +01:00
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
_sync.py 456 -> 245 + _syncstate 131 + _pull 106 + _revisions 34 _firebase_auth 412 -> 242 + _credentials 146 + _autherrors 69 _firebase.py 336 -> 250 + _fbkeys 100 google_id_token 316 -> 232 + tool/_oauth_callback 105 _github.py 255 -> 238 + _gherrors 30 revision_of became its own leaf module because both the push and pull halves need it; without that the two would have imported each other. _fbkeys holds the RTDB key escaping — the escape tables moved with the functions that read them, since the pair has to round-trip exactly or a device silently stops seeing its own writes. crdt-sync/tool/ is outside the coverage source, so the split there has no test net: verified by running it the documented way instead (`python3 -m tool.google_id_token --help` prints its usage, and `from tool.google_id_token import TokenError, fetch_id_token` — what seed_session.py does — still resolves). An AST pass over each pair caught what the suite would not have: _is_revoked needs the error type, so it stayed with it rather than following the credentials; _TERMINAL_AUTH_REASONS and _CONSENT_TIMEOUT_SECONDS had to be carried to their new callers. 259 passed at 100% branch coverage. crdt-sync/tool/seed_session.py and firebase-debug.log are pre-existing local changes and are deliberately not in this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
147 lines
4.9 KiB
Python
147 lines
4.9 KiB
Python
"""Firebase credentials and the stores that persist them.
|
|
|
|
Split from :mod:`crdt_sync._firebase_auth`, which keeps the token provider.
|
|
A credential set is an ID token, a refresh token and an expiry; the stores
|
|
decide where it survives a restart -- memory for tests, a mode-0600 file for
|
|
real installs.
|
|
|
|
Re-exported from :mod:`crdt_sync._firebase_auth`, so existing imports work.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import datetime as dt
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# 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)
|
|
|
|
|
|
@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)
|