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.python3Packages.selenium
|
||||||
pkgs.chromedriver
|
pkgs.chromedriver
|
||||||
pkgs.chromium
|
pkgs.chromium
|
||||||
|
pkgs.xclip
|
||||||
|
pkgs.wl-clipboard
|
||||||
];
|
];
|
||||||
in {
|
in {
|
||||||
devShells.${system}.default = pkgs.mkShell {
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
|||||||
@@ -1,46 +1,142 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.webdriver.chrome.service import Service
|
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.
|
def check_rbw_unlocked() -> bool:
|
||||||
# If ChromeDriver is not in your PATH, specify its location: Service('/path/to/chromedriver')
|
"""Check if rbw vault is unlocked."""
|
||||||
service = Service() # Assumes chromedriver is in your PATH
|
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 ...")
|
def prompt_rbw_unlock() -> bool:
|
||||||
driver.get("https://chat.google.com")
|
"""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
|
def get_google_credentials(entry_name: str) -> tuple[str, str]:
|
||||||
# mail.google.com page and this allows us to extract the correct cookies.
|
"""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")
|
driver.get("https://chat.google.com/u/0/mole/world")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# Get all cookies
|
|
||||||
all_cookies = driver.get_cookies()
|
all_cookies = driver.get_cookies()
|
||||||
|
|
||||||
# Define the cookie names we want (case-insensitive)
|
|
||||||
target_names = {"COMPASS", "SSID", "SID", "OSID", "HSID"}
|
target_names = {"COMPASS", "SSID", "SID", "OSID", "HSID"}
|
||||||
extracted = {}
|
extracted = {}
|
||||||
|
|
||||||
# For COMPASS cookie, if multiple are present, prefer the one with path == "/"
|
|
||||||
compass_cookie = None
|
compass_cookie = None
|
||||||
|
|
||||||
for cookie in all_cookies:
|
for cookie in all_cookies:
|
||||||
@@ -58,12 +154,94 @@ for cookie in all_cookies:
|
|||||||
if compass_cookie:
|
if compass_cookie:
|
||||||
extracted["COMPASS"] = compass_cookie["value"]
|
extracted["COMPASS"] = compass_cookie["value"]
|
||||||
|
|
||||||
# Form JSON object of the extracted cookies.
|
return extracted
|
||||||
json_data = json.dumps(extracted, indent=2)
|
|
||||||
|
|
||||||
|
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("\nExtracted Cookie JSON:")
|
||||||
print(json_data)
|
print(json_data)
|
||||||
|
|
||||||
print("\nClosing the browser window to avoid invalidating the cookies (Google uses refresh tokens).")
|
# 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.")
|
||||||
|
|
||||||
# Close the browser window.
|
print("\nClosing browser to preserve cookie validity...")
|
||||||
|
|
||||||
|
finally:
|
||||||
driver.quit()
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user