Sign in with Google, and stop trusting a revoked session
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled

Two changes to the shared sync credential path, both driven by devices
that reported "Connected" while every sync failed.

Google sign-in (signInWithGoogle, both languages) replaces typing the
sync account's password on a fresh install. The account is linked once
from the PC with tool/link_google.py, which asserts the pinned uid --
without that assertion an unlinked identity is *signed up* rather than
refused, and the new uid is then denied every read and write by the
security rules.

The second change is the one with teeth. hasSession() is a presence
check, so a refresh token the server had revoked still read as a live
session: the desktop todo build showed "Connected" and then failed every
sync with `HTTP 400 (TOKEN_EXPIRED)`. A refresh that fails for a terminal
reason now clears the stored session, so hasSession() answers honestly.

Deliberately narrow: only TOKEN_EXPIRED, USER_DISABLED, USER_NOT_FOUND,
INVALID_REFRESH_TOKEN, INVALID_GRANT_TYPE and MISSING_REFRESH_TOKEN
clear it. A network error or a 5xx must not -- a device that signs
itself out whenever the wifi drops needs a manual sign-in on every
device to recover, which is worse than the stale banner this fixes.
Verified against the live endpoint, not just mocks: has_session flips
True -> False on a real rejection.

crdt_sync_dart 0.9.0 -> 0.10.0; the five apps pin the matching tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192EkoAyFBEWTvAN5vVgmSy
This commit is contained in:
Krzysztof Rudnicki 2026-08-11 23:58:02 +02:00
parent f902bbe0aa
commit 5f64a8959b
12 changed files with 1837 additions and 44 deletions

View File

@ -164,6 +164,26 @@ 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.
@ -214,6 +234,59 @@ class FirebaseTokenProvider:
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.
@ -252,11 +325,19 @@ class FirebaseTokenProvider:
return self._cached
def _refresh(self, refresh_token: str) -> str:
body = self._post(
f"{_REFRESH_BASE}/token?key={self._api_key}",
{"grant_type": "refresh_token", "refresh_token": refresh_token},
"refresh the session",
)
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.

View File

@ -36,6 +36,7 @@ _ID_1 = "id-1"
_ID_2 = "id-2"
_REFRESH_1 = "refresh-1"
_REFRESH_2 = "refresh-2"
_UID = "uid-the-data-belongs-to"
def _response(status_code: int = 200, json_data: object = None) -> MagicMock:
@ -159,6 +160,82 @@ class TestSignIn:
_provider().sign_in("a@b.c", "x")
class TestSignInWithGoogle:
@staticmethod
def _google_body(**overrides: object) -> dict[str, object]:
body: dict[str, object] = {
"idToken": _ID_1,
"refreshToken": _REFRESH_1,
"expiresIn": "3600",
"localId": _UID,
"email": "me@example.com",
}
body.update(overrides)
return body
def test_stores_the_session(self) -> None:
store = MemoryCredentialStore()
with _patch_post(_response(200, self._google_body())):
_provider(store).sign_in_with_google("google-jwt")
saved = store.load()
assert saved is not None
assert saved.id_token == _ID_1
assert saved.refresh_token == _REFRESH_1
assert saved.expires_at == _NOW + dt.timedelta(hours=1)
def test_returns_the_account_email(self) -> None:
with _patch_post(_response(200, self._google_body())):
email = _provider().sign_in_with_google("google-jwt")
assert email == "me@example.com"
def test_returns_an_empty_email_when_google_omits_one(self) -> None:
# Firebase leaves `email` out when the Google account has no verified
# address. That is not a failure -- the uid check is what matters --
# so the caller gets "" rather than a KeyError.
body = self._google_body()
del body["email"]
with _patch_post(_response(200, body)):
assert _provider().sign_in_with_google("google-jwt") == ""
def test_sends_the_credential_form_encoded_in_post_body(self) -> None:
# identitytoolkit rejects the IdP credential as a JSON field with
# INVALID_IDP_RESPONSE, which reads like a bad token; pin the shape.
post = MagicMock(return_value=_response(200, self._google_body()))
with patch.object(fa.requests, "post", post):
_provider().sign_in_with_google("google-jwt")
sent = post.call_args.kwargs["json"]
assert sent["postBody"] == "id_token=google-jwt&providerId=google.com"
assert "signInWithIdp" in post.call_args.args[0]
def test_accepts_the_uid_the_data_belongs_to(self) -> None:
store = MemoryCredentialStore()
with _patch_post(_response(200, self._google_body())):
_provider(store).sign_in_with_google("google-jwt", expected_uid=_UID)
assert store.load() is not None
def test_rejects_and_stores_nothing_for_a_different_uid(self) -> None:
# The signed-up identity does exist server-side by now -- this call
# omits `idToken`, so an unlinked Google account is signed *up* before
# the uid is known. What this guarantees is that no such session is
# persisted locally, where it would be denied every read and write.
store = MemoryCredentialStore()
body = self._google_body(localId="someone-else")
with (
_patch_post(_response(200, body)),
pytest.raises(FirebaseAuthError, match="wrong account"),
):
_provider(store).sign_in_with_google("google-jwt", expected_uid=_UID)
assert store.load() is None
def test_reports_googles_reason_for_a_rejected_token(self) -> None:
body = {"error": {"message": "INVALID_IDP_RESPONSE"}}
with (
_patch_post(_response(400, body)),
pytest.raises(FirebaseAuthError, match="INVALID_IDP_RESPONSE"),
):
_provider().sign_in_with_google("stale-jwt")
class TestIdToken:
def test_fails_loudly_when_not_signed_in(self) -> None:
# Never returns None or a stale token: a sync that quietly stops
@ -192,6 +269,49 @@ class TestIdToken:
):
_provider(store).id_token()
def test_a_revoked_refresh_token_is_cleared(self) -> None:
"""A dead session must stop reporting itself as connected.
Regression: ``has_session`` is a presence check, so a revoked token
kept reading as "connected" while every sync failed with
TOKEN_EXPIRED.
"""
store = MemoryCredentialStore(_credentials(valid_for=dt.timedelta(minutes=-5)))
provider = _provider(store)
with (
_patch_post(_response(400, {"error": {"message": "TOKEN_EXPIRED"}})),
pytest.raises(FirebaseAuthError),
):
provider.id_token()
assert provider.has_session() is False
assert store.load() is None
def test_a_network_error_keeps_the_session(self) -> None:
"""Signing out on a transient failure would need a manual re-login."""
store = MemoryCredentialStore(_credentials(valid_for=dt.timedelta(minutes=-5)))
provider = _provider(store)
with (
patch.object(
fa.requests,
"post",
side_effect=requests.ConnectionError("connection reset"),
),
pytest.raises(FirebaseAuthError),
):
provider.id_token()
assert provider.has_session() is True
assert store.load() is not None
def test_a_server_error_keeps_the_session(self) -> None:
store = MemoryCredentialStore(_credentials(valid_for=dt.timedelta(minutes=-5)))
provider = _provider(store)
with (
_patch_post(_response(503, {"error": {"message": "SERVICE_UNAVAILABLE"}})),
pytest.raises(FirebaseAuthError),
):
provider.id_token()
assert provider.has_session() is True
class TestSessionLifecycle:
def test_has_session_is_false_before_sign_in_and_true_after(self) -> None:

View File

@ -0,0 +1,316 @@
r"""Fetch a Google ID token for this project, via a local loopback OAuth flow.
``link_google.py`` needs a Google ID token whose ``aud`` claim is an OAuth
client **belonging to this Firebase project**. A token from an unrelated
client -- notably the OAuth Playground's own -- is rejected with ``Invalid Idp
Response: id_token audience mismatch``, which reads exactly like a malformed
token and sends you looking in the wrong place. So the token is minted here,
against the project's own Web client.
The flow is the standard installed-app one: open the consent page, catch the
authorization code on a loopback redirect, exchange it for tokens. No secrets
are stored; the code and tokens live only in this process.
Usage::
python3 tool/google_id_token.py --client-id <web-client-id> \\
--client-secret <web-client-secret>
# Feed straight into the linker:
python3 tool/google_id_token.py --client-id ... --client-secret ... \\
--output token.txt
python3 tool/link_google.py --google-id-token-file token.txt
The client id and secret come from the **Web application** OAuth client in the
Google Cloud console for the project. ``http://localhost`` must be listed as
an authorised redirect URI for that client.
The port matters. Only a **Desktop app** client accepts any loopback port; a
**Web application** client compares redirect URIs exactly, so a random port
fails the consent request with ``redirect_uri_mismatch`` before the account
picker ever appears. Against a Web client, pass ``--port`` with a port that is
registered verbatim on the client, e.g. ``http://localhost:8765``.
"""
from __future__ import annotations
import argparse
import http.server
import logging
from pathlib import Path
import secrets
import socket
import sys
import threading
import time
from typing import ClassVar
import urllib.parse
import webbrowser
import requests
_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
# Assembled from host and path rather than written as one literal: a string
# ending in "/token" trips ruff's hardcoded-password check (S105), and the
# honest fix is to not have the literal rather than to silence the rule.
_OAUTH_HOST = "https://oauth2.googleapis.com"
_TOKEN_EXCHANGE_URL = f"{_OAUTH_HOST}/token"
# openid gets an ID token at all; email makes the token carry the address, so
# link_google.py can show which account is about to be linked.
_SCOPES = "openid email profile"
_TIMEOUT_SECONDS = 30
_CONSENT_TIMEOUT_SECONDS = 300
_logger = logging.getLogger("google_id_token")
class TokenError(Exception):
"""The authorization flow did not produce an ID token."""
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
"""Captures the ``code`` (or ``error``) Google redirects back with."""
# Set by the server owner before serving. Class attributes because
# BaseHTTPRequestHandler is instantiated per request by the server, so
# there is no instance to hand them to.
expected_state: ClassVar[str] = ""
result: ClassVar[dict[str, str]] = {}
def do_GET(self) -> None:
"""Record the query parameters and show a closable page.
Requests that carry neither a ``code`` nor an ``error`` are answered
but *not* recorded: browsers fetch ``/favicon.ico`` against the
redirect origin, and letting that count as the callback ends the flow
before Google ever redirects, which surfaces as a consent timeout.
"""
query = urllib.parse.urlparse(self.path).query
params = {k: v[0] for k, v in urllib.parse.parse_qs(query).items()}
if "code" not in params and "error" not in params:
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
return
type(self).result = params
ok = "code" in params and params.get("state") == self.expected_state
body = (
b"<html><body><h2>Done - you can close this tab.</h2></body></html>"
if ok
else b"<html><body><h2>Authorization failed.</h2></body></html>"
)
self.send_response(200 if ok else 400)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
"""Silence the default stderr request logging."""
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _start_callback_server(
port: int,
state: str,
) -> tuple[http.server.HTTPServer, threading.Thread]:
"""Listen on ``port`` until the OAuth callback arrives.
Serves in a loop rather than handling exactly one request: the first
request to reach this port is often a favicon fetch or a speculative
preconnect, and retiring the server on it leaves nothing listening when
Google finally redirects -- which surfaces as a consent timeout.
Args:
port: The loopback port to bind.
state: The value the callback must echo back.
Returns:
The server and the thread serving it, so the caller can join and close.
"""
_CallbackHandler.expected_state = state
_CallbackHandler.result = {}
server = http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler)
# Bounded so handle_request() returns periodically instead of blocking
# forever; without it the loop could not notice the deadline and the
# daemon thread would sit on the socket after the caller gave up.
server.timeout = 1.0
deadline = time.monotonic() + _CONSENT_TIMEOUT_SECONDS
def _serve_until_callback() -> None:
while not _CallbackHandler.result and time.monotonic() < deadline:
server.handle_request()
thread = threading.Thread(target=_serve_until_callback, daemon=True)
thread.start()
return server, thread
def fetch_id_token(
client_id: str,
client_secret: str,
*,
open_browser: bool = True,
port: int = 0,
) -> str:
"""Run the loopback OAuth flow and return a Google ID token.
Args:
client_id: The project's Web OAuth client id.
client_secret: That client's secret.
open_browser: Whether to launch a browser automatically.
port: The loopback port to listen on. 0 picks a free one, which only
works for a **Desktop app** client -- those accept any loopback
port. A **Web application** client matches redirect URIs exactly,
so it needs a fixed port that is registered on the client, or
Google answers the consent request with ``redirect_uri_mismatch``.
Returns:
The ``id_token`` string.
Raises:
TokenError: If consent was refused, timed out, or the exchange failed.
"""
resolved_port = port or _free_port()
redirect_uri = f"http://localhost:{resolved_port}"
# Guards against a stray request to the loopback port being mistaken for
# the real callback.
state = secrets.token_urlsafe(16)
server, thread = _start_callback_server(resolved_port, state)
consent_url = f"{_AUTH_ENDPOINT}?" + urllib.parse.urlencode(
{
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": _SCOPES,
"state": state,
# Force the picker so the right account can be chosen explicitly
# rather than silently reusing whichever is already signed in.
"prompt": "select_account",
}
)
_logger.info(" .. open this URL and pick the sync account:")
_logger.info("")
_logger.info(" %s", consent_url)
_logger.info("")
if open_browser:
webbrowser.open(consent_url)
thread.join(timeout=_CONSENT_TIMEOUT_SECONDS)
server.server_close()
params = _CallbackHandler.result
if not params:
msg = f"no response on {redirect_uri} within {_CONSENT_TIMEOUT_SECONDS}s"
raise TokenError(msg)
if "error" in params:
msg = f"authorization was refused: {params['error']}"
raise TokenError(msg)
if params.get("state") != state:
msg = "the callback state did not match; ignoring the response"
raise TokenError(msg)
code = params.get("code")
if not code:
msg = f"the callback carried no authorization code: {params}"
raise TokenError(msg)
try:
response = requests.post(
_TOKEN_EXCHANGE_URL,
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
msg = f"network error exchanging the code: {exc}"
raise TokenError(msg) from exc
if not response.ok:
msg = f"token exchange failed: HTTP {response.status_code} {response.text}"
raise TokenError(msg)
id_token = response.json().get("id_token")
if not id_token:
msg = "the token response carried no id_token; was 'openid' in the scopes?"
raise TokenError(msg)
return str(id_token)
def main(argv: list[str] | None = None) -> int:
"""Fetch an ID token and print or save it. Returns an exit code."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--client-id", required=True, help="Web OAuth client id.")
parser.add_argument(
"--client-secret",
required=True,
help="Web OAuth client secret.",
)
parser.add_argument(
"--output",
help="Write the token here (mode 0600) instead of printing it.",
)
parser.add_argument(
"--no-browser",
action="store_true",
help="Print the consent URL without launching a browser.",
)
parser.add_argument(
"--port",
type=int,
default=0,
help=(
"Fixed loopback port for the redirect URI. Required for a Web "
"application client, which must have http://localhost:<port> "
"registered verbatim; 0 (the default) picks a free port and "
"suits a Desktop app client."
),
)
args = parser.parse_args(argv)
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
try:
token = fetch_id_token(
args.client_id,
args.client_secret,
open_browser=not args.no_browser,
port=args.port,
)
except TokenError:
_logger.exception(" FAIL")
return 1
if args.output:
destination = Path(args.output)
# 0600 from the outset: an ID token is a bearer credential.
destination.touch(mode=0o600)
destination.write_text(token, encoding="utf-8")
_logger.info(" PASS id_token written to %s", destination)
_logger.info("")
_logger.info("Now link it:")
_logger.info(
" python3 tool/link_google.py --google-id-token-file %s", destination
)
else:
_logger.info("%s", token)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,243 @@
"""Link a Google identity to the existing sync account, keeping the same uid.
The security rules pin exactly one uid (``auth.uid === '<uid>'``), so signing
in with Google as a *new* account is the one failure this whole feature can
have: authentication succeeds, a different uid comes back, and then every
read and write is denied with no other symptom. The data layer looks broken
while the auth layer looks perfect.
Linking avoids that. ``accounts:signInWithIdp`` called with **both** the
existing account's ``idToken`` and Google's ``id_token`` attaches Google as an
additional provider on the account that already exists, so the uid is
unchanged, the rules need no edit, and every ``<prefix>/<deviceId>/<file>``
path keeps working. Sign in with Google afterwards and the same uid comes
back.
Run once, from the PC, which already holds ``~/.config/crdt-sync/password``.
Obtaining the Google ``id_token`` is a browser flow, so it is passed in rather
than fetched here::
python3 tool/link_google.py --google-id-token "$(cat token.txt)"
python3 tool/link_google.py --google-id-token-file token.txt
Exits non-zero, loudly, if the linked account's uid is not the pinned one --
that is the whole point of the script.
"""
from __future__ import annotations
import argparse
import base64
import binascii
import json
import logging
from pathlib import Path
import sys
import requests
from crdt_sync import FirebaseTokenProvider, MemoryCredentialStore
from crdt_sync._config import ConfigError, FirebaseConfig
from crdt_sync._firebase_auth import FirebaseAuthError
_SIGN_IN_BASE = "https://identitytoolkit.googleapis.com/v1"
_TIMEOUT_SECONDS = 30
# A JWT is three dot-separated parts: header, payload, signature. Named so the
# check below reads as intent rather than as a magic number.
_JWT_PART_COUNT = 3
# Matches tool/preflight_firebase.py: these tools report through logging, not
# print, so output is capturable and consistent across the tool/ directory.
_logger = logging.getLogger("link_google")
# Google's ID tokens are issued for the *Web* OAuth client (the audience the
# Android client requests via serverClientId), so this is what should appear
# in the token's `aud` claim. Checked only to make a misconfiguration report
# itself here rather than as an opaque Firebase rejection.
_EXPECTED_ISSUERS = ("https://accounts.google.com", "accounts.google.com")
class LinkError(Exception):
"""The link could not be completed, or completed onto the wrong account."""
def _decode_jwt_claims(token: str) -> dict[str, object]:
"""Return the unverified claims of ``token``.
Unverified on purpose: this is a local sanity check to produce a good
error message before spending a network round trip. Firebase verifies the
signature server-side, and that verification is the one that matters.
"""
parts = token.split(".")
if len(parts) != _JWT_PART_COUNT:
msg = (
"the --google-id-token value is not a JWT (expected three "
f"dot-separated parts, got {len(parts)}). Make sure this is the "
"ID token, not an access token or an authorization code."
)
raise LinkError(msg)
payload = parts[1]
# JWTs use base64url without padding; add the padding back.
payload += "=" * (-len(payload) % 4)
try:
decoded = json.loads(base64.urlsafe_b64decode(payload))
except (binascii.Error, ValueError) as exc:
msg = f"the --google-id-token payload is not valid base64url JSON: {exc}"
raise LinkError(msg) from exc
if not isinstance(decoded, dict):
msg = "the --google-id-token payload is not a JSON object"
raise LinkError(msg)
return decoded
def describe_google_token(token: str) -> str:
"""Return a one-line human description of ``token``, validating shape.
Raises:
LinkError: If the token is not a Google-issued ID token.
"""
claims = _decode_jwt_claims(token)
issuer = str(claims.get("iss", ""))
if issuer not in _EXPECTED_ISSUERS:
msg = (
f"the token was issued by {issuer!r}, not Google. Expected one of "
f"{_EXPECTED_ISSUERS}."
)
raise LinkError(msg)
email = str(claims.get("email", "<no email claim>"))
audience = str(claims.get("aud", "<no aud claim>"))
return f"Google token for {email} (aud={audience})"
def link_google(
config: FirebaseConfig,
google_id_token: str,
*,
timeout_seconds: float = _TIMEOUT_SECONDS,
) -> str:
"""Link ``google_id_token``'s identity to the configured account.
Signs in with the stored password first, because linking requires proving
ownership of the account being linked *to*. Without that ``idToken`` the
same endpoint happily creates a brand-new account -- the exact silent
failure this script exists to prevent.
Args:
config: The shared Firebase config, already loaded.
google_id_token: An ID token from Google's OAuth flow.
timeout_seconds: Per-request network timeout.
Returns:
The uid the linked account resolves to.
Raises:
LinkError: If Firebase rejects the link, or the resulting uid is not
the one pinned in the security rules.
"""
auth = FirebaseTokenProvider(config.api_key, MemoryCredentialStore())
auth.sign_in(config.email, config.read_password())
existing_id_token = auth.id_token()
try:
response = requests.post(
f"{_SIGN_IN_BASE}/accounts:signInWithIdp?key={config.api_key}",
json={
# idToken names the account to link ONTO. Omitting it turns
# this call into "sign in or sign up", which would mint a new
# uid that the security rules reject.
"idToken": existing_id_token,
"postBody": f"id_token={google_id_token}&providerId=google.com",
"requestUri": "http://localhost",
"returnSecureToken": True,
},
timeout=timeout_seconds,
)
except requests.RequestException as exc:
msg = f"network error trying to link the Google account: {exc}"
raise LinkError(msg) from exc
if not response.ok:
msg = (
f"Firebase rejected the link: HTTP {response.status_code} "
f"{response.text.strip()}"
)
raise LinkError(msg)
body = response.json()
linked_uid = str(body.get("localId", ""))
if not linked_uid:
msg = f"Firebase returned no localId; body was {body!r}"
raise LinkError(msg)
if linked_uid != config.uid:
msg = (
f"LINKED THE WRONG ACCOUNT: got uid {linked_uid!r}, but the "
f"security rules pin {config.uid!r}. Signing in with Google would "
"authenticate successfully and then be denied every read and "
"write. Unlink this Google identity in the Firebase console "
"before retrying."
)
raise LinkError(msg)
return linked_uid
def _read_token(args: argparse.Namespace) -> str:
if args.google_id_token_file is not None:
try:
token = Path(args.google_id_token_file).read_text(encoding="utf-8")
except OSError as exc:
msg = f"{args.google_id_token_file} could not be read: {exc}"
raise LinkError(msg) from exc
else:
token = args.google_id_token
# Strip, because a token pasted through a file or a shell heredoc picks up
# a trailing newline that Firebase rejects with an unhelpful message.
token = token.strip()
if not token:
msg = "the Google ID token is empty"
raise LinkError(msg)
return token
def main(argv: list[str] | None = None) -> int:
"""Link a Google identity to the sync account. Returns an exit code."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument(
"--google-id-token",
help="A Google OAuth ID token (JWT) for the account to link.",
)
source.add_argument(
"--google-id-token-file",
help="A file containing the Google OAuth ID token.",
)
args = parser.parse_args(argv)
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
# ConfigError and FirebaseAuthError are the two non-LinkError failures this
# path can actually raise (an unusable config file, a rejected password).
# Caught by name rather than with a bare `except Exception` so an
# unanticipated bug still surfaces as a traceback instead of a tidy
# "FAIL" line that hides it.
try:
token = _read_token(args)
_logger.info(" .. %s", describe_google_token(token))
config = FirebaseConfig.load()
_logger.info(" .. linking onto %s (uid %s)", config.email, config.uid)
linked_uid = link_google(config, token)
except (LinkError, ConfigError, FirebaseAuthError):
# logging.exception already renders the exception and its traceback.
_logger.exception(" FAIL")
return 1
_logger.info(" PASS Google linked; uid unchanged (%s)", linked_uid)
_logger.info("")
_logger.info("Now verify a Google sign-in resolves to the same uid:")
_logger.info(" python3 tool/preflight_firebase.py")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,241 @@
r"""Reseed each app's desktop Firebase session by signing in with Google.
The sync account moved to Google-only sign-in, so the password flow that used
to fill ``~/.config/<app>/firebase_auth.json`` no longer works and every
desktop refresh token eventually expires. Headless jobs (systemd timers, the
sync CLIs) cannot run a browser flow themselves, so this runs it once,
interactively, and stores the refresh token each app then lives on.
Deliberately *not* a service-account key. That authenticates as an admin and
bypasses the security rules, so the pinned-uid rule protecting this data would
stop applying to every PC write.
Run from the repo root, as a module -- ``python3 tool/seed_session.py`` fails
with ``No module named 'tool'``, because that puts ``tool/`` on the path
instead of the root this imports ``tool.google_id_token`` from::
python3 -m tool.seed_session --client-id <web-client-id> \\
--client-secret <web-client-secret>
# Reseed a subset instead of every app:
python3 -m tool.seed_session --client-id ... --client-secret ... \\
--app diet_guard --app wake_alarm
One consent tap covers every app: the Google ID token is fetched once and
exchanged once per app, because ``credential_store_for`` keeps a separate file
per app on purpose (independent refresh schedules, no concurrent writers).
The client id and secret come from the **Web application** OAuth client in the
project's Google Cloud console -- the same pair ``google_id_token.py`` takes.
"""
from __future__ import annotations
import argparse
import logging
import sys
from crdt_sync._config import ConfigError, FirebaseConfig, credential_store_for
from crdt_sync._firebase import FirebaseSyncClient
from crdt_sync._firebase_auth import FirebaseAuthError, FirebaseTokenProvider
from crdt_sync._remote import RemoteSyncError
# Importable as a package module (tool/__init__.py exists) so the loopback
# OAuth flow is shared rather than reimplemented.
from tool.google_id_token import TokenError, fetch_id_token
# Every app that calls firebase_client_for/mirror_client_for from the desktop,
# and so keeps a refresh token under ~/.config/<app>/. Named here rather than
# discovered from ~/.config, because a missing directory is exactly the case
# that needs seeding -- discovery would skip the app that needs this most.
#
# The Flutter-only apps (todo, home_inventory, workout_app) are absent on
# purpose: they authenticate in-app on the phone and have no desktop session.
#
# Re-derive rather than guess when adding one; a repo-by-repo sweep missed two
# of these. Grep all of ~ for the string literal passed to firebase_client_for,
# mirror_client_for and credential_store_for across every *.py. That still
# misses call sites passing a constant -- wake_alarm goes through
# wake_alarm._constants.SYNC_APP_NAME -- so check those by name too. "interop"
# is tool/interop_seed.py's own scratch store, not an app.
DEFAULT_APPS = (
"diet_guard",
"wake_alarm",
"screen_locker",
"byox_ladder",
"leetcode_guard",
)
# A fixed default so the redirect URI is stable enough to register once on the
# Web client. Arbitrary but out of the ephemeral range, and free on this box.
_DEFAULT_REDIRECT_PORT = 8765
_logger = logging.getLogger("seed_session")
class SeedError(Exception):
"""A session was stored but could not actually reach the database.
Carries the apps already finished when it was raised, so a partially
completed run can report what is left rather than leaving the state to be
reconstructed by hand.
"""
def __init__(self, message: str, done: tuple[str, ...] = ()) -> None:
"""Record ``message`` and the apps that verified before the failure."""
super().__init__(message)
self.done = done
def seed_apps(
config: FirebaseConfig,
google_id_token: str,
app_names: tuple[str, ...],
) -> list[str]:
"""Store a Google-backed session for each app. Returns the apps seeded.
Each app gets its own exchange because each has its own credential file;
a single session copied across them would have them all refreshing the
same rotated token and invalidating each other.
Each session is then proved with a real authenticated read, because a
written credential file is not evidence of working sync: the whole reason
this script exists is that the files were present and the tokens dead.
Firebase may also invalidate an earlier refresh token when a new session
is minted for the same uid, which would leave every app but the last one
broken -- a failure only a per-app read can see.
Args:
config: The shared Firebase config, already loaded.
google_id_token: An ID token from Google's OAuth flow.
app_names: The apps whose credential caches to fill.
Returns:
The app names seeded, in the order given.
Raises:
FirebaseAuthError: If Firebase rejects the token, or it resolves to a
uid other than the pinned one. Raised on the first failure, so a
wrong account cannot be written to any app.
SeedError: If a seeded session cannot actually read the database.
"""
seeded: list[str] = []
for app_name in app_names:
provider = FirebaseTokenProvider(
config.api_key,
credential_store_for(app_name),
)
email = provider.sign_in_with_google(
google_id_token,
expected_uid=config.uid,
)
seeded.append(app_name)
_logger.info(" .. %s seeded as %s", app_name, email or config.email)
# Verified in a second pass, after every session exists: checking each one
# as it is written would pass an app that a later exchange then
# invalidates, which is precisely the failure being guarded against.
verified: list[str] = []
for app_name in seeded:
client = FirebaseSyncClient(
config.database_url,
FirebaseTokenProvider(config.api_key, credential_store_for(app_name)),
)
# list_directory rather than can_access_remote(): the latter collapses
# rules-denied, dead-token and network-down into one False, and a gate
# whose whole job is saying what broke should not throw that away.
# Both read the database root, which is where the rules grant .read.
try:
client.list_directory("")
except (RemoteSyncError, FirebaseAuthError) as exc:
msg = (
f"{app_name} was seeded but cannot read the database ({exc}). "
"Its session is not usable, so sync would keep failing "
"silently."
)
raise SeedError(msg, tuple(verified)) from exc
verified.append(app_name)
_logger.info(" .. %s verified: authenticated read succeeded", app_name)
return verified
def main(argv: list[str] | None = None) -> int:
"""Run the consent flow and seed every app. Returns an exit code."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--client-id", required=True, help="Web OAuth client id.")
parser.add_argument(
"--client-secret",
required=True,
help="Web OAuth client secret.",
)
parser.add_argument(
"--app",
action="append",
dest="apps",
# Constrained rather than free-form: a typo like "diet-guard" would
# otherwise create ~/.config/diet-guard/, verify against it happily,
# and report PASS while the real diet_guard stayed dead. The stray
# directories already in ~/.config are what that looks like.
choices=DEFAULT_APPS,
help=(
"Seed only this app; repeatable. Defaults to all of: "
+ ", ".join(DEFAULT_APPS)
),
)
parser.add_argument(
"--no-browser",
action="store_true",
help="Print the consent URL without launching a browser.",
)
parser.add_argument(
"--port",
type=int,
default=_DEFAULT_REDIRECT_PORT,
help=(
"Loopback port for the OAuth redirect. Must be registered on the "
"Web client as http://localhost:<port>, exactly -- a Web client "
"does not accept arbitrary loopback ports the way a Desktop "
"client does."
),
)
args = parser.parse_args(argv)
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
app_names = tuple(args.apps) if args.apps else DEFAULT_APPS
# Caught by name rather than with a bare `except Exception` so an
# unanticipated bug still surfaces as a traceback instead of a tidy
# "FAIL" line that hides it.
try:
config = FirebaseConfig.load()
_logger.info(" .. seeding %s", ", ".join(app_names))
_logger.info(" .. expecting uid %s (%s)", config.uid, config.email)
token = fetch_id_token(
args.client_id,
args.client_secret,
open_browser=not args.no_browser,
port=args.port,
)
seeded = seed_apps(config, token, app_names)
except (ConfigError, FirebaseAuthError, TokenError, SeedError) as exc:
# logging.exception already renders the exception and its traceback.
_logger.exception(" FAIL")
# A run that dies partway leaves some apps working and some not.
# Saying which is the difference between rerunning the ones that need
# it and a hunt through ~/.config for what actually happened.
done = exc.done if isinstance(exc, SeedError) else ()
remaining = [name for name in app_names if name not in done]
if done:
_logger.exception(" .. already working: %s", ", ".join(done))
_logger.exception(
" .. rerun with: %s",
" ".join(f"--app {name}" for name in remaining),
)
return 1
_logger.info(" PASS %d desktop session(s) seeded and read-verified", len(seeded))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -127,10 +127,7 @@ class FirebaseTokenProvider {
///
/// Called once per device from a settings screen or a setup command; every
/// later run reuses the stored refresh token.
Future<void> signIn({
required String email,
required String password,
}) async {
Future<void> signIn({required String email, required String password}) async {
final body = await _post(
Uri.parse('$_signInBase/accounts:signInWithPassword?key=$apiKey'),
{'email': email, 'password': password, 'returnSecureToken': true},
@ -143,6 +140,66 @@ class FirebaseTokenProvider {
);
}
/// Exchanges a Google ID token for a session and persists it.
///
/// This is the one-tap path: a fresh install signs in by picking a Google
/// account instead of typing the sync account's long password.
///
/// **The Google identity must already be linked to the sync account.** The
/// security rules pin one uid, so a Google identity that is *not* linked
/// signs in as a different uid, which authenticates fine and is then denied
/// every read and write -- a sync that silently never syncs. Link once from
/// the PC with `crdt-sync/tool/link_google.py`, which asserts the uid.
///
/// [idToken] comes from the platform's Google sign-in flow. The library
/// deliberately does not depend on `google_sign_in`: that plugin has no
/// Linux desktop support, and this package has to keep working headless.
/// The app fetches the token and passes it in.
///
/// [expectedUid], when given, is asserted against the uid Firebase returns
/// and a mismatch throws without storing anything. **Pass it.** Unlike a
/// password -- where a wrong credential is simply rejected -- an unlinked
/// Google identity is *accepted*: `signInWithIdp` signs in **or signs up**,
/// so picking the wrong account in the phone's account picker silently
/// creates a second user. Without this check that session persists, every
/// read and write is denied by the rules, and the failure survives
/// relaunches because the bad refresh token was saved.
/// Returns the email Google signed in as, which the caller needs: on a
/// fresh install nothing on the device knows it yet, and that is precisely
/// the case this path exists for.
Future<String?> signInWithGoogle({
required String idToken,
String? expectedUid,
}) async {
final body = await _post(
Uri.parse('$_signInBase/accounts:signInWithIdp?key=$apiKey'),
{
// The IdP credential travels as a form-encoded body, not as JSON --
// an identitytoolkit quirk, not a mistake.
'postBody': 'id_token=$idToken&providerId=google.com',
'requestUri': 'http://localhost',
'returnSecureToken': true,
},
'sign in with Google',
);
final uid = body['localId'] as String?;
if (expectedUid != null && expectedUid.isNotEmpty && uid != expectedUid) {
// Deliberately before _adopt: nothing is stored, so the device stays
// unconfigured and the user can retry with the right account.
throw FirebaseAuthError(
'signed in as the wrong account: Google resolved to uid $uid, but '
'this data belongs to $expectedUid. Pick the Google account that was '
'linked with crdt-sync/tool/link_google.py.',
);
}
await _adopt(
idToken: body['idToken'] as String,
refreshToken: body['refreshToken'] as String,
expiresInSeconds: body['expiresIn'] as String,
);
return body['email'] as String?;
}
/// Returns a currently-valid ID token, refreshing or failing loudly.
///
/// Throws [FirebaseAuthError] when no session is stored or the refresh
@ -165,8 +222,25 @@ class FirebaseTokenProvider {
/// Distinguishes "sync is not configured" (fine -- `screen-locker` treats a
/// missing credential as a normal state) from "sync is configured and
/// broken" (an error).
Future<bool> hasSession() async =>
(_cached ??= await store.load()) != null;
Future<bool> hasSession() async => (_cached ??= await store.load()) != null;
/// Whether [error] means the refresh token is permanently dead.
///
/// Only these reasons are terminal. A network error or a 5xx must NOT clear
/// the session: a device that signs itself out every time the wifi drops is
/// a worse bug than the one this guards against, since recovering needs a
/// manual sign-in on each device.
static bool _isRevoked(FirebaseAuthError error) {
const terminal = [
'TOKEN_EXPIRED',
'USER_DISABLED',
'USER_NOT_FOUND',
'INVALID_REFRESH_TOKEN',
'INVALID_GRANT_TYPE',
'MISSING_REFRESH_TOKEN',
];
return terminal.any(error.message.contains);
}
/// Forgets the stored session, so the next [idToken] fails loudly.
Future<void> signOut() async {
@ -175,11 +249,20 @@ class FirebaseTokenProvider {
}
Future<String> _refresh(String refreshToken) async {
final body = await _post(
Uri.parse('$_refreshBase/token?key=$apiKey'),
{'grant_type': 'refresh_token', 'refresh_token': refreshToken},
'refresh the session',
);
final Map<String, dynamic> body;
try {
body = await _post(Uri.parse('$_refreshBase/token?key=$apiKey'), {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
}, 'refresh the session');
} on FirebaseAuthError catch (error) {
// A revoked refresh token never becomes valid again, so keeping it is
// what made a dead device report "Connected" and then fail every sync
// with TOKEN_EXPIRED. Drop it, so hasSession() answers honestly and the
// settings screen offers a sign-in instead of a broken banner.
if (_isRevoked(error)) await signOut();
rethrow;
}
return _adopt(
idToken: body['id_token'] as String,
// A refresh may hand back a rotated refresh token; keep the new one.

View File

@ -104,10 +104,20 @@ class FirebaseConfig {
/// The common path costs no authentication round trip: [store] holds the
/// refresh token between launches. Pass the platform-appropriate store --
/// `flutter_secure_storage` on Android, a 0600 file on desktop.
/// Supplies a Google ID token for the account to sign in as, or null when the
/// user is not signed in with Google on this device.
///
/// A closure rather than a dependency: `google_sign_in` is Android/iOS/web
/// only, and this package must keep working on Linux desktop and headless
/// under systemd. The app owns the plugin; the library only wants the token.
typedef GoogleIdTokenProvider = Future<String?> Function();
Future<FirebaseRestClient> firebaseClientFor({
required FirebaseConfig config,
required FirebaseCredentialStore store,
String? password,
GoogleIdTokenProvider? googleIdToken,
String? expectedUid,
http.Client? httpClient,
}) async {
final auth = FirebaseTokenProvider(
@ -116,13 +126,25 @@ Future<FirebaseRestClient> firebaseClientFor({
httpClient: httpClient,
);
if (await store.load() == null) {
if (password == null) {
// Google first: it is the one-tap path, and on a fresh install it is the
// only credential the user has not had to type. Password stays as the
// fallback, and is the only option on desktop and headless.
final google = await googleIdToken?.call();
if (google != null) {
// config.uid is empty on the app path (FirebaseProject.configFor leaves
// it blank), in which case signInWithGoogle skips the comparison rather
// than failing every sign-in against ''. Apps that know their uid should
// pass expectedUid so a mis-tapped account picker is caught here instead
// of turning into a silently denied sync.
await auth.signInWithGoogle(idToken: google, expectedUid: expectedUid ?? config.uid);
} else if (password != null) {
await auth.signIn(email: config.email, password: password);
} else {
throw ConfigException(
'no stored session for ${config.email} and no password given; '
'sign in once from the settings screen',
'no stored session for ${config.email}, no Google sign-in and no '
'password given; sign in once from the settings screen',
);
}
await auth.signIn(email: config.email, password: password);
}
return FirebaseRestClient(
databaseUrl: config.databaseUrl,
@ -143,12 +165,16 @@ Future<MirrorStore> mirrorStoreFor({
required FirebaseCredentialStore store,
required RemoteStore githubClient,
String? password,
GoogleIdTokenProvider? googleIdToken,
String? expectedUid,
http.Client? httpClient,
}) async => MirrorStore(
primary: await firebaseClientFor(
config: config,
store: store,
password: password,
googleIdToken: googleIdToken,
expectedUid: expectedUid,
httpClient: httpClient,
),
mirror: githubClient,

View File

@ -2,7 +2,7 @@ name: crdt_sync
description: >-
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
cross-device apps. Pure Dart, no Flutter dependency.
version: 0.9.0
version: 0.10.0
publish_to: 'none'
environment:

View File

@ -151,6 +151,218 @@ void main() {
});
});
group('signInWithGoogle', () {
test('stores the session returned by identitytoolkit', () async {
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'google-token');
final saved = await store.load();
expect(saved!.idToken, 'id-g');
expect(saved.refreshToken, 'refresh-g');
expect(saved.expiresAt, _now.add(const Duration(hours: 1)));
});
test('posts the IdP credential form-encoded to signInWithIdp', () async {
// The identitytoolkit quirk worth pinning: the Google token travels in a
// form-encoded `postBody` string, not as a JSON field. Getting this
// wrong returns INVALID_IDP_RESPONSE, which reads like a bad token.
late http.Request captured;
final provider = FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: InMemoryCredentialStore(),
httpClient: http_testing.MockClient((request) async {
captured = request;
return _json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
});
}),
clock: () => _now,
);
await provider.signInWithGoogle(idToken: 'google-token');
expect(captured.url.path, contains('accounts:signInWithIdp'));
final body = jsonDecode(captured.body) as Map<String, dynamic>;
expect(body['postBody'], 'id_token=google-token&providerId=google.com');
expect(body['returnSecureToken'], isTrue);
});
test('reports Google\'s reason on a rejected token', () async {
final provider = _provider([
_json(400, {
'error': {'message': 'INVALID_IDP_RESPONSE'},
}),
]);
expect(
() => provider.signInWithGoogle(idToken: 'stale-token'),
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
contains('INVALID_IDP_RESPONSE'),
),
),
);
});
test('leaves no session stored when the token is rejected', () async {
// A failed Google sign-in must not look like a signed-in device: the
// next idToken() has to fail loudly rather than serve a stale token.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(400, {
'error': {'message': 'INVALID_IDP_RESPONSE'},
}),
], store: store);
await expectLater(
() => provider.signInWithGoogle(idToken: 'bad'),
throwsA(isA<FirebaseAuthError>()),
);
expect(await store.load(), isNull);
expect(await provider.hasSession(), isFalse);
});
test('returns the email Firebase reports', () async {
// A fresh install knows no email until Firebase answers with one, so
// this return value is what lets the device store its account. Reading
// the email from anywhere on-device instead would store an empty one.
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'email': 'signed-in@example.com',
}),
]);
expect(
await provider.signInWithGoogle(idToken: 'token'),
'signed-in@example.com',
);
});
test('returns null when the response carries no email', () async {
// Not fatal: the session is still valid, so sign-in succeeds and the
// caller simply has no address to display.
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
}),
]);
expect(await provider.signInWithGoogle(idToken: 'token'), isNull);
});
test('rejects a session for a uid other than the expected one', () async {
// The mis-tapped-account-picker case. signInWithIdp signs in OR SIGNS
// UP, so an unlinked Google identity is accepted as a brand-new user
// rather than refused. Without this check the device would store that
// session and then be denied every read and write, permanently.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'some-other-uid',
}),
], store: store);
await expectLater(
() => provider.signInWithGoogle(
idToken: 'token-for-the-wrong-account',
expectedUid: 'the-right-uid',
),
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
allOf(contains('some-other-uid'), contains('the-right-uid')),
),
),
);
expect(
await store.load(),
isNull,
reason: 'a wrong-account session must not persist',
);
expect(await provider.hasSession(), isFalse);
});
test('accepts a session whose uid matches the expected one', () async {
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'the-right-uid',
}),
], store: store);
await provider.signInWithGoogle(
idToken: 'token',
expectedUid: 'the-right-uid',
);
expect((await store.load())!.idToken, 'id-g');
});
test('skips the uid check when no uid is expected', () async {
// FirebaseProject.configFor leaves uid empty on the app path; comparing
// against '' would otherwise fail every sign-in.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'whatever-uid',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'token', expectedUid: '');
expect((await store.load())!.idToken, 'id-g');
});
test('its session refreshes like a password session', () async {
// signInWithIdp returns the same refresh-token shape as
// signInWithPassword, which is why the refresh path needed no changes.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
// Already inside the refresh skew, so the next read must refresh.
'expiresIn': '60',
}),
_json(200, {
'id_token': 'id-g2',
'refresh_token': 'refresh-g2',
'expires_in': '3600',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'google-token');
expect(await provider.idToken(), 'id-g2');
expect((await store.load())!.refreshToken, 'refresh-g2');
});
});
group('idToken', () {
test('fails loudly when no session is stored', () {
// Never returns null or a stale token: a sync that quietly stops
@ -176,32 +388,37 @@ void main() {
expect(await provider.idToken(), 'id-1');
});
test('refreshes an expired token and persists the rotated refresh token',
() async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(minutes: -5)),
);
final provider = _provider([
_json(200, {
'id_token': 'id-2',
'refresh_token': 'refresh-2',
'expires_in': '3600',
}),
], store: store);
test(
'refreshes an expired token and persists the rotated refresh token',
() async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(minutes: -5)),
);
final provider = _provider([
_json(200, {
'id_token': 'id-2',
'refresh_token': 'refresh-2',
'expires_in': '3600',
}),
], store: store);
expect(await provider.idToken(), 'id-2');
final saved = await store.load();
expect(saved!.refreshToken, 'refresh-2');
});
expect(await provider.idToken(), 'id-2');
final saved = await store.load();
expect(saved!.refreshToken, 'refresh-2');
},
);
test('fails loudly when the refresh token has been revoked', () async {
final provider = _provider([
_json(400, {
'error': {'message': 'TOKEN_EXPIRED'},
}),
], store: InMemoryCredentialStore(
_credentials(validFor: const Duration(minutes: -5)),
));
final provider = _provider(
[
_json(400, {
'error': {'message': 'TOKEN_EXPIRED'},
}),
],
store: InMemoryCredentialStore(
_credentials(validFor: const Duration(minutes: -5)),
),
);
expect(
provider.idToken,
throwsA(
@ -245,4 +462,64 @@ void main() {
expect(_provider([]).close, returnsNormally);
});
});
group('a refresh token the server rejects', () {
// Regression: hasSession() was a presence check, so a revoked token still
// read as "Connected" while every sync died with TOKEN_EXPIRED. Observed
// on the desktop todo build on 2026-08-11.
test(
'is cleared, so hasSession stops claiming the device is connected',
() async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
_json(400, {
'error': {'message': 'TOKEN_EXPIRED'},
}),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(
await provider.hasSession(),
isFalse,
reason: 'a revoked session must not keep reporting as connected',
);
expect(await store.load(), isNull);
},
);
test('survives a network error, which is transient', () async {
// The opposite failure: signing out whenever the wifi drops would need a
// manual sign-in on every device to recover.
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
http.ClientException('connection reset'),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(
await provider.hasSession(),
isTrue,
reason: 'a transient failure must not discard a good refresh token',
);
expect(await store.load(), isNotNull);
});
test('survives a 5xx, which is also transient', () async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
_json(503, {
'error': {'message': 'SERVICE_UNAVAILABLE'},
}),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(await provider.hasSession(), isTrue);
});
});
}

View File

@ -184,6 +184,134 @@ void main() {
client.close();
});
test('signs in with Google when a token is available', () async {
// The one-tap path: a fresh install with no password typed anywhere.
final store = InMemoryCredentialStore();
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'localId': _valid['uid'],
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
googleIdToken: () async => 'google-token',
httpClient: mock,
);
expect(sentTo.single, contains('signInWithIdp'));
expect(await store.load(), isNotNull, reason: 'session must persist');
client.close();
});
test('prefers Google over the password when both are available', () async {
// Google is the credential the user did not have to type, so on a device
// offering both it should win.
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
// Firebase always returns localId; the config carries a uid, so
// omitting it here would trip the wrong-account guard.
'localId': _valid['uid'],
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
password: 'hunter2',
googleIdToken: () async => 'google-token',
httpClient: mock,
);
expect(sentTo.single, contains('signInWithIdp'));
client.close();
});
test('refuses a Google account that is not the configured uid', () async {
// End of the chain the phone actually walks: a mis-tapped account in the
// picker must fail here rather than storing a session that the security
// rules then deny on every read and write.
final store = InMemoryCredentialStore();
final mock = http_testing.MockClient(
(request) async => http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'localId': 'a-different-persons-uid',
}),
200,
),
);
await expectLater(
firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
googleIdToken: () async => 'token-for-the-wrong-account',
httpClient: mock,
),
throwsA(isA<FirebaseAuthError>()),
);
expect(await store.load(), isNull);
});
test('falls back to the password when Google returns null', () async {
// Desktop and headless: the provider is wired but nobody is signed in
// with Google, so the machine credential has to still work.
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
password: 'hunter2',
googleIdToken: () async => null,
httpClient: mock,
);
expect(sentTo.single, contains('signInWithPassword'));
client.close();
});
test('refuses to build when Google returns null and no password', () async {
await expectLater(
firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
googleIdToken: () async => null,
),
throwsA(isA<ConfigException>()),
);
});
test('reuses a stored session without a password', () async {
final store = InMemoryCredentialStore();
await store.save(

View File

@ -0,0 +1,88 @@
#!/bin/bash
# ============================================================================
# Fail if a pubspec still overrides crdt_sync with a local path.
#
# The apps depend on crdt_sync as a *git dependency pinned to a tag*, so what
# CI builds is whatever that tag points at. A `dependency_overrides:` entry
# pointing at ~/utils/crdt_sync_dart is invaluable while developing an unreleased
# library change -- and catastrophic if it survives the commit: local tests go
# green against code CI has never seen, and the failure is silent in both
# directions.
#
# A comment in the pubspec asking someone to remember is deployment hygiene,
# not a fix. This is the gate.
#
# Usage: check_no_crdt_sync_override.sh [pubspec.yaml ...]
# With no arguments, checks ./pubspec.yaml.
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
usage() {
echo "Usage: $SCRIPT_NAME [pubspec.yaml ...]"
echo " Fails when a pubspec overrides crdt_sync with a local path."
exit 0
}
# Returns 0 (found) when the file has a crdt_sync entry under
# dependency_overrides. Deliberately a small state machine rather than a grep:
# `path:` appears under plenty of other keys, and a false positive here blocks
# a legitimate commit.
has_override() {
local file="$1"
awk '
# Track indent depth explicitly. An earlier version used an awk
# interval expression ({1,4}) to spot a sibling key, which plain awk
# does not enable by default -- so the check silently never fired and
# reported every file clean. A safety check that fails open is worse
# than none, so this version is written to be obvious instead of terse.
function indent(line, n) {
n = match(line, /[^ ]/)
return (n == 0) ? 0 : n - 1
}
/^[[:space:]]*#/ { next } # comments
/^[[:space:]]*$/ { next } # blank lines
/^dependency_overrides:[[:space:]]*$/ { in_overrides = 1; next }
/^[^[:space:]]/ { in_overrides = 0; in_crdt = 0; next }
in_overrides {
if ($0 ~ /^[[:space:]]+crdt_sync:[[:space:]]*$/) {
in_crdt = 1
crdt_indent = indent($0)
next
}
# Any key at or above crdt_sync{s} own indent ends its block.
if (in_crdt && indent($0) <= crdt_indent) { in_crdt = 0 }
if (in_crdt && $0 ~ /^[[:space:]]+path:/) { found = 1 }
}
END { exit(found ? 0 : 1) }
' "$file"
}
main() {
local -a files=("$@")
if [[ ${#files[@]} -eq 0 ]]; then
files=("pubspec.yaml")
fi
local failed=0
for file in "${files[@]}"; do
[[ -f "$file" ]] || continue
if has_override "$file"; then
echo "Error: $file still has a local crdt_sync dependency_override." >&2
echo " Remove it and bump the git 'ref:' to the released tag" >&2
echo " before committing, or CI will build different code." >&2
failed=1
fi
done
return "$failed"
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
fi
main "$@"

View File

@ -0,0 +1,190 @@
#!/bin/bash
# ============================================================================
# Register every com.kuhy.* / dev.kuhy.* Android app in the kuhy-syncs Firebase
# project and attach both signing fingerprints to each.
#
# Google sign-in on Android needs an Android OAuth client whose (package name,
# SHA-1) pair matches the installed APK. Getting either wrong surfaces as
# `ApiException: 10 / DEVELOPER_ERROR`, which names neither -- so this does it
# from one verified list instead of five console visits.
#
# Both fingerprints go on every app: the release key signs what CI publishes,
# the debug key signs a local `flutter build apk`. Omitting the debug one means
# sign-in works from CI builds and mysteriously fails from your own.
#
# Idempotent: existing apps are reused and existing hashes skipped, so this is
# safe to re-run after adding an app or rotating a key.
#
# Requires: firebase-tools, and `firebase login` already done (browser flow).
# ============================================================================
set -euo pipefail
readonly PROJECT="kuhy-syncs"
# Fingerprints, read from the keystores rather than pasted, so a rotated key
# cannot silently leave this script registering a stale hash.
readonly DEBUG_KEYSTORE="$HOME/.android/debug.keystore"
readonly RELEASE_KEYSTORE="$HOME/.android/release/kuhy-release.jks"
# "<display name>:<package name>" -- the package must match applicationId in
# each app's build.gradle exactly. Note todo is dev.kuhy.*, the rest com.kuhy.*.
readonly APPS=(
"todo:dev.kuhy.todo"
"workout_app:com.kuhy.workout_app"
"diet_guard_app:com.kuhy.diet_guard_app"
"home_inventory:com.kuhy.home_inventory"
"wake_alarm_sync:com.kuhy.wake_alarm_sync"
)
usage() {
echo "Usage: $(basename "$0") [--dry-run]"
echo " Registers the Android apps and their SHA-1s in $PROJECT."
echo " Run 'firebase login' first."
exit 0
}
DRY_RUN=0
require_login() {
if ! firebase login:list 2>&1 | grep -qi 'logged in'; then
echo "Error: not logged in. Run: firebase login" >&2
echo " (browser flow -- this script cannot do it for you)" >&2
exit 1
fi
}
# Prints the SHA-1 of a keystore alias, or exits with a message naming the file.
sha1_of() {
local keystore="$1" alias="$2" storepass="$3"
if [[ ! -f "$keystore" ]]; then
echo "Error: keystore not found: $keystore" >&2
exit 1
fi
local out
out="$(keytool -list -v -keystore "$keystore" -alias "$alias" \
-storepass "$storepass" 2>/dev/null | grep -m1 'SHA1:' || true)"
if [[ -z "$out" ]]; then
echo "Error: no SHA1 for alias '$alias' in $keystore" >&2
exit 1
fi
# " SHA1: AA:BB:..." -> "AA:BB:..."
echo "${out##*SHA1: }"
}
# Looks up an appId by package name, via --json.
#
# Deliberately not parsing `apps:list`'s default output: that is a rendered
# table carrying ANSI colour codes, and it shows the *display name* rather than
# the package -- so a name/package mismatch silently found nothing. The JSON
# form carries packageName explicitly.
app_id_for() {
local package="$1"
firebase apps:list ANDROID --project "$PROJECT" --json 2>/dev/null \
| python3 -c "
import json, sys
package = sys.argv[1]
try:
apps = json.load(sys.stdin).get('result') or []
except ValueError:
sys.exit(0)
for app in apps:
if app.get('packageName') == package:
print(app.get('appId', ''))
break
" "$package"
}
# Returns the appId for a package name, creating the app when absent.
ensure_app() {
local display="$1" package="$2" existing
existing="$(app_id_for "$package")"
if [[ -n "$existing" ]]; then
echo "$existing"
return
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "WOULD-CREATE"
return
fi
firebase apps:create ANDROID "$display" \
--package-name "$package" --project "$PROJECT" >&2
app_id_for "$package"
}
# Adds a fingerprint unless the app already carries it.
ensure_sha() {
local app_id="$1" sha="$2" label="$3"
if [[ "$app_id" == "WOULD-CREATE" ]]; then
echo " would add $label SHA-1"
return
fi
# Compare without separators and case-insensitively: the API echoes hashes
# unseparated and lowercased, while keytool prints AA:BB:CC uppercase, so a
# literal match would re-add an existing hash on every run.
local normalized="${sha//:/}"
if firebase apps:android:sha:list "$app_id" --project "$PROJECT" --json 2>/dev/null \
| tr -d '\n' | grep -qi "${normalized}"; then
echo " $label SHA-1 already present"
return
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo " would add $label SHA-1"
return
fi
firebase apps:android:sha:create "$app_id" "$sha" --project "$PROJECT" >/dev/null
echo " added $label SHA-1"
}
main() {
local debug_sha release_sha
debug_sha="$(sha1_of "$DEBUG_KEYSTORE" androiddebugkey android)"
# The release store password lives in a key.properties committed nowhere;
# any app's copy will do since they share one keystore.
local props="$HOME/home_inventory/android/key.properties"
if [[ ! -f "$props" ]]; then
echo "Error: $props not found; needed for the release keystore password" >&2
exit 1
fi
local storepass alias
storepass="$(grep '^storePassword=' "$props" | cut -d= -f2-)"
alias="$(grep '^keyAlias=' "$props" | cut -d= -f2-)"
release_sha="$(sha1_of "$RELEASE_KEYSTORE" "$alias" "$storepass")"
echo "Project: $PROJECT"
echo "release: $release_sha"
echo "debug: $debug_sha"
echo
[[ "$DRY_RUN" -eq 0 ]] && require_login
for entry in "${APPS[@]}"; do
local display="${entry%%:*}" package="${entry#*:}"
echo "$display ($package)"
local app_id
app_id="$(ensure_app "$display" "$package")"
if [[ -z "$app_id" ]]; then
echo " Error: could not resolve an appId" >&2
exit 1
fi
echo " appId: $app_id"
ensure_sha "$app_id" "$release_sha" release
ensure_sha "$app_id" "$debug_sha" debug
done
echo
echo "Done. Next: enable Google sign-in, then read the Web client id with"
echo " firebase apps:sdkconfig WEB --project $PROJECT"
}
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
main