import { createHash, randomBytes } from "node:crypto"; import { kcGetJson, kcSetJson, kcDelete } from "./keychain.js"; import { HOST, PORT } from "./config.js"; // ── Bridge-managed Google OAuth (one-click for Drive / Gmail / Calendar) ───────────────────── // // Why this exists: the community Google MCP servers each want a gcp-oauth.keys.json file plus a // one-time `npx … auth` CLI step. That's unacceptable friction for a consumer product. Instead the // bridge runs Google's OAuth itself — desktop-app authorization-code + PKCE — and stores the // resulting refresh token in the macOS Keychain (account ":google"). The stdio MCP server is // then handed a short-lived access token, so the user only ever clicks "Connect" and approves in // the browser. No files, no terminal. // // Ships gated: a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID / _SECRET) must be // present, and the app must clear Google's verification for the requested scopes (see // docs/Tims stuff/google-oauth-verification-checklist.md). Until the client id is configured, // googleClientConfigured() is false and callers surface an honest "being set up" state rather than // launching a server that will crash with no credentials. const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; // The loopback redirect must match exactly what's registered on the Google desktop OAuth client. export const GOOGLE_REDIRECT_URI = `http://${HOST}:${PORT}/google/callback`; function clientId() { const v = process.env.GOOGLE_OAUTH_CLIENT_ID; return v && v.trim() ? v.trim() : undefined; } function clientSecret() { const v = process.env.GOOGLE_OAUTH_CLIENT_SECRET; return v && v.trim() ? v.trim() : undefined; } // True once the app is shipped with a real Google OAuth client. Gates the whole flow. export function googleClientConfigured() { return clientId() !== undefined; } const pending = new Map(); function base64url(buf) { return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // Begin a sign-in: produce the Google consent URL and stash the PKCE verifier for this id. // Returns undefined if the Google client isn't configured yet (caller surfaces needs_setup). export function buildGoogleAuthUrl(id, scopes) { const cid = clientId(); if (!cid) return undefined; const verifier = base64url(randomBytes(32)); const challenge = base64url(createHash("sha256").update(verifier).digest()); pending.set(id, { verifier, scopes }); const params = new URLSearchParams({ client_id: cid, redirect_uri: GOOGLE_REDIRECT_URI, response_type: "code", scope: scopes.join(" "), code_challenge: challenge, code_challenge_method: "S256", access_type: "offline", // ask for a refresh token prompt: "consent", // force refresh-token issuance on re-consent state: id, // carry the connector id through the round-trip }); return `${AUTH_ENDPOINT}?${params.toString()}`; } // Complete a sign-in: exchange the authorization code for tokens and persist the refresh token. // Throws on any failure (missing flow, Google error) so the caller can report it honestly. export async function exchangeGoogleCode(id, code) { const flow = pending.get(id); pending.delete(id); if (!flow) throw new Error(`no pending Google sign-in for ${id}`); const cid = clientId(); if (!cid) throw new Error("Google OAuth client not configured"); const body = new URLSearchParams({ client_id: cid, code, code_verifier: flow.verifier, grant_type: "authorization_code", redirect_uri: GOOGLE_REDIRECT_URI, }); // Desktop clients may still carry a (non-secret) client secret; include it when present. const secret = clientSecret(); if (secret) body.set("client_secret", secret); const resp = await fetch(TOKEN_ENDPOINT, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, }); const json = (await resp.json()); if (!resp.ok) throw new Error(`Google token exchange failed: ${json.error ?? resp.status} ${json.error_description ?? ""}`.trim()); if (!json.refresh_token) { throw new Error("Google did not return a refresh token (revoke prior access and retry with prompt=consent)"); } const tokens = { refresh_token: json.refresh_token, access_token: json.access_token, expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined, scope: json.scope, }; await kcSetJson(`${id}:google`, tokens); return tokens; } // Return a currently-valid access token for this connector, refreshing if expired. Returns // undefined if the user hasn't signed in (no stored refresh token) — caller → needs_auth. export async function getValidGoogleAccessToken(id) { const stored = await kcGetJson(`${id}:google`); if (!stored?.refresh_token) return undefined; // 60s skew so we never hand out a token that's about to expire mid-call. if (stored.access_token && stored.expires_at && stored.expires_at - 60_000 > nowMs()) { return stored.access_token; } return refreshGoogleToken(id, stored); } async function refreshGoogleToken(id, stored) { const cid = clientId(); if (!cid) return undefined; const body = new URLSearchParams({ client_id: cid, refresh_token: stored.refresh_token, grant_type: "refresh_token", }); const secret = clientSecret(); if (secret) body.set("client_secret", secret); const resp = await fetch(TOKEN_ENDPOINT, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, }); const json = (await resp.json()); if (!resp.ok || !json.access_token) return undefined; const updated = { refresh_token: stored.refresh_token, // Google omits it on refresh; keep the original access_token: json.access_token, expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined, scope: json.scope ?? stored.scope, }; await kcSetJson(`${id}:google`, updated); return updated.access_token; } // True once the user has completed sign-in for this connector (a refresh token is stored). export async function hasGoogleTokens(id) { const stored = await kcGetJson(`${id}:google`); return !!stored?.refresh_token; } // Forget a connector's Google tokens (on remove / sign-out). export async function clearGoogleTokens(id) { await kcDelete(`${id}:google`); } // Wrapped so tests/builds without a real clock injection stay simple; Date.now is fine in the // bridge process (unlike the El soul, this is plain Node). function nowMs() { return Date.now(); }