Line data Source code
1 : #include "gmail_auth.h"
2 : #include "json_util.h"
3 : #include "logger.h"
4 : #include "raii.h"
5 : #include <curl/curl.h>
6 : #include <stdio.h>
7 : #include <stdlib.h>
8 : #include <string.h>
9 : #include <unistd.h>
10 : #include <sys/socket.h>
11 : #include <netinet/in.h>
12 : #include <arpa/inet.h>
13 :
14 : /* ── Built-in OAuth2 credentials (set via CMake -D flags) ─────────── */
15 :
16 : #ifndef GMAIL_DEFAULT_CLIENT_ID
17 : #define GMAIL_DEFAULT_CLIENT_ID ""
18 : #endif
19 : #ifndef GMAIL_DEFAULT_CLIENT_SECRET
20 : #define GMAIL_DEFAULT_CLIENT_SECRET ""
21 : #endif
22 :
23 0 : static const char *get_client_id(const Config *cfg) {
24 0 : return (cfg->gmail_client_id && cfg->gmail_client_id[0])
25 0 : ? cfg->gmail_client_id : GMAIL_DEFAULT_CLIENT_ID;
26 : }
27 :
28 0 : static const char *get_client_secret(const Config *cfg) {
29 0 : return (cfg->gmail_client_secret && cfg->gmail_client_secret[0])
30 0 : ? cfg->gmail_client_secret : GMAIL_DEFAULT_CLIENT_SECRET;
31 : }
32 :
33 : /* ── libcurl write callback ───────────────────────────────────────── */
34 :
35 : typedef struct {
36 : char *data;
37 : size_t len;
38 : size_t cap;
39 : } CurlBuf;
40 :
41 0 : static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
42 0 : CurlBuf *buf = userdata;
43 0 : size_t bytes = size * nmemb;
44 0 : if (buf->len + bytes + 1 > buf->cap) {
45 0 : size_t newcap = (buf->cap ? buf->cap * 2 : 4096);
46 0 : while (newcap < buf->len + bytes + 1) newcap *= 2;
47 0 : char *tmp = realloc(buf->data, newcap);
48 0 : if (!tmp) return 0;
49 0 : buf->data = tmp;
50 0 : buf->cap = newcap;
51 : }
52 0 : memcpy(buf->data + buf->len, ptr, bytes);
53 0 : buf->len += bytes;
54 0 : buf->data[buf->len] = '\0';
55 0 : return bytes;
56 : }
57 :
58 : /* ── HTTP POST helper ─────────────────────────────────────────────── */
59 :
60 0 : static char *http_post(const char *url, const char *postdata, long *http_code) {
61 0 : CURL *curl = curl_easy_init();
62 0 : if (!curl) return NULL;
63 :
64 0 : CurlBuf buf = {0};
65 :
66 0 : curl_easy_setopt(curl, CURLOPT_URL, url);
67 0 : curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postdata);
68 0 : curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
69 0 : curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
70 0 : curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
71 0 : curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
72 0 : curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
73 :
74 0 : CURLcode res = curl_easy_perform(curl);
75 0 : if (res != CURLE_OK) {
76 0 : logger_log(LOG_ERROR, "gmail_auth: HTTP POST %s failed: %s",
77 : url, curl_easy_strerror(res));
78 0 : free(buf.data);
79 0 : curl_easy_cleanup(curl);
80 0 : return NULL;
81 : }
82 :
83 0 : curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code);
84 0 : curl_easy_cleanup(curl);
85 0 : return buf.data;
86 : }
87 :
88 : /* ── Localhost redirect listener ─────────────────────────────────── */
89 :
90 : #define LOOPBACK_PORT_START 8089
91 : #define LOOPBACK_PORT_END 8099
92 :
93 : /**
94 : * @brief Open a TCP listener on localhost and return the port.
95 : * Tries ports 8089–8099 until one is available.
96 : * @param listen_fd On success, set to the listening socket fd.
97 : * @return The port number, or -1 on failure.
98 : */
99 0 : static int open_listener(int *listen_fd) {
100 0 : for (int port = LOOPBACK_PORT_START; port <= LOOPBACK_PORT_END; port++) {
101 0 : int fd = socket(AF_INET, SOCK_STREAM, 0);
102 0 : if (fd < 0) continue;
103 0 : int opt = 1;
104 0 : setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
105 :
106 0 : struct sockaddr_in addr = {0};
107 0 : addr.sin_family = AF_INET;
108 0 : addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
109 0 : addr.sin_port = htons((uint16_t)port);
110 :
111 0 : if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0 &&
112 0 : listen(fd, 1) == 0) {
113 0 : *listen_fd = fd;
114 0 : return port;
115 : }
116 0 : close(fd);
117 : }
118 0 : return -1;
119 : }
120 :
121 : /**
122 : * @brief Wait for Google's redirect and extract the authorization code.
123 : *
124 : * Accepts one HTTP connection, reads the GET request, extracts the
125 : * ?code= parameter, sends a simple HTML "success" response, and
126 : * closes the connection.
127 : *
128 : * @param listen_fd Listening socket fd (will NOT be closed by this function).
129 : * @return Heap-allocated authorization code, or NULL on error/denial.
130 : */
131 0 : static char *wait_for_auth_code(int listen_fd) {
132 0 : struct sockaddr_in cli = {0};
133 0 : socklen_t cli_len = sizeof(cli);
134 0 : int conn = accept(listen_fd, (struct sockaddr *)&cli, &cli_len);
135 0 : if (conn < 0) return NULL;
136 :
137 : /* Read the HTTP request (small — fits in 4K) */
138 0 : char req[4096] = {0};
139 0 : ssize_t n = read(conn, req, sizeof(req) - 1);
140 0 : if (n <= 0) { close(conn); return NULL; }
141 :
142 : /* Extract ?code=... from "GET /callback?code=XXXX&scope=... HTTP/1.1" */
143 0 : char *code_start = strstr(req, "code=");
144 0 : char *auth_code = NULL;
145 0 : if (code_start) {
146 0 : code_start += 5;
147 0 : char *code_end = code_start;
148 0 : while (*code_end && *code_end != '&' && *code_end != ' ' && *code_end != '\r')
149 0 : code_end++;
150 0 : auth_code = strndup(code_start, (size_t)(code_end - code_start));
151 : }
152 :
153 : /* Check for error=access_denied */
154 0 : char *error_start = strstr(req, "error=");
155 0 : if (error_start && !auth_code) {
156 0 : error_start += 6;
157 0 : char *error_end = error_start;
158 0 : while (*error_end && *error_end != '&' && *error_end != ' ') error_end++;
159 0 : char *err = strndup(error_start, (size_t)(error_end - error_start));
160 0 : fprintf(stderr, " Authorization denied: %s\n", err ? err : "unknown");
161 0 : free(err);
162 : }
163 :
164 : /* Send a simple response to the browser */
165 0 : const char *html = auth_code
166 : ? "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
167 : "<html><body><h2>Authorization successful!</h2>"
168 : "<p>You can close this tab and return to email-cli.</p></body></html>"
169 0 : : "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
170 : "<html><body><h2>Authorization failed.</h2>"
171 : "<p>Please try again.</p></body></html>";
172 0 : ssize_t wr = write(conn, html, strlen(html)); (void)wr;
173 0 : close(conn);
174 :
175 0 : return auth_code;
176 : }
177 :
178 : /* ── Authorization Code Flow (Desktop App) ───────────────────────── */
179 :
180 : #define AUTH_URL "https://accounts.google.com/o/oauth2/v2/auth"
181 : #define TOKEN_URL "https://oauth2.googleapis.com/token"
182 : #define GMAIL_SCOPE "https://mail.google.com/"
183 :
184 0 : int gmail_auth_device_flow(Config *cfg) {
185 0 : const char *client_id = get_client_id(cfg);
186 0 : if (!client_id[0]) {
187 0 : fprintf(stderr,
188 : "\n"
189 : " Gmail OAuth2 credentials are not configured yet.\n"
190 : "\n"
191 : " To use Gmail, you need a Google Cloud OAuth2 client_id and\n"
192 : " client_secret. Add them to your account config file:\n"
193 : "\n"
194 : " ~/.config/email-cli/accounts/<email>/config.ini\n"
195 : "\n"
196 : " Add these two lines:\n"
197 : " GMAIL_CLIENT_ID=<your-client-id>.apps.googleusercontent.com\n"
198 : " GMAIL_CLIENT_SECRET=<your-client-secret>\n"
199 : "\n"
200 : " Run 'email-cli help gmail' for the step-by-step setup guide.\n"
201 : "\n");
202 0 : return -1;
203 : }
204 :
205 0 : const char *client_secret = get_client_secret(cfg);
206 :
207 : /* Step 1: Open a localhost listener for the OAuth redirect */
208 0 : int listen_fd = -1;
209 0 : int port = open_listener(&listen_fd);
210 0 : if (port < 0) {
211 0 : fprintf(stderr, "Error: Could not open localhost listener (ports %d-%d busy).\n",
212 : LOOPBACK_PORT_START, LOOPBACK_PORT_END);
213 0 : return -1;
214 : }
215 : /* 5-minute timeout so wait_for_auth_code() can't block forever. */
216 0 : struct timeval _tv = {300, 0};
217 0 : setsockopt(listen_fd, SOL_SOCKET, SO_RCVTIMEO, &_tv, sizeof(_tv));
218 :
219 : char redirect_uri[64];
220 0 : snprintf(redirect_uri, sizeof(redirect_uri), "http://localhost:%d/callback", port);
221 :
222 : /* Step 2: Build the authorization URL and open in browser */
223 0 : RAII_STRING char *auth_url = NULL;
224 0 : char *escaped_scope = curl_easy_escape(NULL, GMAIL_SCOPE, 0);
225 0 : char *escaped_redirect = curl_easy_escape(NULL, redirect_uri, 0);
226 0 : if (asprintf(&auth_url,
227 : "%s?client_id=%s&redirect_uri=%s&response_type=code"
228 : "&scope=%s&access_type=offline&prompt=consent",
229 : AUTH_URL, client_id, escaped_redirect, escaped_scope) == -1) {
230 0 : curl_free(escaped_scope);
231 0 : curl_free(escaped_redirect);
232 0 : close(listen_fd);
233 0 : return -1;
234 : }
235 0 : curl_free(escaped_scope);
236 0 : curl_free(escaped_redirect);
237 :
238 0 : fprintf(stderr,
239 : "\n"
240 : " Opening browser for Gmail authorization...\n"
241 : " If the browser doesn't open, visit this URL manually:\n\n"
242 : " %s\n\n"
243 : " Waiting for authorization... (^C to cancel)\n\n",
244 : auth_url);
245 :
246 : /* Try to open the browser — run in background so system() returns immediately.
247 : * On headless CI/servers this exits quickly; on desktops the browser opens. */
248 0 : RAII_STRING char *browser_cmd = NULL;
249 0 : if (asprintf(&browser_cmd,
250 : "(xdg-open '%s' || open '%s' || start '%s') >/dev/null 2>&1 &",
251 : auth_url, auth_url, auth_url) != -1) {
252 0 : int rc = system(browser_cmd);
253 : (void)rc;
254 : }
255 :
256 : /* Step 3: Wait for the redirect with the auth code */
257 0 : char *auth_code = wait_for_auth_code(listen_fd);
258 0 : close(listen_fd);
259 :
260 0 : if (!auth_code) {
261 0 : fprintf(stderr, "Error: Did not receive authorization code.\n");
262 0 : return -1;
263 : }
264 :
265 : /* Step 4: Exchange the auth code for tokens */
266 0 : RAII_STRING char *post = NULL;
267 0 : char *escaped_code = curl_easy_escape(NULL, auth_code, 0);
268 0 : if (asprintf(&post,
269 : "code=%s&client_id=%s&client_secret=%s"
270 : "&redirect_uri=%s&grant_type=authorization_code",
271 : escaped_code, client_id, client_secret, redirect_uri) == -1) {
272 0 : curl_free(escaped_code);
273 0 : free(auth_code);
274 0 : return -1;
275 : }
276 0 : curl_free(escaped_code);
277 0 : free(auth_code);
278 :
279 0 : const char *url_override2 = getenv("GMAIL_TEST_TOKEN_URL");
280 0 : const char *token_url2 = (url_override2 && url_override2[0]) ? url_override2 : TOKEN_URL;
281 :
282 0 : long tcode = 0;
283 0 : RAII_STRING char *tresp = http_post(token_url2, post, &tcode);
284 0 : if (!tresp || tcode != 200) {
285 0 : fprintf(stderr, "Error: Token exchange failed (HTTP %ld).\n", tcode);
286 0 : if (tresp) {
287 0 : RAII_STRING char *err = json_get_string(tresp, "error_description");
288 0 : if (err) fprintf(stderr, " %s\n", err);
289 : }
290 0 : return -1;
291 : }
292 :
293 0 : char *access = json_get_string(tresp, "access_token");
294 0 : char *refresh = json_get_string(tresp, "refresh_token");
295 :
296 0 : if (!access) {
297 0 : free(refresh);
298 0 : fprintf(stderr, "Error: Token response missing access_token.\n");
299 0 : return -1;
300 : }
301 0 : free(access); /* We only persist the refresh_token */
302 :
303 0 : if (refresh) {
304 0 : free(cfg->gmail_refresh_token);
305 0 : cfg->gmail_refresh_token = refresh;
306 : }
307 :
308 0 : fprintf(stderr, " Authorization successful.\n\n");
309 0 : logger_log(LOG_INFO, "gmail_auth: authorization completed for %s",
310 0 : cfg->user ? cfg->user : "(unknown)");
311 0 : return 0;
312 : }
313 :
314 : /* ── Token Refresh ────────────────────────────────────────────────── */
315 :
316 57 : char *gmail_auth_refresh(const Config *cfg) {
317 : /* Test hook: if GMAIL_TEST_TOKEN is set, skip real OAuth and return it directly */
318 57 : const char *test_token = getenv("GMAIL_TEST_TOKEN");
319 57 : if (test_token && test_token[0])
320 57 : return strdup(test_token);
321 :
322 0 : if (!cfg->gmail_refresh_token || !cfg->gmail_refresh_token[0]) {
323 0 : logger_log(LOG_ERROR, "gmail_auth: no refresh_token available");
324 0 : return NULL;
325 : }
326 :
327 0 : const char *client_id = get_client_id(cfg);
328 0 : const char *client_secret = get_client_secret(cfg);
329 :
330 0 : RAII_STRING char *post = NULL;
331 0 : if (asprintf(&post,
332 : "grant_type=refresh_token&client_id=%s&client_secret=%s"
333 : "&refresh_token=%s",
334 0 : client_id, client_secret, cfg->gmail_refresh_token) == -1)
335 0 : return NULL;
336 :
337 : /* Test hook: GMAIL_TEST_TOKEN_URL overrides the token endpoint for unit tests */
338 0 : const char *url_override = getenv("GMAIL_TEST_TOKEN_URL");
339 0 : const char *token_url = (url_override && url_override[0]) ? url_override : TOKEN_URL;
340 :
341 0 : long code = 0;
342 0 : RAII_STRING char *resp = http_post(token_url, post, &code);
343 0 : if (!resp) return NULL;
344 :
345 0 : if (code == 200) {
346 0 : char *token = json_get_string(resp, "access_token");
347 0 : if (token)
348 0 : logger_log(LOG_DEBUG, "gmail_auth: access token refreshed");
349 0 : return token;
350 : }
351 :
352 : /* Error handling */
353 0 : RAII_STRING char *error = json_get_string(resp, "error");
354 0 : if (error && strcmp(error, "invalid_grant") == 0) {
355 0 : logger_log(LOG_WARN, "gmail_auth: refresh token expired or revoked");
356 0 : fprintf(stderr, "Gmail refresh token expired. Re-authorization needed.\n");
357 0 : } else if (error && strcmp(error, "invalid_client") == 0) {
358 0 : fprintf(stderr, "OAuth2 client credentials are invalid. "
359 : "Check GMAIL_CLIENT_ID/SECRET in config.ini.\n");
360 : } else {
361 0 : logger_log(LOG_ERROR, "gmail_auth: token refresh failed (HTTP %ld): %s",
362 0 : code, error ? error : "unknown");
363 : }
364 :
365 0 : return NULL;
366 : }
|