LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - gmail_auth.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 2.2 % 182 4
Test Date: 2026-05-07 15:53:08 Functions: 12.5 % 8 1

            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              : }
        

Generated by: LCOV version 2.0-1