Files
OpenJarvis/scripts/oauth_all.py
T
Jon Saad-FalconandClaude Opus 4.6 d73014d6bb feat: Morning Digest pipeline + unified OAuth + Apple connectors
Full morning digest system:
- MorningDigestAgent, DigestStore, digest_collect tool, TTS backends
- Connectors: Oura, Strava, Spotify, Google Tasks, Apple Health, Apple Music
- CLI `jarvis digest` command + FastAPI /api/digest endpoints
- Cartesia, Kokoro, OpenAI TTS backends with persona prompts

Unified OAuth setup across all surfaces:
- Generic OAuthProvider registry for Google, Strava, Spotify
- CLI auto-opens browser + catches callback (no more paste-code)
- Server /oauth/start + /oauth/callback endpoints for desktop/browser
- Frontend OAuthPanel with popup + polling

Apple connectors:
- Apple Health reads from HealthKit DB or iPhone export XML
- Apple Music queries Music.app via AppleScript

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:05:35 -07:00

342 lines
12 KiB
Python

#!/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"<html><body><h2>Success!</h2>"
b"<p>You can close this tab.</p></body></html>"
)
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"<html><body><h2>Failed</h2></body></html>")
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"<html><body><h2>Success!</h2>"
b"<p>You can close this tab.</p></body></html>"
)
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"<html><body><h2>Failed</h2></body></html>")
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.")