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()