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 \\ --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: " "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())