comparison connectors/google.c @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -0700
parents
children
comparison
equal deleted inserted replaced
278:8d560f50ed4c 279:b3b547563ec7
1 #include "connectors/connector.h"
2
3 #include <stdio.h>
4 #include <string.h>
5 #include <time.h>
6
7 static boolean json_is_bounded(const char *json, size_t length)
8 {
9 if (!json || length > CONNECTOR_MAX_JSON_BYTES)
10 return FALSE;
11 int32 depth = 0;
12 boolean string = FALSE, escaped = FALSE;
13 for (size_t i = 0; i < length; ++i) {
14 char c = json[i];
15 if (string) {
16 if (escaped)
17 escaped = FALSE;
18 else if (c == '\\')
19 escaped = TRUE;
20 else if (c == '"')
21 string = FALSE;
22 } else if (c == '"')
23 string = TRUE;
24 else if (c == '{' || c == '[') {
25 if (++depth > 32)
26 return FALSE;
27 } else if (c == '}' || c == ']') {
28 if (--depth < 0)
29 return FALSE;
30 }
31 }
32 return !string && depth == 0;
33 }
34
35 static Connector_Status default_transport(
36 const Connector_HTTP_Request *request, const char *access_token,
37 Connector_HTTP_Response *response, Dowa_Arena *arena, void *context)
38 {
39 (void)context;
40 if (!request || !response || !arena)
41 return CONNECTOR_INVALID;
42 Seobeo_Client_Request *client = Seobeo_Client_Request_Create(request->url);
43 if (!client)
44 return CONNECTOR_PROVIDER_ERROR;
45 Seobeo_Client_Request_Set_Method(client, request->method);
46 Seobeo_Client_Request_Set_Timeout_Milliseconds(client, 15000);
47 if (access_token && access_token[0]) {
48 char authorization[2300];
49 int32 length = snprintf(
50 authorization, sizeof(authorization), "Authorization: Bearer %s",
51 access_token);
52 if (length <= 0 || (size_t)length >= sizeof(authorization)) {
53 Seobeo_Client_Request_Destroy(client);
54 return CONNECTOR_INVALID;
55 }
56 Seobeo_Client_Request_Add_Header_Array(client, authorization);
57 }
58 if (request->content_type) {
59 char header[256];
60 int32 length = snprintf(
61 header, sizeof(header), "Content-Type: %s", request->content_type);
62 if (length <= 0 || (size_t)length >= sizeof(header)) {
63 Seobeo_Client_Request_Destroy(client);
64 return CONNECTOR_INVALID;
65 }
66 Seobeo_Client_Request_Add_Header_Array(client, header);
67 }
68 if (request->body && request->body_length)
69 Seobeo_Client_Request_Set_Body(
70 client, request->body, request->body_length);
71 if (request->download_path)
72 Seobeo_Client_Request_Set_Download_Path(client, request->download_path);
73 Seobeo_Client_Response *provider = Seobeo_Client_Request_Execute(client);
74 if (!provider) {
75 Seobeo_Client_Request_Destroy(client);
76 return CONNECTOR_PROVIDER_ERROR;
77 }
78 response->status_code = provider->status_code;
79 response->body_length = provider->body_length;
80 if (provider->body && !request->download_path) {
81 response->body = Dowa_Arena_Allocate(arena, provider->body_length + 1);
82 if (!response->body) {
83 Seobeo_Client_Response_Destroy(provider);
84 Seobeo_Client_Request_Destroy(client);
85 return CONNECTOR_ERROR;
86 }
87 memcpy(response->body, provider->body, provider->body_length);
88 response->body[provider->body_length] = '\0';
89 } else
90 response->body = NULL;
91 Seobeo_Client_Response_Destroy(provider);
92 Seobeo_Client_Request_Destroy(client);
93 return CONNECTOR_OK;
94 }
95
96 static Connector_Status transport(
97 const Connector_Google_Config *config,
98 const Connector_HTTP_Request *request, const char *access_token,
99 Connector_HTTP_Response *response, Dowa_Arena *arena)
100 {
101 Connector_HTTP_Transport implementation =
102 config->transport ? config->transport : default_transport;
103 return implementation(
104 request, access_token, response, arena, config->transport_context);
105 }
106
107 static char *url_join(
108 Dowa_Arena *arena, const char *base, const char *path, const char *query)
109 {
110 if (!arena || !base)
111 return NULL;
112 size_t length = strlen(base) + (path ? strlen(path) : 0) +
113 (query && query[0] ? strlen(query) + 1 : 0) + 1;
114 char *url = Dowa_Arena_Allocate(arena, length);
115 if (!url)
116 return NULL;
117 snprintf(
118 url, length, "%s%s%s%s", base, path ? path : "",
119 query && query[0] ? "?" : "", query && query[0] ? query : "");
120 return url;
121 }
122
123 char *Connector_Google_Authorization_URL(
124 const Connector_Google_Config *config, const Connector_OAuth_Start *start,
125 Dowa_Arena *arena)
126 {
127 if (!config || !start || !arena)
128 return NULL;
129 char client[1024], redirect[2048];
130 if (!Connector_Form_Encode(config->client_id, client, sizeof(client)) ||
131 !Connector_Form_Encode(
132 config->redirect_uri, redirect, sizeof(redirect)))
133 return NULL;
134 const char *scopes =
135 "openid%20email%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive"
136 "%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.readonly"
137 "%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.compose";
138 size_t length = strlen(config->oauth_authorize_url) + strlen(client) +
139 strlen(redirect) + strlen(start->state) + strlen(start->code_challenge) +
140 strlen(scopes) + 256;
141 char *url = Dowa_Arena_Allocate(arena, length);
142 if (!url)
143 return NULL;
144 snprintf(
145 url, length,
146 "%s?client_id=%s&redirect_uri=%s&response_type=code&scope=%s"
147 "&access_type=offline&prompt=consent&state=%s"
148 "&code_challenge=%s&code_challenge_method=S256",
149 config->oauth_authorize_url, client, redirect, scopes, start->state,
150 start->code_challenge);
151 return url;
152 }
153
154 static Connector_Status token_request(
155 const Connector_Google_Config *config, const char *body,
156 Connector_HTTP_Response *response, Connector_Provider_Error *error,
157 Dowa_Arena *arena)
158 {
159 Connector_HTTP_Request request = {
160 .method = "POST",
161 .url = config->oauth_token_url,
162 .content_type = "application/x-www-form-urlencoded",
163 .body = body,
164 .body_length = strlen(body),
165 .download_path = NULL
166 };
167 Connector_Status status = transport(
168 config, &request, NULL, response, arena);
169 if (status != CONNECTOR_OK) {
170 if (error)
171 snprintf(error->code, sizeof(error->code), "transport_error");
172 return status;
173 }
174 if (response->status_code >= 200 && response->status_code < 300)
175 return CONNECTOR_OK;
176 if (error) {
177 error->http_status = response->status_code;
178 snprintf(error->code, sizeof(error->code), "token_endpoint_error");
179 if (json_is_bounded(response->body, response->body_length)) {
180 Dowa_JSON_Value parsed = Dowa_JSON_Parse(
181 response->body, (int32)response->body_length, arena);
182 if (parsed.type == DOWA_JSON_OBJECT) {
183 char *code = Dowa_JSON_Get_String(parsed.object_val, "error");
184 char *description =
185 Dowa_JSON_Get_String(parsed.object_val, "error_description");
186 if (code)
187 snprintf(error->code, sizeof(error->code), "%s", code);
188 if (description)
189 snprintf(
190 error->description, sizeof(error->description), "%s",
191 description);
192 }
193 }
194 }
195 return CONNECTOR_PROVIDER_ERROR;
196 }
197
198 static boolean copy_json_string(
199 Dowa_JSON_Entry *object, const char *key, char *output, size_t output_size)
200 {
201 char *value = Dowa_JSON_Get_String(object, key);
202 if (!value || strlen(value) >= output_size)
203 return FALSE;
204 strcpy(output, value);
205 return TRUE;
206 }
207
208 Connector_Status Connector_Google_Exchange_Code(
209 const Connector_Google_Config *config, const char *code,
210 const char *verifier, Connector_Account *account,
211 Connector_Provider_Error *error, Dowa_Arena *arena)
212 {
213 if (!config || !code || !verifier || !account || !arena)
214 return CONNECTOR_INVALID;
215 if (error)
216 memset(error, 0, sizeof(*error));
217 char encoded_code[4096], encoded_verifier[512], encoded_client[1024];
218 char encoded_secret[2048], encoded_redirect[2048];
219 if (!Connector_Form_Encode(code, encoded_code, sizeof(encoded_code))) {
220 if (error)
221 snprintf(error->code, sizeof(error->code), "encode_code_failed");
222 return CONNECTOR_INVALID;
223 }
224 if (!Connector_Form_Encode(
225 verifier, encoded_verifier, sizeof(encoded_verifier))) {
226 if (error)
227 snprintf(error->code, sizeof(error->code), "encode_verifier_failed");
228 return CONNECTOR_INVALID;
229 }
230 if (!Connector_Form_Encode(
231 config->client_id, encoded_client, sizeof(encoded_client))) {
232 if (error)
233 snprintf(error->code, sizeof(error->code), "encode_client_id_failed");
234 return CONNECTOR_INVALID;
235 }
236 if (!Connector_Form_Encode(
237 config->client_secret, encoded_secret, sizeof(encoded_secret))) {
238 if (error)
239 snprintf(error->code, sizeof(error->code), "encode_client_secret_failed");
240 return CONNECTOR_INVALID;
241 }
242 if (!Connector_Form_Encode(
243 config->redirect_uri, encoded_redirect, sizeof(encoded_redirect))) {
244 if (error)
245 snprintf(error->code, sizeof(error->code), "encode_redirect_uri_failed");
246 return CONNECTOR_INVALID;
247 }
248 char body[12288];
249 int32 body_length = snprintf(
250 body, sizeof(body),
251 "code=%s&client_id=%s&client_secret=%s&redirect_uri=%s"
252 "&code_verifier=%s&grant_type=authorization_code",
253 encoded_code, encoded_client, encoded_secret, encoded_redirect,
254 encoded_verifier);
255 if (body_length <= 0 || (size_t)body_length >= sizeof(body)) {
256 if (error)
257 snprintf(error->code, sizeof(error->code), "token_request_too_large");
258 return CONNECTOR_INVALID;
259 }
260 Connector_HTTP_Response token = {0};
261 Connector_Status status = token_request(config, body, &token, error, arena);
262 if (status != CONNECTOR_OK)
263 return status;
264 if (error)
265 error->http_status = token.status_code;
266 if (!token.body || token.body_length == 0) {
267 if (error) {
268 snprintf(error->code, sizeof(error->code), "empty_token_response");
269 snprintf(
270 error->description, sizeof(error->description),
271 "Google token endpoint returned no response body");
272 }
273 return CONNECTOR_PROVIDER_ERROR;
274 }
275 if (!json_is_bounded(token.body, token.body_length)) {
276 if (error) {
277 snprintf(error->code, sizeof(error->code), "invalid_token_body");
278 snprintf(
279 error->description, sizeof(error->description),
280 "Google token response was not bounded JSON (%zu bytes)",
281 token.body_length);
282 }
283 return CONNECTOR_PROVIDER_ERROR;
284 }
285 Dowa_JSON_Value parsed = Dowa_JSON_Parse(
286 token.body, (int32)token.body_length, arena);
287 if (parsed.type != DOWA_JSON_OBJECT) {
288 if (error) {
289 snprintf(error->code, sizeof(error->code), "invalid_token_json");
290 snprintf(
291 error->description, sizeof(error->description),
292 "Google token response JSON could not be parsed (%zu bytes)",
293 token.body_length);
294 }
295 return CONNECTOR_PROVIDER_ERROR;
296 }
297 Dowa_JSON_Entry *object = parsed.object_val;
298 memset(account, 0, sizeof(*account));
299 strcpy(account->provider, "google");
300 if (!copy_json_string(
301 object, "access_token", account->access_token,
302 sizeof(account->access_token))) {
303 if (error)
304 snprintf(error->code, sizeof(error->code), "invalid_token_response");
305 return CONNECTOR_PROVIDER_ERROR;
306 }
307 char *refresh = Dowa_JSON_Get_String(object, "refresh_token");
308 if (refresh && strlen(refresh) < sizeof(account->refresh_token))
309 strcpy(account->refresh_token, refresh);
310 char *scope = Dowa_JSON_Get_String(object, "scope");
311 if (scope && strlen(scope) < sizeof(account->scopes))
312 strcpy(account->scopes, scope);
313 double expires = Dowa_JSON_Get_Number(object, "expires_in");
314 account->expires_at = (int64)time(NULL) + (int64)expires;
315
316 Connector_HTTP_Request identity_request = {
317 .method = "GET", .url = config->identity_url
318 };
319 Connector_HTTP_Response identity = {0};
320 status = transport(
321 config, &identity_request, account->access_token, &identity, arena);
322 if (status != CONNECTOR_OK || identity.status_code < 200 ||
323 identity.status_code >= 300 ||
324 !json_is_bounded(identity.body, identity.body_length)) {
325 if (error) {
326 error->http_status = identity.status_code;
327 snprintf(error->code, sizeof(error->code), "userinfo_failed");
328 snprintf(
329 error->description, sizeof(error->description),
330 "Google user-info lookup failed after token exchange");
331 }
332 return CONNECTOR_PROVIDER_ERROR;
333 }
334 parsed = Dowa_JSON_Parse(
335 identity.body, (int32)identity.body_length, arena);
336 if (parsed.type != DOWA_JSON_OBJECT)
337 return CONNECTOR_PROVIDER_ERROR;
338 object = parsed.object_val;
339 if (!copy_json_string(
340 object, "sub", account->provider_subject,
341 sizeof(account->provider_subject)) ||
342 !copy_json_string(object, "email", account->email, sizeof(account->email))) {
343 if (error)
344 snprintf(error->code, sizeof(error->code), "invalid_userinfo_response");
345 return CONNECTOR_PROVIDER_ERROR;
346 }
347 int32 written = snprintf(
348 account->account_id, sizeof(account->account_id), "google:%s",
349 account->provider_subject);
350 return written > 0 && (size_t)written < sizeof(account->account_id)
351 ? CONNECTOR_OK : CONNECTOR_PROVIDER_ERROR;
352 }
353
354 Connector_Status Connector_Google_Refresh(
355 const Connector_Google_Config *config, Connector_Account *account,
356 Dowa_Arena *arena)
357 {
358 if (!config || !account || !account->refresh_token[0] || !arena)
359 return CONNECTOR_INVALID;
360 char refresh[4096], client[1024], secret[2048], body[8192];
361 if (!Connector_Form_Encode(
362 account->refresh_token, refresh, sizeof(refresh)) ||
363 !Connector_Form_Encode(config->client_id, client, sizeof(client)) ||
364 !Connector_Form_Encode(
365 config->client_secret, secret, sizeof(secret)))
366 return CONNECTOR_INVALID;
367 int32 length = snprintf(
368 body, sizeof(body),
369 "refresh_token=%s&client_id=%s&client_secret=%s"
370 "&grant_type=refresh_token",
371 refresh, client, secret);
372 if (length <= 0 || (size_t)length >= sizeof(body))
373 return CONNECTOR_INVALID;
374 Connector_HTTP_Response token = {0};
375 Connector_Status status = token_request(config, body, &token, NULL, arena);
376 if (status != CONNECTOR_OK || !json_is_bounded(token.body, token.body_length))
377 return status == CONNECTOR_OK ? CONNECTOR_PROVIDER_ERROR : status;
378 Dowa_JSON_Value parsed = Dowa_JSON_Parse(
379 token.body, (int32)token.body_length, arena);
380 if (parsed.type != DOWA_JSON_OBJECT ||
381 !copy_json_string(
382 parsed.object_val, "access_token", account->access_token,
383 sizeof(account->access_token)))
384 return CONNECTOR_PROVIDER_ERROR;
385 account->expires_at = (int64)time(NULL) +
386 (int64)Dowa_JSON_Get_Number(parsed.object_val, "expires_in");
387 return CONNECTOR_OK;
388 }
389
390 Connector_Status Connector_Google_Revoke(
391 const Connector_Google_Config *config, const char *token, Dowa_Arena *arena)
392 {
393 if (!config || !token || !arena)
394 return CONNECTOR_INVALID;
395 char encoded[4096], body[4200];
396 if (!Connector_Form_Encode(token, encoded, sizeof(encoded)))
397 return CONNECTOR_INVALID;
398 snprintf(body, sizeof(body), "token=%s", encoded);
399 Connector_HTTP_Request request = {
400 .method = "POST", .url = config->oauth_revoke_url,
401 .content_type = "application/x-www-form-urlencoded",
402 .body = body, .body_length = strlen(body)
403 };
404 Connector_HTTP_Response response = {0};
405 Connector_Status status = transport(config, &request, NULL, &response, arena);
406 return status == CONNECTOR_OK && response.status_code >= 200 &&
407 response.status_code < 300 ? CONNECTOR_OK : CONNECTOR_PROVIDER_ERROR;
408 }
409
410 Connector_Status Connector_Google_Execute(
411 const Connector_Google_Config *config, Connector_Operation operation,
412 const Connector_Provider_Request *request, const char *access_token,
413 Connector_Provider_Response *response, Dowa_Arena *arena)
414 {
415 if (!config || !request || !access_token || !response || !arena ||
416 !Connector_Operation_Is_Allowed(operation) ||
417 request->body_length > CONNECTOR_MAX_JSON_BYTES)
418 return CONNECTOR_INVALID;
419 const char *base = operation <= CONNECTOR_OP_DRIVE_UPDATE
420 ? ((operation == CONNECTOR_OP_DRIVE_UPLOAD ||
421 operation == CONNECTOR_OP_DRIVE_UPDATE) && request->body_length
422 ? config->drive_upload_url : config->drive_api_url)
423 : config->gmail_api_url;
424 char *url = url_join(arena, base, request->path, request->query);
425 if (!url)
426 return CONNECTOR_ERROR;
427 Connector_HTTP_Request provider_request = {
428 .method = request->method,
429 .url = url,
430 .content_type = request->content_type,
431 .body = request->body,
432 .body_length = request->body_length,
433 .download_path = request->download_path
434 };
435 Connector_HTTP_Response provider_response = {0};
436 Connector_Status status = transport(
437 config, &provider_request, access_token, &provider_response, arena);
438 if (status != CONNECTOR_OK)
439 return status;
440 response->provider_status = provider_response.status_code;
441 response->body = provider_response.body;
442 response->body_length = provider_response.body_length;
443 response->status = provider_response.status_code >= 200 &&
444 provider_response.status_code < 300
445 ? CONNECTOR_OK : CONNECTOR_PROVIDER_ERROR;
446 return response->status;
447 }