errno

gpsoauth returns BadAuthentication on a 2FA Google account (app passwords do not help)

· tested on gpsoauth, Python 3, Google account with 2FA, Chrome with remote debugging, WSL2 mirrored networking

Symptom

Getting a master token for a library that speaks Google’s mobile auth protocol (in my case gkeepapi for Google Keep) starts with gpsoauth. On an account with 2FA enabled it fails:

>>> import gpsoauth
>>> gpsoauth.perform_master_login("[email protected]", "…", android_id)
{'Error': 'BadAuthentication', 'ErrorDetail': ''}

The reflex is to generate an app password and retry. It fails the same way. App passwords work for IMAP/SMTP, not for this endpoint, so no amount of retrying the password path will produce a token.

  1. Sign in to https://accounts.google.com/EmbeddedSetup in a browser.
  2. Click “I agree”.
  3. The page appears to hang on .../o/android/auththat is expected. That navigation is exactly when the cookie you need is set.
  4. Read the cookie oauth_token (value starts with oauth2_4/…). It is HttpOnly, so document.cookie in the console will not show it.
  5. Exchange it:
import gpsoauth
res = gpsoauth.exchange_token("[email protected]", oauth_token, android_id)
master = res["Token"]        # aas_et/…

The oauth_token is single-use and short-lived — exchange it immediately. If you sit on it while you figure out the next step, you get to repeat the whole dance.

Manually copying it out of the Application tab works, but it is fiddly and you often lose the race. Drive a throwaway Chrome profile over CDP instead. From WSL2 with mirrored networking, 127.0.0.1:9222 reaches Chrome started on the Windows host:

"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" \
  --remote-debugging-port=9222 \
  --user-data-dir='C:\keepdbg' \
  --new-window "https://accounts.google.com/EmbeddedSetup"

Sign in and click “I agree” in that window, then ask the browser for all cookies — Network.getAllCookies returns HttpOnly ones too, which is the entire reason for going through CDP:

import json, urllib.request, websockets, asyncio

ws_url = json.load(urllib.request.urlopen("http://127.0.0.1:9222/json/version"))["webSocketDebuggerUrl"]

async def grab():
    async with websockets.connect(ws_url, max_size=None) as ws:
        await ws.send(json.dumps({"id": 1, "method": "Network.getAllCookies"}))
        while True:
            msg = json.loads(await ws.recv())
            if msg.get("id") == 1:
                return [c for c in msg["result"]["cookies"] if c["name"] == "oauth_token"]

print(asyncio.run(grab()))

Clean up, because that profile is a logged-in session

Delete C:\keepdbg when you are done. It holds an authenticated Google session with no profile password in front of it, and it is trivial to forget about a debug profile sitting in the root of a drive.

Store the resulting aas_et/… master token like any other long-lived credential — file mode 600 outside your repository. It is not a password, but it is equivalent to one for the APIs it unlocks, and unlike a password it does not get invalidated by a routine password change.

google oauth python cdp