From dc5877096ab795b300ce2130dd1ed9b7dff7ea49 Mon Sep 17 00:00:00 2001 From: furiosa Date: Sun, 18 Jan 2026 22:35:24 -0800 Subject: [PATCH] Automate Google login with rbw password lookup - Add rbw integration to retrieve credentials from Bitwarden vault - Automate email/password entry with Selenium WebDriver - Handle 2FA by falling back to manual completion - Add clipboard support for Wayland (wl-copy) and X11 (xclip) - Add CLI flags: --entry, --no-copy, --manual - Add DESIGN.md documenting the implementation approach --- DESIGN.md | 134 ++++++++++++++ flake.nix | 2 + selenium_cookie_extractor_json.py | 280 ++++++++++++++++++++++++------ 3 files changed, 365 insertions(+), 51 deletions(-) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..e3a00b2 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,134 @@ +# Design: Automated Google Login with rbw + +## Overview + +Automate the Google login flow for cookie extraction, using rbw (Bitwarden CLI) for credential lookup. + +## Current State + +- `selenium_cookie_extractor_json.py` opens Chrome incognito, navigates to chat.google.com +- User manually logs in, presses Enter +- Script extracts COMPASS, SSID, SID, OSID, HSID cookies and outputs JSON + +## Proposed Changes + +### 1. rbw Integration + +```python +def check_rbw_unlocked() -> bool: + """Check if rbw vault is unlocked.""" + result = subprocess.run(['rbw', 'unlocked'], capture_output=True) + return result.returncode == 0 + +def prompt_rbw_unlock(): + """Prompt user to unlock rbw vault.""" + print("rbw vault is locked. Please unlock it.") + subprocess.run(['rbw', 'unlock'], check=True) + +def get_google_credentials(entry_name: str = "google.com") -> tuple[str, str]: + """Get username and password from rbw.""" + username = subprocess.run( + ['rbw', 'get', '-f', 'username', entry_name], + capture_output=True, text=True, check=True + ).stdout.strip() + + password = subprocess.run( + ['rbw', 'get', entry_name], + capture_output=True, text=True, check=True + ).stdout.strip() + + return username, password +``` + +### 2. Automated Login Flow + +Google's login has multiple steps: +1. **Email page**: Enter email, click Next +2. **Password page**: Enter password, click Next +3. **Potential 2FA**: May require manual intervention + +```python +def automate_login(driver, username: str, password: str): + """Automate Google login flow.""" + driver.get("https://accounts.google.com/signin") + + # Enter email + email_input = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.ID, "identifierId")) + ) + email_input.send_keys(username) + email_input.send_keys(Keys.RETURN) + + # Wait for password page and enter password + password_input = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.NAME, "Passwd")) + ) + password_input.send_keys(password) + password_input.send_keys(Keys.RETURN) + + # Wait for successful login (presence of chat page or 2FA prompt) + # If 2FA required, prompt user to complete it manually +``` + +### 3. Clipboard Support + +Add `wl-clipboard` and `xclip` to nix dependencies. Try Wayland first, fall back to X11: + +```python +def copy_to_clipboard(text: str) -> bool: + """Copy text to clipboard. Tries wl-copy (Wayland) first, falls back to xclip (X11).""" + try: + subprocess.run(['wl-copy'], input=text.encode(), check=True) + return True + except FileNotFoundError: + pass + # Fall back to xclip (X11) + process = subprocess.Popen(['xclip', '-selection', 'clipboard'], stdin=subprocess.PIPE) + process.communicate(text.encode()) + return process.returncode == 0 +``` + +### 4. CLI Interface + +``` +Usage: gcr [OPTIONS] + +Options: + --copy, -c Copy cookie JSON to clipboard (default: true) + --entry NAME rbw entry name (default: "google.com") + --no-auto Skip auto-login, use manual flow + --help Show this message +``` + +## Implementation Steps + +1. Add new dependencies to `flake.nix`: `xclip` +2. Add selenium wait helpers: `WebDriverWait`, `expected_conditions` +3. Implement rbw functions: check unlocked, prompt unlock, get credentials +4. Implement automated login: email step, password step, 2FA detection +5. Implement clipboard copy +6. Add CLI argument parsing with argparse +7. Update main flow to use automation by default + +## Edge Cases + +1. **rbw vault locked**: Prompt to unlock, fail gracefully if user cancels +2. **Wrong credentials in rbw**: Let login fail, user can retry manually +3. **2FA required**: Detect 2FA page, prompt user to complete manually, then continue +4. **Login timeout**: Add reasonable timeouts with clear error messages +5. **Multiple Google accounts**: Use `--entry` flag to specify which rbw entry + +## Security Considerations + +- Credentials are retrieved from rbw, never stored in script +- Incognito mode prevents cookie persistence +- Browser is closed promptly after extraction + +## Testing Plan + +1. Test with unlocked vault +2. Test with locked vault (unlock prompt) +3. Test with invalid rbw entry name +4. Test full login flow (requires real credentials) +5. Test 2FA flow (manual completion) +6. Test clipboard copy diff --git a/flake.nix b/flake.nix index 2c7eb60..c2de152 100644 --- a/flake.nix +++ b/flake.nix @@ -12,6 +12,8 @@ pkgs.python3Packages.selenium pkgs.chromedriver pkgs.chromium + pkgs.xclip + pkgs.wl-clipboard ]; in { devShells.${system}.default = pkgs.mkShell { diff --git a/selenium_cookie_extractor_json.py b/selenium_cookie_extractor_json.py index 96e0386..ae20f8f 100644 --- a/selenium_cookie_extractor_json.py +++ b/selenium_cookie_extractor_json.py @@ -1,69 +1,247 @@ #!/usr/bin/env python3 -import time +""" +Google Cookie Retrieval - Automated login with rbw password lookup. + +Automates Google login for Mautrix Google Chat bridge authentication. +""" +import argparse import json +import subprocess +import sys +import time + from selenium import webdriver from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait -# Configure Chrome options to run in incognito mode. -options = webdriver.ChromeOptions() -options.add_argument("--incognito") -# Uncomment the following option if you want to auto-open developer tools for each tab. -# options.add_argument("--auto-open-devtools-for-tabs") -# Initialize the Chrome driver. -# If ChromeDriver is not in your PATH, specify its location: Service('/path/to/chromedriver') -service = Service() # Assumes chromedriver is in your PATH +def check_rbw_unlocked() -> bool: + """Check if rbw vault is unlocked.""" + result = subprocess.run(['rbw', 'unlocked'], capture_output=True) + return result.returncode == 0 -driver = webdriver.Chrome(service=service, options=options) -print("Opening https://chat.google.com ...") -driver.get("https://chat.google.com") +def prompt_rbw_unlock() -> bool: + """Prompt user to unlock rbw vault. Returns True if successful.""" + print("rbw vault is locked. Unlocking...") + result = subprocess.run(['rbw', 'unlock']) + return result.returncode == 0 -print("\nA new incognito window has been opened.") -print("Please log in normally. To inspect cookies manually:") -print(" 1. Press F12 to open developer tools.") -print(" 2. Navigate to the Application (Chrome) or Storage (Firefox) tab.") -print(" 3. Expand Cookies and select https://chat.google.com.") -print(" 4. Verify that the COMPASS, SSID, SID, OSID, and HSID cookies are present.") -print("\nIMPORTANT: Once you are logged in and have confirmed the cookies (or just logged in),") -print("press Enter here. (Remember: to keep the validity of these cookies, close the browser soon!)") -input("Press Enter to extract cookies...") -# Navigate to some invalid URL on chat.google.com. This way we won't be redirected back to a -# mail.google.com page and this allows us to extract the correct cookies. -driver.get("https://chat.google.com/u/0/mole/world") +def get_google_credentials(entry_name: str) -> tuple[str, str]: + """Get username and password from rbw.""" + try: + username = subprocess.run( + ['rbw', 'get', '-f', 'username', entry_name], + capture_output=True, text=True, check=True + ).stdout.strip() -# Get all cookies -all_cookies = driver.get_cookies() + password = subprocess.run( + ['rbw', 'get', entry_name], + capture_output=True, text=True, check=True + ).stdout.strip() -# Define the cookie names we want (case-insensitive) -target_names = {"COMPASS", "SSID", "SID", "OSID", "HSID"} -extracted = {} + return username, password + except subprocess.CalledProcessError as e: + print(f"Error retrieving credentials for '{entry_name}': {e.stderr}") + sys.exit(1) -# For COMPASS cookie, if multiple are present, prefer the one with path == "/" -compass_cookie = None -for cookie in all_cookies: - name_upper = cookie["name"].upper() - if name_upper not in target_names: - continue - if name_upper == "COMPASS": - if cookie.get("path", "") == "/": - compass_cookie = cookie - elif compass_cookie is None: - compass_cookie = cookie - else: - extracted[name_upper] = cookie["value"] +def copy_to_clipboard(text: str) -> bool: + """Copy text to clipboard. Tries wl-copy (Wayland) first, falls back to xclip (X11).""" + # Try wl-copy first (Wayland) + try: + result = subprocess.run(['wl-copy'], input=text.encode(), check=True) + return True + except FileNotFoundError: + pass + except subprocess.CalledProcessError: + pass -if compass_cookie: - extracted["COMPASS"] = compass_cookie["value"] + # Fall back to xclip (X11) + try: + process = subprocess.Popen( + ['xclip', '-selection', 'clipboard'], + stdin=subprocess.PIPE + ) + process.communicate(text.encode()) + return process.returncode == 0 + except FileNotFoundError: + print("Warning: Neither wl-copy nor xclip found, cannot copy to clipboard") + return False -# Form JSON object of the extracted cookies. -json_data = json.dumps(extracted, indent=2) -print("\nExtracted Cookie JSON:") -print(json_data) -print("\nClosing the browser window to avoid invalidating the cookies (Google uses refresh tokens).") +def wait_for_chat_page(driver, timeout: int = 120) -> bool: + """Wait for successful login by checking for chat.google.com content.""" + try: + WebDriverWait(driver, timeout).until( + lambda d: "chat.google.com" in d.current_url and + "accounts.google.com" not in d.current_url + ) + return True + except Exception: + return False -# Close the browser window. -driver.quit() + +def automate_login(driver, username: str, password: str) -> bool: + """ + Automate Google login flow. + Returns True if login succeeded, False if 2FA or other intervention needed. + """ + print(f"Logging in as {username}...") + + # Navigate to Google sign-in + driver.get("https://accounts.google.com/signin/v2/identifier?continue=https://chat.google.com") + + try: + # Wait for and enter email + email_input = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.ID, "identifierId")) + ) + email_input.send_keys(username) + email_input.send_keys(Keys.RETURN) + + # Wait for password page + password_input = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.NAME, "Passwd")) + ) + time.sleep(0.5) # Brief pause for page transition + password_input.send_keys(password) + password_input.send_keys(Keys.RETURN) + + # Check if we landed on chat or got stuck (2FA, etc.) + time.sleep(2) + + # If we're still on accounts.google.com, 2FA or challenge is required + if "accounts.google.com" in driver.current_url: + print("\n2FA or additional verification required.") + print("Please complete the verification in the browser.") + return False + + return True + + except Exception as e: + print(f"Login automation error: {e}") + return False + + +def extract_cookies(driver) -> dict: + """Extract the required cookies from the browser.""" + # Navigate to an invalid URL to avoid redirects + driver.get("https://chat.google.com/u/0/mole/world") + time.sleep(1) + + all_cookies = driver.get_cookies() + target_names = {"COMPASS", "SSID", "SID", "OSID", "HSID"} + extracted = {} + compass_cookie = None + + for cookie in all_cookies: + name_upper = cookie["name"].upper() + if name_upper not in target_names: + continue + if name_upper == "COMPASS": + if cookie.get("path", "") == "/": + compass_cookie = cookie + elif compass_cookie is None: + compass_cookie = cookie + else: + extracted[name_upper] = cookie["value"] + + if compass_cookie: + extracted["COMPASS"] = compass_cookie["value"] + + return extracted + + +def main(): + parser = argparse.ArgumentParser( + description="Google Cookie Retrieval - Automated login with rbw" + ) + parser.add_argument( + '--entry', '-e', + default='google.com', + help='rbw entry name for Google credentials (default: google.com)' + ) + parser.add_argument( + '--no-copy', + action='store_true', + help='Do not copy cookies to clipboard' + ) + parser.add_argument( + '--manual', '-m', + action='store_true', + help='Skip auto-login, use manual flow' + ) + args = parser.parse_args() + + # Check and unlock rbw if needed + if not args.manual: + if not check_rbw_unlocked(): + if not prompt_rbw_unlock(): + print("Failed to unlock rbw vault.") + sys.exit(1) + + username, password = get_google_credentials(args.entry) + + # Configure Chrome options + options = webdriver.ChromeOptions() + options.add_argument("--incognito") + + # Initialize Chrome driver + service = Service() + driver = webdriver.Chrome(service=service, options=options) + + try: + if args.manual: + # Manual flow (original behavior) + print("Opening https://chat.google.com ...") + driver.get("https://chat.google.com") + print("\nA new incognito window has been opened.") + print("Please log in normally.") + print("\nOnce logged in, press Enter to extract cookies...") + input() + else: + # Automated flow + success = automate_login(driver, username, password) + + if not success: + # Wait for manual 2FA completion + print("\nWaiting for login to complete...") + print("Press Enter once you've completed verification...") + input() + + # Verify we're logged in + if not wait_for_chat_page(driver, timeout=10): + print("Warning: May not be fully logged in. Attempting cookie extraction anyway.") + + # Extract cookies + cookies = extract_cookies(driver) + + if not cookies: + print("Error: No cookies extracted. Login may have failed.") + sys.exit(1) + + json_data = json.dumps(cookies, indent=2) + + print("\nExtracted Cookie JSON:") + print(json_data) + + # Copy to clipboard + if not args.no_copy: + if copy_to_clipboard(json_data): + print("\nCookies copied to clipboard!") + else: + print("\nNote: Could not copy to clipboard.") + + print("\nClosing browser to preserve cookie validity...") + + finally: + driver.quit() + + +if __name__ == "__main__": + main()