mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +01:00
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Both libraries had already moved `requires-python` to 3.14, but ruff, mypy and pylint were still grading against 3.10 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites these three knobs. What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC` is now correct (UP017), a dead `timezone` import left behind by it, and ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That last one makes the source 3.14-only at the syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). These two are libraries the apps install, so the floor matters more here than anywhere else in the fleet -- every consumer already pins >=3.14. Per project: ruff, ruff-format, mypy --strict and pylint clean, crdt-sync 432 tests and gatelock 389 tests, both at 100% line and branch coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
244 lines
9.2 KiB
Python
244 lines
9.2 KiB
Python
"""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())
|