utils/crdt-sync/tool/google_id_token.py
Krzysztof kuhy Rudnicki 3589d166cd
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
crdt-sync: split five source files — package now fully under the cap
_sync.py         456 -> 245 + _syncstate 131 + _pull 106 + _revisions 34
  _firebase_auth   412 -> 242 + _credentials 146 + _autherrors 69
  _firebase.py     336 -> 250 + _fbkeys 100
  google_id_token  316 -> 232 + tool/_oauth_callback 105
  _github.py       255 -> 238 + _gherrors 30

revision_of became its own leaf module because both the push and pull halves
need it; without that the two would have imported each other. _fbkeys holds
the RTDB key escaping — the escape tables moved with the functions that read
them, since the pair has to round-trip exactly or a device silently stops
seeing its own writes.

crdt-sync/tool/ is outside the coverage source, so the split there has no test
net: verified by running it the documented way instead
(`python3 -m tool.google_id_token --help` prints its usage, and
`from tool.google_id_token import TokenError, fetch_id_token` — what
seed_session.py does — still resolves).

An AST pass over each pair caught what the suite would not have: _is_revoked
needs the error type, so it stayed with it rather than following the
credentials; _TERMINAL_AUTH_REASONS and _CONSENT_TIMEOUT_SECONDS had to be
carried to their new callers.

259 passed at 100% branch coverage. crdt-sync/tool/seed_session.py and
firebase-debug.log are pre-existing local changes and are deliberately not in
this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:30:52 +02:00

240 lines
8.0 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
_logger = logging.getLogger("google_id_token")
from tool._oauth_callback import (
_CONSENT_TIMEOUT_SECONDS,
_CallbackHandler,
_free_port,
_start_callback_server,
)
class TokenError(Exception):
"""The authorization flow did not produce an ID token."""
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())