- 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
248 lines
7.6 KiB
Python
248 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
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
|
|
|
|
|
|
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() -> 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
|
|
|
|
|
|
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()
|
|
|
|
password = subprocess.run(
|
|
['rbw', 'get', entry_name],
|
|
capture_output=True, text=True, check=True
|
|
).stdout.strip()
|
|
|
|
return username, password
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"Error retrieving credentials for '{entry_name}': {e.stderr}")
|
|
sys.exit(1)
|
|
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
|
|
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
|
|
|
|
|
|
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()
|