mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-14 18:11:00 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0192EkoAyFBEWTvAN5vVgmSy
This commit is contained in:
parent
f902bbe0aa
commit
5f64a8959b
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
316
crdt-sync/tool/google_id_token.py
Normal file
316
crdt-sync/tool/google_id_token.py
Normal 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())
|
||||
243
crdt-sync/tool/link_google.py
Normal file
243
crdt-sync/tool/link_google.py
Normal 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())
|
||||
241
crdt-sync/tool/seed_session.py
Normal file
241
crdt-sync/tool/seed_session.py
Normal 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())
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
88
scripts/check_no_crdt_sync_override.sh
Executable file
88
scripts/check_no_crdt_sync_override.sh
Executable 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 "$@"
|
||||
190
scripts/provision_firebase_android.sh
Executable file
190
scripts/provision_firebase_android.sh
Executable 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
|
||||
Loading…
Reference in New Issue
Block a user