google-oauth-connector.mjs
824 lines 26.7 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Google Calendar OAuth connector — Phase 1D (read-only, server-side PKCE + confidential client).
3 *
4 * Gated by CALENDAR_OAUTH_GOOGLE_AUTHORIZED (compile-time; KN-INF-3a flip 2026-07-07). Tests inject
5 * authorizedOverride via handler options — production routes never pass it.
6 *
7 * @see docs/CALENDAR-OAUTH-CONNECTOR-1D-SPEC.md
8 */
9
10 import crypto from 'crypto';
11 import {
12 createPkcePair,
13 createOAuthState,
14 constantTimeEqual,
15 validateAuthorizationResponse,
16 validateTokenResponse,
17 PKCE_METHOD_S256,
18 } from '../companion-oauth-pkce.mjs';
19 import {
20 buildEventId,
21 connectorForClient,
22 countSourceCalendarsForConnector,
23 getConnector,
24 listConnectors,
25 loadCalendarStore,
26 purgeConnectorData,
27 saveConnector,
28 upsertGoogleSourceCalendar,
29 upsertNormalizedEvents,
30 } from './event-store.mjs';
31 import {
32 deleteOAuthTokenVault,
33 readOAuthTokenVault,
34 writeOAuthTokenVault,
35 } from './oauth-token-vault.mjs';
36 import { normalizeGoogleEvents } from './google-event-normalizer.mjs';
37
38 /** Tier 3 compile-time gate — flipped 2026-07-07 (KN-INF-3a hosted calendar OAuth). */
39 export const CALENDAR_OAUTH_GOOGLE_AUTHORIZED = true;
40
41 export const GOOGLE_OAUTH_SCOPES = Object.freeze([
42 'openid',
43 'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
44 'https://www.googleapis.com/auth/calendar.events.readonly',
45 ]);
46
47 export const CONNECTOR_SYNC_RATE_LIMIT_MS = 60_000;
48 export const OAUTH_STATE_TTL_MS = 10 * 60_000;
49 export const REVOKE_SLA_HOURS = 24;
50 export const SYNC_PAST_DAYS = 90;
51 export const SYNC_FUTURE_DAYS = 365;
52
53 const GOOGLE_AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
54 const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
55 const GOOGLE_REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
56 const GOOGLE_USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo';
57
58 /** @type {Map<string, number>} */
59 const lastSyncByConnector = new Map();
60
61 /**
62 * @typedef {Object} GoogleOAuthConnectorEnv
63 * @property {string} [CALENDAR_OAUTH_GOOGLE_AUTHORIZED]
64 * @property {string} [GOOGLE_CALENDAR_OAUTH_CLIENT_ID]
65 * @property {string} [GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET]
66 * @property {string} [KNOWTATION_CALENDAR_OAUTH_SECRET]
67 * @property {string} [CALENDAR_OAUTH_REDIRECT_URI]
68 * @property {string} [SCOOLING_RETURN_URL_ALLOWLIST]
69 */
70
71 /**
72 * @typedef {Object} GoogleOAuthClient
73 * @property {(input: { url: string, method?: string, headers?: Record<string,string>, body?: string }) => Promise<{ ok: boolean, status: number, json: () => Promise<unknown>, headers?: { get?: (k: string) => string|null } }>} fetch
74 * @property {(accessToken: string) => Promise<{ items: unknown[], nextPageToken?: string }>} calendarList
75 * @property {(accessToken: string, calendarId: string, opts: { timeMin: string, timeMax: string, syncToken?: string }) => Promise<{ items: unknown[], nextSyncToken?: string, status?: number }>} eventsList
76 */
77
78 /**
79 * @param {{ authorizedOverride?: boolean }} [opts]
80 * @returns {boolean}
81 */
82 export function isGoogleOAuthConnectorEnabled(opts = {}) {
83 if (opts.authorizedOverride === false) {
84 return false;
85 }
86 if (opts.authorizedOverride === true) {
87 return true;
88 }
89 return CALENDAR_OAUTH_GOOGLE_AUTHORIZED === true;
90 }
91
92 /**
93 * @param {GoogleOAuthConnectorEnv} env
94 * @returns {{ clientId: string, clientSecret: string, vaultSecret: string, redirectUri: string, returnAllowlist: string[] }}
95 */
96 export function readGoogleOAuthEnv(env = process.env) {
97 const clientId = typeof env.GOOGLE_CALENDAR_OAUTH_CLIENT_ID === 'string'
98 ? env.GOOGLE_CALENDAR_OAUTH_CLIENT_ID.trim()
99 : '';
100 const clientSecret = typeof env.GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET === 'string'
101 ? env.GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET.trim()
102 : '';
103 const vaultSecret = typeof env.KNOWTATION_CALENDAR_OAUTH_SECRET === 'string'
104 ? env.KNOWTATION_CALENDAR_OAUTH_SECRET.trim()
105 : '';
106 const redirectUri = typeof env.CALENDAR_OAUTH_REDIRECT_URI === 'string'
107 ? env.CALENDAR_OAUTH_REDIRECT_URI.trim()
108 : '';
109 const allowRaw = typeof env.SCOOLING_RETURN_URL_ALLOWLIST === 'string'
110 ? env.SCOOLING_RETURN_URL_ALLOWLIST
111 : '';
112 const returnAllowlist = allowRaw.split(',').map((s) => s.trim()).filter(Boolean);
113 return { clientId, clientSecret, vaultSecret, redirectUri, returnAllowlist };
114 }
115
116 /**
117 * @param {string} returnUrl
118 * @param {string[]} allowlist
119 * @returns {boolean}
120 */
121 export function isReturnUrlAllowed(returnUrl, allowlist) {
122 if (typeof returnUrl !== 'string' || !returnUrl.trim()) {
123 return false;
124 }
125 return allowlist.some((allowed) => constantTimeEqual(returnUrl, allowed));
126 }
127
128 /**
129 * @param {string} redirectUri
130 * @param {string} expected
131 * @returns {boolean}
132 */
133 export function isRedirectUriAllowed(redirectUri, expected) {
134 return typeof redirectUri === 'string'
135 && typeof expected === 'string'
136 && redirectUri.length > 0
137 && constantTimeEqual(redirectUri, expected);
138 }
139
140 /**
141 * Build Google authorization URL (confidential client + PKCE S256).
142 *
143 * @param {{ clientId: string, redirectUri: string, state: string, codeChallenge: string }} params
144 * @returns {string}
145 */
146 export function buildGoogleAuthorizationUrl(params) {
147 const { clientId, redirectUri, state, codeChallenge } = params;
148 const url = new URL(GOOGLE_AUTH_ENDPOINT);
149 const q = url.searchParams;
150 q.set('response_type', 'code');
151 q.set('client_id', clientId);
152 q.set('redirect_uri', redirectUri);
153 q.set('scope', GOOGLE_OAUTH_SCOPES.join(' '));
154 q.set('state', state);
155 q.set('code_challenge', codeChallenge);
156 q.set('code_challenge_method', PKCE_METHOD_S256);
157 q.set('access_type', 'offline');
158 q.set('prompt', 'consent');
159 return url.toString();
160 }
161
162 /**
163 * @param {string} vaultId
164 * @param {string} connectorId
165 * @param {string} returnUrl
166 * @returns {string}
167 */
168 export function buildOAuthStateBinding(vaultId, connectorId, returnUrl) {
169 return crypto.createHash('sha256')
170 .update(`${vaultId}:${connectorId}:${returnUrl}`, 'utf8')
171 .digest('base64url');
172 }
173
174 /**
175 * @param {number} [nowMs]
176 * @returns {{ timeMin: string, timeMax: string }}
177 */
178 export function buildSyncHorizon(nowMs = Date.now()) {
179 const past = new Date(nowMs - SYNC_PAST_DAYS * 86_400_000);
180 const future = new Date(nowMs + SYNC_FUTURE_DAYS * 86_400_000);
181 return { timeMin: past.toISOString(), timeMax: future.toISOString() };
182 }
183
184 /**
185 * @returns {string}
186 */
187 function newConnectorId() {
188 return `conn_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`;
189 }
190
191 /**
192 * @param {{ ok: false, status: number, code: string, error?: string }} result
193 */
194 export function notAuthorizedResult() {
195 return { ok: false, status: 501, code: 'NOT_AUTHORIZED' };
196 }
197
198 /**
199 * POST /calendar/connectors — begin Google OAuth connect.
200 *
201 * @param {{
202 * dataDir: string,
203 * vaultId: string,
204 * body: unknown,
205 * env?: GoogleOAuthConnectorEnv,
206 * now?: number,
207 * authorizedOverride?: boolean,
208 * }} ctx
209 */
210 export function handleBeginGoogleConnector(ctx) {
211 if (!isGoogleOAuthConnectorEnabled({ authorizedOverride: ctx.authorizedOverride })) {
212 return notAuthorizedResult();
213 }
214 const env = readGoogleOAuthEnv(ctx.env);
215 if (!env.clientId || !env.clientSecret || !env.vaultSecret || !env.redirectUri) {
216 return { ok: false, status: 500, code: 'RUNTIME_ERROR', error: 'OAuth not configured' };
217 }
218 const body = ctx.body && typeof ctx.body === 'object' ? ctx.body : {};
219 if (body.provider !== 'google') {
220 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'provider must be google' };
221 }
222 const returnUrl = typeof body.return_url === 'string' ? body.return_url.trim() : '';
223 if (!isReturnUrlAllowed(returnUrl, env.returnAllowlist)) {
224 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'return_url not allowlisted' };
225 }
226 const displayName = typeof body.display_name === 'string' && body.display_name.trim()
227 ? body.display_name.trim().slice(0, 120)
228 : 'Google Calendar';
229 const now = ctx.now ?? Date.now();
230 const pkce = createPkcePair();
231 const state = createOAuthState();
232 const connectorId = newConnectorId();
233 const expiresAt = new Date(now + OAUTH_STATE_TTL_MS).toISOString();
234 const connector = {
235 connector_id: connectorId,
236 provider: 'google',
237 display_name: displayName,
238 status: /** @type {'pending'} */ ('pending'),
239 oauth_ref: null,
240 account_sub: null,
241 sync_cursors: {},
242 last_sync_at: null,
243 last_sync_error: null,
244 revoked_at: null,
245 oauth_pending: {
246 state,
247 code_verifier: pkce.codeVerifier,
248 return_url: returnUrl,
249 state_binding: buildOAuthStateBinding(ctx.vaultId, connectorId, returnUrl),
250 expires_at: expiresAt,
251 },
252 };
253 saveConnector(ctx.dataDir, ctx.vaultId, connector);
254 const authorizationUrl = buildGoogleAuthorizationUrl({
255 clientId: env.clientId,
256 redirectUri: env.redirectUri,
257 state,
258 codeChallenge: pkce.codeChallenge,
259 });
260 return {
261 ok: true,
262 status: 200,
263 payload: {
264 connector_id: connectorId,
265 authorization_url: authorizationUrl,
266 expires_at: expiresAt,
267 },
268 };
269 }
270
271 /**
272 * @param {Record<string, unknown>} pending
273 * @param {number} now
274 * @returns {boolean}
275 */
276 function isPendingStateValid(pending, now) {
277 if (!pending || typeof pending !== 'object') {
278 return false;
279 }
280 const expiresAt = pending.expires_at;
281 if (typeof expiresAt !== 'string') {
282 return false;
283 }
284 return Date.parse(expiresAt) > now;
285 }
286
287 /**
288 * Exchange authorization code for tokens (server-side confidential client).
289 *
290 * @param {GoogleOAuthClient} client
291 * @param {{ clientId: string, clientSecret: string, redirectUri: string, code: string, codeVerifier: string }} params
292 */
293 async function exchangeAuthorizationCode(client, params) {
294 const body = new URLSearchParams({
295 grant_type: 'authorization_code',
296 code: params.code,
297 redirect_uri: params.redirectUri,
298 client_id: params.clientId,
299 client_secret: params.clientSecret,
300 code_verifier: params.codeVerifier,
301 }).toString();
302 const res = await client.fetch({
303 url: GOOGLE_TOKEN_ENDPOINT,
304 method: 'POST',
305 headers: {
306 'Content-Type': 'application/x-www-form-urlencoded',
307 Accept: 'application/json',
308 },
309 body,
310 });
311 if (!res.ok) {
312 return { ok: false, errorCode: 'token_exchange_failed' };
313 }
314 const validated = validateTokenResponse(await res.json());
315 if (!validated.ok) {
316 return { ok: false, errorCode: validated.errorCode ?? 'invalid_token_response' };
317 }
318 return { ok: true, tokens: validated };
319 }
320
321 /**
322 * @param {GoogleOAuthClient} client
323 * @param {string} accessToken
324 */
325 async function fetchOpenIdSub(client, accessToken) {
326 const res = await client.fetch({
327 url: GOOGLE_USERINFO_ENDPOINT,
328 headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
329 });
330 if (!res.ok) {
331 return null;
332 }
333 const json = await res.json();
334 if (!json || typeof json !== 'object' || typeof json.sub !== 'string') {
335 return null;
336 }
337 return json.sub;
338 }
339
340 /**
341 * @param {string} dataDir
342 * @param {string} state
343 * @returns {{ vaultId: string, connector: import('./event-store.mjs').StoredCalendarConnector } | null}
344 */
345 export function findPendingConnectorByState(dataDir, state) {
346 if (typeof state !== 'string' || !state.trim()) {
347 return null;
348 }
349 const store = loadCalendarStore(dataDir);
350 for (const [vaultId, vault] of Object.entries(store.vaults)) {
351 for (const connector of vault.connectors ?? []) {
352 if (connector.status !== 'pending' || !connector.oauth_pending) {
353 continue;
354 }
355 if (constantTimeEqual(connector.oauth_pending.state, state)) {
356 return { vaultId, connector };
357 }
358 }
359 }
360 return null;
361 }
362
363 /**
364 * GET /calendar/connectors/callback
365 *
366 * @param {{
367 * dataDir: string,
368 * query: Record<string, unknown>,
369 * googleClient: GoogleOAuthClient,
370 * env?: GoogleOAuthConnectorEnv,
371 * now?: number,
372 * authorizedOverride?: boolean,
373 * }} ctx
374 */
375 export async function handleGoogleConnectorCallback(ctx) {
376 if (!isGoogleOAuthConnectorEnabled({ authorizedOverride: ctx.authorizedOverride })) {
377 return { ok: false, status: 501, redirect: null, code: 'NOT_AUTHORIZED' };
378 }
379 const env = readGoogleOAuthEnv(ctx.env);
380 const now = ctx.now ?? Date.now();
381 const gotState = typeof ctx.query.state === 'string' ? ctx.query.state : '';
382 const located = findPendingConnectorByState(ctx.dataDir, gotState);
383 const fallbackReturn = env.returnAllowlist[0] ?? '/';
384
385 if (!located) {
386 const url = new URL(fallbackReturn);
387 url.searchParams.set('connect', 'error');
388 url.searchParams.set('reason', 'state_invalid');
389 return { ok: false, status: 302, redirect: url.toString(), code: 'STATE_INVALID' };
390 }
391
392 const { vaultId, connector: matched } = located;
393 const pending = matched.oauth_pending;
394
395 if (!pending || !isPendingStateValid(pending, now)) {
396 const url = new URL(pending?.return_url ?? fallbackReturn);
397 url.searchParams.set('connect', 'error');
398 url.searchParams.set('reason', 'state_expired');
399 return { ok: false, status: 302, redirect: url.toString(), code: 'STATE_EXPIRED' };
400 }
401
402 const auth = validateAuthorizationResponse({
403 params: ctx.query,
404 expectedState: pending.state,
405 });
406 if (!auth.ok) {
407 const url = new URL(pending.return_url);
408 url.searchParams.set('connect', 'error');
409 url.searchParams.set('reason', 'state_invalid');
410 return { ok: false, status: 302, redirect: url.toString(), code: 'STATE_INVALID' };
411 }
412
413 const returnUrl = pending.return_url;
414 const failRedirect = (reason) => {
415 const url = new URL(returnUrl);
416 url.searchParams.set('connect', 'error');
417 url.searchParams.set('reason', reason);
418 return { ok: false, status: 302, redirect: url.toString(), code: reason.toUpperCase() };
419 };
420
421 const exchange = await exchangeAuthorizationCode(ctx.googleClient, {
422 clientId: env.clientId,
423 clientSecret: env.clientSecret,
424 redirectUri: env.redirectUri,
425 code: auth.code,
426 codeVerifier: pending.code_verifier,
427 });
428
429 if (!exchange.ok || !exchange.tokens.refreshToken) {
430 return failRedirect('token_exchange_failed');
431 }
432
433 const accountSub = await fetchOpenIdSub(ctx.googleClient, exchange.tokens.accessToken) ?? 'unknown';
434 writeOAuthTokenVault(ctx.dataDir, matched.connector_id, env.vaultSecret, {
435 refresh_token: exchange.tokens.refreshToken,
436 scope: exchange.tokens.scope ?? GOOGLE_OAUTH_SCOPES.join(' '),
437 token_type: exchange.tokens.tokenType,
438 obtained_at: new Date(now).toISOString(),
439 account_sub: accountSub,
440 });
441
442 matched.status = 'connected';
443 matched.oauth_ref = matched.connector_id;
444 matched.account_sub = accountSub;
445 matched.oauth_pending = null;
446 matched.last_sync_error = 'none';
447 saveConnector(ctx.dataDir, vaultId, matched);
448
449 await runInitialGoogleSync(ctx.dataDir, vaultId, matched.connector_id, ctx.googleClient, env, now);
450
451 const okUrl = new URL(returnUrl);
452 okUrl.searchParams.set('connect', 'ok');
453 return { ok: true, status: 302, redirect: okUrl.toString(), code: 'OK' };
454 }
455
456 /**
457 * Refresh access token using stored refresh token.
458 *
459 * @param {GoogleOAuthClient} client
460 * @param {{ clientId: string, clientSecret: string, refreshToken: string }} params
461 */
462 async function refreshAccessToken(client, params) {
463 const body = new URLSearchParams({
464 grant_type: 'refresh_token',
465 refresh_token: params.refreshToken,
466 client_id: params.clientId,
467 client_secret: params.clientSecret,
468 }).toString();
469 const res = await client.fetch({
470 url: GOOGLE_TOKEN_ENDPOINT,
471 method: 'POST',
472 headers: {
473 'Content-Type': 'application/x-www-form-urlencoded',
474 Accept: 'application/json',
475 },
476 body,
477 });
478 if (!res.ok) {
479 return { ok: false, errorCode: 'refresh_failed' };
480 }
481 const validated = validateTokenResponse(await res.json());
482 if (!validated.ok) {
483 return { ok: false, errorCode: validated.errorCode ?? 'invalid_grant' };
484 }
485 return { ok: true, accessToken: validated.accessToken };
486 }
487
488 /**
489 * @param {string} dataDir
490 * @param {string} vaultId
491 * @param {string} connectorId
492 * @param {GoogleOAuthClient} googleClient
493 * @param {ReturnType<typeof readGoogleOAuthEnv>} env
494 * @param {number} [nowMs]
495 */
496 export async function runInitialGoogleSync(dataDir, vaultId, connectorId, googleClient, env, nowMs = Date.now()) {
497 return runGoogleConnectorSync(dataDir, vaultId, connectorId, googleClient, env, nowMs, { skipRateLimit: true });
498 }
499
500 /**
501 * @param {string} dataDir
502 * @param {string} vaultId
503 * @param {string} connectorId
504 * @param {GoogleOAuthClient} googleClient
505 * @param {ReturnType<typeof readGoogleOAuthEnv>} env
506 * @param {number} nowMs
507 * @param {{ skipRateLimit?: boolean }} [opts]
508 */
509 export async function runGoogleConnectorSync(
510 dataDir,
511 vaultId,
512 connectorId,
513 googleClient,
514 env,
515 nowMs,
516 opts = {},
517 ) {
518 const connector = getConnector(dataDir, vaultId, connectorId);
519 if (!connector || connector.status !== 'connected') {
520 throw new Error('Connector not connected');
521 }
522 if (!opts.skipRateLimit) {
523 const last = lastSyncByConnector.get(connectorId) ?? 0;
524 if (nowMs - last < CONNECTOR_SYNC_RATE_LIMIT_MS) {
525 return { ok: false, code: 'rate_limited' };
526 }
527 }
528 let tokenPayload;
529 try {
530 tokenPayload = readOAuthTokenVault(dataDir, connectorId, env.vaultSecret);
531 } catch {
532 connector.status = 'needs_reauth';
533 connector.last_sync_error = 'auth_expired';
534 saveConnector(dataDir, vaultId, connector);
535 return { ok: false, code: 'auth_expired' };
536 }
537 const refreshed = await refreshAccessToken(googleClient, {
538 clientId: env.clientId,
539 clientSecret: env.clientSecret,
540 refreshToken: tokenPayload.refresh_token,
541 });
542 if (!refreshed.ok) {
543 connector.status = refreshed.errorCode === 'invalid_grant' ? 'needs_reauth' : connector.status;
544 connector.last_sync_error = refreshed.errorCode === 'invalid_grant' ? 'auth_expired' : 'provider_error';
545 saveConnector(dataDir, vaultId, connector);
546 return { ok: false, code: connector.last_sync_error };
547 }
548 const accessToken = refreshed.accessToken;
549 const list = await googleClient.calendarList(accessToken);
550 const horizon = buildSyncHorizon(nowMs);
551 let synced = 0;
552 let updated = 0;
553 let tombstoned = 0;
554 if (!connector.sync_cursors) {
555 connector.sync_cursors = {};
556 }
557
558 for (const item of list.items) {
559 if (!item || typeof item !== 'object') {
560 continue;
561 }
562 const row = /** @type {Record<string, unknown>} */ (item);
563 const googleId = typeof row.id === 'string' ? row.id : '';
564 if (!googleId) {
565 continue;
566 }
567 const calSummary = typeof row.summary === 'string' ? row.summary : 'Google calendar';
568 const sourceCalendar = upsertGoogleSourceCalendar(
569 dataDir,
570 vaultId,
571 connectorId,
572 googleId,
573 calSummary,
574 );
575 const syncToken = connector.sync_cursors[sourceCalendar.source_calendar_id];
576 const eventsResult = await googleClient.eventsList(accessToken, googleId, {
577 timeMin: horizon.timeMin,
578 timeMax: horizon.timeMax,
579 ...(syncToken ? { syncToken } : {}),
580 });
581 if (eventsResult.status === 410) {
582 delete connector.sync_cursors[sourceCalendar.source_calendar_id];
583 const full = await googleClient.eventsList(accessToken, googleId, {
584 timeMin: horizon.timeMin,
585 timeMax: horizon.timeMax,
586 });
587 const normalized = normalizeGoogleEvents(full.items);
588 const counts = upsertNormalizedEvents(dataDir, vaultId, sourceCalendar.source_calendar_id, normalized);
589 synced += counts.imported;
590 updated += counts.updated;
591 tombstoned += counts.tombstoned;
592 if (full.nextSyncToken) {
593 connector.sync_cursors[sourceCalendar.source_calendar_id] = full.nextSyncToken;
594 }
595 continue;
596 }
597 const normalized = normalizeGoogleEvents(eventsResult.items);
598 const counts = upsertNormalizedEvents(dataDir, vaultId, sourceCalendar.source_calendar_id, normalized);
599 synced += counts.imported;
600 updated += counts.updated;
601 tombstoned += counts.tombstoned;
602 if (eventsResult.nextSyncToken) {
603 connector.sync_cursors[sourceCalendar.source_calendar_id] = eventsResult.nextSyncToken;
604 }
605 }
606
607 connector.last_sync_at = new Date(nowMs).toISOString();
608 connector.last_sync_error = 'none';
609 saveConnector(dataDir, vaultId, connector);
610 lastSyncByConnector.set(connectorId, nowMs);
611 return { ok: true, synced, updated, tombstoned, last_sync_at: connector.last_sync_at };
612 }
613
614 /**
615 * GET /calendar/connectors
616 *
617 * @param {{ dataDir: string, vaultId: string, authorizedOverride?: boolean }} ctx
618 */
619 export function handleListGoogleConnectors(ctx) {
620 if (!isGoogleOAuthConnectorEnabled({ authorizedOverride: ctx.authorizedOverride })) {
621 return notAuthorizedResult();
622 }
623 const connectors = listConnectors(ctx.dataDir, ctx.vaultId)
624 .filter((c) => c.provider === 'google')
625 .map((c) => connectorForClient(c, countSourceCalendarsForConnector(ctx.dataDir, ctx.vaultId, c.connector_id)));
626 return {
627 ok: true,
628 status: 200,
629 payload: {
630 schema: 'knowtation.calendar_connectors/v0',
631 vault_id: ctx.vaultId,
632 connectors,
633 },
634 };
635 }
636
637 /**
638 * POST /calendar/connectors/:id/sync
639 */
640 export async function handleSyncGoogleConnector(ctx) {
641 if (!isGoogleOAuthConnectorEnabled({ authorizedOverride: ctx.authorizedOverride })) {
642 return notAuthorizedResult();
643 }
644 const env = readGoogleOAuthEnv(ctx.env);
645 const now = ctx.now ?? Date.now();
646 const connectorId = ctx.connectorId;
647 const result = await runGoogleConnectorSync(
648 ctx.dataDir,
649 ctx.vaultId,
650 connectorId,
651 ctx.googleClient,
652 env,
653 now,
654 );
655 if (!result.ok) {
656 if (result.code === 'rate_limited') {
657 return { ok: false, status: 429, code: 'RATE_LIMITED' };
658 }
659 if (result.code === 'auth_expired') {
660 return { ok: false, status: 409, code: 'AUTH_EXPIRED' };
661 }
662 return { ok: false, status: 502, code: 'PROVIDER_ERROR' };
663 }
664 return {
665 ok: true,
666 status: 200,
667 payload: {
668 synced: result.synced,
669 updated: result.updated,
670 tombstoned: result.tombstoned,
671 last_sync_at: result.last_sync_at,
672 },
673 };
674 }
675
676 /**
677 * DELETE /calendar/connectors/:id
678 */
679 export async function handleRevokeGoogleConnector(ctx) {
680 if (!isGoogleOAuthConnectorEnabled({ authorizedOverride: ctx.authorizedOverride })) {
681 return notAuthorizedResult();
682 }
683 const env = readGoogleOAuthEnv(ctx.env);
684 const connector = getConnector(ctx.dataDir, ctx.vaultId, ctx.connectorId);
685 if (!connector) {
686 return { ok: false, status: 404, code: 'NOT_FOUND' };
687 }
688 try {
689 const payload = readOAuthTokenVault(ctx.dataDir, ctx.connectorId, env.vaultSecret);
690 await ctx.googleClient.fetch({
691 url: GOOGLE_REVOKE_ENDPOINT,
692 method: 'POST',
693 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
694 body: new URLSearchParams({ token: payload.refresh_token }).toString(),
695 });
696 } catch {
697 // best-effort revoke
698 }
699 deleteOAuthTokenVault(ctx.dataDir, ctx.connectorId);
700 const eventsDeleted = purgeConnectorData(ctx.dataDir, ctx.vaultId, ctx.connectorId);
701 connector.status = 'revoked';
702 connector.revoked_at = new Date(ctx.now ?? Date.now()).toISOString();
703 connector.oauth_ref = null;
704 connector.oauth_pending = null;
705 saveConnector(ctx.dataDir, ctx.vaultId, connector);
706 return {
707 ok: true,
708 status: 200,
709 payload: {
710 revoked: true,
711 events_deleted: eventsDeleted,
712 sla_hours: REVOKE_SLA_HOURS,
713 },
714 };
715 }
716
717 export function createFakeGoogleClient(fixtures = {}) {
718 /** @type {unknown[]} */
719 const tokenCalls = [];
720 return {
721 fetch: async (input) => {
722 tokenCalls.push(input);
723 if (input.url.includes('/token')) {
724 return {
725 ok: true,
726 status: 200,
727 json: async () => fixtures.tokenResponse ?? {
728 access_token: 'access_test_token',
729 refresh_token: 'refresh_test_token',
730 token_type: 'Bearer',
731 expires_in: 3600,
732 scope: GOOGLE_OAUTH_SCOPES.join(' '),
733 },
734 };
735 }
736 if (input.url.includes('userinfo')) {
737 return {
738 ok: true,
739 status: 200,
740 json: async () => fixtures.userinfo ?? { sub: 'google-sub-123' },
741 };
742 }
743 if (input.url.includes('revoke')) {
744 return { ok: true, status: 200, json: async () => ({}) };
745 }
746 return { ok: false, status: 404, json: async () => ({}) };
747 },
748 calendarList: async () => ({
749 items: fixtures.calendars ?? [{ id: 'primary', summary: 'Primary' }],
750 }),
751 eventsList: async (_accessToken, calendarId, opts) => {
752 const bucket = fixtures.eventsByCalendar?.[calendarId];
753 if (bucket?.status === 410) {
754 return { items: [], status: 410 };
755 }
756 if (opts.syncToken && fixtures.eventsByCalendar?.[`${calendarId}:incremental`]) {
757 return fixtures.eventsByCalendar[`${calendarId}:incremental`];
758 }
759 return bucket ?? {
760 items: [{
761 id: 'evt-1',
762 summary: 'Team standup',
763 start: { dateTime: '2026-06-18T17:00:00Z', timeZone: 'UTC' },
764 end: { dateTime: '2026-06-18T17:30:00Z', timeZone: 'UTC' },
765 status: 'confirmed',
766 }],
767 nextSyncToken: 'sync-token-1',
768 };
769 },
770 };
771 }
772
773 /**
774 * Production Google API client (real fetch). Only invoked when gate is enabled.
775 *
776 * @returns {GoogleOAuthClient}
777 */
778 export function createProductionGoogleClient() {
779 const fetchImpl = globalThis.fetch.bind(globalThis);
780 return {
781 fetch: async (input) => {
782 const res = await fetchImpl(input.url, {
783 method: input.method ?? 'GET',
784 headers: input.headers,
785 ...(input.body !== undefined ? { body: input.body } : {}),
786 });
787 return {
788 ok: res.ok,
789 status: res.status,
790 json: () => res.json(),
791 headers: { get: (k) => res.headers.get(k) },
792 };
793 },
794 calendarList: async (accessToken) => {
795 const res = await fetchImpl('https://www.googleapis.com/calendar/v3/users/me/calendarList', {
796 headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
797 });
798 const json = await res.json();
799 return { items: Array.isArray(json.items) ? json.items : [] };
800 },
801 eventsList: async (accessToken, calendarId, opts) => {
802 const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`);
803 url.searchParams.set('singleEvents', 'true');
804 url.searchParams.set('orderBy', 'startTime');
805 if (opts.timeMin) url.searchParams.set('timeMin', opts.timeMin);
806 if (opts.timeMax) url.searchParams.set('timeMax', opts.timeMax);
807 if (opts.syncToken) url.searchParams.set('syncToken', opts.syncToken);
808 const res = await fetchImpl(url.toString(), {
809 headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
810 });
811 if (res.status === 410) {
812 return { items: [], status: 410 };
813 }
814 const json = await res.json();
815 return {
816 items: Array.isArray(json.items) ? json.items : [],
817 nextSyncToken: typeof json.nextSyncToken === 'string' ? json.nextSyncToken : undefined,
818 status: res.status,
819 };
820 },
821 };
822 }
823
824 export { buildEventId };
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago