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 9 : static const char *get_client_id(const Config *cfg) {
24 4 : return (cfg->gmail_client_id && cfg->gmail_client_id[0])
25 13 : ? cfg->gmail_client_id : GMAIL_DEFAULT_CLIENT_ID;
26 : }
27 :
28 9 : static const char *get_client_secret(const Config *cfg) {
29 4 : return (cfg->gmail_client_secret && cfg->gmail_client_secret[0])
30 13 : ? 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 6 : static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
42 6 : CurlBuf *buf = userdata;
43 6 : size_t bytes = size * nmemb;
44 6 : if (buf->len + bytes + 1 > buf->cap) {
45 6 : size_t newcap = (buf->cap ? buf->cap * 2 : 4096);
46 6 : while (newcap < buf->len + bytes + 1) newcap *= 2;
47 6 : char *tmp = realloc(buf->data, newcap);
48 6 : if (!tmp) return 0;
49 6 : buf->data = tmp;
50 6 : buf->cap = newcap;
51 : }
52 6 : memcpy(buf->data + buf->len, ptr, bytes);
53 6 : buf->len += bytes;
54 6 : buf->data[buf->len] = '\0';
55 6 : return bytes;
56 : }
57 :
58 : /* ── HTTP POST helper ─────────────────────────────────────────────── */
59 :
60 7 : static char *http_post(const char *url, const char *postdata, long *http_code) {
61 7 : CURL *curl = curl_easy_init();
62 7 : if (!curl) return NULL;
63 :
64 7 : CurlBuf buf = {0};
65 :
66 7 : curl_easy_setopt(curl, CURLOPT_URL, url);
67 7 : curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postdata);
68 7 : curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
69 7 : curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
70 7 : curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
71 7 : curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
72 7 : curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
73 :
74 7 : CURLcode res = curl_easy_perform(curl);
75 7 : if (res != CURLE_OK) {
76 1 : logger_log(LOG_ERROR, "gmail_auth: HTTP POST %s failed: %s",
77 : url, curl_easy_strerror(res));
78 1 : free(buf.data);
79 1 : curl_easy_cleanup(curl);
80 1 : return NULL;
81 : }
82 :
83 6 : curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code);
84 6 : curl_easy_cleanup(curl);
85 6 : 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 4 : static int open_listener(int *listen_fd) {
100 4 : for (int port = LOOPBACK_PORT_START; port <= LOOPBACK_PORT_END; port++) {
101 4 : int fd = socket(AF_INET, SOCK_STREAM, 0);
102 4 : if (fd < 0) continue;
103 4 : int opt = 1;
104 4 : setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
105 :
106 4 : struct sockaddr_in addr = {0};
107 4 : addr.sin_family = AF_INET;
108 4 : addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
109 4 : addr.sin_port = htons((uint16_t)port);
110 :
111 8 : if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0 &&
112 4 : listen(fd, 1) == 0) {
113 4 : *listen_fd = fd;
114 4 : 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 4 : static char *wait_for_auth_code(int listen_fd) {
132 4 : struct sockaddr_in cli = {0};
133 4 : socklen_t cli_len = sizeof(cli);
134 4 : int conn = accept(listen_fd, (struct sockaddr *)&cli, &cli_len);
135 4 : if (conn < 0) return NULL;
136 :
137 : /* Read the HTTP request (small — fits in 4K) */
138 3 : char req[4096] = {0};
139 3 : ssize_t n = read(conn, req, sizeof(req) - 1);
140 3 : if (n <= 0) { close(conn); return NULL; }
141 :
142 : /* Extract ?code=... from "GET /callback?code=XXXX&scope=... HTTP/1.1" */
143 3 : char *code_start = strstr(req, "code=");
144 3 : char *auth_code = NULL;
145 3 : if (code_start) {
146 2 : code_start += 5;
147 2 : char *code_end = code_start;
148 34 : while (*code_end && *code_end != '&' && *code_end != ' ' && *code_end != '\r')
149 32 : code_end++;
150 2 : auth_code = strndup(code_start, (size_t)(code_end - code_start));
151 : }
152 :
153 : /* Check for error=access_denied */
154 3 : char *error_start = strstr(req, "error=");
155 3 : if (error_start && !auth_code) {
156 1 : error_start += 6;
157 1 : char *error_end = error_start;
158 14 : while (*error_end && *error_end != '&' && *error_end != ' ') error_end++;
159 1 : char *err = strndup(error_start, (size_t)(error_end - error_start));
160 1 : fprintf(stderr, " Authorization denied: %s\n", err ? err : "unknown");
161 1 : free(err);
162 : }
163 :
164 : /* Send a simple response to the browser */
165 3 : 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 3 : : "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 3 : ssize_t wr = write(conn, html, strlen(html)); (void)wr;
173 3 : close(conn);
174 :
175 3 : 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 4 : int gmail_auth_device_flow(Config *cfg) {
185 4 : const char *client_id = get_client_id(cfg);
186 4 : 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 4 : const char *client_secret = get_client_secret(cfg);
206 :
207 : /* Step 1: Open a localhost listener for the OAuth redirect */
208 4 : int listen_fd = -1;
209 4 : int port = open_listener(&listen_fd);
210 4 : 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 4 : struct timeval _tv = {300, 0};
217 4 : setsockopt(listen_fd, SOL_SOCKET, SO_RCVTIMEO, &_tv, sizeof(_tv));
218 :
219 : char redirect_uri[64];
220 4 : snprintf(redirect_uri, sizeof(redirect_uri), "http://localhost:%d/callback", port);
221 :
222 : /* Step 2: Build the authorization URL and open in browser */
223 4 : RAII_STRING char *auth_url = NULL;
224 4 : char *escaped_scope = curl_easy_escape(NULL, GMAIL_SCOPE, 0);
225 4 : char *escaped_redirect = curl_easy_escape(NULL, redirect_uri, 0);
226 4 : 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 4 : curl_free(escaped_scope);
236 4 : curl_free(escaped_redirect);
237 :
238 4 : 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 4 : RAII_STRING char *browser_cmd = NULL;
249 4 : 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 4 : int rc = system(browser_cmd);
253 : (void)rc;
254 : }
255 :
256 : /* Step 3: Wait for the redirect with the auth code */
257 4 : char *auth_code = wait_for_auth_code(listen_fd);
258 4 : close(listen_fd);
259 :
260 4 : if (!auth_code) {
261 2 : fprintf(stderr, "Error: Did not receive authorization code.\n");
262 2 : return -1;
263 : }
264 :
265 : /* Step 4: Exchange the auth code for tokens */
266 2 : RAII_STRING char *post = NULL;
267 2 : char *escaped_code = curl_easy_escape(NULL, auth_code, 0);
268 2 : 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 2 : curl_free(escaped_code);
277 2 : free(auth_code);
278 :
279 2 : const char *url_override2 = getenv("GMAIL_TEST_TOKEN_URL");
280 2 : const char *token_url2 = (url_override2 && url_override2[0]) ? url_override2 : TOKEN_URL;
281 :
282 2 : long tcode = 0;
283 4 : RAII_STRING char *tresp = http_post(token_url2, post, &tcode);
284 2 : if (!tresp || tcode != 200) {
285 1 : fprintf(stderr, "Error: Token exchange failed (HTTP %ld).\n", tcode);
286 1 : if (tresp) {
287 2 : RAII_STRING char *err = json_get_string(tresp, "error_description");
288 1 : if (err) fprintf(stderr, " %s\n", err);
289 : }
290 1 : return -1;
291 : }
292 :
293 1 : char *access = json_get_string(tresp, "access_token");
294 1 : char *refresh = json_get_string(tresp, "refresh_token");
295 :
296 1 : if (!access) {
297 0 : free(refresh);
298 0 : fprintf(stderr, "Error: Token response missing access_token.\n");
299 0 : return -1;
300 : }
301 1 : free(access); /* We only persist the refresh_token */
302 :
303 1 : if (refresh) {
304 1 : free(cfg->gmail_refresh_token);
305 1 : cfg->gmail_refresh_token = refresh;
306 : }
307 :
308 1 : fprintf(stderr, " Authorization successful.\n\n");
309 1 : logger_log(LOG_INFO, "gmail_auth: authorization completed for %s",
310 1 : cfg->user ? cfg->user : "(unknown)");
311 1 : return 0;
312 : }
313 :
314 : /* ── Token Refresh ────────────────────────────────────────────────── */
315 :
316 149 : char *gmail_auth_refresh(const Config *cfg) {
317 : /* Test hook: if GMAIL_TEST_TOKEN is set, skip real OAuth and return it directly */
318 149 : const char *test_token = getenv("GMAIL_TEST_TOKEN");
319 149 : if (test_token && test_token[0])
320 134 : return strdup(test_token);
321 :
322 15 : if (!cfg->gmail_refresh_token || !cfg->gmail_refresh_token[0]) {
323 10 : logger_log(LOG_ERROR, "gmail_auth: no refresh_token available");
324 10 : return NULL;
325 : }
326 :
327 5 : const char *client_id = get_client_id(cfg);
328 5 : const char *client_secret = get_client_secret(cfg);
329 :
330 5 : RAII_STRING char *post = NULL;
331 5 : if (asprintf(&post,
332 : "grant_type=refresh_token&client_id=%s&client_secret=%s"
333 : "&refresh_token=%s",
334 5 : 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 5 : const char *url_override = getenv("GMAIL_TEST_TOKEN_URL");
339 5 : const char *token_url = (url_override && url_override[0]) ? url_override : TOKEN_URL;
340 :
341 5 : long code = 0;
342 10 : RAII_STRING char *resp = http_post(token_url, post, &code);
343 5 : if (!resp) return NULL;
344 :
345 4 : if (code == 200) {
346 2 : char *token = json_get_string(resp, "access_token");
347 2 : if (token)
348 1 : logger_log(LOG_DEBUG, "gmail_auth: access token refreshed");
349 2 : return token;
350 : }
351 :
352 : /* Error handling */
353 2 : RAII_STRING char *error = json_get_string(resp, "error");
354 2 : 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 2 : } else if (error && strcmp(error, "invalid_client") == 0) {
358 1 : fprintf(stderr, "OAuth2 client credentials are invalid. "
359 : "Check GMAIL_CLIENT_ID/SECRET in config.ini.\n");
360 : } else {
361 1 : logger_log(LOG_ERROR, "gmail_auth: token refresh failed (HTTP %ld): %s",
362 1 : code, error ? error : "unknown");
363 : }
364 :
365 2 : return NULL;
366 : }
|