mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Prove Firebase accepts an id_token issued to the stored Desktop OAuth client.
|
|
|
|
Runs the PKCE loopback flow with the installed-app client (secret included in
|
|
the exchange), then calls Firebase Auth REST ``signInWithIdp`` with the result.
|
|
The consent click in the browser is the only human step. No app credential
|
|
file is written: the session lives in memory and is dropped.
|
|
|
|
Run from the crdt-sync directory::
|
|
|
|
python3 -m tool.verify_desktop_client
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import logging
|
|
import secrets
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
from crdt_sync._autherrors import FirebaseAuthError
|
|
from crdt_sync._config import ConfigError, FirebaseConfig
|
|
from crdt_sync._credentials import MemoryCredentialStore
|
|
from crdt_sync._firebase_auth import FirebaseTokenProvider
|
|
from tool._oauth_callback import (
|
|
_CONSENT_TIMEOUT_SECONDS,
|
|
_CallbackHandler,
|
|
_free_port,
|
|
_start_callback_server,
|
|
)
|
|
from tool._pkce import build_consent_url, make_pkce, open_detached
|
|
from tool._token_exchange import TokenError, _authorization_code, _exchange_code
|
|
from tool.client_probe import probe_client
|
|
from tool.desktop_client import ClientFileError, DesktopClient, load_client
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
_logger = logging.getLogger("verify_desktop_client")
|
|
|
|
|
|
def audience_of(id_token: str) -> str:
|
|
"""Return the ``aud`` claim of an unverified JWT, or ``?`` if unreadable."""
|
|
try:
|
|
segment = id_token.split(".")[1]
|
|
claims = json.loads(
|
|
base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
|
|
)
|
|
return str(claims["aud"])
|
|
except IndexError, ValueError, KeyError, TypeError:
|
|
return "?"
|
|
|
|
|
|
def hint_for(message: str) -> str:
|
|
"""Return what to do about a known failure message, or an empty string."""
|
|
if "audience mismatch" in message:
|
|
return (
|
|
"Firebase does not trust this client yet: Firebase console > "
|
|
"Authentication > Sign-in method > Google > Safelist client IDs "
|
|
"from external projects, add the Desktop client id, then rerun."
|
|
)
|
|
if "wrong account" in message:
|
|
return (
|
|
"Pick the sync account in the consent picker (login_hint only "
|
|
"preselects it). Firebase may have created a stray user for the "
|
|
"other account: check Authentication > Users."
|
|
)
|
|
return ""
|
|
|
|
|
|
def fetch_id_token(
|
|
client: DesktopClient,
|
|
login_hint: str,
|
|
*,
|
|
open_browser: bool = True,
|
|
opener: Callable[[str], bool] = open_detached,
|
|
) -> str:
|
|
"""Run the PKCE loopback flow with ``client`` and return its id_token."""
|
|
port = _free_port()
|
|
redirect_uri = f"http://127.0.0.1:{port}"
|
|
state = secrets.token_urlsafe(16)
|
|
pkce = make_pkce()
|
|
server, thread = _start_callback_server(port, state)
|
|
url = build_consent_url(
|
|
client_id=client.client_id,
|
|
redirect_uri=redirect_uri,
|
|
challenge=pkce.challenge,
|
|
state=state,
|
|
login_hint=login_hint,
|
|
)
|
|
_logger.info(" .. consent page (opened in your browser, tap Allow):")
|
|
_logger.info(" %s", url)
|
|
if open_browser:
|
|
opener(url)
|
|
thread.join(timeout=_CONSENT_TIMEOUT_SECONDS)
|
|
server.server_close()
|
|
code = _authorization_code(_CallbackHandler.result, redirect_uri, state)
|
|
return _exchange_code(
|
|
code, client.client_id, client.client_secret, redirect_uri, pkce.verifier
|
|
)
|
|
|
|
|
|
def sign_in(config: FirebaseConfig, id_token: str) -> str:
|
|
"""Call ``signInWithIdp`` in memory; return the account email.
|
|
|
|
Raises:
|
|
FirebaseAuthError: With Firebase's exact message, or on a uid other
|
|
than the pinned one.
|
|
"""
|
|
provider = FirebaseTokenProvider(config.api_key, MemoryCredentialStore())
|
|
return provider.sign_in_with_google(id_token, expected_uid=config.uid)
|
|
|
|
|
|
def _fail(message: str) -> int:
|
|
_logger.error(" FAIL %s", message)
|
|
hint = hint_for(message)
|
|
if hint:
|
|
_logger.error(" .. %s", hint)
|
|
return 1
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Probe the stored client, run the consent flow, sign in to Firebase."""
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--no-browser", action="store_true", help="only print the URL")
|
|
parser.add_argument(
|
|
"--skip-probe", action="store_true", help="skip click-free checks"
|
|
)
|
|
parser.add_argument(
|
|
"--probe-only", action="store_true", help="stop after the click-free checks"
|
|
)
|
|
args = parser.parse_args(argv)
|
|
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
|
|
|
|
try:
|
|
config = FirebaseConfig.load()
|
|
client = load_client()
|
|
if not args.skip_probe:
|
|
results = probe_client(client)
|
|
for result in results:
|
|
_logger.info(" %s %s", "ok" if result.ok else "!!", result.detail)
|
|
failed = [r.detail for r in results if not r.ok]
|
|
if failed:
|
|
return _fail("; ".join(failed))
|
|
if args.probe_only:
|
|
return 0
|
|
id_token = fetch_id_token(
|
|
client, config.email, open_browser=not args.no_browser
|
|
)
|
|
_logger.info(" .. id_token audience: %s", audience_of(id_token))
|
|
email = sign_in(config, id_token)
|
|
except (ConfigError, ClientFileError, TokenError, FirebaseAuthError) as exc:
|
|
return _fail(str(exc))
|
|
|
|
_logger.info(
|
|
" PASS Firebase accepted the Desktop client: uid %s, %s", config.uid, email
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|