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
This commit is contained in:
2026-01-18 22:35:24 -08:00
committed by John Ogle
parent a1f6956657
commit dc5877096a
3 changed files with 365 additions and 51 deletions

134
DESIGN.md Normal file
View File

@@ -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

View File

@@ -12,6 +12,8 @@
pkgs.python3Packages.selenium
pkgs.chromedriver
pkgs.chromium
pkgs.xclip
pkgs.wl-clipboard
];
in {
devShells.${system}.default = pkgs.mkShell {

View File

@@ -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()