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:
134
DESIGN.md
Normal file
134
DESIGN.md
Normal 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
|
||||
@@ -12,6 +12,8 @@
|
||||
pkgs.python3Packages.selenium
|
||||
pkgs.chromedriver
|
||||
pkgs.chromium
|
||||
pkgs.xclip
|
||||
pkgs.wl-clipboard
|
||||
];
|
||||
in {
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user