Files
neuron-connectd/dist/google-oauth.js
Tim Lingo a01236753a feat(connectors): clean-install bundling + Google OAuth foundation
Make connectors work on a clean Mac with no Node/npm installed, and lay
the foundation for one-click Google sign-in.

Clean-install bundling:
- bundle scripts: `npm run bundle` -> self-contained dist/connectd.cjs (CJS,
  no node_modules); `bundle:servers` -> pre-bundled MCP servers (fs-server.mjs
  for "Your files", runs without npx); `bundle:all`.
- runtime resolution in the bridge: a connector config can use command "@node"
  (the bundled runtime, process.execPath) and arg "@bundled/<server>" (a server
  shipped alongside the bridge), resolved at spawn time via
  NEURON_CONNECTD_BUNDLE_DIR. So remote connectors + "Your files" work offline,
  zero deps.
- graceful ENOENT -> needs_setup ("coming soon") instead of a cryptic spawn
  crash for not-yet-bundled npx connectors on a clean machine.

Google OAuth foundation (google-oauth.ts):
- desktop-style authorization-code + PKCE flow, refresh, Keychain storage,
  gated on GOOGLE_OAUTH_CLIENT_ID (honest needs_setup until configured).
- new auth mode "google" for stdio connectors + /google/oauth/start and
  /google/callback endpoints. NOTE: pending the Neuron Google OAuth client +
  Google verification; not certified end-to-end yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:27:04 -05:00

156 lines
6.8 KiB
JavaScript

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 "<id>: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();
}