#!/usr/bin/env python3 """Run OAuth flows for Google, Strava, and Spotify in sequence. Usage: uv run python scripts/oauth_all.py [--google] [--strava] [--spotify] uv run python scripts/oauth_all.py # runs all three """ from __future__ import annotations import argparse import json import os import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from typing import Any, Dict, List, Optional from urllib.parse import parse_qs, urlencode, urlparse import httpx CONFIG_DIR = Path.home() / ".openjarvis" / "connectors" CONFIG_DIR.mkdir(parents=True, exist_ok=True) CALLBACK_PORT = 8789 REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}/callback" # ── Credentials (read from stored files, then env vars) ────────────────────── def _load_creds(filename: str) -> Dict[str, str]: """Load client_id/client_secret from a stored credential file.""" path = CONFIG_DIR / filename if path.exists(): try: data = json.loads(path.read_text(encoding="utf-8")) return { "client_id": data.get("client_id", ""), "client_secret": data.get("client_secret", ""), } except (json.JSONDecodeError, OSError): pass return {"client_id": "", "client_secret": ""} _google = _load_creds("google.json") GOOGLE_CLIENT_ID = os.environ.get("OPENJARVIS_GOOGLE_CLIENT_ID", "") or _google["client_id"] GOOGLE_CLIENT_SECRET = os.environ.get("OPENJARVIS_GOOGLE_CLIENT_SECRET", "") or _google["client_secret"] _strava = _load_creds("strava.json") STRAVA_CLIENT_ID = os.environ.get("OPENJARVIS_STRAVA_CLIENT_ID", "") or _strava["client_id"] STRAVA_CLIENT_SECRET = os.environ.get("OPENJARVIS_STRAVA_CLIENT_SECRET", "") or _strava["client_secret"] _spotify = _load_creds("spotify.json") SPOTIFY_CLIENT_ID = os.environ.get("OPENJARVIS_SPOTIFY_CLIENT_ID", "") or _spotify["client_id"] SPOTIFY_CLIENT_SECRET = os.environ.get("OPENJARVIS_SPOTIFY_CLIENT_SECRET", "") or _spotify["client_secret"] # ── Generic OAuth helpers ──────────────────────────────────────────────────── def _wait_for_code(port: int = CALLBACK_PORT, timeout: int = 120) -> str: """Start a localhost server and wait for the OAuth callback with ?code=.""" auth_code: List[str] = [] error: List[str] = [] class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: params = parse_qs(urlparse(self.path).query) if "code" in params: auth_code.append(params["code"][0]) self.send_response(200) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write( b"

Success!

" b"

You can close this tab.

" ) else: error.append(params.get("error", ["unknown"])[0]) self.send_response(400) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write(b"

Failed

") def log_message(self, *args: Any) -> None: pass server = HTTPServer(("127.0.0.1", port), Handler) server.timeout = timeout while not auth_code and not error: server.handle_request() server.server_close() if error: raise RuntimeError(f"OAuth error: {error[0]}") if not auth_code: raise RuntimeError("OAuth timed out") return auth_code[0] def _save(path: Path, data: Dict[str, Any]) -> None: path.write_text(json.dumps(data, indent=2), encoding="utf-8") path.chmod(0o600) print(f" Saved tokens to {path}") # ── Google ─────────────────────────────────────────────────────────────────── def do_google() -> None: print("\n=== Google OAuth (Drive, Calendar, Contacts, Gmail, Tasks) ===") scopes = [ "openid", "email", "profile", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/contacts.readonly", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/tasks.readonly", ] url = ( "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode({ "client_id": GOOGLE_CLIENT_ID, "redirect_uri": REDIRECT_URI, "response_type": "code", "scope": " ".join(scopes), "access_type": "offline", "prompt": "consent", }) ) print(f" Opening browser...") webbrowser.open(url) code = _wait_for_code() resp = httpx.post( "https://oauth2.googleapis.com/token", data={ "code": code, "client_id": GOOGLE_CLIENT_ID, "client_secret": GOOGLE_CLIENT_SECRET, "redirect_uri": REDIRECT_URI, "grant_type": "authorization_code", }, timeout=30.0, ) resp.raise_for_status() tokens = resp.json() payload = { "access_token": tokens["access_token"], "refresh_token": tokens.get("refresh_token", ""), "token_type": tokens.get("token_type", "Bearer"), "expires_in": tokens.get("expires_in", 3600), "client_id": GOOGLE_CLIENT_ID, "client_secret": GOOGLE_CLIENT_SECRET, } # Save to shared + all connector-specific files for name in ["google", "gdrive", "gcalendar", "gcontacts", "gmail", "google_tasks"]: _save(CONFIG_DIR / f"{name}.json", payload) print(" Google OAuth complete!") # ── Strava ─────────────────────────────────────────────────────────────────── def do_strava() -> None: print("\n=== Strava OAuth ===") url = ( "https://www.strava.com/oauth/authorize?" + urlencode({ "client_id": STRAVA_CLIENT_ID, "redirect_uri": REDIRECT_URI, "response_type": "code", "scope": "activity:read_all", }) ) print(f" Opening browser...") webbrowser.open(url) code = _wait_for_code() resp = httpx.post( "https://www.strava.com/oauth/token", data={ "client_id": STRAVA_CLIENT_ID, "client_secret": STRAVA_CLIENT_SECRET, "code": code, "grant_type": "authorization_code", }, timeout=30.0, ) resp.raise_for_status() tokens = resp.json() payload = { "access_token": tokens["access_token"], "refresh_token": tokens.get("refresh_token", ""), "client_id": STRAVA_CLIENT_ID, "client_secret": STRAVA_CLIENT_SECRET, } _save(CONFIG_DIR / "strava.json", payload) print(" Strava OAuth complete!") # ── Spotify ────────────────────────────────────────────────────────────────── def _make_self_signed_cert() -> tuple[str, str]: """Generate a temporary self-signed cert for localhost HTTPS.""" import ssl import subprocess import tempfile cert_dir = Path(tempfile.mkdtemp()) key_path = str(cert_dir / "key.pem") cert_path = str(cert_dir / "cert.pem") subprocess.run( [ "openssl", "req", "-x509", "-newkey", "rsa:2048", "-keyout", key_path, "-out", cert_path, "-days", "1", "-nodes", "-subj", "/CN=localhost", ], capture_output=True, check=True, ) return cert_path, key_path def _wait_for_code_https(port: int = 8888, timeout: int = 120) -> str: """Like _wait_for_code but serves over HTTPS with a self-signed cert.""" import ssl cert_path, key_path = _make_self_signed_cert() auth_code: List[str] = [] error: List[str] = [] class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: params = parse_qs(urlparse(self.path).query) if "code" in params: auth_code.append(params["code"][0]) self.send_response(200) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write( b"

Success!

" b"

You can close this tab.

" ) else: error.append(params.get("error", ["unknown"])[0]) self.send_response(400) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write(b"

Failed

") def log_message(self, *args: Any) -> None: pass server = HTTPServer(("127.0.0.1", port), Handler) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(cert_path, key_path) server.socket = ctx.wrap_socket(server.socket, server_side=True) server.timeout = timeout while not auth_code and not error: server.handle_request() server.server_close() if error: raise RuntimeError(f"OAuth error: {error[0]}") if not auth_code: raise RuntimeError("OAuth timed out") return auth_code[0] def do_spotify() -> None: print("\n=== Spotify OAuth ===") spotify_port = 8888 spotify_redirect = f"http://127.0.0.1:{spotify_port}/callback" url = ( "https://accounts.spotify.com/authorize?" + urlencode({ "client_id": SPOTIFY_CLIENT_ID, "redirect_uri": spotify_redirect, "response_type": "code", "scope": "user-read-recently-played", }) ) print(f" Opening browser...") webbrowser.open(url) code = _wait_for_code(port=spotify_port) import base64 auth_header = base64.b64encode( f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_CLIENT_SECRET}".encode() ).decode() resp = httpx.post( "https://accounts.spotify.com/api/token", headers={"Authorization": f"Basic {auth_header}"}, data={ "code": code, "redirect_uri": spotify_redirect, "grant_type": "authorization_code", }, timeout=30.0, ) resp.raise_for_status() tokens = resp.json() payload = { "access_token": tokens["access_token"], "refresh_token": tokens.get("refresh_token", ""), "client_id": SPOTIFY_CLIENT_ID, "client_secret": SPOTIFY_CLIENT_SECRET, } _save(CONFIG_DIR / "spotify.json", payload) print(" Spotify OAuth complete!") # ── Main ───────────────────────────────────────────────────────────────────── if __name__ == "__main__": parser = argparse.ArgumentParser(description="Run OAuth flows for OpenJarvis connectors") parser.add_argument("--google", action="store_true", help="Only Google") parser.add_argument("--strava", action="store_true", help="Only Strava") parser.add_argument("--spotify", action="store_true", help="Only Spotify") args = parser.parse_args() run_all = not (args.google or args.strava or args.spotify) if run_all or args.google: do_google() if run_all or args.strava: do_strava() if run_all or args.spotify: do_spotify() print("\nDone! All OAuth flows complete.")