utils/crdt-sync/tool/google_id_token.py
Krzysztof kuhy Rudnicki 5f64a8959b
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
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
2026-08-11 23:58:02 +02:00

317 lines
11 KiB
Python

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())