a01236753a
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>
156 lines
6.8 KiB
JavaScript
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();
|
|
}
|