mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:47:14 +01:00
The three Python packages pinned ruff twice at different versions: requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the older one ever ran, so `select = ["ALL"]` was enforced two minor versions behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and no other rule. Resolved by adding the notice rather than ignoring the rule, so the gate keeps its teeth. Two gatelock modules sat at exactly the 250-line cap, so a one-line header tipped them to 251. Both are split along a real seam rather than shaved: _widget_fills.py colour arithmetic over LockConfig, no tkinter import _surfaces_focus.py selects among existing surfaces, never mutates them The focus split keeps _surfaces.py's documented containment (it stays the only module that creates, moves or destroys windows), and the static guard in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is covered with no list to extend. widgets.py re-exports the fills helpers, which keeps gatelock.widgets the single public surface its tests import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
246 lines
8.7 KiB
Python
246 lines
8.7 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""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
|
|
|
|
import datetime as dt
|
|
|
|
import requests as _requests
|
|
|
|
from crdt_sync._autherrors import FirebaseAuthError, _is_revoked, _reason
|
|
from crdt_sync._credentials import (
|
|
CredentialStore,
|
|
FileCredentialStore,
|
|
FirebaseCredentials,
|
|
MemoryCredentialStore,
|
|
_utcnow,
|
|
)
|
|
from crdt_sync._http import new_session
|
|
|
|
__all__ = [
|
|
"CredentialStore",
|
|
"FileCredentialStore",
|
|
"FirebaseAuthError",
|
|
"FirebaseCredentials",
|
|
"FirebaseTokenProvider",
|
|
"MemoryCredentialStore",
|
|
]
|
|
|
|
# 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()
|
|
|
|
|
|
_SIGN_IN_BASE = "https://identitytoolkit.googleapis.com/v1"
|
|
_REFRESH_BASE = "https://securetoken.googleapis.com/v1"
|
|
_DEFAULT_TIMEOUT_SECONDS = 15
|
|
|
|
|
|
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()
|