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