oauth_script.py

  1#!/usr/bin/env python3
  2"""
  3OAuth2 helper for Matcha email client.
  4
  5Handles the full OAuth2 flow for Gmail and Outlook:
  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  oauth.py auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
 13  oauth.py token  <email>
 14  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# --- Provider configuration ---
 35
 36PROVIDERS = {
 37    "gmail": {
 38        "name": "Gmail",
 39        "auth_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
 40        "token_endpoint": "https://oauth2.googleapis.com/token",
 41        "revoke_endpoint": "https://oauth2.googleapis.com/revoke",
 42        "scopes": ["https://mail.google.com/"],
 43        "extra_auth_params": {
 44            "access_type": "offline",
 45            "prompt": "consent",
 46        },
 47        "credentials_help": [
 48            "To set up Gmail OAuth2:",
 49            "  1. Go to https://console.cloud.google.com/apis/credentials",
 50            "  2. Create an OAuth 2.0 Client ID (Desktop application)",
 51            "  3. Enable the Gmail API",
 52        ],
 53    },
 54    "outlook": {
 55        "name": "Outlook",
 56        "auth_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
 57        "token_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
 58        "revoke_endpoint": None,  # Microsoft does not support token revocation via endpoint
 59        "scopes": [
 60            "https://outlook.office365.com/IMAP.AccessAsUser.All",
 61            "https://outlook.office365.com/SMTP.Send",
 62            "offline_access",
 63        ],
 64        "extra_auth_params": {
 65            "prompt": "consent",
 66        },
 67        "credentials_help": [
 68            "To set up Outlook OAuth2:",
 69            "  1. Go to https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
 70            "  2. Register a new application (any name, e.g. 'Matcha')",
 71            "  3. Set a redirect URI: http://localhost:8189 (Web platform)",
 72            "  4. Under 'Certificates & secrets', create a new client secret",
 73            "  5. Under 'API permissions', add:",
 74            "     - Microsoft Graph > Delegated > email",
 75            "     - Microsoft Graph > Delegated > offline_access",
 76            "     - Microsoft Graph > Delegated > User.Read",
 77            "     - Microsoft Graph > Delegated > Mail.ReadWrite",
 78            "     - Microsoft Graph > Delegated > Mail.Send",
 79            "     - Microsoft Graph > Delegated > IMAP.AccessAsUser.All",
 80            "     - Microsoft Graph > Delegated > SMTP.Send",
 81        ],
 82    },
 83}
 84
 85REDIRECT_PORT = 8189
 86REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}"
 87
 88
 89def get_token_dir():
 90    """Return the token storage directory, creating it if needed."""
 91    home = os.path.expanduser("~")
 92    token_dir = os.path.join(home, ".config", "matcha", "oauth_tokens")
 93    os.makedirs(token_dir, mode=0o700, exist_ok=True)
 94    return token_dir
 95
 96
 97def token_file_for(email):
 98    """Return the token file path for a given email address."""
 99    safe_name = hashlib.sha256(email.encode()).hexdigest()[:16]
100    return os.path.join(get_token_dir(), f"{safe_name}.json")
101
102
103def load_tokens(email):
104    """Load stored tokens for the given email, or return None."""
105    path = token_file_for(email)
106    if not os.path.exists(path):
107        return None
108    with open(path, "r") as f:
109        return json.load(f)
110
111
112def save_tokens(email, tokens):
113    """Save tokens to disk with restrictive permissions."""
114    path = token_file_for(email)
115    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
116    with os.fdopen(fd, "w") as f:
117        json.dump(tokens, f, indent=2)
118
119
120def client_credentials_file_for(provider):
121    """Return the client credentials file path for a given provider."""
122    home = os.path.expanduser("~")
123    if provider == "gmail":
124        # Keep backwards-compatible path for Gmail
125        return os.path.join(home, ".config", "matcha", "oauth_client.json")
126    return os.path.join(home, ".config", "matcha", f"oauth_client_{provider}.json")
127
128
129def load_client_credentials(provider):
130    """Load OAuth2 client credentials for the given provider."""
131    path = client_credentials_file_for(provider)
132    if not os.path.exists(path):
133        # Also try the generic path as fallback
134        home = os.path.expanduser("~")
135        generic = os.path.join(home, ".config", "matcha", "oauth_client.json")
136        if provider != "gmail" and os.path.exists(generic):
137            with open(generic, "r") as f:
138                data = json.load(f)
139            # Only use generic if it has provider-specific keys
140            pid = data.get("provider")
141            if pid == provider:
142                return data.get("client_id"), data.get("client_secret")
143        if not os.path.exists(path):
144            return None, None
145    with open(path, "r") as f:
146        data = json.load(f)
147    return data.get("client_id"), data.get("client_secret")
148
149
150def save_client_credentials(provider, client_id, client_secret):
151    """Save OAuth2 client credentials for the given provider."""
152    path = client_credentials_file_for(provider)
153    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
154    with os.fdopen(fd, "w") as f:
155        json.dump({"client_id": client_id, "client_secret": client_secret}, f, indent=2)
156
157
158def exchange_code(code, client_id, client_secret, provider):
159    """Exchange an authorization code for tokens."""
160    cfg = PROVIDERS[provider]
161    data = urllib.parse.urlencode(
162        {
163            "code": code,
164            "client_id": client_id,
165            "client_secret": client_secret,
166            "redirect_uri": REDIRECT_URI,
167            "grant_type": "authorization_code",
168        }
169    ).encode()
170
171    req = urllib.request.Request(cfg["token_endpoint"], data=data, method="POST")
172    req.add_header("Content-Type", "application/x-www-form-urlencoded")
173
174    with urllib.request.urlopen(req) as resp:
175        return json.loads(resp.read().decode())
176
177
178def refresh_access_token(refresh_token, client_id, client_secret, provider):
179    """Use a refresh token to get a new access token."""
180    cfg = PROVIDERS[provider]
181    params = {
182        "refresh_token": refresh_token,
183        "client_id": client_id,
184        "client_secret": client_secret,
185        "grant_type": "refresh_token",
186    }
187    # Outlook requires scope on refresh
188    if provider == "outlook":
189        params["scope"] = " ".join(cfg["scopes"])
190
191    data = urllib.parse.urlencode(params).encode()
192
193    req = urllib.request.Request(cfg["token_endpoint"], data=data, method="POST")
194    req.add_header("Content-Type", "application/x-www-form-urlencoded")
195
196    with urllib.request.urlopen(req) as resp:
197        return json.loads(resp.read().decode())
198
199
200def revoke_token(token, provider):
201    """Revoke an OAuth2 token."""
202    cfg = PROVIDERS[provider]
203    if cfg["revoke_endpoint"] is None:
204        return False
205
206    data = urllib.parse.urlencode({"token": token}).encode()
207    req = urllib.request.Request(cfg["revoke_endpoint"], data=data, method="POST")
208    req.add_header("Content-Type", "application/x-www-form-urlencoded")
209
210    try:
211        with urllib.request.urlopen(req) as resp:
212            return resp.status == 200
213    except urllib.error.HTTPError:
214        return False
215
216
217class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
218    """HTTP handler that captures the OAuth2 callback."""
219
220    auth_code = None
221    error = None
222
223    def do_GET(self):
224        parsed = urllib.parse.urlparse(self.path)
225        params = urllib.parse.parse_qs(parsed.query)
226
227        if "code" in params:
228            OAuthCallbackHandler.auth_code = params["code"][0]
229            self.send_response(200)
230            self.send_header("Content-Type", "text/html")
231            self.end_headers()
232            self.wfile.write(b"""
233            <html><body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
234            <h2>Authorization successful!</h2>
235            <p>You can close this window and return to Matcha.</p>
236            </body></html>
237            """)
238        elif "error" in params:
239            OAuthCallbackHandler.error = params["error"][0]
240            self.send_response(400)
241            self.send_header("Content-Type", "text/html")
242            self.end_headers()
243            self.wfile.write(
244                f"""
245            <html><body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
246            <h2>Authorization failed</h2>
247            <p>Error: {params["error"][0]}</p>
248            </body></html>
249            """.encode()
250            )
251        else:
252            self.send_response(404)
253            self.end_headers()
254
255    def log_message(self, format, *args):
256        """Suppress HTTP server logs."""
257        pass
258
259
260def detect_provider(email):
261    """Detect the OAuth2 provider from an email address."""
262    domain = email.rsplit("@", 1)[-1].lower() if "@" in email else ""
263    outlook_domains = {
264        "outlook.com",
265        "hotmail.com",
266        "live.com",
267        "msn.com",
268        "outlook.co.uk",
269        "hotmail.co.uk",
270        "live.co.uk",
271        "outlook.de",
272        "hotmail.de",
273        "outlook.fr",
274        "hotmail.fr",
275        "outlook.it",
276        "hotmail.it",
277        "outlook.es",
278        "hotmail.es",
279        "outlook.jp",
280        "hotmail.co.jp",
281    }
282    if domain in ("gmail.com", "googlemail.com"):
283        return "gmail"
284    if domain in outlook_domains:
285        return "outlook"
286    return None
287
288
289def do_auth(email, provider, client_id, client_secret):
290    """Run the full OAuth2 authorization flow."""
291    cfg = PROVIDERS[provider]
292    state = secrets.token_urlsafe(32)
293
294    auth_params = {
295        "client_id": client_id,
296        "redirect_uri": REDIRECT_URI,
297        "response_type": "code",
298        "scope": " ".join(cfg["scopes"]),
299        "state": state,
300        "login_hint": email,
301    }
302    auth_params.update(cfg["extra_auth_params"])
303
304    auth_url = f"{cfg['auth_endpoint']}?{urllib.parse.urlencode(auth_params)}"
305
306    # Reset handler state
307    OAuthCallbackHandler.auth_code = None
308    OAuthCallbackHandler.error = None
309
310    # Start local HTTP server for callback
311    server = http.server.HTTPServer(("localhost", REDIRECT_PORT), OAuthCallbackHandler)
312    server.timeout = 120  # 2 minute timeout
313
314    print(f"Opening browser for {cfg['name']} authorization...", file=sys.stderr)
315    print(f"If the browser doesn't open, visit this URL:", file=sys.stderr)
316    print(f"  {auth_url}", file=sys.stderr)
317
318    webbrowser.open(auth_url)
319
320    # Wait for the callback
321    while OAuthCallbackHandler.auth_code is None and OAuthCallbackHandler.error is None:
322        server.handle_request()
323
324    server.server_close()
325
326    if OAuthCallbackHandler.error:
327        print(f"Authorization error: {OAuthCallbackHandler.error}", file=sys.stderr)
328        sys.exit(1)
329
330    code = OAuthCallbackHandler.auth_code
331    print("Authorization code received, exchanging for tokens...", file=sys.stderr)
332
333    # Exchange code for tokens
334    token_response = exchange_code(code, client_id, client_secret, provider)
335
336    if "error" in token_response:
337        print(f"Token exchange error: {token_response['error']}", file=sys.stderr)
338        sys.exit(1)
339
340    # Store tokens with metadata
341    tokens = {
342        "access_token": token_response["access_token"],
343        "refresh_token": token_response.get("refresh_token"),
344        "expires_at": int(time.time()) + token_response.get("expires_in", 3600),
345        "token_type": token_response.get("token_type", "Bearer"),
346        "email": email,
347        "provider": provider,
348    }
349
350    save_tokens(email, tokens)
351    save_client_credentials(provider, client_id, client_secret)
352
353    print("Authorization complete! Tokens saved.", file=sys.stderr)
354    # Print the access token to stdout for immediate use
355    print(tokens["access_token"])
356
357
358def do_token(email):
359    """Get a fresh access token, refreshing if needed."""
360    tokens = load_tokens(email)
361    if tokens is None:
362        print("No tokens found. Run 'auth' first.", file=sys.stderr)
363        sys.exit(1)
364
365    provider = tokens.get("provider", "gmail")
366
367    # Check if token is expired (with 5 minute buffer)
368    if time.time() >= tokens.get("expires_at", 0) - 300:
369        client_id, client_secret = load_client_credentials(provider)
370        if not client_id or not client_secret:
371            print("No client credentials found. Run 'auth' first.", file=sys.stderr)
372            sys.exit(1)
373
374        refresh_token = tokens.get("refresh_token")
375        if not refresh_token:
376            print("No refresh token available. Run 'auth' again.", file=sys.stderr)
377            sys.exit(1)
378
379        try:
380            new_tokens = refresh_access_token(
381                refresh_token, client_id, client_secret, provider
382            )
383        except urllib.error.HTTPError as e:
384            print(f"Token refresh failed: {e}", file=sys.stderr)
385            sys.exit(1)
386
387        tokens["access_token"] = new_tokens["access_token"]
388        tokens["expires_at"] = int(time.time()) + new_tokens.get("expires_in", 3600)
389        # Refresh tokens may be rotated
390        if "refresh_token" in new_tokens:
391            tokens["refresh_token"] = new_tokens["refresh_token"]
392
393        save_tokens(email, tokens)
394
395    print(tokens["access_token"])
396
397
398def do_revoke(email):
399    """Revoke and delete stored tokens."""
400    tokens = load_tokens(email)
401    if tokens is None:
402        print("No tokens found.", file=sys.stderr)
403        sys.exit(1)
404
405    provider = tokens.get("provider", "gmail")
406
407    # Try to revoke the refresh token first, then access token
408    revoked = False
409    if tokens.get("refresh_token"):
410        revoked = revoke_token(tokens["refresh_token"], provider)
411    if not revoked and tokens.get("access_token"):
412        revoked = revoke_token(tokens["access_token"], provider)
413
414    # Delete local token file
415    path = token_file_for(email)
416    if os.path.exists(path):
417        os.remove(path)
418
419    if revoked:
420        print("Token revoked and deleted.", file=sys.stderr)
421    else:
422        print(
423            "Local tokens deleted (remote revocation may have failed).", file=sys.stderr
424        )
425
426
427def main():
428    parser = argparse.ArgumentParser(description="OAuth2 helper for Matcha")
429    subparsers = parser.add_subparsers(dest="command")
430
431    # auth command
432    auth_parser = subparsers.add_parser("auth", help="Authorize an email account")
433    auth_parser.add_argument("email", help="Email address")
434    auth_parser.add_argument(
435        "--provider",
436        help="OAuth2 provider (gmail or outlook)",
437        choices=["gmail", "outlook"],
438    )
439    auth_parser.add_argument("--client-id", help="OAuth2 client ID")
440    auth_parser.add_argument("--client-secret", help="OAuth2 client secret")
441
442    # token command
443    token_parser = subparsers.add_parser("token", help="Get a fresh access token")
444    token_parser.add_argument("email", help="Email address")
445
446    # revoke command
447    revoke_parser = subparsers.add_parser("revoke", help="Revoke stored tokens")
448    revoke_parser.add_argument("email", help="Email address")
449
450    args = parser.parse_args()
451
452    if args.command == "auth":
453        provider = args.provider
454        if not provider:
455            provider = detect_provider(args.email)
456        if not provider:
457            print(
458                "Error: Could not detect provider from email address.", file=sys.stderr
459            )
460            print("Use --provider gmail or --provider outlook", file=sys.stderr)
461            sys.exit(1)
462
463        client_id = args.client_id
464        client_secret = args.client_secret
465
466        # Fall back to stored credentials
467        if not client_id or not client_secret:
468            client_id, client_secret = load_client_credentials(provider)
469
470        if not client_id or not client_secret:
471            cfg = PROVIDERS[provider]
472            print(
473                f"Error: OAuth2 client credentials required for {cfg['name']}.",
474                file=sys.stderr,
475            )
476            print("", file=sys.stderr)
477            for line in cfg["credentials_help"]:
478                print(line, file=sys.stderr)
479            print("", file=sys.stderr)
480            cred_file = client_credentials_file_for(provider)
481            print(f"Create {cred_file} with:", file=sys.stderr)
482            print(
483                '  {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}',
484                file=sys.stderr,
485            )
486            sys.exit(1)
487
488        do_auth(args.email, provider, client_id, client_secret)
489
490    elif args.command == "token":
491        do_token(args.email)
492
493    elif args.command == "revoke":
494        do_revoke(args.email)
495
496    else:
497        parser.print_help()
498        sys.exit(1)
499
500
501if __name__ == "__main__":
502    main()