From 5f64a8959b1db342da1f73157b1148aa0dfa38a8 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 11 Aug 2026 23:58:02 +0200 Subject: [PATCH] Sign in with Google, and stop trusting a revoked session 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) Claude-Session: https://claude.ai/code/session_0192EkoAyFBEWTvAN5vVgmSy --- crdt-sync/crdt_sync/_firebase_auth.py | 91 ++++- .../crdt_sync/tests/test_firebase_auth.py | 120 +++++++ crdt-sync/tool/google_id_token.py | 316 +++++++++++++++++ crdt-sync/tool/link_google.py | 243 +++++++++++++ crdt-sync/tool/seed_session.py | 241 +++++++++++++ .../lib/src/firebase_auth_rest.dart | 105 +++++- crdt_sync_dart/lib/src/firebase_config.dart | 34 +- crdt_sync_dart/pubspec.yaml | 2 +- .../test/firebase_auth_rest_test.dart | 323 ++++++++++++++++-- crdt_sync_dart/test/firebase_config_test.dart | 128 +++++++ scripts/check_no_crdt_sync_override.sh | 88 +++++ scripts/provision_firebase_android.sh | 190 +++++++++++ 12 files changed, 1837 insertions(+), 44 deletions(-) create mode 100644 crdt-sync/tool/google_id_token.py create mode 100644 crdt-sync/tool/link_google.py create mode 100644 crdt-sync/tool/seed_session.py create mode 100755 scripts/check_no_crdt_sync_override.sh create mode 100755 scripts/provision_firebase_android.sh diff --git a/crdt-sync/crdt_sync/_firebase_auth.py b/crdt-sync/crdt_sync/_firebase_auth.py index 81afd2a..501a98f 100644 --- a/crdt-sync/crdt_sync/_firebase_auth.py +++ b/crdt-sync/crdt_sync/_firebase_auth.py @@ -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. diff --git a/crdt-sync/crdt_sync/tests/test_firebase_auth.py b/crdt-sync/crdt_sync/tests/test_firebase_auth.py index 8409aa0..f9c1971 100644 --- a/crdt-sync/crdt_sync/tests/test_firebase_auth.py +++ b/crdt-sync/crdt_sync/tests/test_firebase_auth.py @@ -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: diff --git a/crdt-sync/tool/google_id_token.py b/crdt-sync/tool/google_id_token.py new file mode 100644 index 0000000..70e85c5 --- /dev/null +++ b/crdt-sync/tool/google_id_token.py @@ -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 \\ + --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"

Done - you can close this tab.

" + if ok + else b"

Authorization failed.

" + ) + 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: " + "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()) diff --git a/crdt-sync/tool/link_google.py b/crdt-sync/tool/link_google.py new file mode 100644 index 0000000..071f8c4 --- /dev/null +++ b/crdt-sync/tool/link_google.py @@ -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 === ''``), 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 ``//`` +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", "")) + audience = str(claims.get("aud", "")) + 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()) diff --git a/crdt-sync/tool/seed_session.py b/crdt-sync/tool/seed_session.py new file mode 100644 index 0000000..3bb08fb --- /dev/null +++ b/crdt-sync/tool/seed_session.py @@ -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//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 \\ + --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//. 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:, 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()) diff --git a/crdt_sync_dart/lib/src/firebase_auth_rest.dart b/crdt_sync_dart/lib/src/firebase_auth_rest.dart index c35ad96..04056a6 100644 --- a/crdt_sync_dart/lib/src/firebase_auth_rest.dart +++ b/crdt_sync_dart/lib/src/firebase_auth_rest.dart @@ -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 signIn({ - required String email, - required String password, - }) async { + Future 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 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 hasSession() async => - (_cached ??= await store.load()) != null; + Future 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 signOut() async { @@ -175,11 +249,20 @@ class FirebaseTokenProvider { } Future _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 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. diff --git a/crdt_sync_dart/lib/src/firebase_config.dart b/crdt_sync_dart/lib/src/firebase_config.dart index 2a2e85a..4b471ba 100644 --- a/crdt_sync_dart/lib/src/firebase_config.dart +++ b/crdt_sync_dart/lib/src/firebase_config.dart @@ -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 Function(); + Future 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 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 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, diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index 5de3542..3374161 100644 --- a/crdt_sync_dart/pubspec.yaml +++ b/crdt_sync_dart/pubspec.yaml @@ -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: diff --git a/crdt_sync_dart/test/firebase_auth_rest_test.dart b/crdt_sync_dart/test/firebase_auth_rest_test.dart index 33f36f9..1927ee0 100644 --- a/crdt_sync_dart/test/firebase_auth_rest_test.dart +++ b/crdt_sync_dart/test/firebase_auth_rest_test.dart @@ -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; + 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().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()), + ); + 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().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())); + 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())); + 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())); + expect(await provider.hasSession(), isTrue); + }); + }); } diff --git a/crdt_sync_dart/test/firebase_config_test.dart b/crdt_sync_dart/test/firebase_config_test.dart index ef562e2..daf1aa7 100644 --- a/crdt_sync_dart/test/firebase_config_test.dart +++ b/crdt_sync_dart/test/firebase_config_test.dart @@ -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 = []; + 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 = []; + 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()), + ); + 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 = []; + 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()), + ); + }); + test('reuses a stored session without a password', () async { final store = InMemoryCredentialStore(); await store.save( diff --git a/scripts/check_no_crdt_sync_override.sh b/scripts/check_no_crdt_sync_override.sh new file mode 100755 index 0000000..967e6c8 --- /dev/null +++ b/scripts/check_no_crdt_sync_override.sh @@ -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 "$@" diff --git a/scripts/provision_firebase_android.sh b/scripts/provision_firebase_android.sh new file mode 100755 index 0000000..5ab64b8 --- /dev/null +++ b/scripts/provision_firebase_android.sh @@ -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" + +# ":" -- 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