// OAuth for remote MCP servers — the MCP authorization spec (2025-06-18). // // This is what removes "go to a cloud console and download a JSON" from connecting. The remote MCP // server is the OAuth *resource server*; it points at its own authorization server, and the client // registers ITSELF at connect time via Dynamic Client Registration (RFC 7591). So Ada needs no // pre-registered client id per service, and the vendor running the MCP server owns the upstream // app registration. // // Deliberately NOT used for stdio servers: the spec says stdio implementations SHOULD NOT follow // it and should take credentials from the environment instead, which is what mcp.ts already does. // // 401 + WWW-Authenticate → /.well-known/oauth-protected-resource // → /.well-known/oauth-authorization-server // → POST /register (dynamic client registration) // → browser: /authorize + PKCE // → 127.0.0.1 callback (loopback, per OAuth 2.1) // → POST /token → access + refresh token import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; export interface AsMetadata { issuer?: string; authorization_endpoint: string; token_endpoint: string; registration_endpoint?: string; code_challenge_methods_supported?: string[]; } export interface StoredAuth { access_token: string; refresh_token?: string; expires_at?: number; client_id: string; client_secret?: string; token_endpoint: string; } /** Tokens live outside any project: they belong to you and this machine, not to a checkout. */ const storePath = (): string => join(homedir(), ".ada", "mcp-auth.json"); /** * Encrypt the token store when the app has given us a key. * * File permissions are not protection here: the store was written 0600, and NTFS does not enforce * POSIX bits at all — on Windows that file was readable by anything running as the user. The key * comes from ADA_TOKEN_KEY, which the desktop app derives through the OS keystore (DPAPI on * Windows, Keychain on macOS) and passes to this process. * * No key means plaintext, exactly as before: `ada serve` run on its own still has to work. */ function storeKey(): Buffer | null { const raw = process.env.ADA_TOKEN_KEY; if (!raw) return null; try { const k = Buffer.from(raw, "base64"); return k.length === 32 ? k : null; } catch { return null; } } const ENC_PREFIX = "ada-enc-v1:"; // so a plaintext store is recognisable and can be migrated function encryptStore(json: string, key: Buffer): string { const iv = randomBytes(12); const c = createCipheriv("aes-256-gcm", key, iv); const body = Buffer.concat([c.update(json, "utf8"), c.final()]); return ENC_PREFIX + Buffer.concat([iv, c.getAuthTag(), body]).toString("base64"); } function decryptStore(text: string, key: Buffer): string | null { try { const buf = Buffer.from(text.slice(ENC_PREFIX.length), "base64"); const d = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12)); d.setAuthTag(buf.subarray(12, 28)); return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString("utf8"); } catch { return null; // wrong key, or tampered — treated as "no tokens", never as a crash } } /** * Set when the file on disk is encrypted and THIS process cannot read it. * * "Cannot read it" was treated as "there is nothing here", and the next write then replaced a store * full of tokens with whatever this process happened to know — in plaintext. Every sign-in on the * machine, gone, silently. It takes only running `ada` from a terminal, where the desktop app's key * is absent, next to a store the app wrote. */ let storeLocked = false; function readStore(): Record { let text: string; storeLocked = false; try { text = readFileSync(storePath(), "utf8"); } catch { return {}; } if (text.startsWith(ENC_PREFIX)) { const key = storeKey(); if (!key) { storeLocked = true; // encrypted, and this process was not given the key return {}; } const plain = decryptStore(text, key); if (!plain) { // We HAVE a key and it does not open this file — the key changed under us (a reinstall, a // restored profile, a keystore entry regenerated by something else). Refusing to write, as // the keyless case does, would be permanent: nothing could ever be signed in again, because // every future write hits this same unreadable file. So move it aside and start clean. // RENAMED, never deleted: it is unreadable to us, not necessarily unrecoverable to someone // who still has the old key. const aside = `${storePath()}.unreadable-${new Date().toISOString().replace(/[:.]/g, "-")}`; try { renameSync(storePath(), aside); console.error(`mcp auth: the token store could not be decrypted with this key. Kept it at ${aside}; sign in again to build a new one.`); return {}; } catch { storeLocked = true; // could not even move it — then certainly do not overwrite it return {}; } } try { return JSON.parse(plain) as Record; } catch { storeLocked = true; // decrypted to nonsense; refuse to "fix" it by flattening it return {}; } } try { return JSON.parse(text) as Record; } catch { return {}; } } function writeStore(all: Record): boolean { // Refuse rather than destroy. A process that could not read the store has no business replacing // it: the sign-in it is trying to save is worth less than every sign-in already there. if (storeLocked) { console.error("mcp auth: the token store is encrypted and this process has no key — refusing to overwrite it"); return false; } const p = storePath(); mkdirSync(dirname(p), { recursive: true }); const json = JSON.stringify(all, null, 2); const key = storeKey(); // 0600 is kept for POSIX, where it does mean something. On Windows the encryption is the // protection, because the mode bits are ignored. writeFileSync(p, key ? encryptStore(json, key) : json, { mode: 0o600 }); return true; } /** * Re-write a plaintext store encrypted, once a key becomes available. * * Without this, everything signed in before the app had a key would sit in plaintext forever — * the tokens that most need protecting are the ones already there. */ export function migrateStoreEncryption(): "encrypted" | "already" | "no-key" | "nothing" { const key = storeKey(); if (!key) return "no-key"; let text: string; try { text = readFileSync(storePath(), "utf8"); } catch { return "nothing"; } if (text.startsWith(ENC_PREFIX)) return "already"; writeStore(JSON.parse(text) as Record); return "encrypted"; } /** * Hold an exclusive lock across a read-modify-write of the store. * * setAuth was `read → change one entry → write the whole file`, unlocked. Ada runs more than one * process against this file — the desktop engine, a scheduled run in another folder, `ada` in a * terminal — and two of them interleaving means the second write is built on a snapshot taken * before the first, so it silently deletes whatever the first added. Signing in to two connectors * at once could lose one, and the symptom is the worst kind: a sign-in that plainly succeeded and * then simply is not there. * * `wx` is the atomic primitive — creating the lock file succeeds for exactly one process. A stale * lock (a crash mid-write) is broken after 10s, and after ~2s of waiting we proceed anyway: losing * one sign-in to a race is bad, but refusing to store a sign-in at all is worse. */ function withStoreLock(fn: () => T): T { const lock = `${storePath()}.lock`; mkdirSync(dirname(lock), { recursive: true }); const deadline = Date.now() + 2_000; for (;;) { try { closeSync(openSync(lock, "wx")); break; } catch (e) { if ((e as NodeJS.ErrnoException).code !== "EEXIST") break; // can't lock here — proceed unlocked try { if (Date.now() - statSync(lock).mtimeMs > 10_000) unlinkSync(lock); // stale: someone died holding it } catch { /* it vanished between the stat and the unlink — fine, retry */ } if (Date.now() > deadline) break; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); // sync sleep; these APIs are sync } } try { return fn(); } finally { try { unlinkSync(lock); } catch { /* already gone */ } } } export function getAuth(serverUrl: string): StoredAuth | null { return readStore()[canonicalResource(serverUrl)] ?? null; } /** Returns false when the token could NOT be persisted — a sign-in that is not saved has not * happened, and reporting it as success is how the browser said "Connected" while the app sat * waiting for a token that was thrown away. */ export function setAuth(serverUrl: string, auth: StoredAuth): boolean { return withStoreLock(() => { const all = readStore(); all[canonicalResource(serverUrl)] = auth; return writeStore(all); }); } export function clearAuth(serverUrl: string): void { withStoreLock(() => { const all = readStore(); delete all[canonicalResource(serverUrl)]; writeStore(all); }); } /** * The `resource` parameter (RFC 8707) — the identity the token is bound to. * * Fragments are stripped and the host is lowercased; the path is KEPT, because one host can serve * several MCP servers and a token minted for one must not be accepted by another. */ export function canonicalResource(url: string): string { const u = new URL(url); u.hash = ""; u.search = ""; u.protocol = u.protocol.toLowerCase(); u.hostname = u.hostname.toLowerCase(); if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.slice(0, -1); return u.pathname === "/" ? `${u.protocol}//${u.host}` : u.toString(); } /** * The `resource_metadata` URL a 401 points at. * * Parsed rather than assumed: the spec REQUIRES servers to advertise it in WWW-Authenticate, and * guessing the well-known path instead breaks any server that hosts its metadata elsewhere. */ export function parseWwwAuthenticate(header: string | null): string | null { if (!header) return null; return /resource_metadata\s*=\s*"([^"]+)"/i.exec(header)?.[1] ?? null; } /** * Candidate metadata URLs for an issuer, in the order to try them. * * RFC 8414 inserts the well-known segment BEFORE the issuer's path (`/.well-known/x/tenant1`), * which is the opposite of what most people write by hand — get it wrong and every multi-tenant * authorization server 404s. The OIDC form appends instead, so both are tried. */ export function metadataUrls(issuer: string): string[] { const u = new URL(issuer); const path = u.pathname.replace(/\/$/, ""); const origin = `${u.protocol}//${u.host}`; return [ `${origin}/.well-known/oauth-authorization-server${path}`, `${origin}/.well-known/openid-configuration${path}`, `${origin}${path}/.well-known/openid-configuration`, ]; } /** PKCE S256 pair. The verifier never leaves this process until the token exchange. */ export function pkce(): { verifier: string; challenge: string } { const verifier = randomBytes(32).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } async function getJson(url: string): Promise | null> { try { const r = await fetch(url, { headers: { accept: "application/json" } }); return r.ok ? ((await r.json()) as Record) : null; } catch { return null; } } /** Find the authorization server for an MCP endpoint, following the spec's discovery chain. */ export async function discover(serverUrl: string, wwwAuthenticate: string | null): Promise { const origin = new URL(serverUrl).origin; const prmUrl = parseWwwAuthenticate(wwwAuthenticate) ?? `${origin}/.well-known/oauth-protected-resource${new URL(serverUrl).pathname.replace(/\/$/, "")}`; const prm = (await getJson(prmUrl)) ?? (await getJson(`${origin}/.well-known/oauth-protected-resource`)); // Some servers skip protected-resource metadata and are their own authorization server. const issuers = (prm?.authorization_servers as string[] | undefined) ?? [origin]; for (const issuer of issuers) { for (const candidate of metadataUrls(issuer)) { const meta = await getJson(candidate); if (meta?.authorization_endpoint && meta?.token_endpoint) return meta as unknown as AsMetadata; } } return null; } /** Register Ada with an authorization server it has never met (RFC 7591). */ export async function register(meta: AsMetadata, redirectUri: string): Promise<{ client_id: string; client_secret?: string } | null> { if (!meta.registration_endpoint) return null; try { const r = await fetch(meta.registration_endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_name: "Ada", // Sent because consent screens render whichever of these they trust. With only // `client_name`, some authorization servers fall back to the redirect host and the user is // asked to "grant 127.0.0.1 access" — which reads like something to refuse, and should. // // These are the product's own site, not a source repo: this is the screen where somebody // decides whether to hand Ada their Linear workspace, and a github.com// URL // reads like an individual's project rather than the app being installed. The logo must // resolve for a stranger with no session — a 404 renders as a broken image right next to // the Approve button. client_uri: "https://adacodelabs.com", logo_uri: "https://adacodelabs.com/assets/img/icon.png", software_id: "dev.ada.app", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", // a desktop app is a public client application_type: "native", }), }); if (!r.ok) return null; const j = (await r.json()) as { client_id?: string; client_secret?: string }; return j.client_id ? { client_id: j.client_id, client_secret: j.client_secret } : null; } catch { return null; } } /** * Hold a loopback listener open for the redirect. * * 127.0.0.1 on an OS-assigned port, which OAuth 2.1 allows for native apps specifically so the * secret-less desktop case stays safe. `state` is checked here rather than by the caller — a * mismatched callback is an attack, not a retry. */ /** * The page the browser lands on after consent. It is the last thing you see in a sign-in, so it says * which service connected and follows the browser's light/dark setting rather than flashing white. * * `detail` is a query parameter reflected back into the page, so it is escaped — an unescaped one is * script injection reachable by anything that can reach the loopback port while a sign-in is open. */ function callbackPage(ok: boolean, label: string | undefined, detail?: string): string { const esc = (s: string): string => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); const who = label ? ` to ${esc(label)}` : ""; const title = ok ? `Connected${who}` : "Sign-in failed"; const body = ok ? "You can close this tab and go back to Ada." : esc(detail ?? "state mismatch"); const mark = ok ? '' : ''; return `${title} · Ada

${title}

${body}

Ada
`; } export function awaitCallback(state: string, label?: string): Promise<{ port: number; code: Promise }> { return new Promise((resolveOuter, rejectOuter) => { let settle: (code: string) => void; let fail: (e: Error) => void; const code = new Promise((res, rej) => { settle = res; fail = rej; }); // Nobody may be awaiting this yet — the caller gets `finish()` and may never call it, or call // it long after a bad callback arrives. Without this, a state mismatch or a timeout becomes an // UNHANDLED rejection and takes down the whole agent process. The handler is a no-op; `finish` // still sees the rejection when it awaits. code.catch(() => {}); const server = createServer((req, res) => { const u = new URL(req.url ?? "/", "http://127.0.0.1"); const got = u.searchParams.get("code"); const err = u.searchParams.get("error"); const ok = got && u.searchParams.get("state") === state; res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(callbackPage(!!ok, label, err ?? undefined)); if (ok) settle(got); else fail(new Error(err ?? "state mismatch — the callback did not come from the sign-in Ada started")); setTimeout(() => server.close(), 500); }); server.on("error", rejectOuter); server.listen(0, "127.0.0.1", () => { const port = (server.address() as { port: number }).port; resolveOuter({ port, code }); }); setTimeout(() => { fail(new Error("timed out waiting for the browser")); server.close(); }, 300_000).unref?.(); }); } /** * Which token endpoints Ada's backend can complete an exchange for. * * Asked once per process. A provider that will not register clients (Google) needs a client secret * to finish, and routing that through the backend is how the secret stays off every installer. */ interface BackendOauth { hosts: string[]; clients: Record; } let exchangeHostsCache: Promise | null = null; function backendOauth(): Promise { if (exchangeHostsCache) return exchangeHostsCache; const base = process.env.ADA_BACKEND_URL; exchangeHostsCache = (async () => { if (!base) return { hosts: [], clients: {} }; try { const r = await fetch(`${base.replace(/\/+$/, "")}/mcp/oauth/hosts`, { signal: AbortSignal.timeout(6000) }); if (!r.ok) return { hosts: [], clients: {} }; const j = (await r.json()) as Partial; return { hosts: j.hosts ?? [], clients: j.clients ?? {} }; } catch { return { hosts: [], clients: {} }; // unreachable backend must never block a sign-in } })(); return exchangeHostsCache; } async function backendExchangeHosts(): Promise { return (await backendOauth()).hosts; } /** * The client id this deployment signs users in with, for a given authorization server. * * This is what makes "just click sign in" true for providers that will not register clients. The * id comes from the backend, the secret never leaves it, and nobody is asked to paste anything. */ export async function backendClientFor(tokenEndpoint: string): Promise<{ client_id: string } | null> { try { const host = new URL(tokenEndpoint).hostname.toLowerCase(); const c = (await backendOauth()).clients[host]; return c?.client_id ? { client_id: c.client_id } : null; } catch { return null; } } /** Whether the backend can sign users in to a named provider — used to hide the setup entirely. */ export async function backendHasProvider(provider: string): Promise { return Object.values((await backendOauth()).clients).some((c) => c.provider === provider); } /** * Exchange an authorization code (or refresh token) for an access token. * * Goes through Ada's backend when that deployment holds the client secret for this endpoint, and * directly otherwise. The backend returns the tokens rather than keeping them, so either path ends * with the tokens on this machine and nowhere else. * * The fallback matters: a backend that is down, unconfigured or unreachable must degrade to the * local exchange, not to a broken sign-in. */ export async function tokenRequest( meta: AsMetadata, body: Record, ): Promise<{ access_token?: string; refresh_token?: string; expires_in?: number; error?: string }> { const base = process.env.ADA_BACKEND_URL; if (base && !body.client_secret) { let host = ""; try { host = new URL(meta.token_endpoint).hostname.toLowerCase(); } catch { /* fall through to the direct call, which will report it properly */ } if (host && (await backendExchangeHosts()).includes(host)) { try { const r = await fetch(`${base.replace(/\/+$/, "")}/mcp/oauth/exchange`, { method: "POST", headers: { "content-type": "application/json", // The exchange spends Ada's client secret, so the backend requires a signed-in caller. ...(process.env.ADA_CLIENT_KEY ? { authorization: `Bearer ${process.env.ADA_CLIENT_KEY}` } : {}), }, body: JSON.stringify({ token_endpoint: meta.token_endpoint, ...body }), signal: AbortSignal.timeout(25_000), }); const j = (await r.json().catch(() => ({}))) as Record; if (r.ok && j.access_token) return j as { access_token?: string; refresh_token?: string; expires_in?: number }; // A refusal from our own backend is worth reporting; a 404 means it holds no client for // this host, which is simply "do it yourself". if (r.status !== 404) return { error: String(j.error_description ?? j.error ?? `exchange failed (${r.status})`) }; } catch { /* backend unreachable — fall through and exchange directly */ } } } const r = await fetch(meta.token_endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: new URLSearchParams(body).toString(), }); const j = (await r.json().catch(() => ({}))) as Record; if (!r.ok) return { error: String(j.error_description ?? j.error ?? `token endpoint ${r.status}`) }; return j as { access_token?: string; refresh_token?: string; expires_in?: number }; } /** A stored token, refreshed if it is about to expire. Null when re-auth is needed. */ export async function validAccessToken(serverUrl: string): Promise { const auth = getAuth(serverUrl); if (!auth) return null; // 60s of slack: a token that expires mid-request is a failure the user reads as "it broke". if (!auth.expires_at || auth.expires_at - 60_000 > Date.now()) return auth.access_token; if (!auth.refresh_token) return null; const t = await tokenRequest({ authorization_endpoint: "", token_endpoint: auth.token_endpoint }, { grant_type: "refresh_token", refresh_token: auth.refresh_token, client_id: auth.client_id, resource: canonicalResource(serverUrl), ...(auth.client_secret ? { client_secret: auth.client_secret } : {}), }); if (!t.access_token) return null; setAuth(serverUrl, { ...auth, access_token: t.access_token, refresh_token: t.refresh_token ?? auth.refresh_token, expires_at: t.expires_in ? Date.now() + t.expires_in * 1000 : undefined, }); return t.access_token; } /** * The whole sign-in, start to finish. Returns the URL to open — the caller opens the browser, * because only it knows whether there is one. */ export async function beginLogin( serverUrl: string, wwwAuthenticate: string | null, opts: { client?: { client_id: string; client_secret?: string }; scopes?: string[]; label?: string } = {}, ): Promise<{ url: string; finish: () => Promise<{ ok: boolean; error?: string }> } | { error: string }> { const meta = await discover(serverUrl, wwwAuthenticate); if (!meta) return { error: "this server did not advertise an OAuth authorization server" }; const state = randomBytes(16).toString("base64url"); const { port, code } = await awaitCallback(state, opts.label); const redirectUri = `http://127.0.0.1:${port}/callback`; // Three ways to be a client, in the order that costs the user least: // 1. one we already registered for this server, // 2. one we register now (dynamic registration — nothing for you to do), // 3. one you supplied, for the servers that refuse to register anybody. // Google is the reason (3) exists: its own remote Calendar MCP server does not offer dynamic // registration, so every client — Ada, Claude, Antigravity — has to be told a client id. // Order: one we already have, one we can register now, one the BACKEND signs in with, one that // was configured locally. The backend comes before the local config because that is the whole // point — a provider that will not self-register should still cost the user nothing. const existing = getAuth(serverUrl); const reg = existing?.client_id ? { client_id: existing.client_id, client_secret: existing.client_secret } : (((await register(meta, redirectUri)) ?? (await backendClientFor(meta.token_endpoint)) ?? opts.client ?? null) as { client_id: string; client_secret?: string } | null); if (!reg) return { error: opts.client ? "this server rejected the client id it was given" : "this server does not register clients automatically, and no client id has been set up for it", }; const { verifier, challenge } = pkce(); const resource = canonicalResource(serverUrl); const auth = new URL(meta.authorization_endpoint); for (const [k, v] of Object.entries({ response_type: "code", client_id: reg.client_id, redirect_uri: redirectUri, state, code_challenge: challenge, code_challenge_method: "S256", resource, // Scopes only when the server names them. Google refuses an authorize request with no scope; // a server that hands out its own scopes rejects one that asks for something unexpected. ...(opts.scopes?.length ? { scope: opts.scopes.join(" ") } : {}), // Google's dialect, keyed on Google's endpoint rather than on "has scopes" — GitHub and Slack // take scopes too, and `prompt` is not an unknown param everywhere. Google returns a refresh // token only when both are present; without one the connector silently dies an hour later. ...(meta.token_endpoint.includes("googleapis.com") ? { access_type: "offline", prompt: "consent" } : {}), })) auth.searchParams.set(k, v); const finish = async (): Promise<{ ok: boolean; error?: string }> => { try { const got = await code; const t = await tokenRequest(meta, { grant_type: "authorization_code", code: got, redirect_uri: redirectUri, client_id: reg.client_id, code_verifier: verifier, resource, ...(reg.client_secret ? { client_secret: reg.client_secret } : {}), }); if (!t.access_token) return { ok: false, error: t.error ?? "no access token returned" }; const saved = setAuth(serverUrl, { access_token: t.access_token, refresh_token: t.refresh_token, expires_at: t.expires_in ? Date.now() + t.expires_in * 1000 : undefined, client_id: reg.client_id, client_secret: reg.client_secret, token_endpoint: meta.token_endpoint, }); // A token that could not be stored is not a sign-in. Reporting success here is what produced // the worst version of this: the browser saying "Connected to Gmail" while the app waited // three minutes for a token it had already thrown away, with nothing on screen to explain it. if (!saved) return { ok: false, error: "signed in, but the token could not be saved — Ada cannot read its own token store. Restart Ada; if it persists, the encryption key is unusable and the store must be reset.", }; return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; } }; return { url: auth.toString(), finish }; }