oauth_script.py

  1#!/usr/bin/env python3
  2"""
  3Gmail OAuth2 helper for Matcha email client.
  4
  5Handles the full OAuth2 flow for Gmail:
  6  - Browser-based authorization
  7  - Localhost callback server for auth code capture
  8  - Token exchange and refresh
  9  - Secure token storage in ~/.config/matcha/oauth_tokens/
 10
 11Usage:
 12  gmail_oauth.py auth   <email> [--client-id ID --client-secret SECRET]
 13  gmail_oauth.py token  <email>
 14  gmail_oauth.py revoke <email>
 15
 16The 'auth' command initiates the OAuth2 flow, opening a browser.
 17The 'token' command prints a fresh access token to stdout (refreshing if needed).
 18The 'revoke' command deletes stored tokens for the given account.
 19"""
 20
 21import argparse
 22import hashlib
 23import http.server
 24import json
 25import os
 26import secrets
 27import sys
 28import threading
 29import time
 30import urllib.parse
 31import urllib.request
 32import webbrowser
 33
 34# Gmail OAuth2 scopes for IMAP and SMTP access
 35SCOPES = [
 36    "https://mail.google.com/",
 37]
 38
 39TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
 40AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
 41REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke"
 42
 43REDIRECT_PORT = 8189
 44REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}"
 45
 46
 47def get_token_dir():
 48    """Return the token storage directory, creating it if needed."""
 49    home = os.path.expanduser("~")
 50    token_dir = os.path.join(home, ".config", "matcha", "oauth_tokens")
 51    os.makedirs(token_dir, mode=0o700, exist_ok=True)
 52    return token_dir
 53
 54
 55def token_file_for(email):
 56    """Return the token file path for a given email address."""
 57    safe_name = hashlib.sha256(email.encode()).hexdigest()[:16]
 58    return os.path.join(get_token_dir(), f"{safe_name}.json")
 59
 60
 61def load_tokens(email):
 62    """Load stored tokens for the given email, or return None."""
 63    path = token_file_for(email)
 64    if not os.path.exists(path):
 65        return None
 66    with open(path, "r") as f:
 67        return json.load(f)
 68
 69
 70def save_tokens(email, tokens):
 71    """Save tokens to disk with restrictive permissions."""
 72    path = token_file_for(email)
 73    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
 74    with os.fdopen(fd, "w") as f:
 75        json.dump(tokens, f, indent=2)
 76
 77
 78def load_client_credentials():
 79    """Load OAuth2 client credentials from ~/.config/matcha/oauth_client.json."""
 80    home = os.path.expanduser("~")
 81    cred_path = os.path.join(home, ".config", "matcha", "oauth_client.json")
 82    if not os.path.exists(cred_path):
 83        return None, None
 84    with open(cred_path, "r") as f:
 85        data = json.load(f)
 86    return data.get("client_id"), data.get("client_secret")
 87
 88
 89def save_client_credentials(client_id, client_secret):
 90    """Save OAuth2 client credentials to ~/.config/matcha/oauth_client.json."""
 91    home = os.path.expanduser("~")
 92    cred_path = os.path.join(home, ".config", "matcha", "oauth_client.json")
 93    fd = os.open(cred_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
 94    with os.fdopen(fd, "w") as f:
 95        json.dump({"client_id": client_id, "client_secret": client_secret}, f, indent=2)
 96
 97
 98def exchange_code(code, client_id, client_secret):
 99    """Exchange an authorization code for tokens."""
100    data = urllib.parse.urlencode({
101        "code": code,
102        "client_id": client_id,
103        "client_secret": client_secret,
104        "redirect_uri": REDIRECT_URI,
105        "grant_type": "authorization_code",
106    }).encode()
107
108    req = urllib.request.Request(TOKEN_ENDPOINT, data=data, method="POST")
109    req.add_header("Content-Type", "application/x-www-form-urlencoded")
110
111    with urllib.request.urlopen(req) as resp:
112        return json.loads(resp.read().decode())
113
114
115def refresh_access_token(refresh_token, client_id, client_secret):
116    """Use a refresh token to get a new access token."""
117    data = urllib.parse.urlencode({
118        "refresh_token": refresh_token,
119        "client_id": client_id,
120        "client_secret": client_secret,
121        "grant_type": "refresh_token",
122    }).encode()
123
124    req = urllib.request.Request(TOKEN_ENDPOINT, data=data, method="POST")
125    req.add_header("Content-Type", "application/x-www-form-urlencoded")
126
127    with urllib.request.urlopen(req) as resp:
128        return json.loads(resp.read().decode())
129
130
131def revoke_token(token):
132    """Revoke an OAuth2 token."""
133    data = urllib.parse.urlencode({"token": token}).encode()
134    req = urllib.request.Request(REVOKE_ENDPOINT, data=data, method="POST")
135    req.add_header("Content-Type", "application/x-www-form-urlencoded")
136
137    try:
138        with urllib.request.urlopen(req) as resp:
139            return resp.status == 200
140    except urllib.error.HTTPError:
141        return False
142
143
144class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
145    """HTTP handler that captures the OAuth2 callback."""
146
147    auth_code = None
148    error = None
149
150    def do_GET(self):
151        parsed = urllib.parse.urlparse(self.path)
152        params = urllib.parse.parse_qs(parsed.query)
153
154        if "code" in params:
155            OAuthCallbackHandler.auth_code = params["code"][0]
156            self.send_response(200)
157            self.send_header("Content-Type", "text/html")
158            self.end_headers()
159            self.wfile.write(b"""
160            <html><body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
161            <h2>Authorization successful!</h2>
162            <p>You can close this window and return to Matcha.</p>
163            </body></html>
164            """)
165        elif "error" in params:
166            OAuthCallbackHandler.error = params["error"][0]
167            self.send_response(400)
168            self.send_header("Content-Type", "text/html")
169            self.end_headers()
170            self.wfile.write(f"""
171            <html><body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
172            <h2>Authorization failed</h2>
173            <p>Error: {params['error'][0]}</p>
174            </body></html>
175            """.encode())
176        else:
177            self.send_response(404)
178            self.end_headers()
179
180    def log_message(self, format, *args):
181        """Suppress HTTP server logs."""
182        pass
183
184
185def do_auth(email, client_id, client_secret):
186    """Run the full OAuth2 authorization flow."""
187    # Generate PKCE code verifier/challenge for extra security
188    state = secrets.token_urlsafe(32)
189
190    auth_params = urllib.parse.urlencode({
191        "client_id": client_id,
192        "redirect_uri": REDIRECT_URI,
193        "response_type": "code",
194        "scope": " ".join(SCOPES),
195        "access_type": "offline",
196        "prompt": "consent",
197        "state": state,
198        "login_hint": email,
199    })
200
201    auth_url = f"{AUTH_ENDPOINT}?{auth_params}"
202
203    # Reset handler state
204    OAuthCallbackHandler.auth_code = None
205    OAuthCallbackHandler.error = None
206
207    # Start local HTTP server for callback
208    server = http.server.HTTPServer(("localhost", REDIRECT_PORT), OAuthCallbackHandler)
209    server.timeout = 120  # 2 minute timeout
210
211    print(f"Opening browser for Gmail authorization...", file=sys.stderr)
212    print(f"If the browser doesn't open, visit this URL:", file=sys.stderr)
213    print(f"  {auth_url}", file=sys.stderr)
214
215    webbrowser.open(auth_url)
216
217    # Wait for the callback
218    while OAuthCallbackHandler.auth_code is None and OAuthCallbackHandler.error is None:
219        server.handle_request()
220
221    server.server_close()
222
223    if OAuthCallbackHandler.error:
224        print(f"Authorization error: {OAuthCallbackHandler.error}", file=sys.stderr)
225        sys.exit(1)
226
227    code = OAuthCallbackHandler.auth_code
228    print("Authorization code received, exchanging for tokens...", file=sys.stderr)
229
230    # Exchange code for tokens
231    token_response = exchange_code(code, client_id, client_secret)
232
233    if "error" in token_response:
234        print(f"Token exchange error: {token_response['error']}", file=sys.stderr)
235        sys.exit(1)
236
237    # Store tokens with metadata
238    tokens = {
239        "access_token": token_response["access_token"],
240        "refresh_token": token_response.get("refresh_token"),
241        "expires_at": int(time.time()) + token_response.get("expires_in", 3600),
242        "token_type": token_response.get("token_type", "Bearer"),
243        "email": email,
244    }
245
246    save_tokens(email, tokens)
247    save_client_credentials(client_id, client_secret)
248
249    print("Authorization complete! Tokens saved.", file=sys.stderr)
250    # Print the access token to stdout for immediate use
251    print(tokens["access_token"])
252
253
254def do_token(email):
255    """Get a fresh access token, refreshing if needed."""
256    tokens = load_tokens(email)
257    if tokens is None:
258        print("No tokens found. Run 'auth' first.", file=sys.stderr)
259        sys.exit(1)
260
261    # Check if token is expired (with 5 minute buffer)
262    if time.time() >= tokens.get("expires_at", 0) - 300:
263        client_id, client_secret = load_client_credentials()
264        if not client_id or not client_secret:
265            print("No client credentials found. Run 'auth' first.", file=sys.stderr)
266            sys.exit(1)
267
268        refresh_token = tokens.get("refresh_token")
269        if not refresh_token:
270            print("No refresh token available. Run 'auth' again.", file=sys.stderr)
271            sys.exit(1)
272
273        try:
274            new_tokens = refresh_access_token(refresh_token, client_id, client_secret)
275        except urllib.error.HTTPError as e:
276            print(f"Token refresh failed: {e}", file=sys.stderr)
277            sys.exit(1)
278
279        tokens["access_token"] = new_tokens["access_token"]
280        tokens["expires_at"] = int(time.time()) + new_tokens.get("expires_in", 3600)
281        # Refresh tokens may be rotated
282        if "refresh_token" in new_tokens:
283            tokens["refresh_token"] = new_tokens["refresh_token"]
284
285        save_tokens(email, tokens)
286
287    print(tokens["access_token"])
288
289
290def do_revoke(email):
291    """Revoke and delete stored tokens."""
292    tokens = load_tokens(email)
293    if tokens is None:
294        print("No tokens found.", file=sys.stderr)
295        sys.exit(1)
296
297    # Try to revoke the refresh token first, then access token
298    revoked = False
299    if tokens.get("refresh_token"):
300        revoked = revoke_token(tokens["refresh_token"])
301    if not revoked and tokens.get("access_token"):
302        revoked = revoke_token(tokens["access_token"])
303
304    # Delete local token file
305    path = token_file_for(email)
306    if os.path.exists(path):
307        os.remove(path)
308
309    if revoked:
310        print("Token revoked and deleted.", file=sys.stderr)
311    else:
312        print("Local tokens deleted (remote revocation may have failed).", file=sys.stderr)
313
314
315def main():
316    parser = argparse.ArgumentParser(description="Gmail OAuth2 helper for Matcha")
317    subparsers = parser.add_subparsers(dest="command")
318
319    # auth command
320    auth_parser = subparsers.add_parser("auth", help="Authorize a Gmail account")
321    auth_parser.add_argument("email", help="Gmail address")
322    auth_parser.add_argument("--client-id", help="OAuth2 client ID")
323    auth_parser.add_argument("--client-secret", help="OAuth2 client secret")
324
325    # token command
326    token_parser = subparsers.add_parser("token", help="Get a fresh access token")
327    token_parser.add_argument("email", help="Gmail address")
328
329    # revoke command
330    revoke_parser = subparsers.add_parser("revoke", help="Revoke stored tokens")
331    revoke_parser.add_argument("email", help="Gmail address")
332
333    args = parser.parse_args()
334
335    if args.command == "auth":
336        client_id = args.client_id
337        client_secret = args.client_secret
338
339        # Fall back to stored credentials
340        if not client_id or not client_secret:
341            client_id, client_secret = load_client_credentials()
342
343        if not client_id or not client_secret:
344            print("Error: OAuth2 client credentials required.", file=sys.stderr)
345            print("", file=sys.stderr)
346            print("To set up Gmail OAuth2:", file=sys.stderr)
347            print("  1. Go to https://console.cloud.google.com/apis/credentials", file=sys.stderr)
348            print("  2. Create an OAuth 2.0 Client ID (Desktop application)", file=sys.stderr)
349            print("  3. Enable the Gmail API", file=sys.stderr)
350            print("  4. Run: gmail_oauth.py auth <email> --client-id YOUR_ID --client-secret YOUR_SECRET", file=sys.stderr)
351            print("", file=sys.stderr)
352            print("Or create ~/.config/matcha/oauth_client.json with:", file=sys.stderr)
353            print('  {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}', file=sys.stderr)
354            sys.exit(1)
355
356        do_auth(args.email, client_id, client_secret)
357
358    elif args.command == "token":
359        do_token(args.email)
360
361    elif args.command == "revoke":
362        do_revoke(args.email)
363
364    else:
365        parser.print_help()
366        sys.exit(1)
367
368
369if __name__ == "__main__":
370    main()